diff --git a/.github/workflows/build-android-e2e.yml b/.github/workflows/build-android-e2e.yml index 9e7f2fba86c8..588c2aa506eb 100644 --- a/.github/workflows/build-android-e2e.yml +++ b/.github/workflows/build-android-e2e.yml @@ -62,17 +62,6 @@ jobs: configure-keystores: true target: ${{ inputs.keystore_target }} # qa for taget=main and flask for target=flask - - name: Cache Gradle dependencies - uses: cirruslabs/cache@v4 - id: gradle-cache-restore - env: - GRADLE_CACHE_VERSION: 1 - with: - path: | - ~/_work/.gradle/caches - ~/_work/.gradle/wrapper - key: gradle-${{ env.GRADLE_CACHE_VERSION }}-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - - name: Setup project dependencies with retry uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 with: @@ -121,13 +110,31 @@ jobs: ${{ steps.determine-target-paths.outputs.apk-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}.apk ${{ steps.determine-target-paths.outputs.test-apk-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}-androidTest.apk ${{ steps.determine-target-paths.outputs.aab-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}.aab - key: android-apk-${{ inputs.build_type }}-${{ env.CACHE_GENERATION }}-${{ steps.generate-fingerprint.outputs.fingerprint }} + # Include Gradle properties in key to force rebuild when properties change + # Keep the `hashFiles` call for Gradle config in-sync with these steps: + # - "Cache Gradle dependencies" + # - "Cache build artifacts" + key: android-apk-${{ inputs.build_type }}-${{ env.CACHE_GENERATION }}-${{ steps.generate-fingerprint.outputs.fingerprint }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} restore-keys: | android-apk-${{ inputs.build_type }}-${{ env.CACHE_GENERATION }}- android-apk- + - name: Cache Gradle dependencies + uses: cirruslabs/cache@v4 + if: ${{ steps.apk-cache-restore.outputs.cache-hit != 'true' }} + env: + GRADLE_CACHE_VERSION: 1 + with: + path: | + ~/_work/.gradle/caches + ~/_work/.gradle/wrapper + # Keep the `hashFiles` call for Gradle config in-sync with these steps: + # - "Check and restore cached APKs if Fingerprint is found" + # - "Cache build artifacts" + key: gradle-${{ env.GRADLE_CACHE_VERSION }}-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + - name: Build Android E2E APKs - if: ${{ steps.apk-cache-restore.outputs.cache-hit != 'true' || steps.gradle-cache-restore.outputs.cache-hit != 'true' }} + if: ${{ steps.apk-cache-restore.outputs.cache-hit != 'true' }} run: | echo "🏗 Building Android E2E APKs..." export NODE_OPTIONS="--max-old-space-size=8192" @@ -178,7 +185,7 @@ jobs: MM_INFURA_PROJECT_ID: ${{ secrets.MM_INFURA_PROJECT_ID }} - name: Repack APK with JS updates using @expo/repack-app - if: ${{ steps.apk-cache-restore.outputs.cache-hit == 'true' && steps.gradle-cache-restore.outputs.cache-hit == 'true' }} + if: ${{ steps.apk-cache-restore.outputs.cache-hit == 'true' }} run: | echo "📦 Repacking APK with updated JavaScript bundle using @expo/repack-app..." # Use the optimized repack script which uses @expo/repack-app @@ -229,14 +236,17 @@ jobs: # Cache build artifacts with the pre-build fingerprint - name: Cache build artifacts - if: ${{ steps.apk-cache-restore.outputs.cache-hit != 'true' || steps.gradle-cache-restore.outputs.cache-hit != 'true' }} + if: ${{ steps.apk-cache-restore.outputs.cache-hit != 'true' }} uses: cirruslabs/cache@v4 with: path: | ${{ steps.determine-target-paths.outputs.apk-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}.apk ${{ steps.determine-target-paths.outputs.test-apk-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}-androidTest.apk ${{ steps.determine-target-paths.outputs.aab-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}.aab - key: android-apk-${{ inputs.build_type }}-${{ env.CACHE_GENERATION }}-${{ steps.generate-fingerprint.outputs.fingerprint }} + # Keep the `hashFiles` call for Gradle config in-sync with these steps: + # - "Check and restore cached APKs if Fingerprint is found" + # - "Cache Gradle dependencies" + key: android-apk-${{ inputs.build_type }}-${{ env.CACHE_GENERATION }}-${{ steps.generate-fingerprint.outputs.fingerprint }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - name: Upload Android APK id: upload-apk diff --git a/.github/workflows/build-ios-e2e.yml b/.github/workflows/build-ios-e2e.yml index df4df6b7f885..1f22cd202ba7 100644 --- a/.github/workflows/build-ios-e2e.yml +++ b/.github/workflows/build-ios-e2e.yml @@ -58,13 +58,13 @@ jobs: - name: Cache Xcode derived data uses: cirruslabs/cache@0ea6c28a9b52ff2a1a01354742d8fbe0c4599693 + env: + XCODE_CACHE_VERSION: 1 with: path: | ~/Library/Developer/Xcode/DerivedData ios/build - key: ${{ runner.os }}-xcode-${{ hashFiles('ios/**/*.{h,m,mm,swift}', 'ios/**/Podfile.lock', 'yarn.lock') }} - restore-keys: | - ${{ runner.os }}-xcode- + key: ${{ runner.os }}-xcode-${{ env.XCODE_CACHE_VERSION }}-${{ hashFiles('ios/**/*.{h,m,mm,swift}', 'ios/**/Podfile.lock', 'yarn.lock') }} # Install Node.js, Xcode tools, and other iOS development dependencies - name: Installing iOS Environment Setup diff --git a/.github/workflows/create-release-pr.yml b/.github/workflows/create-release-pr.yml index 0d75cdbebe0e..853e021cdfc8 100644 --- a/.github/workflows/create-release-pr.yml +++ b/.github/workflows/create-release-pr.yml @@ -79,7 +79,7 @@ jobs: create-release-pr: needs: [resolve-bases, resolve-previous-ref, generate-build-version] name: Create Release Pull Request using Github Tools - uses: MetaMask/github-tools/.github/workflows/create-release-pr.yml@7fe185fdb0e60981c898e88d82e44ff33f604daa + uses: MetaMask/github-tools/.github/workflows/create-release-pr.yml@749c097218590d5cd36d28d07967e12ba830b146 with: platform: mobile checkout-base-branch: ${{ needs.resolve-bases.outputs.checkout_base }} @@ -87,7 +87,7 @@ jobs: semver-version: ${{ inputs.semver-version }} previous-version-ref: ${{ needs.resolve-previous-ref.outputs.previous_ref }} mobile-build-version: ${{ needs.generate-build-version.outputs.build-version }} - github-tools-version: 7fe185fdb0e60981c898e88d82e44ff33f604daa + github-tools-version: 749c097218590d5cd36d28d07967e12ba830b146 secrets: # This token needs write permissions to metamask-mobile & read permissions to metamask-planning # If called from auto-create-release-pr use the PR_TOKEN passed in as an input, if called manually use github secret token values diff --git a/.github/workflows/run-e2e-smoke-tests-android.yml b/.github/workflows/run-e2e-smoke-tests-android.yml index cda6ac2e6523..3bf0ad70aecd 100644 --- a/.github/workflows/run-e2e-smoke-tests-android.yml +++ b/.github/workflows/run-e2e-smoke-tests-android.yml @@ -134,20 +134,20 @@ jobs: changed_files: ${{ inputs.changed_files }} secrets: inherit - prediction-market-android-smoke: - strategy: - matrix: - split: [1] - fail-fast: false - uses: ./.github/workflows/run-e2e-workflow.yml - with: - test-suite-name: prediction_market_android_smoke-${{ matrix.split }} - platform: android - test_suite_tag: 'SmokePredictions' - split_number: ${{ matrix.split }} - total_splits: 1 - changed_files: ${{ inputs.changed_files }} - secrets: inherit + # prediction-market-android-smoke: + # strategy: + # matrix: + # split: [1] + # fail-fast: false + # uses: ./.github/workflows/run-e2e-workflow.yml + # with: + # test-suite-name: prediction_market_android_smoke-${{ matrix.split }} + # platform: android + # test_suite_tag: 'SmokePredictions' + # split_number: ${{ matrix.split }} + # total_splits: 1 + # changed_files: ${{ inputs.changed_files }} + # secrets: inherit rewards-android-smoke: strategy: @@ -177,7 +177,7 @@ jobs: - network-abstraction-android-smoke - network-expansion-android-smoke - confirmations-redesigned-android-smoke - - prediction-market-android-smoke + # - prediction-market-android-smoke - rewards-android-smoke steps: - name: Checkout diff --git a/.github/workflows/run-e2e-smoke-tests-ios.yml b/.github/workflows/run-e2e-smoke-tests-ios.yml index 3265ab8d1a2c..812d7adc8a68 100644 --- a/.github/workflows/run-e2e-smoke-tests-ios.yml +++ b/.github/workflows/run-e2e-smoke-tests-ios.yml @@ -134,20 +134,20 @@ jobs: changed_files: ${{ inputs.changed_files }} secrets: inherit - prediction-market-ios-smoke: - strategy: - matrix: - split: [1] - fail-fast: false - uses: ./.github/workflows/run-e2e-workflow.yml - with: - test-suite-name: prediction_market_ios_smoke-${{ matrix.split }} - platform: ios - test_suite_tag: 'SmokePredictions' - split_number: ${{ matrix.split }} - total_splits: 1 - changed_files: ${{ inputs.changed_files }} - secrets: inherit + # prediction-market-ios-smoke: + # strategy: + # matrix: + # split: [1] + # fail-fast: false + # uses: ./.github/workflows/run-e2e-workflow.yml + # with: + # test-suite-name: prediction_market_ios_smoke-${{ matrix.split }} + # platform: ios + # test_suite_tag: 'SmokePredictions' + # split_number: ${{ matrix.split }} + # total_splits: 1 + # changed_files: ${{ inputs.changed_files }} + # secrets: inherit rewards-ios-smoke: strategy: @@ -177,7 +177,7 @@ jobs: - accounts-ios-smoke - network-abstraction-ios-smoke - network-expansion-ios-smoke - - prediction-market-ios-smoke + # - prediction-market-ios-smoke - rewards-ios-smoke steps: - name: Checkout diff --git a/.yarn/patches/@metamask-network-enablement-controller-npm-3.0.0-cfba64ad39.patch b/.yarn/patches/@metamask-network-enablement-controller-npm-3.0.0-cfba64ad39.patch new file mode 100644 index 000000000000..7d1746545fc8 --- /dev/null +++ b/.yarn/patches/@metamask-network-enablement-controller-npm-3.0.0-cfba64ad39.patch @@ -0,0 +1,16 @@ +diff --git a/dist/NetworkEnablementController.cjs b/dist/NetworkEnablementController.cjs +index d4a40bea9e4ed3c28e347d96e309efe1ff889e81..fab280760de6bd5cdfdbecf01495c2d5616b2e16 100644 +--- a/dist/NetworkEnablementController.cjs ++++ b/dist/NetworkEnablementController.cjs +@@ -25,6 +25,11 @@ const getDefaultNetworkEnablementControllerState = () => ({ + [controller_utils_1.ChainId[controller_utils_1.BuiltInNetworkName.Mainnet]]: true, + [controller_utils_1.ChainId[controller_utils_1.BuiltInNetworkName.LineaMainnet]]: true, + [controller_utils_1.ChainId[controller_utils_1.BuiltInNetworkName.BaseMainnet]]: true, ++ [controller_utils_1.ChainId[controller_utils_1.BuiltInNetworkName.ArbitrumOne]]: true, ++ [controller_utils_1.ChainId[controller_utils_1.BuiltInNetworkName.BscMainnet]]: true, ++ [controller_utils_1.ChainId[controller_utils_1.BuiltInNetworkName.OptimismMainnet]]: true, ++ [controller_utils_1.ChainId[controller_utils_1.BuiltInNetworkName.PolygonMainnet]]: true, ++ [controller_utils_1.ChainId[controller_utils_1.BuiltInNetworkName.SeiMainnet]]: true, + }, + [utils_1.KnownCaipNamespace.Solana]: { + [keyring_api_1.SolScope.Mainnet]: true, diff --git a/app/animations/Carousel_Confetti.riv b/app/animations/Carousel_Confetti.riv new file mode 100644 index 000000000000..48401f8234c8 Binary files /dev/null and b/app/animations/Carousel_Confetti.riv differ diff --git a/app/components/Base/Keypad/__snapshots__/Keypad.test.tsx.snap b/app/components/Base/Keypad/__snapshots__/Keypad.test.tsx.snap index 043357220569..9fc193e295be 100644 --- a/app/components/Base/Keypad/__snapshots__/Keypad.test.tsx.snap +++ b/app/components/Base/Keypad/__snapshots__/Keypad.test.tsx.snap @@ -48,7 +48,7 @@ exports[`Keypad components components should render correctly and match snapshot "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -100,7 +100,7 @@ exports[`Keypad components components should render correctly and match snapshot "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -152,7 +152,7 @@ exports[`Keypad components components should render correctly and match snapshot "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -218,7 +218,7 @@ exports[`Keypad components components should render correctly and match snapshot "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -270,7 +270,7 @@ exports[`Keypad components components should render correctly and match snapshot "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -322,7 +322,7 @@ exports[`Keypad components components should render correctly and match snapshot "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -388,7 +388,7 @@ exports[`Keypad components components should render correctly and match snapshot "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -440,7 +440,7 @@ exports[`Keypad components components should render correctly and match snapshot "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -492,7 +492,7 @@ exports[`Keypad components components should render correctly and match snapshot "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -558,7 +558,7 @@ exports[`Keypad components components should render correctly and match snapshot "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -610,7 +610,7 @@ exports[`Keypad components components should render correctly and match snapshot "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -661,7 +661,7 @@ exports[`Keypad components components should render correctly and match snapshot { "alignItems": "center", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -737,7 +737,7 @@ exports[`Keypad should render correctly and match snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -789,7 +789,7 @@ exports[`Keypad should render correctly and match snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -841,7 +841,7 @@ exports[`Keypad should render correctly and match snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -907,7 +907,7 @@ exports[`Keypad should render correctly and match snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -959,7 +959,7 @@ exports[`Keypad should render correctly and match snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1011,7 +1011,7 @@ exports[`Keypad should render correctly and match snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1077,7 +1077,7 @@ exports[`Keypad should render correctly and match snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1129,7 +1129,7 @@ exports[`Keypad should render correctly and match snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1181,7 +1181,7 @@ exports[`Keypad should render correctly and match snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1247,7 +1247,7 @@ exports[`Keypad should render correctly and match snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1301,7 +1301,7 @@ exports[`Keypad should render correctly and match snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1354,7 +1354,7 @@ exports[`Keypad should render correctly and match snapshot 1`] = ` { "alignItems": "center", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, diff --git a/app/components/Base/Keypad/components.tsx b/app/components/Base/Keypad/components.tsx index d58b3ebc0f1b..10dc84fdef36 100644 --- a/app/components/Base/Keypad/components.tsx +++ b/app/components/Base/Keypad/components.tsx @@ -20,14 +20,14 @@ const createStyles = (colors: Colors) => backgroundColor: colors.background.muted, borderRadius: 12, paddingHorizontal: 16, - height: 40, + height: 48, justifyContent: 'center', alignItems: 'center', }, keypadDeleteButton: { borderRadius: 12, paddingHorizontal: 16, - height: 40, + height: 48, justifyContent: 'center', alignItems: 'center', }, diff --git a/app/components/UI/Bridge/Views/BridgeView/__snapshots__/BridgeView.test.tsx.snap b/app/components/UI/Bridge/Views/BridgeView/__snapshots__/BridgeView.test.tsx.snap index 4fc64263f596..43aac49038ae 100644 --- a/app/components/UI/Bridge/Views/BridgeView/__snapshots__/BridgeView.test.tsx.snap +++ b/app/components/UI/Bridge/Views/BridgeView/__snapshots__/BridgeView.test.tsx.snap @@ -575,7 +575,7 @@ exports[`BridgeView Bottom Content blurs input when opening QuoteExpiredModal 1` { "alignItems": "center", "backgroundColor": "#ffffff", - "borderColor": "#3c4d9d0f", + "borderColor": "#ffffff", "borderRadius": 8, "borderWidth": 2, "height": 32, @@ -964,7 +964,7 @@ exports[`BridgeView Bottom Content blurs input when opening QuoteExpiredModal 1` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1016,7 +1016,7 @@ exports[`BridgeView Bottom Content blurs input when opening QuoteExpiredModal 1` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1068,7 +1068,7 @@ exports[`BridgeView Bottom Content blurs input when opening QuoteExpiredModal 1` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1134,7 +1134,7 @@ exports[`BridgeView Bottom Content blurs input when opening QuoteExpiredModal 1` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1186,7 +1186,7 @@ exports[`BridgeView Bottom Content blurs input when opening QuoteExpiredModal 1` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1238,7 +1238,7 @@ exports[`BridgeView Bottom Content blurs input when opening QuoteExpiredModal 1` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1304,7 +1304,7 @@ exports[`BridgeView Bottom Content blurs input when opening QuoteExpiredModal 1` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1356,7 +1356,7 @@ exports[`BridgeView Bottom Content blurs input when opening QuoteExpiredModal 1` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1408,7 +1408,7 @@ exports[`BridgeView Bottom Content blurs input when opening QuoteExpiredModal 1` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1474,7 +1474,7 @@ exports[`BridgeView Bottom Content blurs input when opening QuoteExpiredModal 1` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1528,7 +1528,7 @@ exports[`BridgeView Bottom Content blurs input when opening QuoteExpiredModal 1` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1581,7 +1581,7 @@ exports[`BridgeView Bottom Content blurs input when opening QuoteExpiredModal 1` { "alignItems": "center", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -2234,7 +2234,7 @@ exports[`BridgeView renders 1`] = ` { "alignItems": "center", "backgroundColor": "#ffffff", - "borderColor": "#3c4d9d0f", + "borderColor": "#ffffff", "borderRadius": 8, "borderWidth": 2, "height": 32, @@ -2623,7 +2623,7 @@ exports[`BridgeView renders 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -2675,7 +2675,7 @@ exports[`BridgeView renders 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -2727,7 +2727,7 @@ exports[`BridgeView renders 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -2793,7 +2793,7 @@ exports[`BridgeView renders 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -2845,7 +2845,7 @@ exports[`BridgeView renders 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -2897,7 +2897,7 @@ exports[`BridgeView renders 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -2963,7 +2963,7 @@ exports[`BridgeView renders 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -3015,7 +3015,7 @@ exports[`BridgeView renders 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -3067,7 +3067,7 @@ exports[`BridgeView renders 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -3133,7 +3133,7 @@ exports[`BridgeView renders 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -3187,7 +3187,7 @@ exports[`BridgeView renders 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -3240,7 +3240,7 @@ exports[`BridgeView renders 1`] = ` { "alignItems": "center", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, diff --git a/app/components/UI/Bridge/components/BridgeDestNetworkSelector/__snapshots__/BridgeDestNetworkSelector.test.tsx.snap b/app/components/UI/Bridge/components/BridgeDestNetworkSelector/__snapshots__/BridgeDestNetworkSelector.test.tsx.snap index 0efb066f6177..62e8094525bb 100644 --- a/app/components/UI/Bridge/components/BridgeDestNetworkSelector/__snapshots__/BridgeDestNetworkSelector.test.tsx.snap +++ b/app/components/UI/Bridge/components/BridgeDestNetworkSelector/__snapshots__/BridgeDestNetworkSelector.test.tsx.snap @@ -433,447 +433,416 @@ exports[`BridgeDestNetworkSelector renders with initial state and displays netwo + + + + Select network + + + + - - - - Select network - - - - - - - - + } + width={24} + /> + - + + + - + } + } + > - - + - - - - - - - Optimism - - + } + testID="network-avatar-image" + /> + + Optimism + - - + + + + + - - - - - - Solana - - + } + testID="network-avatar-image" + /> + + Solana + - - + + + + + - - - - - - Bitcoin - - + } + testID="network-avatar-image" + /> + + Bitcoin + - + - - + + - + diff --git a/app/components/UI/Bridge/components/BridgeDestTokenSelector/__snapshots__/BridgeDestTokenSelector.test.tsx.snap b/app/components/UI/Bridge/components/BridgeDestTokenSelector/__snapshots__/BridgeDestTokenSelector.test.tsx.snap index be6828ac6556..b760fbae608e 100644 --- a/app/components/UI/Bridge/components/BridgeDestTokenSelector/__snapshots__/BridgeDestTokenSelector.test.tsx.snap +++ b/app/components/UI/Bridge/components/BridgeDestTokenSelector/__snapshots__/BridgeDestTokenSelector.test.tsx.snap @@ -499,10 +499,10 @@ exports[`BridgeDestTokenSelector renders with initial state and displays tokens { "alignItems": "center", "borderRadius": 8, - "height": 28, + "height": 32, "justifyContent": "center", "opacity": 1, - "width": 28, + "width": 32, } } testID="bridge-token-selector-close-button" @@ -510,15 +510,15 @@ exports[`BridgeDestTokenSelector renders with initial state and displays tokens diff --git a/app/components/UI/Bridge/components/BridgeNetworkSelectorBase.tsx b/app/components/UI/Bridge/components/BridgeNetworkSelectorBase.tsx index 12be0d267779..04a4c02240ee 100644 --- a/app/components/UI/Bridge/components/BridgeNetworkSelectorBase.tsx +++ b/app/components/UI/Bridge/components/BridgeNetworkSelectorBase.tsx @@ -1,41 +1,20 @@ import React from 'react'; -import { StyleSheet, TouchableOpacity, ScrollView } from 'react-native'; -import { Box } from '../../Box/Box'; +import { ScrollView, StyleSheet } from 'react-native'; import Text, { TextVariant, } from '../../../../component-library/components/Texts/Text'; -import { useStyles } from '../../../../component-library/hooks'; -import { Theme } from '../../../../util/theme/models'; import BottomSheetHeader from '../../../../component-library/components/BottomSheets/BottomSheetHeader'; import BottomSheet from '../../../../component-library/components/BottomSheets/BottomSheet'; -import Icon, { - IconName, -} from '../../../../component-library/components/Icons/Icon'; -import { IconSize } from '../../../../component-library/components/Icons/Icon/Icon.types'; import { strings } from '../../../../../locales/i18n'; -import { FlexDirection, AlignItems, JustifyContent } from '../../Box/box.types'; +import { ButtonIconSizes } from '../../../../component-library/components/Buttons/ButtonIcon'; import { useNavigation } from '@react-navigation/native'; -const createStyles = (params: { theme: Theme }) => { - const { theme } = params; - return StyleSheet.create({ - content: { - flex: 1, - backgroundColor: theme.colors.background.default, - }, - headerTitle: { - flex: 1, - textAlign: 'center', - }, - closeButton: { - position: 'absolute', - right: 0, - }, - closeIconBox: { - padding: 8, - }, - }); -}; +const styles = StyleSheet.create({ + headerTitle: { + flex: 1, + textAlign: 'center', + }, +}); interface BridgeNetworkSelectorBaseProps { children: React.ReactNode; @@ -44,42 +23,23 @@ interface BridgeNetworkSelectorBaseProps { export const BridgeNetworkSelectorBase: React.FC< BridgeNetworkSelectorBaseProps > = ({ children }) => { - const { styles, theme } = useStyles(createStyles, {}); const navigation = useNavigation(); return ( - - - - - - {strings('bridge.select_network')} - - - navigation.goBack()} - testID="bridge-network-selector-close-button" - > - - - - - - + navigation.goBack()} + closeButtonProps={{ + testID: 'bridge-network-selector-close-button', + size: ButtonIconSizes.Lg, + }} + > + + {strings('bridge.select_network')} + + - - {children} - - + {children} ); }; diff --git a/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/__snapshots__/BridgeSourceNetworkSelector.test.tsx.snap b/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/__snapshots__/BridgeSourceNetworkSelector.test.tsx.snap index ea34b8146e98..9b92cc927587 100644 --- a/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/__snapshots__/BridgeSourceNetworkSelector.test.tsx.snap +++ b/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/__snapshots__/BridgeSourceNetworkSelector.test.tsx.snap @@ -433,990 +433,959 @@ exports[`BridgeSourceNetworkSelector renders with initial state and displays net + + + + + Select network + + + + + + + + + + + + - - - - Select network - - - - - - - - + Deselect all + + - - - + } + } + > - - - Deselect all - - - - - - - - - + + + + + + - - - - - + + - + Ethereum Mainnet + + + + - - - - - - Ethereum Mainnet - - - - - $22600 - - - + $22600 + - - + + + + + - - + + + + + + - - - - - + + - + Optimism + + + + - - - - - - Optimism - - - - - $12000 - - - + $12000 + - - + + + + + - - + + + + + + - - - - - + + - + Solana + + + + - - - - - - Solana - - - - - $30012.75599 - - - + $30012.75599 + - - + + + + + - - + + + + + + - - - - - + + - + Bitcoin + + + + - - - - - - Bitcoin - - - - - $1500 - - - + $1500 + - + - - - - - - Apply - + + + + Apply + + + - + diff --git a/app/components/UI/Bridge/components/BridgeSourceTokenSelector/__snapshots__/BridgeSourceTokenSelector.test.tsx.snap b/app/components/UI/Bridge/components/BridgeSourceTokenSelector/__snapshots__/BridgeSourceTokenSelector.test.tsx.snap index d8925904637b..5ff5f3b243c0 100644 --- a/app/components/UI/Bridge/components/BridgeSourceTokenSelector/__snapshots__/BridgeSourceTokenSelector.test.tsx.snap +++ b/app/components/UI/Bridge/components/BridgeSourceTokenSelector/__snapshots__/BridgeSourceTokenSelector.test.tsx.snap @@ -499,10 +499,10 @@ exports[`BridgeSourceTokenSelector renders with initial state and displays token { "alignItems": "center", "borderRadius": 8, - "height": 28, + "height": 32, "justifyContent": "center", "opacity": 1, - "width": 28, + "width": 32, } } testID="bridge-token-selector-close-button" @@ -510,15 +510,15 @@ exports[`BridgeSourceTokenSelector renders with initial state and displays token diff --git a/app/components/UI/Bridge/components/BridgeTokenSelectorBase.tsx b/app/components/UI/Bridge/components/BridgeTokenSelectorBase.tsx index 2ad77c0abf1b..29b604cd743a 100644 --- a/app/components/UI/Bridge/components/BridgeTokenSelectorBase.tsx +++ b/app/components/UI/Bridge/components/BridgeTokenSelectorBase.tsx @@ -20,6 +20,7 @@ import { FlatList } from 'react-native-gesture-handler'; import BottomSheet, { BottomSheetRef, } from '../../../../component-library/components/BottomSheets/BottomSheet'; +import { ButtonIconSizes } from '../../../../component-library/components/Buttons/ButtonIcon'; // FlashList on iOS had some issues so we use FlatList for both platforms now const ListComponent = FlatList; @@ -221,7 +222,10 @@ export const BridgeTokenSelectorBase: React.FC< > {title ?? strings('bridge.select_token')} diff --git a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx index fdee76cda16d..7382019e55ca 100644 --- a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx +++ b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx @@ -420,7 +420,7 @@ describe('QuoteDetailsCard', () => { // The key is testing the shouldShowPriceImpactWarning conditional branches // Verify the Price Impact section is visible (this exercises the component logic) - expect(getByText('Price Impact')).toBeTruthy(); + expect(getByText('Price impact')).toBeTruthy(); // Test the shouldShowPriceImpactWarning branches by checking for tooltip presence const hasWarningTooltip = diff --git a/app/components/UI/Bridge/components/QuoteDetailsCard/__snapshots__/QuoteDetailsCard.test.tsx.snap b/app/components/UI/Bridge/components/QuoteDetailsCard/__snapshots__/QuoteDetailsCard.test.tsx.snap index c06950da0886..c7d41ec92758 100644 --- a/app/components/UI/Bridge/components/QuoteDetailsCard/__snapshots__/QuoteDetailsCard.test.tsx.snap +++ b/app/components/UI/Bridge/components/QuoteDetailsCard/__snapshots__/QuoteDetailsCard.test.tsx.snap @@ -570,7 +570,7 @@ exports[`QuoteDetailsCard renders initial state 1`] = ` } testID="label" > - Network Fee + Network fee - Price Impact + Price impact { color: theme.colors.text.default, fontSize: 24, }, - networkBadge: { - borderColor: theme.colors.background.muted, - }, }); }; @@ -84,7 +81,6 @@ export const TokenButton: React.FC = ({ variant={BadgeVariant.Network} imageSource={networkImageSource} name={networkName} - style={styles.networkBadge} /> } > diff --git a/app/components/UI/Carousel/StackCard/StackCard.tsx b/app/components/UI/Carousel/StackCard/StackCard.tsx index c87aab22a88e..e254476500e2 100644 --- a/app/components/UI/Carousel/StackCard/StackCard.tsx +++ b/app/components/UI/Carousel/StackCard/StackCard.tsx @@ -35,21 +35,8 @@ export const StackCard: React.FC = ({ nextCardBgOpacity, onSlideClick, onTransitionToNextCard, - onTransitionToEmpty, }) => { const tw = useTailwind(); - const isEmptyCard = slide.variableName === 'empty'; - - // Auto-dismiss empty card after 1000ms when it becomes current - React.useEffect(() => { - if (isCurrentCard && isEmptyCard) { - const timer = setTimeout(() => { - onTransitionToEmpty?.(); - }, 1000); - - return () => clearTimeout(timer); - } - }, [isCurrentCard, isEmptyCard, onTransitionToEmpty]); return ( = ({ )} /> )} - {isEmptyCard ? ( - // Empty card layout - centered text only - - - {slide.title} - + {/* Regular card layout - image + text + close button */} + + + - ) : ( - // Regular card layout - image + text + close button - - - + + + {slide.title} + + onTransitionToNextCard?.()} + testID={`carousel-slide-${slide.id}-close-button`} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} /> - - - - {slide.title} - - onTransitionToNextCard?.()} - testID={`carousel-slide-${slide.id}-close-button`} - hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} - /> - - - - {slide.description} - - + + + {slide.description} + - )} + diff --git a/app/components/UI/Carousel/StackCard/StackCard.types.ts b/app/components/UI/Carousel/StackCard/StackCard.types.ts index 3935214bde99..d1c8e6b0cf8b 100644 --- a/app/components/UI/Carousel/StackCard/StackCard.types.ts +++ b/app/components/UI/Carousel/StackCard/StackCard.types.ts @@ -13,5 +13,4 @@ export interface StackCardProps { nextCardBgOpacity: Animated.Value; onSlideClick: (slideId: string, navigation: NavigationAction) => void; onTransitionToNextCard?: () => void; - onTransitionToEmpty?: () => void; } diff --git a/app/components/UI/Carousel/StackCardEmpty/StackCardEmpty.test.tsx b/app/components/UI/Carousel/StackCardEmpty/StackCardEmpty.test.tsx index 36bc832ee075..6f88897e99a6 100644 --- a/app/components/UI/Carousel/StackCardEmpty/StackCardEmpty.test.tsx +++ b/app/components/UI/Carousel/StackCardEmpty/StackCardEmpty.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { render } from '@testing-library/react-native'; import { Animated } from 'react-native'; +import { ANIMATION_TIMINGS } from '../animations/animationTimings'; import { StackCardEmpty } from './StackCardEmpty'; // Mock dependencies @@ -26,12 +27,6 @@ jest.mock('@metamask/design-system-react-native', () => ({ }, })); -jest.mock('../animations/animationTimings', () => ({ - ANIMATION_TIMINGS: { - EMPTY_STATE_IDLE_TIME: 500, - }, -})); - // Mock i18n jest.mock('../../../../../locales/i18n', () => ({ strings: jest.fn((key: string) => { @@ -42,12 +37,31 @@ jest.mock('../../../../../locales/i18n', () => ({ }), })); +// Mock Animated.View to verify listener props are set correctly +jest.mock('react-native', () => ({ + ...jest.requireActual('react-native'), + Animated: { + ...jest.requireActual('react-native').Animated, + View: 'View', + }, +})); + +jest.mock('rive-react-native', () => ({ + __esModule: true, + default: 'Rive', + Alignment: { Center: 'Center' }, + Fit: { Cover: 'Cover' }, +})); + describe('StackCardEmpty', () => { + const createAnimatedValue = (initialValue = 0) => + new Animated.Value(initialValue); + const defaultProps = { - emptyStateOpacity: new Animated.Value(1), - emptyStateScale: new Animated.Value(1), - emptyStateTranslateY: new Animated.Value(0), - nextCardBgOpacity: new Animated.Value(0), + emptyStateOpacity: createAnimatedValue(1), + emptyStateScale: createAnimatedValue(1), + emptyStateTranslateY: createAnimatedValue(0), + nextCardBgOpacity: createAnimatedValue(0), onTransitionToEmpty: jest.fn(), }; @@ -57,60 +71,117 @@ describe('StackCardEmpty', () => { }); afterEach(() => { + jest.runOnlyPendingTimers(); jest.useRealTimers(); }); - it('renders empty state card with centered text', () => { - const { getByText, getByTestId } = render( - , - ); + describe('rendering', () => { + it('renders empty state card with correct content and structure', () => { + const { getByTestId, getByText } = render( + , + ); - expect(getByText("You're all caught up!")).toBeTruthy(); - expect(getByTestId('carousel-empty-state')).toBeTruthy(); + const emptyCard = getByTestId('carousel-empty-state'); + expect(emptyCard).toBeDefined(); + expect(getByText("You're all caught up!")).toBeDefined(); + }); }); - it('has proper styling with border and background', () => { - const { getByTestId } = render(); + describe('auto-dismiss behavior', () => { + it('calls onTransitionToEmpty after idle timeout', () => { + render(); - const emptyCard = getByTestId('carousel-empty-state'); - expect(emptyCard).toBeTruthy(); - }); + jest.advanceTimersByTime(ANIMATION_TIMINGS.EMPTY_STATE_IDLE_TIME); + + expect(defaultProps.onTransitionToEmpty).toHaveBeenCalledTimes(1); + }); - it('auto-dismisses after idle time when onTransitionToEmpty is provided', () => { - render(); + it('does not call onTransitionToEmpty before timeout', () => { + render(); - // Fast-forward time - jest.advanceTimersByTime(500); + jest.advanceTimersByTime(ANIMATION_TIMINGS.EMPTY_STATE_IDLE_TIME - 100); - expect(defaultProps.onTransitionToEmpty).toHaveBeenCalled(); - }); + expect(defaultProps.onTransitionToEmpty).not.toHaveBeenCalled(); + }); + + it('does not call onTransitionToEmpty when callback is undefined', () => { + render( + , + ); + + jest.advanceTimersByTime(ANIMATION_TIMINGS.EMPTY_STATE_IDLE_TIME + 100); - it('does not auto-dismiss when onTransitionToEmpty is not provided', () => { - render( - , - ); + expect(defaultProps.onTransitionToEmpty).not.toHaveBeenCalled(); + }); - // Fast-forward time - jest.advanceTimersByTime(600); + it('clears dismiss timer on unmount before timeout completes', () => { + const { unmount } = render(); - expect(defaultProps.onTransitionToEmpty).not.toHaveBeenCalled(); + jest.advanceTimersByTime(250); + unmount(); + jest.advanceTimersByTime(ANIMATION_TIMINGS.EMPTY_STATE_IDLE_TIME); + + expect(defaultProps.onTransitionToEmpty).not.toHaveBeenCalled(); + }); }); - it('cleans up timer on unmount', () => { - const { unmount } = render(); + describe('animation value listeners', () => { + it('sets up listener on emptyStateOpacity', () => { + const removeListenerMock = jest.fn(); + const opacityValue = { + addListener: jest.fn(), + removeListener: removeListenerMock, + } as Partial; + + render( + , + ); + + expect(opacityValue.addListener).toHaveBeenCalled(); + }); + + it('removes listener on unmount', () => { + const removeListenerMock = jest.fn(); + const opacityValue = { + addListener: jest.fn().mockReturnValue(123), + removeListener: removeListenerMock, + } as Partial; + + const { unmount } = render( + , + ); + + unmount(); - unmount(); + expect(removeListenerMock).toHaveBeenCalledWith(123); + }); + + it('clears pending animation timeout on unmount', () => { + const opacityValue = createAnimatedValue(0.5); + const { unmount } = render( + , + ); - // Should not crash or call callback after unmount - jest.advanceTimersByTime(600); - expect(defaultProps.onTransitionToEmpty).not.toHaveBeenCalled(); + jest.advanceTimersByTime(50); + unmount(); + jest.advanceTimersByTime(100); + + // Component should be unmounted, no callbacks should fire + expect(defaultProps.onTransitionToEmpty).not.toHaveBeenCalled(); + }); }); - describe('Animation Values', () => { - it('uses provided animation values for styling', () => { - const customOpacity = new Animated.Value(0.5); - const customScale = new Animated.Value(0.9); - const customTranslateY = new Animated.Value(10); + describe('animation values', () => { + it('renders with custom animation values', () => { + const customOpacity = createAnimatedValue(0.5); + const customScale = createAnimatedValue(0.9); + const customTranslateY = createAnimatedValue(10); const { getByTestId } = render( { />, ); - expect(getByTestId('carousel-empty-state')).toBeTruthy(); + expect(getByTestId('carousel-empty-state')).toBeDefined(); + }); + }); + + describe('background overlay', () => { + it('renders with nextCardBgOpacity applied to overlay', () => { + const bgOpacity = createAnimatedValue(0.5); + + const { getByTestId } = render( + , + ); + + expect(getByTestId('carousel-empty-state')).toBeDefined(); + }); + }); + + describe('timeout cleanup and memory management', () => { + it('prevents multiple timeouts from being created', () => { + const opacityValue = createAnimatedValue(0); + const { rerender } = render( + , + ); + + // Trigger multiple updates + rerender( + , + ); + + jest.advanceTimersByTime(100); + + // Only one dismiss should fire + expect(defaultProps.onTransitionToEmpty).not.toHaveBeenCalled(); + }); + + it('clears all pending timers on unmount', () => { + const { unmount } = render(); + + // Schedule multiple operations + jest.advanceTimersByTime(100); + unmount(); + + // Clear any pending timers + jest.clearAllTimers(); + + expect(defaultProps.onTransitionToEmpty).not.toHaveBeenCalled(); + }); + }); + + describe('component rendering with animation setup', () => { + it('renders empty state text correctly', () => { + const { getByText } = render(); + + expect(getByText("You're all caught up!")).toBeDefined(); + }); + + it('unmounts without throwing errors', () => { + const { unmount } = render(); + + expect(() => unmount()).not.toThrow(); }); }); }); diff --git a/app/components/UI/Carousel/StackCardEmpty/StackCardEmpty.tsx b/app/components/UI/Carousel/StackCardEmpty/StackCardEmpty.tsx index edcee874f78e..1839a6f5e7d9 100644 --- a/app/components/UI/Carousel/StackCardEmpty/StackCardEmpty.tsx +++ b/app/components/UI/Carousel/StackCardEmpty/StackCardEmpty.tsx @@ -1,5 +1,6 @@ -import React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { Animated, Dimensions } from 'react-native'; +import Rive, { Alignment, Fit, RiveRef } from 'rive-react-native'; import { Box, Text, @@ -11,11 +12,21 @@ import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { ANIMATION_TIMINGS } from '../animations/animationTimings'; import { StackCardEmptyProps } from './StackCardEmpty.types'; import { strings } from '../../../../../locales/i18n'; +import CarouselConfetti from '../../../../animations/Carousel_Confetti.riv'; const BANNER_HEIGHT = 100; const SCREEN_WIDTH = Dimensions.get('window').width; const BANNER_WIDTH = SCREEN_WIDTH - 32; +// Opacity threshold at which to trigger the confetti animation +// Set to 0.95 instead of 1.0 to account for animation rounding and ensure +// the animation fires reliably as the card reaches full visibility +const OPACITY_TRIGGER_THRESHOLD = 0.95; + +// Delay before triggering the confetti animation after opacity reaches threshold +// This delay ensures the Rive component has fully loaded and is ready to fire animations +const CONFETTI_TRIGGER_DELAY = 50; + export const StackCardEmpty: React.FC = ({ emptyStateOpacity, emptyStateScale, @@ -24,9 +35,52 @@ export const StackCardEmpty: React.FC = ({ onTransitionToEmpty, }) => { const tw = useTailwind(); + const riveRef = useRef(null); + const hasTriggeredAnimation = useRef(false); + const timeoutIdRef = useRef(null); + const [riveError, setRiveError] = useState(false); + + // Fire the confetti animation when the card transitions to full visibility (becomes current) + useEffect(() => { + // Use animated value listener to detect when opacity reaches ~1 (fully visible) + const listenerId = emptyStateOpacity.addListener(({ value }) => { + // Trigger animation when opacity is close to 1 (card is fully visible/current) + if ( + value >= OPACITY_TRIGGER_THRESHOLD && + !hasTriggeredAnimation.current + ) { + // Clear any existing timeout before creating a new one + if (timeoutIdRef.current) { + clearTimeout(timeoutIdRef.current); + } + + timeoutIdRef.current = setTimeout(() => { + if (riveRef.current && !hasTriggeredAnimation.current) { + try { + // Fire the Confetti state machine with "Start" trigger + riveRef.current.fireState('Confetti', 'Start'); + hasTriggeredAnimation.current = true; + } catch (error) { + console.warn('Error triggering Rive confetti animation:', error); + } + } + timeoutIdRef.current = null; + }, CONFETTI_TRIGGER_DELAY); + } + }); + + return () => { + emptyStateOpacity.removeListener(listenerId); + // Clear any pending timeout when the effect cleanup runs + if (timeoutIdRef.current) { + clearTimeout(timeoutIdRef.current); + timeoutIdRef.current = null; + } + }; + }, [emptyStateOpacity]); - // Auto-dismiss empty card after 1000ms when rendered - React.useEffect(() => { + // Auto-dismiss empty card after 2000ms when rendered + useEffect(() => { if (onTransitionToEmpty) { const timer = setTimeout(() => { onTransitionToEmpty(); @@ -44,7 +98,7 @@ export const StackCardEmpty: React.FC = ({ { scale: emptyStateScale }, { translateY: emptyStateTranslateY }, ], - zIndex: 2, // Same as next card + zIndex: 2, })} > = ({ }, )} > + {/* Confetti animation background layer */} + {!riveError && ( + + { + console.warn('Rive animation failed to load:', error); + setRiveError(true); + }} + /> + + )} + {/* Animated pressed background overlay */} - + + {/* Text content layer on top */} + { }; }); -jest.mock('./animationTimings', () => ({ - ANIMATION_TIMINGS: { - EMPTY_STATE_IDLE_TIME: 500, - EMPTY_STATE_FADE_DURATION: 200, - EMPTY_STATE_FOLD_DELAY: 50, - EMPTY_STATE_FOLD_DURATION: 200, - EMPTY_STATE_HEIGHT_DURATION: 300, - }, -})); - describe('useTransitionToEmpty', () => { beforeEach(() => { jest.clearAllMocks(); @@ -125,8 +115,8 @@ describe('useTransitionToEmpty', () => { const transitionPromise = result.current.executeTransition(mockCallback); - // Fast-forward timers - jest.advanceTimersByTime(500); + // Fast-forward timers to complete the idle timeout + jest.runAllTimers(); await expect(transitionPromise).resolves.toBeUndefined(); expect(mockCallback).toHaveBeenCalled(); diff --git a/app/components/UI/Carousel/animations/useTransitionToNextCard.test.ts b/app/components/UI/Carousel/animations/useTransitionToNextCard.test.ts index 4d241fc07af4..6107f90b86a3 100644 --- a/app/components/UI/Carousel/animations/useTransitionToNextCard.test.ts +++ b/app/components/UI/Carousel/animations/useTransitionToNextCard.test.ts @@ -66,14 +66,6 @@ jest.mock('react-native', () => { }; }); -jest.mock('./animationTimings', () => ({ - ANIMATION_TIMINGS: { - CARD_EXIT_DURATION: 300, - CARD_ENTER_DELAY: 100, - CARD_ENTER_DURATION: 250, - }, -})); - describe('useTransitionToNextCard', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/app/components/UI/Carousel/index.tsx b/app/components/UI/Carousel/index.tsx index 003a78e33541..b0f08f2b1faf 100644 --- a/app/components/UI/Carousel/index.tsx +++ b/app/components/UI/Carousel/index.tsx @@ -148,6 +148,9 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { const isAnimating = useRef(false); + // Ref to track if we're mid-animation on the last card + const dismissingLastCardRef = useRef(false); + // Animation hooks const transitionToNextCard = useTransitionToNextCard({ currentCardOpacity, @@ -236,8 +239,15 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { const regular = orderByCardPlacement(regularContentfulSlides.map(patch)); slides = [...priority, ...regular]; - // Always add empty card as the last card - if (slides.length > 0) { + // Check if there are any non-dismissed slides (or if we're in the final dismissal flow) + const hasNonDismissedSlides = slides.some( + (s) => !dismissedBanners.includes(s.id), + ); + const shouldAddEmpty = + hasNonDismissedSlides || dismissingLastCardRef.current; + + // Add empty card only if there are non-dismissed slides or during dismissal animation + if (shouldAddEmpty && slides.length > 0) { const emptyCard: CarouselSlide = { id: `empty-card-${Date.now()}`, title: '', @@ -258,6 +268,7 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { isZeroBalance, priorityContentfulSlides, regularContentfulSlides, + dismissedBanners, ]); const visibleSlides = useMemo(() => { @@ -276,6 +287,15 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { return !dismissedBanners.includes(slide.id); }); + + // If we're in the middle of dismissing the last card, + // keep the empty card in visibleSlides so the animation completes + if (dismissingLastCardRef.current && filtered.length === 0) { + // Re-add the empty card so the animation completes + const emptyCards = slidesConfig.filter((s) => s.variableName === 'empty'); + return emptyCards.length > 0 ? emptyCards : []; + } + return filtered.slice(0, MAX_CAROUSEL_SLIDES); }, [ slidesConfig, @@ -408,30 +428,53 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { isAnimating.current = true; setIsTransitioning(true); + // Check if next card is the empty card (last non-empty slide being dismissed) + const isNextCardEmpty = nextSlide?.variableName === 'empty'; + + // Set flag to keep empty card visible during dismissal animation + if (isNextCardEmpty) { + dismissingLastCardRef.current = true; + } + try { await transitionToNextCard.executeTransition('nextCard'); - // After animation, dismiss banner and reset + // After animation, dismiss banner immediately so Redux knows it's gone dispatch(dismissBanner(slideId)); - // Set up new next card if there will be one + // Set up animations based on what's next requestAnimationFrame(() => { - if (safeActiveSlideIndex < visibleSlides.length - 2) { - nextCardOpacity.setValue(0.7); + if (isNextCardEmpty) { + // Empty card is now current - set it to full visibility + currentCardOpacity.setValue(1); + currentCardScale.setValue(1); + currentCardTranslateY.setValue(0); + + // No next card after empty + nextCardOpacity.setValue(0); nextCardScale.setValue(0.96); nextCardTranslateY.setValue(8); - nextCardBgOpacity.setValue(1); - } + nextCardBgOpacity.setValue(0); + } else { + // Regular transition - set up new next card if there will be one + if (safeActiveSlideIndex < visibleSlides.length - 2) { + nextCardOpacity.setValue(0.7); + nextCardScale.setValue(0.96); + nextCardTranslateY.setValue(8); + nextCardBgOpacity.setValue(1); + } - currentCardOpacity.setValue(1); - currentCardScale.setValue(1); - currentCardTranslateY.setValue(0); + currentCardOpacity.setValue(1); + currentCardScale.setValue(1); + currentCardTranslateY.setValue(0); + } setIsTransitioning(false); isAnimating.current = false; }); } catch (error) { console.error('Transition to next card failed:', error); + dismissingLastCardRef.current = false; setIsTransitioning(false); isAnimating.current = false; } @@ -441,6 +484,7 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { dispatch, safeActiveSlideIndex, visibleSlides.length, + nextSlide, currentCardOpacity, currentCardScale, currentCardTranslateY, @@ -459,6 +503,12 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { try { // Trigger empty state component (fold-up and remove carousel) await transitionToEmpty.executeTransition(() => { + // Reset the flag here to indicate that the last card has finished dismissing. + // This must happen inside the transition callback to ensure the animation and + // state are synchronized. If this flag were not reset at this point, future + // transitions to the empty state would be blocked, causing the carousel to get + // stuck and preventing further dismissals or animations. + dismissingLastCardRef.current = false; onEmptyState?.(); setIsCarouselVisible(false); }); @@ -466,6 +516,7 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { isAnimating.current = false; } catch (error) { console.error('Transition to empty failed:', error); + dismissingLastCardRef.current = false; isAnimating.current = false; } }, [transitionToEmpty, onEmptyState]); @@ -505,7 +556,6 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { nextCardBgOpacity={nextCardBgOpacity} onSlideClick={handleSlideClick} onTransitionToNextCard={() => handleTransitionToNextCard(slide.id)} - onTransitionToEmpty={() => handleTransitionToEmpty()} /> ); }, diff --git a/app/components/UI/Earn/Views/EarnInputView/__snapshots__/EarnInputView.test.tsx.snap b/app/components/UI/Earn/Views/EarnInputView/__snapshots__/EarnInputView.test.tsx.snap index 6ad27be20054..8c0f524f1194 100644 --- a/app/components/UI/Earn/Views/EarnInputView/__snapshots__/EarnInputView.test.tsx.snap +++ b/app/components/UI/Earn/Views/EarnInputView/__snapshots__/EarnInputView.test.tsx.snap @@ -894,7 +894,7 @@ exports[`EarnInputView render matches snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -946,7 +946,7 @@ exports[`EarnInputView render matches snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -998,7 +998,7 @@ exports[`EarnInputView render matches snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1064,7 +1064,7 @@ exports[`EarnInputView render matches snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1116,7 +1116,7 @@ exports[`EarnInputView render matches snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1168,7 +1168,7 @@ exports[`EarnInputView render matches snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1234,7 +1234,7 @@ exports[`EarnInputView render matches snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1286,7 +1286,7 @@ exports[`EarnInputView render matches snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1338,7 +1338,7 @@ exports[`EarnInputView render matches snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1404,7 +1404,7 @@ exports[`EarnInputView render matches snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1458,7 +1458,7 @@ exports[`EarnInputView render matches snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1511,7 +1511,7 @@ exports[`EarnInputView render matches snapshot 1`] = ` { "alignItems": "center", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -2493,7 +2493,7 @@ exports[`EarnInputView when values are entered in the keypad updates ETH and fia "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -2545,7 +2545,7 @@ exports[`EarnInputView when values are entered in the keypad updates ETH and fia "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -2597,7 +2597,7 @@ exports[`EarnInputView when values are entered in the keypad updates ETH and fia "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -2663,7 +2663,7 @@ exports[`EarnInputView when values are entered in the keypad updates ETH and fia "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -2715,7 +2715,7 @@ exports[`EarnInputView when values are entered in the keypad updates ETH and fia "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -2767,7 +2767,7 @@ exports[`EarnInputView when values are entered in the keypad updates ETH and fia "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -2833,7 +2833,7 @@ exports[`EarnInputView when values are entered in the keypad updates ETH and fia "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -2885,7 +2885,7 @@ exports[`EarnInputView when values are entered in the keypad updates ETH and fia "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -2937,7 +2937,7 @@ exports[`EarnInputView when values are entered in the keypad updates ETH and fia "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -3003,7 +3003,7 @@ exports[`EarnInputView when values are entered in the keypad updates ETH and fia "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -3057,7 +3057,7 @@ exports[`EarnInputView when values are entered in the keypad updates ETH and fia "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -3110,7 +3110,7 @@ exports[`EarnInputView when values are entered in the keypad updates ETH and fia { "alignItems": "center", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, diff --git a/app/components/UI/Earn/Views/EarnWithdrawInputView/__snapshots__/EarnWithdrawInputView.test.tsx.snap b/app/components/UI/Earn/Views/EarnWithdrawInputView/__snapshots__/EarnWithdrawInputView.test.tsx.snap index f8e7fcb528e3..82bcd49c3067 100644 --- a/app/components/UI/Earn/Views/EarnWithdrawInputView/__snapshots__/EarnWithdrawInputView.test.tsx.snap +++ b/app/components/UI/Earn/Views/EarnWithdrawInputView/__snapshots__/EarnWithdrawInputView.test.tsx.snap @@ -780,7 +780,7 @@ exports[`EarnWithdrawInputView render matches snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -832,7 +832,7 @@ exports[`EarnWithdrawInputView render matches snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -884,7 +884,7 @@ exports[`EarnWithdrawInputView render matches snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -950,7 +950,7 @@ exports[`EarnWithdrawInputView render matches snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1002,7 +1002,7 @@ exports[`EarnWithdrawInputView render matches snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1054,7 +1054,7 @@ exports[`EarnWithdrawInputView render matches snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1120,7 +1120,7 @@ exports[`EarnWithdrawInputView render matches snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1172,7 +1172,7 @@ exports[`EarnWithdrawInputView render matches snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1224,7 +1224,7 @@ exports[`EarnWithdrawInputView render matches snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1290,7 +1290,7 @@ exports[`EarnWithdrawInputView render matches snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1344,7 +1344,7 @@ exports[`EarnWithdrawInputView render matches snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1397,7 +1397,7 @@ exports[`EarnWithdrawInputView render matches snapshot 1`] = ` { "alignItems": "center", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, diff --git a/app/components/UI/FundActionMenu/FundActionMenu.test.tsx b/app/components/UI/FundActionMenu/FundActionMenu.test.tsx index 512c67619368..9b0130b242a1 100644 --- a/app/components/UI/FundActionMenu/FundActionMenu.test.tsx +++ b/app/components/UI/FundActionMenu/FundActionMenu.test.tsx @@ -14,6 +14,7 @@ import { createDepositNavigationDetails } from '../Ramp/Deposit/routes/utils'; import { useMetrics } from '../../hooks/useMetrics'; import useRampNetwork from '../Ramp/Aggregator/hooks/useRampNetwork'; import useDepositEnabled from '../Ramp/Deposit/hooks/useDepositEnabled'; +import useRampsUnifiedV1Enabled from '../Ramp/hooks/useRampsUnifiedV1Enabled'; import { trace, TraceName } from '../../../util/trace'; import FundActionMenu from './FundActionMenu'; @@ -54,6 +55,7 @@ jest.mock('react-redux'); jest.mock('../../hooks/useMetrics'); jest.mock('../Ramp/Aggregator/hooks/useRampNetwork'); jest.mock('../Ramp/Deposit/hooks/useDepositEnabled'); +jest.mock('../Ramp/hooks/useRampsUnifiedV1Enabled'); jest.mock('../../../util/trace'); jest.mock('../../../util/networks', () => ({ getDecimalChainId: jest.fn(), @@ -79,6 +81,10 @@ const mockUseRampNetwork = useRampNetwork as jest.MockedFunction< const mockUseDepositEnabled = useDepositEnabled as jest.MockedFunction< typeof useDepositEnabled >; +const mockUseRampsUnifiedV1Enabled = + useRampsUnifiedV1Enabled as jest.MockedFunction< + typeof useRampsUnifiedV1Enabled + >; const mockTrace = trace as jest.MockedFunction; const { getDecimalChainId } = jest.requireMock('../../../util/networks'); const { createBuyNavigationDetails, createSellNavigationDetails } = @@ -128,6 +134,7 @@ describe('FundActionMenu', () => { mockUseRampNetwork.mockReturnValue([true, true]); mockUseDepositEnabled.mockReturnValue({ isDepositEnabled: true }); + mockUseRampsUnifiedV1Enabled.mockReturnValue(false); getDecimalChainId.mockReturnValue(1); createBuyNavigationDetails.mockReturnValue(['BuyScreen', {}] as never); createSellNavigationDetails.mockReturnValue(['SellScreen', {}] as never); @@ -221,7 +228,7 @@ describe('FundActionMenu', () => { }); it('renders all buttons when all features are enabled', () => { - const { getByTestId } = render(); + const { getByTestId, queryByTestId } = render(); expect( getByTestId(WalletActionsBottomSheetSelectorsIDs.DEPOSIT_BUTTON), @@ -232,6 +239,20 @@ describe('FundActionMenu', () => { expect( getByTestId(WalletActionsBottomSheetSelectorsIDs.SELL_BUTTON), ).toBeOnTheScreen(); + // Unified buy button not shown when hook returns false + expect( + queryByTestId(WalletActionsBottomSheetSelectorsIDs.BUY_UNIFIED_BUTTON), + ).toBeNull(); + }); + + it('renders unified buy button when useRampsUnifiedV1Enabled returns true', () => { + mockUseRampsUnifiedV1Enabled.mockReturnValue(true); + + const { getByTestId } = render(); + + expect( + getByTestId(WalletActionsBottomSheetSelectorsIDs.BUY_UNIFIED_BUTTON), + ).toBeOnTheScreen(); }); }); @@ -279,6 +300,20 @@ describe('FundActionMenu', () => { expect(sellButton.props.accessibilityState.disabled).toBe(true); }); + + it('calls same navigation as buy button when unified buy button is pressed', async () => { + mockUseRampsUnifiedV1Enabled.mockReturnValue(true); + + const { getByTestId } = render(); + + fireEvent.press( + getByTestId(WalletActionsBottomSheetSelectorsIDs.BUY_UNIFIED_BUTTON), + ); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('BuyScreen', {}); + }); + }); }); describe('Navigation Behavior with Route Params', () => { diff --git a/app/components/UI/FundActionMenu/FundActionMenu.tsx b/app/components/UI/FundActionMenu/FundActionMenu.tsx index ee660bb91f4b..7aae2e623399 100644 --- a/app/components/UI/FundActionMenu/FundActionMenu.tsx +++ b/app/components/UI/FundActionMenu/FundActionMenu.tsx @@ -34,6 +34,7 @@ import type { ActionConfig, } from './FundActionMenu.types'; import { getDetectedGeolocation } from '../../../reducers/fiatOrders'; +import useRampsUnifiedV1Enabled from '../Ramp/hooks/useRampsUnifiedV1Enabled'; const FundActionMenu = () => { const sheetRef = useRef(null); @@ -50,6 +51,7 @@ const FundActionMenu = () => { const { trackEvent, createEventBuilder } = useMetrics(); const canSignTransactions = useSelector(selectCanSignTransactions); const rampGeodetectedRegion = useSelector(getDetectedGeolocation); + const rampUnifiedV1Enabled = useRampsUnifiedV1Enabled(); const closeBottomSheetAndNavigate = useCallback( (navigateFunc: () => void) => { @@ -63,7 +65,10 @@ const FundActionMenu = () => { closeBottomSheetAndNavigate(config.navigationAction); // Special handling for buy action with custom onBuy - if (config.type === 'buy' && customOnBuy) { + if ( + (config.type === 'buy' || config.type === 'buy-unified') && + customOnBuy + ) { return; // Skip analytics for custom onBuy } @@ -102,13 +107,42 @@ const FundActionMenu = () => { const actionConfigs: ActionConfig[] = useMemo( () => [ + { + type: 'buy-unified', + label: strings('fund_actionmenu.buy_unified'), + description: strings('fund_actionmenu.buy_unified_description'), + iconName: IconName.Add, + testID: WalletActionsBottomSheetSelectorsIDs.BUY_UNIFIED_BUTTON, + isVisible: rampUnifiedV1Enabled, + analyticsEvent: MetaMetricsEvents.BUY_BUTTON_CLICKED, + analyticsProperties: { + text: 'Buy', + location: 'FundActionMenu', + chain_id_destination: getChainIdForAsset(), + region: rampGeodetectedRegion, + }, + // TODO: Using same action for now, replace with go to buy action + navigationAction: () => { + if (customOnBuy) { + customOnBuy(); + } else if (assetContext) { + navigate( + ...createBuyNavigationDetails({ + assetId: assetContext.assetId, + }), + ); + } else { + navigate(...createBuyNavigationDetails()); + } + }, + }, { type: 'deposit', label: strings('fund_actionmenu.deposit'), description: strings('fund_actionmenu.deposit_description'), iconName: IconName.Money, testID: WalletActionsBottomSheetSelectorsIDs.DEPOSIT_BUTTON, - isVisible: isDepositEnabled, + isVisible: isDepositEnabled && !rampUnifiedV1Enabled, analyticsEvent: MetaMetricsEvents.RAMPS_BUTTON_CLICKED, analyticsProperties: { text: 'Deposit', @@ -126,7 +160,7 @@ const FundActionMenu = () => { description: strings('fund_actionmenu.buy_description'), iconName: IconName.Add, testID: WalletActionsBottomSheetSelectorsIDs.BUY_BUTTON, - isVisible: true, + isVisible: !rampUnifiedV1Enabled, analyticsEvent: MetaMetricsEvents.BUY_BUTTON_CLICKED, analyticsProperties: { text: 'Buy', @@ -172,11 +206,12 @@ const FundActionMenu = () => { ] as ActionConfig[], [ isDepositEnabled, - isNetworkRampSupported, - rampGeodetectedRegion, - canSignTransactions, + rampUnifiedV1Enabled, chainId, + rampGeodetectedRegion, getChainIdForAsset, + isNetworkRampSupported, + canSignTransactions, navigate, customOnBuy, assetContext, diff --git a/app/components/UI/FundActionMenu/FundActionMenu.types.ts b/app/components/UI/FundActionMenu/FundActionMenu.types.ts index d7501f29dd40..df311d6c37f0 100644 --- a/app/components/UI/FundActionMenu/FundActionMenu.types.ts +++ b/app/components/UI/FundActionMenu/FundActionMenu.types.ts @@ -18,7 +18,7 @@ export type FundActionMenuRouteProp = RouteProp< >; export interface ActionConfig { - type: 'deposit' | 'buy' | 'sell'; + type: 'deposit' | 'buy' | 'sell' | 'buy-unified'; label: string; description: string; iconName: IconName; diff --git a/app/components/UI/Perps/PERPS_ARCH.md b/app/components/UI/Perps/PERPS_ARCH.md deleted file mode 100644 index 0419bf8a0ef8..000000000000 --- a/app/components/UI/Perps/PERPS_ARCH.md +++ /dev/null @@ -1,209 +0,0 @@ -# Perps Architecture - -## Hooks - Categorized to prevent duplication - -### Controller Access - -- `usePerpsTrading` - Trading ops (place/cancel/close) -- `usePerpsDeposit` - Deposit flow -- `usePerpsDepositQuote` - Deposit quotes -- `usePerpsMarkets` - Market data -- `usePerpsNetwork` - Network config -- `usePerpsWithdrawQuote` - Withdrawal quotes - -### State Management - -- `usePerpsAccount` - Redux account state -- `usePerpsConnection` - Connection provider -- `usePerpsPositions` - Position list -- `usePerpsNetworkConfig` - Network state - -### Live Data (Stream Architecture) - -- `useLivePrices` - Real-time prices with component-level debouncing (NEW) -- `usePerpsPrices` - Legacy real-time prices (being deprecated) -- `usePerpsPositionData` - Position updates -- Future: `useLiveOrders`, `useLivePositions`, `useLiveFills` - -### Calculations - -- `usePerpsLiquidationPrice` - Liquidation calc -- `usePerpsOrderFees` - Fee calc -- `useMinimumOrderAmount` - Min order calc -- `usePerpsMarketData` - Market-specific data -- `usePerpsMarketStats` - Market statistics - -### Validation (Protocol + UI) - -- `usePerpsOrderValidation` - Order validation -- `usePerpsClosePositionValidation` - Close validation -- `useWithdrawValidation` - Withdrawal validation - -### Form Management - -- `usePerpsOrderForm` - Order form state -- `usePerpsOrderExecution` - Order execution flow -- `usePerpsClosePosition` - Close position flow -- `usePerpsTPSLUpdate` - TP/SL updates - -### UI Utilities - -- `useColorPulseAnimation` - Animations -- `useBalanceComparison` - Balance compare -- `useHasExistingPosition` - Position check -- `useStableArray` - Array stability - -### Assets/Tokens - -- `usePerpsAssetMetadata` - Asset metadata -- `usePerpsPaymentTokens` - Payment tokens -- `useWithdrawTokens` - Withdrawal tokens - -### Special Purpose - -- `usePerpsEligibility` - User eligibility check - -## Duplication Prevention - -Before creating a new hook: - -1. Check existing hooks in relevant category -2. Consider composing existing hooks -3. Follow naming: `usePerps[Feature][Action]` -4. Keep single responsibility - -## Stream Architecture (WebSocket Management) - -### Overview - -Single WebSocket subscriptions shared across all components with component-level debouncing. This prevents subscription interference and reduces WebSocket connections by 90%. - -### WebSocket Pre-warming (Persistent Connections) - -Pre-warming creates persistent subscriptions that stay alive throughout the Perps session: - -- **Problem**: WebSocket subscriptions start on-demand, causing ~10 second delays before data arrives -- **Solution**: Create persistent subscriptions with no-op callbacks when entering Perps environment -- **Implementation**: - - `prewarm()` creates actual subscriptions that keep connections alive - - `PerpsConnectionManager` stores cleanup functions and only calls them when leaving Perps -- **Result**: Connections stay alive, cache continuously populated, instant data for all components - -### Single WebSocket Connection Architecture - -To minimize network overhead and ensure data consistency: - -- **Shared webData2**: Single subscription provides both positions (with TP/SL) and orders data -- **Reference Counting**: Tracks subscriber count to maintain connection while needed -- **Automatic Cleanup**: Disconnects when last subscriber unsubscribes -- **Result**: One WebSocket connection per data type instead of per component - -### Provider Setup - -- `PerpsStreamProvider` wraps all routes in `/routes/index.tsx` -- Provides access to stream channels without holding state -- No re-renders propagated to parent components -- `PerpsConnectionManager` pre-loads critical subscriptions on connection - -### Stream Hooks - -Located in `/hooks/stream/`: - -```typescript -// Each component sets its own update rate -const prices = useLivePrices({ - symbols: ['BTC', 'ETH'], - throttleMs: 10000, // 10s for order view -}); -``` - -Available hooks: - -- `useLivePrices(options)` - Real-time prices with custom throttle -- `useLiveOrders(options)` - Order updates (future) -- `useLivePositions(options)` - Position updates (future) -- `useLiveFills(options)` - Fill notifications (future) - -### Benefits - -- **90% fewer WebSocket connections** - Single subscription per data type -- **No subscription interference** - Each component controls its rate -- **Component-level control** - Different rates for different views -- **Instant first render** - Cached data available immediately -- **Zero parent re-renders** - Updates go directly to subscribers -- **No empty initial states** - Pre-warmed subscriptions provide data immediately - -### Migration Path - -1. Replace `usePerpsPrices` with `useLivePrices` -2. Set appropriate throttle for each view: - - Order entry: 10000ms (stable prices) - - Market list: 2000ms (responsive updates) - - Market details: 500ms (near real-time) - - Charts: 100ms (smooth animations) - -## Architecture Layers - -``` -┌─────────────────────────────────────┐ -│ Components (UI) │ -├─────────────────────────────────────┤ -│ Hooks (React) │ -├─────────────────────────────────────┤ -│ Stream Manager (WebSocket) │ <- NEW LAYER -├─────────────────────────────────────┤ -│ Connection Manager (Pre-warming) │ <- NEW LAYER -├─────────────────────────────────────┤ -│ Controller (Business) │ -├─────────────────────────────────────┤ -│ Provider (Protocol) │ -└─────────────────────────────────────┘ -``` - -## Key Patterns - -### Validation Flow - -Provider validation (protocol rules) → Hook adds UI rules → Component displays errors - -### Data Flow - -Controller → Redux Store → Hooks → Components - -### Real-time Updates - -WebSocket → Controller → Redux → Hooks with subscription - -### Form Management - -Component input → Hook state → Validation → Controller action - -## Quick Hook Selection Guide - -| Need | Use Hook | -| -------------- | ----------------------------------------------------------- | -| Place order | `usePerpsTrading` + `usePerpsOrderExecution` | -| Validate order | `usePerpsOrderValidation` | -| Get prices | `useLivePrices` (NEW) or `usePerpsPrices` (legacy) | -| Manage form | `usePerpsOrderForm` | -| Calculate fees | `usePerpsOrderFees` | -| Check position | `useHasExistingPosition` | -| Close position | `usePerpsClosePosition` + `usePerpsClosePositionValidation` | -| Get account | `usePerpsAccount` | -| Deposit funds | `usePerpsDeposit` | -| Withdraw funds | `usePerpsWithdrawQuote` + `useWithdrawValidation` | - -## File Structure - -``` -/Perps -├── /components # UI components -├── /controllers # Business logic -├── /hooks # React integration (30+ hooks) -│ └── /stream # WebSocket stream hooks (NEW) -├── /providers # Protocol implementations -│ └── PerpsStreamManager.tsx # WebSocket manager (NEW) -├── /utils # Helper functions -├── /constants # Config values -└── /types # TypeScript definitions -``` diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx index 8714eb092f66..570aa704e33d 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx @@ -218,6 +218,7 @@ jest.mock('../../hooks/usePerpsPositionData', () => ({ isLoadingHistory: false, error: null, refreshCandleData: mockRefreshCandleData, + hasHistoricalData: true, }), })); @@ -1422,8 +1423,8 @@ describe('PerpsMarketDetailsView', () => { }, ); - // Find and press the Trading View link - const tradingViewLink = getByText('Trading View.'); + // Find and press the TradingView link + const tradingViewLink = getByText('TradingView.'); fireEvent.press(tradingViewLink); // Verify Linking.openURL was called with correct URL @@ -1452,8 +1453,8 @@ describe('PerpsMarketDetailsView', () => { }, ); - // Find and press the Trading View link - const tradingViewLink = getByText('Trading View.'); + // Find and press the TradingView link + const tradingViewLink = getByText('TradingView.'); fireEvent.press(tradingViewLink); // Wait for the error to be logged diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx index d7c2367d638b..f7ff4304adcf 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx @@ -93,6 +93,8 @@ import { useConfirmNavigation } from '../../../../Views/confirmations/hooks/useC import Engine from '../../../../../core/Engine'; import { setPerpsChartPreferredCandlePeriod } from '../../../../../actions/settings'; import { selectPerpsChartPreferredCandlePeriod } from '../../selectors/chartPreferences'; +import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; + interface MarketDetailsRouteParams { market: PerpsMarketData; initialTab?: PerpsTabId; @@ -322,7 +324,7 @@ const PerpsMarketDetailsView: React.FC = () => { // Get comprehensive market statistics const marketStats = usePerpsMarketStats(market?.symbol || ''); - const { candleData, isLoadingHistory, refreshCandleData } = + const { candleData, isLoadingHistory, refreshCandleData, hasHistoricalData } = usePerpsPositionData({ coin: market?.symbol || '', selectedDuration: TimeDuration.YEAR_TO_DATE, @@ -336,6 +338,29 @@ const PerpsMarketDetailsView: React.FC = () => { loadOnMount: true, }); + // Compute TP/SL lines for the chart based on existing position and selected orders + const tpslLines = useMemo(() => { + if (existingPosition) { + return { + entryPrice: existingPosition.entryPrice, + takeProfitPrice: + selectedOrderTPSL.takeProfitPrice || existingPosition.takeProfitPrice, + stopLossPrice: + selectedOrderTPSL.stopLossPrice || existingPosition.stopLossPrice, + liquidationPrice: existingPosition.liquidationPrice || undefined, + }; + } + + if (selectedOrderTPSL.takeProfitPrice || selectedOrderTPSL.stopLossPrice) { + return { + takeProfitPrice: selectedOrderTPSL.takeProfitPrice, + stopLossPrice: selectedOrderTPSL.stopLossPrice, + }; + } + + return undefined; + }, [existingPosition, selectedOrderTPSL]); + // Track Perps asset screen load performance with simplified API usePerpsMeasurement({ traceName: TraceName.PerpsPositionDetailsView, @@ -566,6 +591,14 @@ const PerpsMarketDetailsView: React.FC = () => { return status.isOpen ? 'market_hours' : 'after_hours_trading'; })(); + // Determine risk disclaimer source and HIP type based on market + const riskDisclaimerParams = useMemo(() => { + const isHip3 = !!market?.marketSource; + return { + source: isHip3 ? market.marketSource : 'Hyperliquid', + }; + }, [market?.marketSource]); + // Determine if any action buttons will be visible const hasLongShortButtons = useMemo( () => !isLoadingPosition && !hasZeroBalance, @@ -641,34 +674,22 @@ const PerpsMarketDetailsView: React.FC = () => { > {/* TradingView Chart Section */} - + {hasHistoricalData ? ( + + ) : ( + + )} {/* Candle Period Selector */} = () => { variant={TextVariant.BodyXS} color={TextColor.Alternative} > - {strings('perps.risk_disclaimer')}{' '} + {strings('perps.risk_disclaimer', riskDisclaimerParams)}{' '} - Trading View. + TradingView. diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsFundingTransactionView.test.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsFundingTransactionView.test.tsx index 9fad4653961d..42ca71a19436 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsFundingTransactionView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsFundingTransactionView.test.tsx @@ -26,7 +26,6 @@ const mockUseNavigation = jest.fn(); const mockUseRoute = jest.fn(); const mockUsePerpsNetwork = jest.fn(); const mockUsePerpsBlockExplorerUrl = jest.fn(); -const mockGetHyperliquidExplorerUrl = jest.fn(); const mockFormatPerpsFiat = jest.fn(); const mockFormatTransactionDate = jest.fn(); const mockGetPerpsTransactionsDetailsNavbar = jest.fn(); @@ -56,10 +55,6 @@ jest.mock('../../../Navbar', () => ({ mockGetPerpsTransactionsDetailsNavbar(), })); -jest.mock('../../utils/blockchainUtils', () => ({ - getHyperliquidExplorerUrl: () => mockGetHyperliquidExplorerUrl(), -})); - describe('PerpsFundingTransactionView', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/app/components/UI/Perps/components/PerpsCancelAllOrdersModal/PerpsCancelAllOrdersModal.styles.ts b/app/components/UI/Perps/components/PerpsCancelAllOrdersModal/PerpsCancelAllOrdersModal.styles.ts deleted file mode 100644 index f156008e8bc9..000000000000 --- a/app/components/UI/Perps/components/PerpsCancelAllOrdersModal/PerpsCancelAllOrdersModal.styles.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { StyleSheet } from 'react-native'; -import type { Theme } from '../../../../../util/theme/models'; - -const styleSheet = (_params: { theme: Theme }) => - StyleSheet.create({ - contentContainer: { - paddingHorizontal: 16, - paddingVertical: 24, - minHeight: 100, - }, - loadingContainer: { - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 16, - }, - loadingText: { - marginTop: 16, - }, - footerContainer: { - paddingTop: 16, - }, - }); - -export default styleSheet; diff --git a/app/components/UI/Perps/components/PerpsCancelAllOrdersModal/PerpsCancelAllOrdersModal.test.tsx b/app/components/UI/Perps/components/PerpsCancelAllOrdersModal/PerpsCancelAllOrdersModal.test.tsx deleted file mode 100644 index 45b9a75034b3..000000000000 --- a/app/components/UI/Perps/components/PerpsCancelAllOrdersModal/PerpsCancelAllOrdersModal.test.tsx +++ /dev/null @@ -1,401 +0,0 @@ -import React from 'react'; -import { - render, - screen, - fireEvent, - waitFor, -} from '@testing-library/react-native'; -import PerpsCancelAllOrdersModal from './PerpsCancelAllOrdersModal'; -import Engine from '../../../../../core/Engine'; - -// Mock dependencies -jest.mock('../../../../../core/Engine', () => ({ - context: { - PerpsController: { - cancelOrders: jest.fn(), - }, - }, -})); - -jest.mock('../../hooks/usePerpsToasts', () => ({ - __esModule: true, - default: () => ({ - showToast: jest.fn(), - }), -})); - -jest.mock('expo-haptics', () => ({ - NotificationFeedbackType: { - Success: 'success', - Error: 'error', - }, -})); - -jest.mock('../../../../../component-library/hooks', () => ({ - useStyles: () => ({ - styles: { - contentContainer: {}, - loadingContainer: {}, - loadingText: {}, - footerContainer: {}, - }, - theme: { - colors: { - primary: { default: '#000000' }, - accent03: { normal: '#00FF00', dark: '#008800' }, - accent01: { light: '#FF0000', dark: '#880000' }, - }, - }, - }), -})); - -jest.mock('../../../../hooks/useStyles', () => ({ - useStyles: () => ({ - styles: { - contentContainer: {}, - loadingContainer: {}, - loadingText: {}, - footerContainer: {}, - }, - theme: { - colors: { - primary: { default: '#000000' }, - accent03: { normal: '#00FF00', dark: '#008800' }, - accent01: { light: '#FF0000', dark: '#880000' }, - }, - }, - }), -})); - -jest.mock( - '../../../../../component-library/components/BottomSheets/BottomSheet', - () => { - const MockReact = jest.requireActual('react'); - const { View } = jest.requireActual('react-native'); - return { - __esModule: true, - default: MockReact.forwardRef( - ( - { - children, - }: { - children: React.ReactNode; - }, - ref: React.Ref<{ - onOpenBottomSheet: () => void; - onCloseBottomSheet: () => void; - }>, - ) => { - MockReact.useImperativeHandle(ref, () => ({ - onOpenBottomSheet: jest.fn(), - onCloseBottomSheet: jest.fn(), - })); - - return {children}; - }, - ), - }; - }, -); - -jest.mock( - '../../../../../component-library/components/BottomSheets/BottomSheetHeader', - () => { - const { View } = jest.requireActual('react-native'); - return function MockBottomSheetHeader({ - children, - }: { - children: React.ReactNode; - }) { - return {children}; - }; - }, -); - -jest.mock( - '../../../../../component-library/components/BottomSheets/BottomSheetFooter', - () => { - const { View, TouchableOpacity, Text } = jest.requireActual('react-native'); - return { - __esModule: true, - default: function MockBottomSheetFooter({ - buttonPropsArray, - }: { - buttonPropsArray: { - label: string; - onPress: () => void; - disabled?: boolean; - }[]; - }) { - return ( - - {buttonPropsArray.map((button, index) => ( - - {button.label} - - ))} - - ); - }, - ButtonsAlignment: { - Horizontal: 'horizontal', - Vertical: 'vertical', - }, - }; - }, -); - -jest.mock('./PerpsCancelAllOrdersModal.styles', () => () => ({})); - -jest.mock('../../../../../../locales/i18n', () => ({ - strings: jest.fn((key: string, options?: Record) => { - const translations: Record = { - 'perps.cancel_all_modal.title': 'Cancel All Orders', - 'perps.cancel_all_modal.description': - 'Are you sure you want to cancel all open orders?', - 'perps.cancel_all_modal.keep_orders': 'Keep Orders', - 'perps.cancel_all_modal.confirm': 'Cancel All', - 'perps.cancel_all_modal.canceling': 'Canceling...', - 'perps.cancel_all_modal.success_title': 'Orders Canceled', - 'perps.cancel_all_modal.success_message': `${options?.count} orders canceled successfully`, - 'perps.cancel_all_modal.partial_success': `${options?.successCount} of ${options?.totalCount} orders canceled`, - 'perps.cancel_all_modal.error_title': 'Cancellation Failed', - 'perps.cancel_all_modal.error_message': `Failed to cancel ${options?.count} orders`, - }; - return translations[key] || key; - }), -})); - -const mockOrders = [ - { - orderId: '1', - symbol: 'BTC', - side: 'buy' as const, - orderType: 'limit' as const, - size: '0.1', - originalSize: '0.1', - price: '50000', - filledSize: '0', - remainingSize: '0.1', - status: 'open' as const, - timestamp: Date.now(), - }, - { - orderId: '2', - symbol: 'ETH', - side: 'sell' as const, - orderType: 'limit' as const, - size: '1.0', - originalSize: '1.0', - price: '3000', - filledSize: '0', - remainingSize: '1.0', - status: 'open' as const, - timestamp: Date.now(), - }, -]; - -describe('PerpsCancelAllOrdersModal', () => { - const mockOnClose = jest.fn(); - const mockOnSuccess = jest.fn(); - const mockCancelOrders = Engine.context.PerpsController - .cancelOrders as jest.Mock; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - describe('Visibility', () => { - it('returns null when isVisible is false', () => { - const { toJSON } = render( - , - ); - - expect(toJSON()).toBeNull(); - }); - - it('renders when isVisible is true', () => { - render( - , - ); - - expect(screen.getByTestId('bottom-sheet')).toBeOnTheScreen(); - expect(screen.getByTestId('bottom-sheet-header')).toBeOnTheScreen(); - expect(screen.getByTestId('bottom-sheet-footer')).toBeOnTheScreen(); - }); - }); - - describe('Button Interactions', () => { - it('renders Keep Orders button', () => { - render( - , - ); - - expect(screen.getByTestId('footer-button-0')).toBeOnTheScreen(); - }); - - it('renders Cancel All button', () => { - render( - , - ); - - expect(screen.getByTestId('footer-button-1')).toBeOnTheScreen(); - }); - - it('calls handleKeepOrders when Keep Orders button is pressed', () => { - render( - , - ); - - fireEvent.press(screen.getByTestId('footer-button-0')); - - // Component should attempt to close the bottom sheet - expect(screen.getByTestId('bottom-sheet')).toBeOnTheScreen(); - }); - }); - - describe('Cancel Orders', () => { - it('calls cancelOrders with cancelAll true when Cancel All button is pressed', async () => { - mockCancelOrders.mockResolvedValue({ - success: true, - successCount: 2, - failureCount: 0, - }); - - render( - , - ); - - fireEvent.press(screen.getByTestId('footer-button-1')); - - await waitFor(() => { - expect(mockCancelOrders).toHaveBeenCalledWith({ cancelAll: true }); - }); - }); - - it('calls onSuccess when all orders are canceled successfully', async () => { - mockCancelOrders.mockResolvedValue({ - success: true, - successCount: 2, - failureCount: 0, - }); - - render( - , - ); - - fireEvent.press(screen.getByTestId('footer-button-1')); - - await waitFor(() => { - expect(mockOnSuccess).toHaveBeenCalled(); - }); - }); - - it('calls onSuccess on partial success', async () => { - mockCancelOrders.mockResolvedValue({ - success: false, - successCount: 1, - failureCount: 1, - }); - - render( - , - ); - - fireEvent.press(screen.getByTestId('footer-button-1')); - - await waitFor(() => { - expect(mockOnSuccess).toHaveBeenCalled(); - }); - }); - - it('does not call onSuccess when all orders fail to cancel', async () => { - mockCancelOrders.mockResolvedValue({ - success: false, - successCount: 0, - failureCount: 2, - }); - - render( - , - ); - - fireEvent.press(screen.getByTestId('footer-button-1')); - - await waitFor(() => { - expect(mockCancelOrders).toHaveBeenCalled(); - }); - - expect(mockOnSuccess).not.toHaveBeenCalled(); - }); - - it('handles error when cancelOrders throws', async () => { - mockCancelOrders.mockRejectedValue(new Error('Network error')); - - render( - , - ); - - fireEvent.press(screen.getByTestId('footer-button-1')); - - await waitFor(() => { - expect(mockCancelOrders).toHaveBeenCalled(); - }); - - expect(mockOnSuccess).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/app/components/UI/Perps/components/PerpsCancelAllOrdersModal/PerpsCancelAllOrdersModal.tsx b/app/components/UI/Perps/components/PerpsCancelAllOrdersModal/PerpsCancelAllOrdersModal.tsx deleted file mode 100644 index 317acdff3663..000000000000 --- a/app/components/UI/Perps/components/PerpsCancelAllOrdersModal/PerpsCancelAllOrdersModal.tsx +++ /dev/null @@ -1,208 +0,0 @@ -import React, { useCallback, useState, useMemo } from 'react'; -import { View, ActivityIndicator } from 'react-native'; -import { NotificationFeedbackType } from 'expo-haptics'; -import BottomSheet, { - BottomSheetRef, -} from '../../../../../component-library/components/BottomSheets/BottomSheet'; -import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader'; -import BottomSheetFooter, { - ButtonsAlignment, -} from '../../../../../component-library/components/BottomSheets/BottomSheetFooter'; -import Text, { - TextColor, - TextVariant, -} from '../../../../../component-library/components/Texts/Text'; -import { - ButtonSize, - ButtonVariants, -} from '../../../../../component-library/components/Buttons/Button'; -import { IconName } from '../../../../../component-library/components/Icons/Icon'; -import { ToastVariants } from '../../../../../component-library/components/Toast/Toast.types'; -import { useStyles } from '../../../../hooks/useStyles'; -import { strings } from '../../../../../../locales/i18n'; -import Engine from '../../../../../core/Engine'; -import createStyles from './PerpsCancelAllOrdersModal.styles'; -import usePerpsToasts, { - type PerpsToastOptions, -} from '../../hooks/usePerpsToasts'; -import type { Order } from '../../controllers/types'; - -interface PerpsCancelAllOrdersModalProps { - isVisible: boolean; - onClose: () => void; - orders: Order[]; - onSuccess?: () => void; -} - -const PerpsCancelAllOrdersModal: React.FC = ({ - isVisible, - onClose, - orders: _orders, - onSuccess, -}) => { - const { styles, theme } = useStyles(createStyles, {}); - const bottomSheetRef = React.useRef(null); - const [isCanceling, setIsCanceling] = useState(false); - const { showToast } = usePerpsToasts(); - - const showSuccessToast = useCallback( - (title: string, message?: string) => { - const toastConfig: PerpsToastOptions = { - variant: ToastVariants.Icon, - iconName: IconName.CheckBold, - backgroundColor: theme.colors.accent03.normal, - iconColor: theme.colors.accent03.dark, - hapticsType: NotificationFeedbackType.Success, - hasNoTimeout: false, - labelOptions: message - ? [ - { label: title, isBold: true }, - { label: '\n', isBold: false }, - { label: message, isBold: false }, - ] - : [{ label: title, isBold: true }], - } as PerpsToastOptions; - showToast(toastConfig); - }, - [showToast, theme.colors.accent03], - ); - - const showErrorToast = useCallback( - (title: string, message?: string) => { - const toastConfig: PerpsToastOptions = { - variant: ToastVariants.Icon, - iconName: IconName.Warning, - backgroundColor: theme.colors.accent01.light, - iconColor: theme.colors.accent01.dark, - hapticsType: NotificationFeedbackType.Error, - hasNoTimeout: false, - labelOptions: message - ? [ - { label: title, isBold: true }, - { label: '\n', isBold: false }, - { label: message, isBold: false }, - ] - : [{ label: title, isBold: true }], - } as PerpsToastOptions; - showToast(toastConfig); - }, - [showToast, theme.colors.accent01], - ); - - const handleConfirm = useCallback(async () => { - setIsCanceling(true); - try { - const result = await Engine.context.PerpsController.cancelOrders({ - cancelAll: true, - }); - - if (result.success && result.successCount > 0) { - showSuccessToast( - strings('perps.cancel_all_modal.success_title'), - strings('perps.cancel_all_modal.success_message', { - count: result.successCount, - }), - ); - onSuccess?.(); - bottomSheetRef.current?.onCloseBottomSheet(); - } else if (result.successCount > 0 && result.failureCount > 0) { - // Partial success - showSuccessToast( - strings('perps.cancel_all_modal.success_title'), - strings('perps.cancel_all_modal.partial_success', { - successCount: result.successCount, - totalCount: result.successCount + result.failureCount, - }), - ); - onSuccess?.(); - bottomSheetRef.current?.onCloseBottomSheet(); - } else { - showErrorToast( - strings('perps.cancel_all_modal.error_title'), - strings('perps.cancel_all_modal.error_message', { - count: result.failureCount, - }), - ); - } - } catch (error) { - showErrorToast( - strings('perps.cancel_all_modal.error_title'), - error instanceof Error ? error.message : 'Unknown error', - ); - } finally { - setIsCanceling(false); - } - }, [showSuccessToast, showErrorToast, onSuccess]); - - const handleKeepOrders = useCallback(() => { - bottomSheetRef.current?.onCloseBottomSheet(); - }, []); - - const footerButtons = useMemo( - () => [ - { - label: strings('perps.cancel_all_modal.keep_orders'), - onPress: handleKeepOrders, - variant: ButtonVariants.Secondary, - size: ButtonSize.Lg, - disabled: isCanceling, - }, - { - label: isCanceling - ? strings('perps.cancel_all_modal.canceling') - : strings('perps.cancel_all_modal.confirm'), - onPress: handleConfirm, - variant: ButtonVariants.Primary, - size: ButtonSize.Lg, - disabled: isCanceling, - }, - ], - [handleKeepOrders, handleConfirm, isCanceling], - ); - - if (!isVisible) return null; - - return ( - - - - {strings('perps.cancel_all_modal.title')} - - - - - {isCanceling ? ( - - - - {strings('perps.cancel_all_modal.canceling')} - - - ) : ( - - {strings('perps.cancel_all_modal.description')} - - )} - - - - - ); -}; - -export default PerpsCancelAllOrdersModal; diff --git a/app/components/UI/Perps/components/PerpsCloseAllPositionsModal/PerpsCloseAllPositionsModal.styles.ts b/app/components/UI/Perps/components/PerpsCloseAllPositionsModal/PerpsCloseAllPositionsModal.styles.ts deleted file mode 100644 index 38a8a44e121b..000000000000 --- a/app/components/UI/Perps/components/PerpsCloseAllPositionsModal/PerpsCloseAllPositionsModal.styles.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { StyleSheet } from 'react-native'; -import type { Theme } from '../../../../../util/theme/models'; - -const styleSheet = (_params: { theme: Theme }) => - StyleSheet.create({ - contentContainer: { - paddingHorizontal: 16, - paddingVertical: 16, - }, - description: { - marginBottom: 24, - }, - loadingContainer: { - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 32, - }, - loadingText: { - marginTop: 16, - }, - footerContainer: { - paddingTop: 16, - }, - }); - -export default styleSheet; diff --git a/app/components/UI/Perps/components/PerpsCloseAllPositionsModal/PerpsCloseAllPositionsModal.test.tsx b/app/components/UI/Perps/components/PerpsCloseAllPositionsModal/PerpsCloseAllPositionsModal.test.tsx deleted file mode 100644 index 91f19000b7cb..000000000000 --- a/app/components/UI/Perps/components/PerpsCloseAllPositionsModal/PerpsCloseAllPositionsModal.test.tsx +++ /dev/null @@ -1,445 +0,0 @@ -import React from 'react'; -import { render, fireEvent, waitFor } from '@testing-library/react-native'; -import PerpsCloseAllPositionsModal from './PerpsCloseAllPositionsModal'; -import Engine from '../../../../../core/Engine'; -import type { Position } from '../../controllers/types'; - -// Mock Engine -jest.mock('../../../../../core/Engine', () => ({ - context: { - PerpsController: { - closePositions: jest.fn(), - }, - }, -})); - -// Mock hooks -jest.mock('../../hooks', () => ({ - usePerpsCloseAllCalculations: jest.fn(), -})); - -jest.mock('../../hooks/stream', () => ({ - usePerpsLivePrices: jest.fn(), -})); - -jest.mock('../../hooks/usePerpsToasts', () => ({ - __esModule: true, - default: jest.fn(), -})); - -jest.mock('../../../../hooks/useStyles', () => ({ - useStyles: () => ({ - styles: { - contentContainer: {}, - description: {}, - loadingContainer: {}, - loadingText: {}, - footerContainer: {}, - }, - theme: { - colors: { - accent03: { normal: '#00ff00', dark: '#008800' }, - accent01: { light: '#ffcccc', dark: '#cc0000' }, - primary: { default: '#0000ff' }, - }, - }, - }), -})); - -jest.mock('../../../../../../locales/i18n', () => ({ - strings: (key: string, params?: Record) => { - if (key === 'perps.close_all_modal.success_message' && params) { - return `Successfully closed ${params.count} position(s)`; - } - if (key === 'perps.close_all_modal.partial_success' && params) { - return `Closed ${params.successCount} of ${params.totalCount} positions`; - } - if (key === 'perps.close_all_modal.error_message' && params) { - return `Failed to close ${params.count} position(s)`; - } - return key; - }, -})); - -// Mock BottomSheet components -jest.mock( - '../../../../../component-library/components/BottomSheets/BottomSheet', - () => { - const mockReact = jest.requireActual('react'); - return mockReact.forwardRef( - (props: { children: React.ReactNode; onClose?: () => void }, _ref) => ( - <>{props.children} - ), - ); - }, -); - -jest.mock( - '../../../../../component-library/components/BottomSheets/BottomSheetHeader', - () => 'BottomSheetHeader', -); - -jest.mock( - '../../../../../component-library/components/BottomSheets/BottomSheetFooter', - () => { - const { View, TouchableOpacity, Text } = jest.requireActual('react-native'); - return { - __esModule: true, - default: ({ - buttonPropsArray, - }: { - buttonPropsArray?: { - label: string; - onPress: () => void; - disabled?: boolean; - }[]; - }) => ( - - {buttonPropsArray?.map((buttonProps, index) => ( - - {buttonProps.label} - - ))} - - ), - ButtonsAlignment: { - Horizontal: 'Horizontal', - }, - }; - }, -); - -jest.mock('../PerpsCloseSummary', () => 'PerpsCloseSummary'); - -const mockUsePerpsCloseAllCalculations = jest.requireMock('../../hooks') - .usePerpsCloseAllCalculations as jest.Mock; -const mockUsePerpsLivePrices = jest.requireMock('../../hooks/stream') - .usePerpsLivePrices as jest.Mock; -const mockUsePerpsToasts = jest.requireMock('../../hooks/usePerpsToasts') - .default as jest.Mock; - -describe('PerpsCloseAllPositionsModal', () => { - const mockPositions: Position[] = [ - { - coin: 'BTC', - size: '0.5', - entryPrice: '50000', - positionValue: '25000', - unrealizedPnl: '100', - marginUsed: '1000', - leverage: { type: 'cross' as const, value: 25 }, - liquidationPrice: '48000', - maxLeverage: 50, - returnOnEquity: '10', - cumulativeFunding: { - allTime: '0', - sinceOpen: '0', - sinceChange: '0', - }, - takeProfitPrice: undefined, - stopLossPrice: undefined, - takeProfitCount: 0, - stopLossCount: 0, - }, - ]; - - const mockCalculations = { - totalMargin: 1000, - totalPnl: 100, - totalFees: 10, - receiveAmount: 1090, - totalEstimatedPoints: 50, - avgFeeDiscountPercentage: 5, - avgBonusBips: 10, - avgMetamaskFeeRate: 0.01, - avgProtocolFeeRate: 0.00045, - avgOriginalMetamaskFeeRate: 0.015, - isLoading: false, - hasError: false, - shouldShowRewards: true, - }; - - const mockShowToast = jest.fn(); - const mockOnClose = jest.fn(); - const mockOnSuccess = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - mockUsePerpsCloseAllCalculations.mockReturnValue(mockCalculations); - mockUsePerpsLivePrices.mockReturnValue({}); - mockUsePerpsToasts.mockReturnValue({ - showToast: mockShowToast, - }); - }); - - it('returns null when not visible', () => { - // Arrange & Act - const { queryByText } = render( - , - ); - - // Assert - expect(queryByText('perps.close_all_modal.title')).toBeNull(); - }); - - it('renders when visible with positions', () => { - // Arrange & Act - const { getByText } = render( - , - ); - - // Assert - expect(getByText('perps.close_all_modal.title')).toBeTruthy(); - expect(getByText('perps.close_all_modal.description')).toBeTruthy(); - }); - - it('renders footer buttons with correct labels', () => { - // Arrange & Act - const { getByText } = render( - , - ); - - // Assert - expect(getByText('perps.close_all_modal.keep_positions')).toBeTruthy(); - expect(getByText('perps.close_all_modal.close_all')).toBeTruthy(); - }); - - it('closes modal when keep positions button is pressed', () => { - // Arrange - const { getByTestId } = render( - , - ); - - // Act - const keepButton = getByTestId('footer-button-0'); - fireEvent.press(keepButton); - - // Assert - Button should be pressable (bottomSheetRef.current?.onCloseBottomSheet is called internally) - expect(keepButton).toBeTruthy(); - }); - - it('handles successful close all operation', async () => { - // Arrange - const mockClosePositions = Engine.context.PerpsController - .closePositions as jest.Mock; - mockClosePositions.mockResolvedValue({ - success: true, - successCount: 1, - failureCount: 0, - }); - - const { getByTestId } = render( - , - ); - - // Act - const closeButton = getByTestId('footer-button-1'); - fireEvent.press(closeButton); - - // Assert - await waitFor(() => { - expect(mockClosePositions).toHaveBeenCalledWith({ closeAll: true }); - expect(mockShowToast).toHaveBeenCalled(); - expect(mockOnSuccess).toHaveBeenCalled(); - }); - }); - - it('handles partial success close all operation', async () => { - // Arrange - const mockClosePositions = Engine.context.PerpsController - .closePositions as jest.Mock; - mockClosePositions.mockResolvedValue({ - success: false, - successCount: 1, - failureCount: 1, - }); - - const { getByTestId } = render( - , - ); - - // Act - const closeButton = getByTestId('footer-button-1'); - fireEvent.press(closeButton); - - // Assert - await waitFor(() => { - expect(mockClosePositions).toHaveBeenCalledWith({ closeAll: true }); - expect(mockShowToast).toHaveBeenCalled(); - expect(mockOnSuccess).toHaveBeenCalled(); - }); - }); - - it('handles failed close all operation', async () => { - // Arrange - const mockClosePositions = Engine.context.PerpsController - .closePositions as jest.Mock; - mockClosePositions.mockResolvedValue({ - success: false, - successCount: 0, - failureCount: 1, - }); - - const { getByTestId } = render( - , - ); - - // Act - const closeButton = getByTestId('footer-button-1'); - fireEvent.press(closeButton); - - // Assert - await waitFor(() => { - expect(mockClosePositions).toHaveBeenCalledWith({ closeAll: true }); - expect(mockShowToast).toHaveBeenCalled(); - }); - }); - - it('handles error during close all operation', async () => { - // Arrange - const mockClosePositions = Engine.context.PerpsController - .closePositions as jest.Mock; - mockClosePositions.mockRejectedValue(new Error('Network error')); - - const { getByTestId } = render( - , - ); - - // Act - const closeButton = getByTestId('footer-button-1'); - fireEvent.press(closeButton); - - // Assert - await waitFor(() => { - expect(mockClosePositions).toHaveBeenCalledWith({ closeAll: true }); - expect(mockShowToast).toHaveBeenCalled(); - }); - }); - - it('shows loading state when closing', () => { - // Arrange - const mockClosePositions = Engine.context.PerpsController - .closePositions as jest.Mock; - mockClosePositions.mockImplementation( - () => - new Promise((resolve) => { - setTimeout( - () => - resolve({ - success: true, - successCount: 1, - failureCount: 0, - }), - 100, - ); - }), - ); - - const { getByTestId, getAllByText } = render( - , - ); - - // Act - const closeButton = getByTestId('footer-button-1'); - fireEvent.press(closeButton); - - // Assert - Should show closing text (appears in both button and loading message) - const closingElements = getAllByText('perps.close_all_modal.closing'); - expect(closingElements.length).toBeGreaterThan(0); - }); - - it('disables buttons when closing', async () => { - // Arrange - const mockClosePositions = Engine.context.PerpsController - .closePositions as jest.Mock; - mockClosePositions.mockImplementation( - () => - new Promise((resolve) => { - setTimeout( - () => - resolve({ - success: true, - successCount: 1, - failureCount: 0, - }), - 100, - ); - }), - ); - - const { getByTestId } = render( - , - ); - - // Act - const closeButton = getByTestId('footer-button-1'); - fireEvent.press(closeButton); - - // Assert - Buttons should be disabled during closing - await waitFor(() => { - const keepButton = getByTestId('footer-button-0'); - expect(keepButton.props.disabled).toBe(true); - }); - }); - - it('renders PerpsCloseSummary when not closing', () => { - // Arrange & Act - const { UNSAFE_getByType } = render( - , - ); - - // Assert - expect(UNSAFE_getByType('PerpsCloseSummary' as never)).toBeTruthy(); - }); -}); diff --git a/app/components/UI/Perps/components/PerpsCloseAllPositionsModal/PerpsCloseAllPositionsModal.tsx b/app/components/UI/Perps/components/PerpsCloseAllPositionsModal/PerpsCloseAllPositionsModal.tsx deleted file mode 100644 index 9ff46da48ae0..000000000000 --- a/app/components/UI/Perps/components/PerpsCloseAllPositionsModal/PerpsCloseAllPositionsModal.tsx +++ /dev/null @@ -1,297 +0,0 @@ -import React, { useCallback, useState, useMemo } from 'react'; -import { View, ActivityIndicator } from 'react-native'; -import { NotificationFeedbackType } from 'expo-haptics'; -import BottomSheet, { - BottomSheetRef, -} from '../../../../../component-library/components/BottomSheets/BottomSheet'; -import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader'; -import BottomSheetFooter, { - ButtonsAlignment, -} from '../../../../../component-library/components/BottomSheets/BottomSheetFooter'; -import Text, { - TextColor, - TextVariant, -} from '../../../../../component-library/components/Texts/Text'; -import { - ButtonSize, - ButtonVariants, -} from '../../../../../component-library/components/Buttons/Button'; -import { IconName } from '../../../../../component-library/components/Icons/Icon'; -import { ToastVariants } from '../../../../../component-library/components/Toast/Toast.types'; -import { useStyles } from '../../../../hooks/useStyles'; -import { strings } from '../../../../../../locales/i18n'; -import Engine from '../../../../../core/Engine'; -import createStyles from './PerpsCloseAllPositionsModal.styles'; -import usePerpsToasts, { - type PerpsToastOptions, -} from '../../hooks/usePerpsToasts'; -import { DevLogger } from '../../../../../core/SDKConnect/utils/DevLogger'; -import type { Position } from '../../controllers/types'; -import { usePerpsCloseAllCalculations } from '../../hooks'; -import { usePerpsLivePrices } from '../../hooks/stream'; -import PerpsCloseSummary from '../PerpsCloseSummary'; - -interface PerpsCloseAllPositionsModalProps { - isVisible: boolean; - onClose: () => void; - positions: Position[]; - onSuccess?: () => void; -} - -const PerpsCloseAllPositionsModal: React.FC< - PerpsCloseAllPositionsModalProps -> = ({ isVisible, onClose, positions, onSuccess }) => { - const { styles, theme } = useStyles(createStyles, {}); - const bottomSheetRef = React.useRef(null); - const [isClosing, setIsClosing] = useState(false); - const { showToast } = usePerpsToasts(); - - // Fetch current prices for fee calculations (throttled to avoid excessive updates) - const symbols = useMemo(() => positions.map((pos) => pos.coin), [positions]); - const priceData = usePerpsLivePrices({ - symbols, - throttleMs: 1000, - }); - - const showSuccessToast = useCallback( - (title: string, message?: string) => { - const toastConfig: PerpsToastOptions = { - variant: ToastVariants.Icon, - iconName: IconName.CheckBold, - backgroundColor: theme.colors.accent03.normal, - iconColor: theme.colors.accent03.dark, - hapticsType: NotificationFeedbackType.Success, - hasNoTimeout: false, - labelOptions: message - ? [ - { label: title, isBold: true }, - { label: '\n', isBold: false }, - { label: message, isBold: false }, - ] - : [{ label: title, isBold: true }], - } as PerpsToastOptions; - showToast(toastConfig); - }, - [showToast, theme.colors.accent03], - ); - - const showErrorToast = useCallback( - (title: string, message?: string) => { - const toastConfig: PerpsToastOptions = { - variant: ToastVariants.Icon, - iconName: IconName.Warning, - backgroundColor: theme.colors.accent01.light, - iconColor: theme.colors.accent01.dark, - hapticsType: NotificationFeedbackType.Error, - hasNoTimeout: false, - labelOptions: message - ? [ - { label: title, isBold: true }, - { label: '\n', isBold: false }, - { label: message, isBold: false }, - ] - : [{ label: title, isBold: true }], - } as PerpsToastOptions; - showToast(toastConfig); - }, - [showToast, theme.colors.accent01], - ); - - // Use the fixed hook for accurate fee and rewards calculations - const calculations = usePerpsCloseAllCalculations({ - positions, - priceData, - }); - - const handleCloseAll = useCallback(async () => { - const startTime = Date.now(); - setIsClosing(true); - - DevLogger.log( - '[PerpsCloseAllPositionsModal] Starting close all positions', - { - positionCount: positions.length, - totalMargin: calculations.totalMargin, - totalPnl: calculations.totalPnl, - estimatedTotalFees: calculations.totalFees, - estimatedReceiveAmount: calculations.receiveAmount, - }, - ); - - try { - const result = await Engine.context.PerpsController.closePositions({ - closeAll: true, - }); - - const executionTime = Date.now() - startTime; - - if (result.success && result.successCount > 0) { - DevLogger.log( - '[PerpsCloseAllPositionsModal] Close all positions succeeded', - { - successCount: result.successCount, - failureCount: result.failureCount, - executionTimeMs: executionTime, - }, - ); - - showSuccessToast( - strings('perps.close_all_modal.success_title'), - strings('perps.close_all_modal.success_message', { - count: result.successCount, - }), - ); - onSuccess?.(); - bottomSheetRef.current?.onCloseBottomSheet(); - } else if (result.successCount > 0 && result.failureCount > 0) { - DevLogger.log( - '[PerpsCloseAllPositionsModal] Close all positions partially succeeded', - { - successCount: result.successCount, - failureCount: result.failureCount, - totalCount: result.successCount + result.failureCount, - executionTimeMs: executionTime, - }, - ); - - showSuccessToast( - strings('perps.close_all_modal.success_title'), - strings('perps.close_all_modal.partial_success', { - successCount: result.successCount, - totalCount: result.successCount + result.failureCount, - }), - ); - onSuccess?.(); - bottomSheetRef.current?.onCloseBottomSheet(); - } else { - DevLogger.log( - '[PerpsCloseAllPositionsModal] Close all positions failed', - { - failureCount: result.failureCount, - executionTimeMs: executionTime, - }, - ); - - showErrorToast( - strings('perps.close_all_modal.error_title'), - strings('perps.close_all_modal.error_message', { - count: result.failureCount, - }), - ); - } - } catch (error) { - const executionTime = Date.now() - startTime; - DevLogger.log('[PerpsCloseAllPositionsModal] Close all positions error', { - error: error instanceof Error ? error.message : 'Unknown error', - errorStack: error instanceof Error ? error.stack : undefined, - executionTimeMs: executionTime, - }); - - showErrorToast( - strings('perps.close_all_modal.error_title'), - error instanceof Error ? error.message : 'Unknown error', - ); - } finally { - setIsClosing(false); - } - }, [ - showSuccessToast, - showErrorToast, - onSuccess, - positions.length, - calculations, - ]); - - const handleKeepPositions = useCallback(() => { - bottomSheetRef.current?.onCloseBottomSheet(); - }, []); - - const footerButtons = useMemo( - () => [ - { - label: strings('perps.close_all_modal.keep_positions'), - onPress: handleKeepPositions, - variant: ButtonVariants.Secondary, - size: ButtonSize.Lg, - disabled: isClosing, - }, - { - label: isClosing - ? strings('perps.close_all_modal.closing') - : strings('perps.close_all_modal.close_all'), - onPress: handleCloseAll, - variant: ButtonVariants.Primary, - size: ButtonSize.Lg, - disabled: isClosing, - danger: true, - }, - ], - [handleKeepPositions, handleCloseAll, isClosing], - ); - - if (!isVisible) return null; - - return ( - - - - {strings('perps.close_all_modal.title')} - - - - - - {strings('perps.close_all_modal.description')} - - - {isClosing ? ( - - - - {strings('perps.close_all_modal.closing')} - - - ) : ( - - )} - - - - - ); -}; - -export default PerpsCloseAllPositionsModal; diff --git a/app/components/UI/Perps/components/PerpsOrderTypeBottomSheet/PerpsOrderTypeBottomSheet.styles.ts b/app/components/UI/Perps/components/PerpsOrderTypeBottomSheet/PerpsOrderTypeBottomSheet.styles.ts index cf71e57c2267..f7ac2160dc72 100644 --- a/app/components/UI/Perps/components/PerpsOrderTypeBottomSheet/PerpsOrderTypeBottomSheet.styles.ts +++ b/app/components/UI/Perps/components/PerpsOrderTypeBottomSheet/PerpsOrderTypeBottomSheet.styles.ts @@ -4,13 +4,11 @@ import { Theme } from '../../../../../util/theme/models'; export const createStyles = (colors: Theme['colors']) => StyleSheet.create({ container: { - paddingHorizontal: 16, paddingVertical: 24, }, option: { paddingVertical: 16, paddingHorizontal: 16, - borderRadius: 12, marginBottom: 16, }, optionSelected: { diff --git a/app/components/UI/Perps/components/PerpsRecentActivityList/PerpsRecentActivityList.test.tsx b/app/components/UI/Perps/components/PerpsRecentActivityList/PerpsRecentActivityList.test.tsx index 00a5bb8532d3..37ffaee59d18 100644 --- a/app/components/UI/Perps/components/PerpsRecentActivityList/PerpsRecentActivityList.test.tsx +++ b/app/components/UI/Perps/components/PerpsRecentActivityList/PerpsRecentActivityList.test.tsx @@ -321,9 +321,9 @@ describe('PerpsRecentActivityList', () => { fireEvent.press(seeAllButton); expect(mockNavigate).toHaveBeenCalledTimes(1); - expect(mockNavigate).toHaveBeenCalledWith(Routes.TRANSACTIONS_VIEW, { - screen: Routes.TRANSACTIONS_VIEW, - params: { redirectToPerpsTransactions: true }, + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ACTIVITY, { + redirectToPerpsTransactions: true, + showBackButton: true, }); }); diff --git a/app/components/UI/Perps/components/PerpsRecentActivityList/PerpsRecentActivityList.tsx b/app/components/UI/Perps/components/PerpsRecentActivityList/PerpsRecentActivityList.tsx index 2bdec8e439f8..c0d68f0ccc39 100644 --- a/app/components/UI/Perps/components/PerpsRecentActivityList/PerpsRecentActivityList.tsx +++ b/app/components/UI/Perps/components/PerpsRecentActivityList/PerpsRecentActivityList.tsx @@ -34,9 +34,9 @@ const PerpsRecentActivityList: React.FC = ({ const navigation = useNavigation>(); const handleSeeAll = useCallback(() => { - navigation.navigate(Routes.TRANSACTIONS_VIEW, { - screen: Routes.TRANSACTIONS_VIEW, - params: { redirectToPerpsTransactions: true }, + navigation.navigate(Routes.PERPS.ACTIVITY, { + redirectToPerpsTransactions: true, + showBackButton: true, }); }, [navigation]); diff --git a/app/components/UI/Perps/components/TradingViewChart/utils/chartCalculations.test.ts b/app/components/UI/Perps/components/TradingViewChart/utils/chartCalculations.test.ts deleted file mode 100644 index 8ae9139a3a6a..000000000000 --- a/app/components/UI/Perps/components/TradingViewChart/utils/chartCalculations.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { CandlePeriod, TimeDuration } from '../../../constants/chartConfig'; -import { - getDurationInMinutes, - getPeriodInMinutes, - getCandleCount, - createIntervalUpdateMessage, -} from './chartCalculations'; - -describe('chartCalculations', () => { - describe('getDurationInMinutes', () => { - it('converts duration enums to correct minutes', () => { - expect(getDurationInMinutes(TimeDuration.ONE_HOUR)).toBe(60); - expect(getDurationInMinutes(TimeDuration.ONE_DAY)).toBe(1440); - expect(getDurationInMinutes(TimeDuration.ONE_WEEK)).toBe(10080); - expect(getDurationInMinutes(TimeDuration.ONE_MONTH)).toBe(43200); - expect(getDurationInMinutes(TimeDuration.YEAR_TO_DATE)).toBe(525600); - expect(getDurationInMinutes(TimeDuration.MAX)).toBe(1051200); // 2 years - }); - - it('returns default for unknown duration', () => { - expect(getDurationInMinutes('UNKNOWN')).toBe(1440); // 1 day default - }); - }); - - describe('getPeriodInMinutes', () => { - it('converts period enums to correct minutes', () => { - expect(getPeriodInMinutes(CandlePeriod.ONE_MINUTE)).toBe(1); - expect(getPeriodInMinutes(CandlePeriod.THREE_MINUTES)).toBe(3); - expect(getPeriodInMinutes(CandlePeriod.FIVE_MINUTES)).toBe(5); - expect(getPeriodInMinutes(CandlePeriod.FIFTEEN_MINUTES)).toBe(15); - expect(getPeriodInMinutes(CandlePeriod.THIRTY_MINUTES)).toBe(30); - expect(getPeriodInMinutes(CandlePeriod.ONE_HOUR)).toBe(60); - expect(getPeriodInMinutes(CandlePeriod.TWO_HOURS)).toBe(120); - expect(getPeriodInMinutes(CandlePeriod.FOUR_HOURS)).toBe(240); - expect(getPeriodInMinutes(CandlePeriod.EIGHT_HOURS)).toBe(480); - expect(getPeriodInMinutes(CandlePeriod.TWELVE_HOURS)).toBe(720); - expect(getPeriodInMinutes(CandlePeriod.ONE_DAY)).toBe(1440); - expect(getPeriodInMinutes(CandlePeriod.THREE_DAYS)).toBe(4320); - expect(getPeriodInMinutes(CandlePeriod.ONE_WEEK)).toBe(10080); - expect(getPeriodInMinutes(CandlePeriod.ONE_MONTH)).toBe(43200); - }); - - it('returns default for unknown period', () => { - expect(getPeriodInMinutes('UNKNOWN')).toBe(60); // 1 hour default - }); - }); - - describe('getCandleCount', () => { - it('calculates correct candle count for various combinations', () => { - // Basic calculations - expect( - getCandleCount(TimeDuration.ONE_HOUR, CandlePeriod.ONE_MINUTE), - ).toBe(60); - expect(getCandleCount(TimeDuration.ONE_DAY, CandlePeriod.ONE_HOUR)).toBe( - 24, - ); - expect(getCandleCount(TimeDuration.ONE_WEEK, CandlePeriod.ONE_HOUR)).toBe( - 168, - ); - expect( - getCandleCount(TimeDuration.ONE_WEEK, CandlePeriod.TWO_HOURS), - ).toBe(84); - }); - - it('enforces minimum candle count of 10', () => { - // Very long period relative to duration should result in minimum 10 - expect(getCandleCount(TimeDuration.ONE_HOUR, CandlePeriod.ONE_DAY)).toBe( - 10, - ); - expect(getCandleCount(TimeDuration.ONE_DAY, CandlePeriod.ONE_WEEK)).toBe( - 10, - ); - }); - - it('enforces maximum candle count of 500', () => { - // Very short period relative to duration should cap at 500 - expect( - getCandleCount(TimeDuration.ONE_DAY, CandlePeriod.ONE_MINUTE), - ).toBe(500); - expect(getCandleCount(TimeDuration.MAX, CandlePeriod.ONE_MINUTE)).toBe( - 500, - ); - expect( - getCandleCount(TimeDuration.YEAR_TO_DATE, CandlePeriod.ONE_MINUTE), - ).toBe(500); - }); - - it('handles unknown duration and period', () => { - // Unknown duration defaults to 1 day, unknown period defaults to 1 hour - expect(getCandleCount('UNKNOWN', 'UNKNOWN')).toBe(24); - expect(getCandleCount(TimeDuration.ONE_DAY, 'UNKNOWN')).toBe(24); - expect(getCandleCount('UNKNOWN', CandlePeriod.ONE_HOUR)).toBe(24); - }); - }); - - describe('createIntervalUpdateMessage', () => { - beforeAll(() => { - // Mock Date to ensure consistent timestamps in tests - jest.useFakeTimers(); - jest.setSystemTime(new Date('2024-01-01T00:00:00.000Z')); - }); - - afterAll(() => { - jest.useRealTimers(); - }); - - it('creates correct interval update message', () => { - const message = createIntervalUpdateMessage( - TimeDuration.ONE_DAY, - CandlePeriod.ONE_HOUR, - ); - - expect(message).toEqual({ - type: 'UPDATE_INTERVAL', - duration: TimeDuration.ONE_DAY, - candlePeriod: CandlePeriod.ONE_HOUR, - candleCount: 24, - timestamp: '2024-01-01T00:00:00.000Z', - }); - }); - - it('handles string parameters', () => { - const message = createIntervalUpdateMessage('1d', '1h'); - - expect(message).toEqual({ - type: 'UPDATE_INTERVAL', - duration: '1d', - candlePeriod: '1h', - candleCount: 24, // Unknown strings default to 1 day / 1 hour = 24 - timestamp: '2024-01-01T00:00:00.000Z', - }); - }); - - it('applies candle count limits in message', () => { - // Test maximum limit - const maxMessage = createIntervalUpdateMessage( - TimeDuration.MAX, - CandlePeriod.ONE_MINUTE, - ); - expect(maxMessage.candleCount).toBe(500); - - // Test minimum limit - const minMessage = createIntervalUpdateMessage( - TimeDuration.ONE_HOUR, - CandlePeriod.ONE_DAY, - ); - expect(minMessage.candleCount).toBe(10); - }); - }); -}); diff --git a/app/components/UI/Perps/components/TradingViewChart/utils/chartCalculations.ts b/app/components/UI/Perps/components/TradingViewChart/utils/chartCalculations.ts deleted file mode 100644 index 5b0ef73ae3d3..000000000000 --- a/app/components/UI/Perps/components/TradingViewChart/utils/chartCalculations.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { CandlePeriod, TimeDuration } from '../../../constants/chartConfig'; - -/** - * Converts time duration enum to minutes - */ -export const getDurationInMinutes = ( - duration: TimeDuration | string, -): number => { - switch (duration) { - case TimeDuration.ONE_HOUR: - return 60; - case TimeDuration.ONE_DAY: - return 24 * 60; // 1440 minutes - case TimeDuration.ONE_WEEK: - return 7 * 24 * 60; // 10080 minutes - case TimeDuration.ONE_MONTH: - return 30 * 24 * 60; // 43200 minutes - case TimeDuration.YEAR_TO_DATE: - return 365 * 24 * 60; // 525600 minutes - case TimeDuration.MAX: - return 2 * 365 * 24 * 60; // 2 years in minutes - default: - return 24 * 60; // Default to 1 day - } -}; - -/** - * Converts candle period enum to minutes - */ -export const getPeriodInMinutes = (period: CandlePeriod | string): number => { - switch (period) { - case CandlePeriod.ONE_MINUTE: - return 1; - case CandlePeriod.THREE_MINUTES: - return 3; - case CandlePeriod.FIVE_MINUTES: - return 5; - case CandlePeriod.FIFTEEN_MINUTES: - return 15; - case CandlePeriod.THIRTY_MINUTES: - return 30; - case CandlePeriod.ONE_HOUR: - return 60; - case CandlePeriod.TWO_HOURS: - return 2 * 60; - case CandlePeriod.FOUR_HOURS: - return 4 * 60; - case CandlePeriod.EIGHT_HOURS: - return 8 * 60; - case CandlePeriod.TWELVE_HOURS: - return 12 * 60; - case CandlePeriod.ONE_DAY: - return 24 * 60; - case CandlePeriod.THREE_DAYS: - return 3 * 24 * 60; - case CandlePeriod.ONE_WEEK: - return 7 * 24 * 60; - case CandlePeriod.ONE_MONTH: - return 30 * 24 * 60; - default: - return 60; // Default to 1 hour - } -}; - -/** - * Calculates the number of candles needed for a given duration and period - * Enforces minimum of 10 and maximum of 500 candles - */ -export const getCandleCount = ( - duration: TimeDuration | string, - period: CandlePeriod | string, -): number => { - const durationMinutes = getDurationInMinutes(duration); - const periodMinutes = getPeriodInMinutes(period); - - const rawCount = Math.ceil(durationMinutes / periodMinutes); - - // Enforce bounds: minimum 10, maximum 500 - return Math.max(10, Math.min(500, rawCount)); -}; - -/** - * Creates an interval update message for the TradingView chart - */ -export const createIntervalUpdateMessage = ( - duration: TimeDuration | string, - candlePeriod: CandlePeriod | string, -) => { - const candleCount = getCandleCount(duration, candlePeriod); - - return { - type: 'UPDATE_INTERVAL', - duration, - candlePeriod, - candleCount, - timestamp: new Date().toISOString(), - }; -}; diff --git a/app/components/UI/Perps/hooks/useArbitrumTransactionMonitor.test.ts b/app/components/UI/Perps/hooks/useArbitrumTransactionMonitor.test.ts deleted file mode 100644 index 8b267ebf8377..000000000000 --- a/app/components/UI/Perps/hooks/useArbitrumTransactionMonitor.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { renderHook, act } from '@testing-library/react-native'; -import { useSelector } from 'react-redux'; -import { useArbitrumTransactionMonitor } from './useArbitrumTransactionMonitor'; -import { detectHyperLiquidWithdrawal } from '../utils/arbitrumWithdrawalDetection'; -import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; - -// Mock dependencies -jest.mock('react-redux', () => ({ - ...jest.requireActual('react-redux'), - useSelector: jest.fn(), -})); - -jest.mock('../utils/arbitrumWithdrawalDetection'); -jest.mock('../../../../core/SDKConnect/utils/DevLogger'); - -const mockUseSelector = useSelector as jest.MockedFunction; -const mockDetectHyperLiquidWithdrawal = - detectHyperLiquidWithdrawal as jest.MockedFunction< - typeof detectHyperLiquidWithdrawal - >; -const mockDevLogger = DevLogger as jest.Mocked; - -describe('useArbitrumTransactionMonitor', () => { - const mockSelectedAddress = '0x1234567890123456789012345678901234567890'; - const mockChainId = '0xa4b1'; - const mockTransactions = { - tx1: { - hash: '0x123', - txParams: { - from: '0x2df1c51e09aecf9cacb7bc98cb1742757f163df7', // HyperLiquid bridge contract - to: mockSelectedAddress, - data: - '0xa9059cbb' + - '0000000000000000000000001234567890123456789012345678901234567890' + // recipient - '0000000000000000000000000000000000000000000000000000000005f5e100', // 100 USDC - }, - chainId: mockChainId, - time: 1640995200000, - status: 'confirmed', - blockNumber: '12345', - }, - tx2: { - hash: '0x456', - txParams: { - from: '0x2df1c51e09aecf9cacb7bc98cb1742757f163df7', // HyperLiquid bridge contract - to: mockSelectedAddress, - data: - '0xa9059cbb' + - '0000000000000000000000001234567890123456789012345678901234567890' + // recipient - '0000000000000000000000000000000000000000000000000000000007a120', // 0.5 USDC - }, - chainId: mockChainId, - time: 1640995201000, - status: 'confirmed', - blockNumber: '12346', - }, - }; - - const mockWithdrawal = { - id: 'arbitrum-withdrawal-0x123', - timestamp: 1640995200000, - amount: '100', - txHash: '0x123', - from: '0x2df1c51e09aecf9cacb7bc98cb1742757f163df7', - to: mockSelectedAddress, - status: 'completed' as const, - blockNumber: '12345', - }; - - // Helper function to set up mocks for each test - const setupMocks = ( - overrides: { - selectedAddress?: string | null; - chainId?: string; - transactions?: Record; - } = {}, - ) => { - const { - selectedAddress = mockSelectedAddress, - chainId = mockChainId, - transactions = mockTransactions, - } = overrides; - - mockUseSelector - .mockReturnValueOnce(selectedAddress) // selectedAddress - .mockReturnValueOnce(chainId) // currentChainId - .mockReturnValueOnce(transactions); // allTransactions - }; - - beforeEach(() => { - jest.clearAllMocks(); - mockDetectHyperLiquidWithdrawal.mockReturnValue(mockWithdrawal); - }); - - describe('Arbitrum detection', () => { - it('does not process transactions when not on Arbitrum', async () => { - setupMocks({ chainId: '0x1' }); // Set up mocks for non-Arbitrum network - const { result } = renderHook(() => useArbitrumTransactionMonitor()); - - // Wait for the effect to complete - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - expect(result.current.withdrawals).toEqual([]); - expect(mockDetectHyperLiquidWithdrawal).not.toHaveBeenCalled(); - }); - - it('does not process transactions when no selected address', async () => { - setupMocks({ selectedAddress: null }); // Set up mocks with no selected address - const { result } = renderHook(() => useArbitrumTransactionMonitor()); - - // Wait for the effect to complete - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - expect(result.current.withdrawals).toEqual([]); - expect(mockDetectHyperLiquidWithdrawal).not.toHaveBeenCalled(); - }); - }); - - describe('transaction processing', () => { - it('handles empty transactions object', async () => { - setupMocks({ transactions: {} }); // Set up mocks with empty transactions - const { result } = renderHook(() => useArbitrumTransactionMonitor()); - - // Wait for the effect to complete - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - expect(result.current.withdrawals).toEqual([]); - }); - }); - - describe('loading and error states', () => { - it('sets loading state during processing', () => { - const { result } = renderHook(() => useArbitrumTransactionMonitor()); - - act(() => { - mockUseSelector - .mockReturnValueOnce(mockSelectedAddress) - .mockReturnValueOnce(mockChainId) - .mockReturnValueOnce(mockTransactions); - }); - - // Loading should be false after processing completes - expect(result.current.isLoading).toBe(false); - }); - - it('handles processing errors gracefully', () => { - mockDetectHyperLiquidWithdrawal.mockImplementation(() => { - throw new Error('Detection error'); - }); - - const { result } = renderHook(() => useArbitrumTransactionMonitor()); - - act(() => { - mockUseSelector - .mockReturnValueOnce(mockSelectedAddress) - .mockReturnValueOnce(mockChainId) - .mockReturnValueOnce(mockTransactions); - }); - - expect(result.current.error).toBe('Detection error'); - expect(result.current.withdrawals).toEqual([]); - expect(mockDevLogger.log).toHaveBeenCalledWith( - 'Error processing Arbitrum transactions:', - 'Detection error', - ); - }); - - it('handles non-Error exceptions', () => { - mockDetectHyperLiquidWithdrawal.mockImplementation(() => { - throw 'String error'; - }); - - const { result } = renderHook(() => useArbitrumTransactionMonitor()); - - act(() => { - mockUseSelector - .mockReturnValueOnce(mockSelectedAddress) - .mockReturnValueOnce(mockChainId) - .mockReturnValueOnce(mockTransactions); - }); - - expect(result.current.error).toBe('Failed to process transactions'); - }); - }); - - describe('logging', () => { - it('logs detected withdrawals', () => { - setupMocks(); // Set up default mocks - renderHook(() => useArbitrumTransactionMonitor()); - - expect(mockDevLogger.log).toHaveBeenCalledWith( - 'Arbitrum withdrawals detected:', - expect.objectContaining({ - count: 2, - withdrawals: expect.arrayContaining([ - expect.objectContaining({ - id: expect.any(String), - amount: expect.any(String), - txHash: expect.any(String), - }), - ]), - }), - ); - }); - }); -}); diff --git a/app/components/UI/Perps/hooks/useArbitrumTransactionMonitor.ts b/app/components/UI/Perps/hooks/useArbitrumTransactionMonitor.ts deleted file mode 100644 index 5c8e66747266..000000000000 --- a/app/components/UI/Perps/hooks/useArbitrumTransactionMonitor.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useSelector } from 'react-redux'; -import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; -import type { RootState } from '../../../../reducers'; -import type { TransactionMeta } from '@metamask/transaction-controller'; -import { selectChainId } from '../../../../selectors/networkController'; -import { - ARBITRUM_MAINNET_CHAIN_ID, - ARBITRUM_TESTNET_CHAIN_ID, - detectHyperLiquidWithdrawal, -} from '../utils/arbitrumWithdrawalDetection'; - -interface ArbitrumWithdrawal { - id: string; - timestamp: number; - amount: string; - txHash: string; - from: string; - to: string; - status: 'completed' | 'failed' | 'pending'; - blockNumber?: string; -} - -interface UseArbitrumTransactionMonitorResult { - withdrawals: ArbitrumWithdrawal[]; - isLoading: boolean; - error: string | null; - refetch: () => void; -} - -/** - * Hook to monitor Arbitrum transactions for HyperLiquid withdrawals - * - * This hook: - * 1. Monitors transactions on Arbitrum network - * 2. Detects USDC transfers from HyperLiquid bridge contracts - * 3. Creates withdrawal records for the transaction history - */ -export const useArbitrumTransactionMonitor = - (): UseArbitrumTransactionMonitorResult => { - const [withdrawals, setWithdrawals] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - // Get current account and network info - const selectedAddress = useSelector( - (state: RootState) => - state.engine.backgroundState.PreferencesController?.selectedAddress, - ); - - const currentChainId = useSelector(selectChainId); - - // Get all transactions from TransactionController - const allTransactions = useSelector( - (state: RootState) => - state.engine.backgroundState.TransactionController?.transactions || {}, - ); - - // Check if we're on Arbitrum - const isArbitrum = useMemo( - () => - currentChainId === ARBITRUM_MAINNET_CHAIN_ID || - currentChainId === ARBITRUM_TESTNET_CHAIN_ID, - [currentChainId], - ); - - /** - * Detect if a transaction is a HyperLiquid withdrawal using utility function - */ - const detectWithdrawal = useCallback( - (tx: TransactionMeta): ArbitrumWithdrawal | null => { - if (!currentChainId || !selectedAddress || !tx.hash) { - return null; - } - - // Convert TransactionMeta to the expected format - const txForDetection = { - hash: tx.hash, - from: tx.txParams?.from, - to: tx.txParams?.to, - data: tx.txParams?.data, - chainId: tx.chainId, - time: tx.time, - status: tx.status, - blockNumber: tx.blockNumber, - }; - - return detectHyperLiquidWithdrawal( - txForDetection, - selectedAddress, - currentChainId, - ); - }, - [currentChainId, selectedAddress], - ); - - /** - * Process transactions to find withdrawals - */ - const processTransactions = useCallback(() => { - if (!isArbitrum || !selectedAddress) { - setWithdrawals([]); - return; - } - - setIsLoading(true); - setError(null); - - try { - const transactionList = Object.values(allTransactions); - const detectedWithdrawals: ArbitrumWithdrawal[] = []; - - transactionList.forEach((tx) => { - const withdrawal = detectWithdrawal(tx); - if (withdrawal) { - detectedWithdrawals.push(withdrawal); - } - }); - - // Sort by timestamp descending (newest first) - detectedWithdrawals.sort((a, b) => b.timestamp - a.timestamp); - - setWithdrawals(detectedWithdrawals); - - DevLogger.log('Arbitrum withdrawals detected:', { - count: detectedWithdrawals.length, - withdrawals: detectedWithdrawals, - }); - } catch (err) { - const errorMessage = - err instanceof Error ? err.message : 'Failed to process transactions'; - setError(errorMessage); - DevLogger.log('Error processing Arbitrum transactions:', errorMessage); - } finally { - setIsLoading(false); - } - }, [isArbitrum, selectedAddress, allTransactions, detectWithdrawal]); - - /** - * Refetch withdrawals - */ - const refetch = useCallback(() => { - processTransactions(); - }, [processTransactions]); - - // Process transactions when dependencies change - useEffect(() => { - processTransactions(); - }, [processTransactions]); - - return { - withdrawals, - isLoading, - error, - refetch, - }; - }; diff --git a/app/components/UI/Perps/hooks/usePerpsErrorTracking.ts b/app/components/UI/Perps/hooks/usePerpsErrorTracking.ts deleted file mode 100644 index 0306afdb6c10..000000000000 --- a/app/components/UI/Perps/hooks/usePerpsErrorTracking.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { useCallback } from 'react'; -import { MetaMetricsEvents, useMetrics } from '../../../hooks/useMetrics'; -import { PerpsEventProperties } from '../constants/eventNames'; -import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; -import { PERPS_ERROR_CODES } from '../controllers/PerpsController'; -import { isPerpsErrorCode } from '../utils/perpsErrorHandler'; - -/** - * Error context for tracking - */ -export interface PerpsErrorContext { - operation?: string; - asset?: string; - direction?: 'long' | 'short'; - orderType?: 'market' | 'limit'; - amount?: string | number; - provider?: string; - [key: string]: string | number | undefined; -} - -/** - * Hook for tracking Perps errors with PERPS_ERROR event - */ -export function usePerpsErrorTracking() { - const { trackEvent, createEventBuilder } = useMetrics(); - - /** - * Extract error code from error - */ - const getErrorCode = useCallback((error: unknown): string => { - // Check if it's a PerpsController error code - const errorString = error instanceof Error ? error.message : String(error); - - // Check each known error code - for (const code of Object.values(PERPS_ERROR_CODES)) { - if (isPerpsErrorCode(error, code)) { - return code; - } - } - - // For Hyperliquid-specific errors, try to extract meaningful info - if (errorString.includes('insufficient')) { - return 'INSUFFICIENT_BALANCE'; - } - if (errorString.includes('slippage')) { - return 'SLIPPAGE_EXCEEDED'; - } - if (errorString.includes('market closed')) { - return 'MARKET_CLOSED'; - } - if (errorString.includes('position size')) { - return 'INVALID_POSITION_SIZE'; - } - if (errorString.includes('leverage')) { - return 'INVALID_LEVERAGE'; - } - if (errorString.includes('price')) { - return 'INVALID_PRICE'; - } - - // Default to the raw error message if not a known code - return errorString; - }, []); - - /** - * Track error with PERPS_ERROR_ENCOUNTERED event - */ - const trackError = useCallback( - (error: unknown, context?: PerpsErrorContext) => { - const errorCode = getErrorCode(error); - const errorMessage = - error instanceof Error ? error.message : String(error); - - // Log error for debugging - DevLogger.log('PerpsErrorTracking: Error encountered', { - errorCode, - errorMessage, - context, - stack: error instanceof Error ? error.stack : undefined, - }); - - // Build event properties - const eventProperties: Record = { - 'Error Code': errorCode, - [PerpsEventProperties.ERROR_MESSAGE]: errorMessage, - [PerpsEventProperties.TIMESTAMP]: Date.now(), - }; - - // Add context properties if provided - if (context) { - if (context.operation) { - eventProperties.Operation = context.operation; - } - if (context.asset) { - eventProperties[PerpsEventProperties.ASSET] = context.asset; - } - if (context.direction) { - eventProperties[PerpsEventProperties.DIRECTION] = - context.direction === 'long' ? 'Long' : 'Short'; - } - if (context.orderType) { - eventProperties[PerpsEventProperties.ORDER_TYPE] = context.orderType; - } - if (context.amount !== undefined) { - eventProperties.Amount = String(context.amount); - } - if (context.provider) { - eventProperties.Provider = context.provider; - } - } - - // Track the error event - trackEvent( - createEventBuilder(MetaMetricsEvents.PERPS_ERROR) - .addProperties(eventProperties) - .build(), - ); - - return errorCode; - }, - [getErrorCode, trackEvent, createEventBuilder], - ); - - return { - trackError, - getErrorCode, - }; -} diff --git a/app/components/UI/Perps/hooks/usePerpsImagePrefetch.test.ts b/app/components/UI/Perps/hooks/usePerpsImagePrefetch.test.ts deleted file mode 100644 index 818c5646bae3..000000000000 --- a/app/components/UI/Perps/hooks/usePerpsImagePrefetch.test.ts +++ /dev/null @@ -1,750 +0,0 @@ -import { renderHook, act } from '@testing-library/react-hooks'; -import { waitFor } from '@testing-library/react-native'; -import { Image } from 'expo-image'; -import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger'; -import { - usePerpsImagePrefetch, - usePerpsVisibleImagePrefetch, - usePerpsClearImageCache, -} from './usePerpsImagePrefetch'; - -// Mock expo-image -jest.mock('expo-image', () => ({ - Image: { - prefetch: jest.fn(), - clearDiskCache: jest.fn(), - clearMemoryCache: jest.fn(), - }, -})); - -// Mock DevLogger -jest.mock('../../../../core/SDKConnect/utils/DevLogger'); - -// Test utilities -const createMockSymbols = (count: number): string[] => - Array.from({ length: count }, (_, i) => `TOKEN${i}`); - -const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); - -describe('usePerpsImagePrefetch', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - afterEach(() => { - jest.clearAllTimers(); - }); - - describe('Basic functionality', () => { - it('should not prefetch when symbols array is empty', () => { - // Arrange - const emptySymbols: string[] = []; - - // Act - const { result } = renderHook(() => usePerpsImagePrefetch(emptySymbols)); - - // Assert - expect(Image.prefetch).not.toHaveBeenCalled(); - expect(result.current.prefetchedCount).toBe(0); - expect(result.current.isPrefetching).toBe(false); - }); - - it('should prefetch images for provided symbols', async () => { - // Arrange - const symbols = ['BTC', 'ETH']; - (Image.prefetch as jest.Mock).mockResolvedValue(true); - - // Act - const { result } = renderHook(() => usePerpsImagePrefetch(symbols)); - - // Assert - wait for prefetch to be called - await waitFor(() => { - expect(Image.prefetch).toHaveBeenCalledWith( - expect.stringContaining('BTC.svg'), - expect.objectContaining({ cachePolicy: 'memory-disk' }), - ); - expect(Image.prefetch).toHaveBeenCalledWith( - expect.stringContaining('ETH.svg'), - expect.objectContaining({ cachePolicy: 'memory-disk' }), - ); - }); - - await waitFor(() => { - expect(result.current.prefetchedCount).toBe(2); - }); - }); - - it('should convert symbols to uppercase for URLs', async () => { - // Arrange - const symbols = ['btc', 'eth']; - (Image.prefetch as jest.Mock).mockResolvedValue(true); - - // Act - renderHook(() => usePerpsImagePrefetch(symbols)); - - // Assert - await waitFor(() => { - expect(Image.prefetch).toHaveBeenCalledWith( - 'https://app.hyperliquid.xyz/coins/BTC.svg', - expect.any(Object), - ); - expect(Image.prefetch).toHaveBeenCalledWith( - 'https://app.hyperliquid.xyz/coins/ETH.svg', - expect.any(Object), - ); - }); - }); - - it('should use memory-disk cache policy', async () => { - // Arrange - const symbols = ['BTC']; - (Image.prefetch as jest.Mock).mockResolvedValue(true); - - // Act - renderHook(() => usePerpsImagePrefetch(symbols)); - - // Assert - await waitFor(() => { - expect(Image.prefetch).toHaveBeenCalledWith(expect.any(String), { - cachePolicy: 'memory-disk', - }); - }); - }); - }); - - describe('Batch processing', () => { - it('should process images in default batch size of 25', async () => { - // Arrange - jest.useFakeTimers(); - const symbols = Array.from({ length: 30 }, (_, i) => `TOKEN${i}`); - (Image.prefetch as jest.Mock).mockResolvedValue(true); - - // Act - renderHook(() => usePerpsImagePrefetch(symbols)); - - // Assert - First batch - await waitFor(() => { - expect(Image.prefetch).toHaveBeenCalledTimes(25); - }); - - // Advance timer for batch delay - act(() => { - jest.advanceTimersByTime(50); - }); - - // Assert - Second batch - await waitFor(() => { - expect(Image.prefetch).toHaveBeenCalledTimes(30); - }); - - jest.useRealTimers(); - }); - - it('should respect custom batch size from options', async () => { - // Arrange - jest.useFakeTimers(); - const symbols = Array.from({ length: 15 }, (_, i) => `TOKEN${i}`); - const options = { batchSize: 5 }; - (Image.prefetch as jest.Mock).mockResolvedValue(true); - - // Act - renderHook(() => usePerpsImagePrefetch(symbols, options)); - - // Assert - First batch - await waitFor(() => { - expect(Image.prefetch).toHaveBeenCalledTimes(5); - }); - - // Advance timer - act(() => { - jest.advanceTimersByTime(50); - }); - - // Assert - Second batch - await waitFor(() => { - expect(Image.prefetch).toHaveBeenCalledTimes(10); - }); - - jest.useRealTimers(); - }); - - it('should add 50ms delay between batches', async () => { - // Arrange - jest.useFakeTimers(); - const symbols = Array.from({ length: 50 }, (_, i) => `TOKEN${i}`); - (Image.prefetch as jest.Mock).mockResolvedValue(true); - - // Act - const { result } = renderHook(() => - usePerpsImagePrefetch(symbols, { batchSize: 25 }), - ); - - // Wait for first batch to complete - await waitFor(() => { - expect(Image.prefetch).toHaveBeenCalledTimes(25); - }); - - // Advance timer for batch delay - act(() => { - jest.advanceTimersByTime(50); - }); - - // Wait for second batch to complete - await waitFor(() => { - expect(Image.prefetch).toHaveBeenCalledTimes(50); - }); - - // Both calls should have delay - await waitFor(() => { - expect(result.current.prefetchedCount).toBe(50); - }); - - jest.useRealTimers(); - }); - - it('should complete all batches even if some fail', async () => { - // Arrange - jest.useFakeTimers(); - const symbols = Array.from({ length: 10 }, (_, i) => `TOKEN${i}`); - (Image.prefetch as jest.Mock) - .mockResolvedValueOnce(true) - .mockRejectedValueOnce(new Error('Network error')) - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(true); - - // Act - const { result } = renderHook(() => - usePerpsImagePrefetch(symbols, { batchSize: 5 }), - ); - - // Assert - All symbols attempted despite error - await waitFor(() => { - expect(Image.prefetch).toHaveBeenCalledTimes(5); - }); - - act(() => { - jest.advanceTimersByTime(50); - }); - - await waitFor(() => { - expect(Image.prefetch).toHaveBeenCalledTimes(10); - }); - - // Check that both batches were attempted - await waitFor(() => { - expect(result.current.prefetchedCount).toBe(9); // All except the failed one - }); - - jest.useRealTimers(); - }); - }); - - describe('Deduplication', () => { - it('should not prefetch already cached symbols', async () => { - // Arrange - jest.useFakeTimers(); - const symbols = ['BTC', 'ETH']; - (Image.prefetch as jest.Mock).mockResolvedValue(true); - - // Act - First render - const { rerender } = renderHook( - ({ syms }) => usePerpsImagePrefetch(syms), - { initialProps: { syms: symbols } }, - ); - - // Wait for initial prefetch to complete - await waitFor(() => { - expect(Image.prefetch).toHaveBeenCalledTimes(2); - }); - - // Act - Second render with same symbols - jest.clearAllMocks(); - rerender({ syms: symbols }); - - // Give it a moment to potentially make calls - act(() => { - jest.runAllTimers(); - }); - - // Assert - Should not prefetch again - expect(Image.prefetch).not.toHaveBeenCalled(); - - jest.useRealTimers(); - }); - - it('should handle duplicate symbols in input array', async () => { - // Arrange - const symbols = ['BTC', 'btc', 'BTC', 'ETH', 'eth']; - (Image.prefetch as jest.Mock).mockResolvedValue(true); - - // Act - renderHook(() => usePerpsImagePrefetch(symbols)); - - // Assert - Should prefetch all symbols (hook converts to uppercase internally) - await waitFor(() => { - expect(Image.prefetch).toHaveBeenCalledTimes(5); // All 5 symbols get prefetched - }); - - // But URLs should be uppercase - expect(Image.prefetch).toHaveBeenCalledWith( - expect.stringContaining('BTC.svg'), - expect.any(Object), - ); - expect(Image.prefetch).toHaveBeenCalledWith( - expect.stringContaining('ETH.svg'), - expect.any(Object), - ); - }); - - it('should track successfully prefetched symbols', async () => { - // Arrange - const symbols = ['BTC', 'ETH', 'SOL']; - (Image.prefetch as jest.Mock) - .mockResolvedValueOnce(true) // BTC success - .mockResolvedValueOnce(false) // ETH fail - .mockResolvedValueOnce(true); // SOL success - - // Act - const { result } = renderHook(() => usePerpsImagePrefetch(symbols)); - - // Assert - wait for count to update - await waitFor(() => { - expect(result.current.prefetchedCount).toBe(2); // Only BTC and SOL - }); - }); - - it('should handle mixed case duplicates correctly', async () => { - // Arrange - const firstBatch = ['btc', 'eth']; - const secondBatch = ['BTC', 'ETH', 'SOL']; - (Image.prefetch as jest.Mock).mockResolvedValue(true); - - // Act - First render with lowercase - const { rerender } = renderHook( - ({ syms }) => usePerpsImagePrefetch(syms), - { initialProps: { syms: firstBatch } }, - ); - - await waitFor(() => { - expect(Image.prefetch).toHaveBeenCalledTimes(2); - }); - - // Act - Second render with uppercase (duplicates) + new symbol - jest.clearAllMocks(); - rerender({ syms: secondBatch }); - - // Assert - Should only prefetch SOL - await waitFor(() => { - expect(Image.prefetch).toHaveBeenCalledTimes(1); - expect(Image.prefetch).toHaveBeenCalledWith( - expect.stringContaining('SOL.svg'), - expect.any(Object), - ); - }); - }); - }); - - describe('Error handling', () => { - it('should continue processing when individual prefetch fails', async () => { - // Arrange - const symbols = ['BTC', 'ETH', 'SOL']; - (Image.prefetch as jest.Mock) - .mockResolvedValueOnce(true) - .mockRejectedValueOnce(new Error('Network error')) - .mockResolvedValueOnce(true); - - // Act - renderHook(() => usePerpsImagePrefetch(symbols)); - - // Assert - All symbols attempted - await waitFor(() => { - expect(Image.prefetch).toHaveBeenCalledTimes(3); - }); - }); - - it('should log errors in development mode', async () => { - // Arrange - const originalDev = __DEV__; - Object.defineProperty(global, '__DEV__', { - value: true, - writable: true, - configurable: true, - }); - const symbols = ['BTC']; - const testError = new Error('Test error'); - (Image.prefetch as jest.Mock).mockRejectedValue(testError); - - // Act - renderHook(() => usePerpsImagePrefetch(symbols)); - - // Wait for the hook to process - await waitFor(() => { - expect(Image.prefetch).toHaveBeenCalled(); - }); - - // Assert - When individual prefetch fails, it logs success count - expect(DevLogger.log).toHaveBeenCalledWith( - expect.stringContaining('Prefetched 0/1'), - ); - - // Cleanup - Object.defineProperty(global, '__DEV__', { - value: originalDev, - writable: true, - configurable: true, - }); - }); - - it('should handle network timeouts gracefully', async () => { - // Arrange - const symbols = ['BTC', 'ETH']; - (Image.prefetch as jest.Mock).mockRejectedValue(new Error('Timeout')); - - // Act - const { result } = renderHook(() => usePerpsImagePrefetch(symbols)); - - // Wait for the hook to process - await waitFor(() => { - expect(Image.prefetch).toHaveBeenCalled(); - }); - - // Assert - Should handle error and continue - expect(result.current.prefetchedCount).toBe(0); - }); - }); - - describe('Edge cases', () => { - it.each([ - { symbols: [], expectedCalls: 0, description: 'empty array' }, - { symbols: ['BTC'], expectedCalls: 1, description: 'single symbol' }, - { - symbols: ['btc', 'BTC'], - expectedCalls: 2, // Hook doesn't deduplicate input - description: 'duplicate with different case', - }, - { - symbols: new Array(100).fill('ETH'), - expectedCalls: 100, // Hook doesn't deduplicate input - description: 'many duplicates', - }, - { - symbols: ['BTC', '', null, 'ETH', undefined, 'SOL', ' '], - expectedCalls: 3, // Hook now correctly filters and processes only valid symbols (BTC, ETH, SOL) - description: 'invalid values mixed with valid', - }, - ])( - 'should handle $description correctly', - async ({ symbols, expectedCalls }) => { - // Arrange - (Image.prefetch as jest.Mock).mockResolvedValue(true); - - // Act - renderHook(() => usePerpsImagePrefetch(symbols)); - - // Assert - if (expectedCalls === 0) { - expect(Image.prefetch).not.toHaveBeenCalled(); - } else { - await waitFor(() => { - expect(Image.prefetch).toHaveBeenCalledTimes(expectedCalls); - }); - } - }, - ); - - it('should handle very large arrays efficiently', async () => { - // Arrange - jest.useFakeTimers(); - const symbols = createMockSymbols(500); - (Image.prefetch as jest.Mock).mockResolvedValue(true); - - // Act - const { result } = renderHook(() => - usePerpsImagePrefetch(symbols, { batchSize: 50 }), - ); - - // Advance timer for all batches - act(() => { - jest.advanceTimersByTime(500); // 10 batches with 50ms delay - }); - - // Wait for completion - await waitFor(() => { - expect(result.current.isPrefetching).toBe(false); - }); - - // Assert - expect(Image.prefetch).toHaveBeenCalledTimes(500); - expect(result.current.prefetchedCount).toBe(500); - - jest.useRealTimers(); - }); - }); - - describe('Concurrent execution prevention', () => { - it('should not start new prefetch while one is in progress', async () => { - // Arrange - const symbols1 = ['BTC', 'ETH']; - const symbols2 = ['SOL', 'AVAX']; - let resolvePrefetch: (value: boolean) => void; - (Image.prefetch as jest.Mock).mockImplementation( - () => - new Promise((resolve) => { - resolvePrefetch = resolve; - }), - ); - - // Act - Start first prefetch - const { rerender } = renderHook( - ({ syms }) => usePerpsImagePrefetch(syms), - { initialProps: { syms: symbols1 } }, - ); - - // Wait a tick for the effect to run - await act(async () => { - await flushPromises(); - }); - - // Immediately try to start another while first is pending - rerender({ syms: symbols2 }); - - // Complete the first prefetch - act(() => { - resolvePrefetch(true); - }); - - await act(async () => { - await flushPromises(); - }); - - // Assert - Only first batch should be processed - expect(Image.prefetch).toHaveBeenCalledTimes(2); - expect(Image.prefetch).toHaveBeenCalledWith( - expect.stringContaining('BTC.svg'), - expect.any(Object), - ); - expect(Image.prefetch).toHaveBeenCalledWith( - expect.stringContaining('ETH.svg'), - expect.any(Object), - ); - }); - }); -}); - -describe('usePerpsVisibleImagePrefetch', () => { - beforeEach(() => { - jest.clearAllMocks(); - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.runOnlyPendingTimers(); - jest.useRealTimers(); - }); - - it('should prefetch visible range plus lookahead', async () => { - // Arrange - const allSymbols = Array.from({ length: 100 }, (_, i) => `TOKEN${i}`); - const visibleRange = { first: 10, last: 20 }; - const prefetchAhead = 5; - (Image.prefetch as jest.Mock).mockResolvedValue(true); - - // Act - renderHook(() => - usePerpsVisibleImagePrefetch(allSymbols, visibleRange, prefetchAhead), - ); - - // Assert - Should prefetch from 10 to 25 (20 + 5) - await waitFor(() => { - // With batch size of 5, should have made calls for indices 10-24 - expect(Image.prefetch).toHaveBeenCalled(); - const calls = (Image.prefetch as jest.Mock).mock.calls; - const prefetchedSymbols = calls.map((call) => { - const url = call[0]; - const match = url.match(/TOKEN(\d+)\.svg/); - return match ? parseInt(match[1]) : -1; - }); - expect(Math.min(...prefetchedSymbols)).toBe(10); - expect(Math.max(...prefetchedSymbols)).toBeLessThanOrEqual(25); - }); - }); - - it('should handle edge of list correctly', async () => { - // Arrange - const allSymbols = ['BTC', 'ETH', 'SOL']; - const visibleRange = { first: 1, last: 2 }; - const prefetchAhead = 10; // More than available - (Image.prefetch as jest.Mock).mockResolvedValue(true); - - // Act - renderHook(() => - usePerpsVisibleImagePrefetch(allSymbols, visibleRange, prefetchAhead), - ); - - // Assert - Should only prefetch available symbols (ETH and SOL) - await waitFor(() => { - expect(Image.prefetch).toHaveBeenCalledTimes(2); - expect(Image.prefetch).toHaveBeenCalledWith( - expect.stringContaining('ETH.svg'), - expect.any(Object), - ); - expect(Image.prefetch).toHaveBeenCalledWith( - expect.stringContaining('SOL.svg'), - expect.any(Object), - ); - }); - }); - - it('should handle negative indices correctly', async () => { - // Arrange - const allSymbols = ['BTC', 'ETH', 'SOL']; - const visibleRange = { first: -5, last: 1 }; - (Image.prefetch as jest.Mock).mockResolvedValue(true); - - // Act - renderHook(() => usePerpsVisibleImagePrefetch(allSymbols, visibleRange)); - - // Assert - Should start from 0 - await waitFor(() => { - expect(Image.prefetch).toHaveBeenCalledWith( - expect.stringContaining('BTC.svg'), - expect.any(Object), - ); - }); - }); - - it('should handle empty symbols array', () => { - // Arrange - const allSymbols: string[] = []; - const visibleRange = { first: 0, last: 10 }; - - // Act - renderHook(() => usePerpsVisibleImagePrefetch(allSymbols, visibleRange)); - - // Assert - expect(Image.prefetch).not.toHaveBeenCalled(); - }); - - it('should use high priority and smaller batch size', async () => { - // Arrange - const allSymbols = Array.from({ length: 20 }, (_, i) => `TOKEN${i}`); - const visibleRange = { first: 0, last: 10 }; - (Image.prefetch as jest.Mock).mockResolvedValue(true); - - // Act - renderHook(() => usePerpsVisibleImagePrefetch(allSymbols, visibleRange)); - - // Assert - Should use batch size of 5 - await waitFor(() => { - expect(Image.prefetch).toHaveBeenCalledTimes(5); // First batch - }); - - act(() => { - jest.advanceTimersByTime(50); - }); - - await waitFor(() => { - expect(Image.prefetch).toHaveBeenCalledTimes(10); // Second batch - }); - }); -}); - -describe('usePerpsClearImageCache', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should clear both memory and disk cache', async () => { - // Arrange - (Image.clearMemoryCache as jest.Mock).mockResolvedValue(undefined); - (Image.clearDiskCache as jest.Mock).mockResolvedValue(undefined); - - // Act - const { result } = renderHook(() => usePerpsClearImageCache()); - let success: boolean = false; - await act(async () => { - success = await result.current.clearCache(); - }); - - // Assert - expect(Image.clearMemoryCache).toHaveBeenCalled(); - expect(Image.clearDiskCache).toHaveBeenCalled(); - expect(success).toBe(true); - expect(DevLogger.log).toHaveBeenCalledWith( - 'Image cache cleared successfully', - ); - }); - - it('should only clear disk cache when diskOnly is true', async () => { - // Arrange - (Image.clearMemoryCache as jest.Mock).mockResolvedValue(undefined); - (Image.clearDiskCache as jest.Mock).mockResolvedValue(undefined); - - // Act - const { result } = renderHook(() => usePerpsClearImageCache()); - await act(async () => { - await result.current.clearCache(true); - }); - - // Assert - expect(Image.clearMemoryCache).not.toHaveBeenCalled(); - expect(Image.clearDiskCache).toHaveBeenCalled(); - }); - - it('should return false and log on error', async () => { - // Arrange - const cacheError = new Error('Cache error'); - (Image.clearMemoryCache as jest.Mock).mockRejectedValue(cacheError); - - // Act - const { result } = renderHook(() => usePerpsClearImageCache()); - let success: boolean = true; - await act(async () => { - success = await result.current.clearCache(); - }); - - // Assert - expect(success).toBe(false); - expect(DevLogger.log).toHaveBeenCalledWith( - 'Failed to clear image cache:', - cacheError, - ); - }); - - it('should provide stable function reference', () => { - // Act - const { result, rerender } = renderHook(() => usePerpsClearImageCache()); - const firstRef = result.current.clearCache; - - rerender(); - const secondRef = result.current.clearCache; - - // Assert - expect(firstRef).toBe(secondRef); - }); - - it('should handle disk cache error separately', async () => { - // Arrange - (Image.clearMemoryCache as jest.Mock).mockResolvedValue(undefined); - (Image.clearDiskCache as jest.Mock).mockRejectedValue( - new Error('Disk error'), - ); - - // Act - const { result } = renderHook(() => usePerpsClearImageCache()); - let success: boolean = true; - await act(async () => { - success = await result.current.clearCache(); - }); - - // Assert - expect(Image.clearMemoryCache).toHaveBeenCalled(); - expect(success).toBe(false); - }); -}); diff --git a/app/components/UI/Perps/hooks/usePerpsImagePrefetch.ts b/app/components/UI/Perps/hooks/usePerpsImagePrefetch.ts deleted file mode 100644 index daef066232d1..000000000000 --- a/app/components/UI/Perps/hooks/usePerpsImagePrefetch.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { Image } from 'expo-image'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger'; -import { getAssetIconUrl } from '../utils/marketUtils'; - -/** - * Hook to prefetch Perps market icons for better performance - * Images are cached to disk persistently using expo-image - * - * @param symbols - Array of market symbols to prefetch - * @param options - Prefetch options - */ -export const usePerpsImagePrefetch = ( - symbols: string[], - options?: { - batchSize?: number; // Number of images to prefetch at once - }, -) => { - const [prefetchedSymbols, setPrefetchedSymbols] = useState>( - new Set(), - ); - const [isPrefetching, setIsPrefetching] = useState(false); - const prefetchedRef = useRef>(new Set()); - const isPrefetchingRef = useRef(false); - const pendingSymbolsRef = useRef([]); - - const processBatch = useCallback( - async (symbolsToProcess: string[]) => { - const batchSize = options?.batchSize || 25; // Increased default for ~173 markets - - try { - // Process in batches to avoid overwhelming the network - for (let i = 0; i < symbolsToProcess.length; i += batchSize) { - const batch = symbolsToProcess.slice(i, i + batchSize); - // Additional safety check before URL construction - const urls = batch - .filter( - (symbol) => symbol && typeof symbol === 'string' && symbol.trim(), - ) - .map((symbol) => getAssetIconUrl(symbol)); - - // Prefetch with persistent disk caching - // expo-image handles all caching internally, no need for HTTP headers - const results = await Promise.allSettled( - urls.map((url) => - Image.prefetch(url, { - cachePolicy: 'memory-disk', - }), - ), - ); - - // Track successfully prefetched symbols - const newPrefetched: string[] = []; - for (const [index, result] of results.entries()) { - if (result.status === 'fulfilled' && result.value) { - const symbol = batch[index].toUpperCase(); - prefetchedRef.current.add(symbol); - newPrefetched.push(symbol); - } - } - - // Update state to trigger re-render - if (newPrefetched.length > 0) { - setPrefetchedSymbols( - (prev) => new Set([...prev, ...newPrefetched]), - ); - } - - // Log progress in development - if (__DEV__) { - const successCount = results.filter( - (r) => r.status === 'fulfilled' && r.value, - ).length; - DevLogger.log( - `Prefetched ${successCount}/${batch.length} icons (batch ${ - Math.floor(i / batchSize) + 1 - })`, - ); - } - - // Smaller delay between batches for faster loading - if (i + batchSize < symbolsToProcess.length) { - await new Promise((resolve) => setTimeout(resolve, 50)); - } - } - } catch (error) { - DevLogger.log('Error prefetching images:', error); - } - }, - [options?.batchSize], - ); - - const processPendingSymbols = useCallback(async () => { - const pendingSymbols = pendingSymbolsRef.current; - if (pendingSymbols.length === 0) { - return; - } - - pendingSymbolsRef.current = []; - - // Process pending symbols - const pendingSymbolsToPrefetch = pendingSymbols - .filter((symbol) => symbol && typeof symbol === 'string' && symbol.trim()) - .filter((symbol) => !prefetchedRef.current.has(symbol.toUpperCase())); - - if (pendingSymbolsToPrefetch.length > 0) { - // Small delay to allow state to settle - setTimeout(async () => { - if (!isPrefetchingRef.current) { - isPrefetchingRef.current = true; - setIsPrefetching(true); - - try { - await processBatch(pendingSymbolsToPrefetch); - } finally { - isPrefetchingRef.current = false; - setIsPrefetching(false); - // Recursively handle any new pending symbols - await processPendingSymbols(); - } - } - }, 100); - } - }, [processBatch]); - - const prefetchImages = useCallback( - async (symbolsToPrefetch: string[]) => { - isPrefetchingRef.current = true; - setIsPrefetching(true); - - try { - await processBatch(symbolsToPrefetch); - } finally { - isPrefetchingRef.current = false; - setIsPrefetching(false); - // Process any pending symbols that arrived during prefetching - await processPendingSymbols(); - } - }, - [processBatch, processPendingSymbols], - ); - - const filterSymbolsToPrefetch = useCallback( - (inputSymbols: string[]) => - inputSymbols - .filter( - (symbol) => symbol && typeof symbol === 'string' && symbol.trim(), - ) - .filter((symbol) => !prefetchedRef.current.has(symbol.toUpperCase())), - [], - ); - - useEffect(() => { - if (!symbols?.length) { - return; - } - - // If currently prefetching, queue these symbols for later processing - if (isPrefetchingRef.current) { - pendingSymbolsRef.current = symbols; - return; - } - - // Filter out invalid symbols and already prefetched symbols - const symbolsToPrefetch = filterSymbolsToPrefetch(symbols); - - if (symbolsToPrefetch.length === 0) { - return; - } - - prefetchImages(symbolsToPrefetch); - }, [symbols, prefetchImages, filterSymbolsToPrefetch]); - - return { - prefetchedCount: prefetchedSymbols.size, - isPrefetching, - }; -}; - -/** - * Hook to prefetch visible market icons plus next N items - * Useful for FlashList optimization - */ -export const usePerpsVisibleImagePrefetch = ( - allSymbols: string[], - visibleRange: { first: number; last: number }, - prefetchAhead: number = 10, -) => { - const symbolsToPrefetch = useMemo(() => { - if (!allSymbols?.length) return []; - - const start = Math.max(0, visibleRange.first); - const end = Math.min(allSymbols.length, visibleRange.last + prefetchAhead); - - return allSymbols.slice(start, end); - }, [allSymbols, visibleRange, prefetchAhead]); - - return usePerpsImagePrefetch(symbolsToPrefetch, { - batchSize: 5, - }); -}; - -/** - * Hook to clear image cache if needed - * Useful for debugging or when switching environments - */ -export const usePerpsClearImageCache = () => { - const clearCache = useCallback(async (diskOnly = false) => { - try { - if (!diskOnly) { - await Image.clearMemoryCache(); - } - await Image.clearDiskCache(); - DevLogger.log('Image cache cleared successfully'); - return true; - } catch (error) { - DevLogger.log('Failed to clear image cache:', error); - return false; - } - }, []); - - return { clearCache }; -}; diff --git a/app/components/UI/Perps/hooks/usePerpsMarketStats.test.ts b/app/components/UI/Perps/hooks/usePerpsMarketStats.test.ts index 451800b1f6dd..9d9679e56059 100644 --- a/app/components/UI/Perps/hooks/usePerpsMarketStats.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsMarketStats.test.ts @@ -80,6 +80,7 @@ describe('usePerpsMarketStats', () => { priceData: null, isLoadingHistory: false, refreshCandleData: jest.fn(), + hasHistoricalData: true, }); // Act: Render the hook with a symbol @@ -104,6 +105,7 @@ describe('usePerpsMarketStats', () => { priceData: null, isLoadingHistory: true, refreshCandleData: jest.fn(), + hasHistoricalData: false, }); // Act: Render the hook @@ -122,6 +124,7 @@ describe('usePerpsMarketStats', () => { priceData: null, isLoadingHistory: false, refreshCandleData: jest.fn(), + hasHistoricalData: false, }); // Act: Render the hook @@ -158,6 +161,7 @@ describe('usePerpsMarketStats', () => { priceData: null, isLoadingHistory: false, refreshCandleData: jest.fn(), + hasHistoricalData: true, }); const { result } = renderHook(() => usePerpsMarketStats('BTC')); @@ -185,6 +189,7 @@ describe('usePerpsMarketStats', () => { priceData: null, isLoadingHistory: false, refreshCandleData: jest.fn(), + hasHistoricalData: true, }); const { result } = renderHook(() => usePerpsMarketStats('BTC')); diff --git a/app/components/UI/Perps/hooks/usePerpsNavigation.test.ts b/app/components/UI/Perps/hooks/usePerpsNavigation.test.ts index bd9971b5c1ad..ff2a1f325d5e 100644 --- a/app/components/UI/Perps/hooks/usePerpsNavigation.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsNavigation.test.ts @@ -75,7 +75,10 @@ describe('usePerpsNavigation', () => { result.current.navigateToActivity(); - expect(mockNavigate).toHaveBeenCalledWith(Routes.TRANSACTIONS_VIEW); + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ACTIVITY, { + redirectToPerpsTransactions: true, + showBackButton: true, + }); }); it('navigates to settings when rewards disabled', () => { diff --git a/app/components/UI/Perps/hooks/usePerpsNavigation.ts b/app/components/UI/Perps/hooks/usePerpsNavigation.ts index b6c327e58fc0..71b87751c185 100644 --- a/app/components/UI/Perps/hooks/usePerpsNavigation.ts +++ b/app/components/UI/Perps/hooks/usePerpsNavigation.ts @@ -87,7 +87,10 @@ export const usePerpsNavigation = (): PerpsNavigationHandlers => { }, [navigation]); const navigateToActivity = useCallback(() => { - navigation.navigate(Routes.TRANSACTIONS_VIEW); + navigation.navigate(Routes.PERPS.ACTIVITY, { + redirectToPerpsTransactions: true, + showBackButton: true, + }); }, [navigation]); const navigateToRewardsOrSettings = useCallback(() => { diff --git a/app/components/UI/Perps/hooks/usePerpsPositionData.test.ts b/app/components/UI/Perps/hooks/usePerpsPositionData.test.ts index f4b3a2bd3f55..12b063d8ea3b 100644 --- a/app/components/UI/Perps/hooks/usePerpsPositionData.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsPositionData.test.ts @@ -615,7 +615,7 @@ describe('usePerpsPositionData', () => { }); // Assert - Should have empty candles array (no historical + no live candle created) - expect(result.current.candleData?.candles).toEqual([]); + expect(result.current.candleData?.candles).toEqual(undefined); // Assert - priceData should be set to the update object (even without price field) // The hook doesn't validate price field existence, just coin matching @@ -1214,7 +1214,7 @@ describe('usePerpsPositionData', () => { }); // Assert - expect(result.current.candleData?.candles).toEqual([]); + expect(result.current.candleData?.candles).toEqual(undefined); expect(result.current.isLoadingHistory).toBe(false); }); diff --git a/app/components/UI/Perps/hooks/usePerpsPositionData.ts b/app/components/UI/Perps/hooks/usePerpsPositionData.ts index 4ab867252209..b97a89c187c9 100644 --- a/app/components/UI/Perps/hooks/usePerpsPositionData.ts +++ b/app/components/UI/Perps/hooks/usePerpsPositionData.ts @@ -25,6 +25,7 @@ export const usePerpsPositionData = ({ const [priceData, setPriceData] = useState(null); const [isLoadingHistory, setIsLoadingHistory] = useState(false); const [liveCandle, setLiveCandle] = useState(null); + const [hasHistoricalData, setHasHistoricalData] = useState(false); const prevMergedDataRef = useRef(null); // Helper function to get the current candle's start time based on interval @@ -103,18 +104,27 @@ export const usePerpsPositionData = ({ // Load historical candles useEffect(() => { setIsLoadingHistory(true); + setHasHistoricalData(false); const loadHistoricalData = async () => { try { const historicalData = await fetchHistoricalCandles(); - setCandleData((prev) => { - // Prevent re-render if data is identical - if (isEqual(prev, historicalData)) { - return prev; - } - return historicalData; - }); + // Only set data and flag if we received valid data + if (historicalData && historicalData.candles?.length > 0) { + setCandleData((prev) => { + // Prevent re-render if data is identical + if (isEqual(prev, historicalData)) { + return prev; + } + return historicalData; + }); + setHasHistoricalData(true); + } else { + // No valid data received + setHasHistoricalData(false); + } } catch (err) { console.error('Error loading historical candles:', err); + setHasHistoricalData(false); } finally { setIsLoadingHistory(false); } @@ -241,7 +251,10 @@ export const usePerpsPositionData = ({ // Merge historical candles with live candle for chart display const candleDataWithLive = useMemo(() => { - if (!candleData || !liveCandle) return candleData; + // Don't return any data until we have successfully loaded historical candles + if (!hasHistoricalData || !candleData) return null; + + if (!liveCandle) return candleData; // Check if live candle already exists in historical data const existingCandleIndex = candleData.candles.findIndex( @@ -270,21 +283,29 @@ export const usePerpsPositionData = ({ prevMergedDataRef.current = mergedData; return mergedData; - }, [candleData, liveCandle]); + }, [candleData, liveCandle, hasHistoricalData]); const refreshCandleData = useCallback(async () => { setIsLoadingHistory(true); try { const historicalData = await fetchHistoricalCandles(); - setCandleData((prev) => { - // Prevent re-render if data is identical - if (isEqual(prev, historicalData)) { - return prev; - } - return historicalData; - }); + + if (historicalData && historicalData.candles?.length > 0) { + setCandleData((prev) => { + // Prevent re-render if data is identical + if (isEqual(prev, historicalData)) { + return prev; + } + return historicalData; + }); + setHasHistoricalData(true); + } else { + // No valid data received on refresh + setHasHistoricalData(false); + } } catch (err) { console.error('Error refreshing candle data:', err); + setHasHistoricalData(false); } finally { setIsLoadingHistory(false); } @@ -295,5 +316,6 @@ export const usePerpsPositionData = ({ priceData, isLoadingHistory, refreshCandleData, + hasHistoricalData, }; }; diff --git a/app/components/UI/Perps/routes/index.tsx b/app/components/UI/Perps/routes/index.tsx index 086b52ea547e..30a3daf63642 100644 --- a/app/components/UI/Perps/routes/index.tsx +++ b/app/components/UI/Perps/routes/index.tsx @@ -19,6 +19,7 @@ import { Confirm } from '../../../Views/confirmations/components/confirm'; import PerpsGTMModal from '../components/PerpsGTMModal'; import PerpsTPSLView from '../Views/PerpsTPSLView/PerpsTPSLView'; import PerpsHeroCardView from '../Views/PerpsHeroCardView'; +import ActivityView from '../../../Views/ActivityView'; import PerpsStreamBridge from '../components/PerpsStreamBridge'; import { HIP3DebugView } from '../Debug'; @@ -185,6 +186,14 @@ const PerpsScreenStack = () => ( headerShown: false, }} /> + {/* Modal stack for bottom sheet modals */} ; -const mockDevLogger = DevLogger as jest.Mocked; -const mockDetectHyperLiquidWithdrawal = - detectHyperLiquidWithdrawal as jest.MockedFunction< - typeof detectHyperLiquidWithdrawal - >; -const mockTransformArbitrumWithdrawalsToHistoryItems = - transformArbitrumWithdrawalsToHistoryItems as jest.MockedFunction< - typeof transformArbitrumWithdrawalsToHistoryItems - >; -const mockSelectChainId = selectChainId as jest.MockedFunction< - typeof selectChainId ->; -const mockStore = store as jest.Mocked; - -describe('ArbitrumWithdrawalService', () => { - let service: ArbitrumWithdrawalService; - let mockTransactionController: unknown; - let mockPreferencesController: unknown; - - beforeEach(() => { - jest.clearAllMocks(); - - // Mock Engine context - mockTransactionController = { - state: { - transactions: { - tx1: { - hash: '0x123', - txParams: { - from: '0xbridge', - to: '0xuser', - data: '0xdata', - }, - chainId: '0xa4b1', - time: 1640995200000, - status: 'confirmed', - blockNumber: '12345', - }, - tx2: { - hash: '0x456', - txParams: { - from: '0xother', - to: '0xuser', - data: '0xdata2', - }, - chainId: '0xa4b1', - time: 1640995201000, - status: 'confirmed', - blockNumber: '12346', - }, - }, - }, - }; - - mockPreferencesController = { - state: { - selectedAddress: '0xuser', - }, - }; - - ( - mockEngine as unknown as { - context: { - TransactionController: unknown; - PreferencesController: unknown; - }; - } - ).context = { - TransactionController: mockTransactionController, - PreferencesController: mockPreferencesController, - }; - - // Mock store - mockStore.getState.mockReturnValue({ - engine: { - backgroundState: { - NetworkController: { - provider: { - chainId: '0xa4b1', - }, - }, - }, - }, - } as unknown as RootState); - - // Mock selectors - mockSelectChainId.mockReturnValue('0xa4b1'); - - service = new ArbitrumWithdrawalService(); - }); - - describe('getTransactions', () => { - it('returns transactions from TransactionController', () => { - const transactions = ( - service as unknown as { getTransactions: () => TransactionMeta[] } - ).getTransactions(); - - expect(transactions).toHaveLength(2); - expect(transactions[0].hash).toBe('0x123'); - expect(transactions[1].hash).toBe('0x456'); - }); - - it('returns empty array when TransactionController throws error', () => { - ( - mockEngine as unknown as { context: { TransactionController: unknown } } - ).context.TransactionController = undefined; - - const transactions = ( - service as unknown as { getTransactions: () => TransactionMeta[] } - ).getTransactions(); - - expect(transactions).toEqual([]); - expect(mockDevLogger.log).toHaveBeenCalledWith( - 'Error getting transactions from TransactionController:', - expect.any(Error), - ); - }); - - it('returns empty array when transactions state is undefined', () => { - ( - mockTransactionController as unknown as { - state: { transactions: undefined }; - } - ).state.transactions = undefined; - - const transactions = ( - service as unknown as { getTransactions: () => TransactionMeta[] } - ).getTransactions(); - - expect(transactions).toEqual([]); - }); - }); - - describe('getCurrentChainId', () => { - it('returns chain ID from store', () => { - const chainId = ( - service as unknown as { getCurrentChainId: () => string | null } - ).getCurrentChainId(); - - expect(chainId).toBe('0xa4b1'); - expect(mockSelectChainId).toHaveBeenCalledWith(mockStore.getState()); - }); - - it('returns null when selector throws error', () => { - mockSelectChainId.mockImplementation(() => { - throw new Error('Selector error'); - }); - - const chainId = ( - service as unknown as { getCurrentChainId: () => string | null } - ).getCurrentChainId(); - - expect(chainId).toBeNull(); - expect(mockDevLogger.log).toHaveBeenCalledWith( - 'Error getting current chain ID:', - expect.any(Error), - ); - }); - - it('returns null when selector returns undefined', () => { - mockSelectChainId.mockReturnValue( - null as unknown as SupportedCaipChainId, - ); - - const chainId = ( - service as unknown as { getCurrentChainId: () => string | null } - ).getCurrentChainId(); - - expect(chainId).toBeNull(); - }); - }); - - describe('getCurrentAddress', () => { - it('returns selected address from PreferencesController', () => { - const address = ( - service as unknown as { getCurrentAddress: () => string | null } - ).getCurrentAddress(); - - expect(address).toBe('0xuser'); - }); - - it('returns null when PreferencesController throws error', () => { - ( - mockEngine as unknown as { context: { PreferencesController: unknown } } - ).context.PreferencesController = undefined; - - const address = ( - service as unknown as { getCurrentAddress: () => string | null } - ).getCurrentAddress(); - - expect(address).toBeNull(); - expect(mockDevLogger.log).toHaveBeenCalledWith( - 'Error getting current address:', - expect.any(Error), - ); - }); - - it('returns null when selectedAddress is undefined', () => { - ( - mockPreferencesController as unknown as { - state: { selectedAddress: undefined }; - } - ).state.selectedAddress = undefined; - - const address = ( - service as unknown as { getCurrentAddress: () => string | null } - ).getCurrentAddress(); - - expect(address).toBeNull(); - }); - }); - - describe('detectWithdrawals', () => { - const mockWithdrawal = { - id: 'arbitrum-withdrawal-0x123', - timestamp: 1640995200000, - amount: '100', - txHash: '0x123', - from: '0xbridge', - to: '0xuser', - status: 'completed' as const, - blockNumber: '12345', - }; - - beforeEach(() => { - mockDetectHyperLiquidWithdrawal.mockReturnValue(mockWithdrawal); - }); - - it('detects withdrawals from transactions', () => { - const withdrawals = service.detectWithdrawals(); - - expect(withdrawals).toHaveLength(2); - expect(withdrawals[0]).toEqual(mockWithdrawal); - expect(mockDetectHyperLiquidWithdrawal).toHaveBeenCalledTimes(2); - }); - - it('sorts withdrawals by timestamp descending', () => { - const mockWithdrawal2 = { - ...mockWithdrawal, - id: 'arbitrum-withdrawal-0x456', - txHash: '0x456', - timestamp: 1640995201000, - }; - - mockDetectHyperLiquidWithdrawal - .mockReturnValueOnce(mockWithdrawal) - .mockReturnValueOnce(mockWithdrawal2); - - const withdrawals = service.detectWithdrawals(); - - expect(withdrawals[0].timestamp).toBeGreaterThan( - withdrawals[1].timestamp, - ); - }); - - it('uses provided user address and chain ID', () => { - const customAddress = '0xcustom'; - const customChainId = '0x66eee'; - - service.detectWithdrawals(customAddress, customChainId); - - expect(mockDetectHyperLiquidWithdrawal).toHaveBeenCalledWith( - expect.any(Object), - customAddress, - customChainId, - ); - }); - - it('filters out null detection results', () => { - mockDetectHyperLiquidWithdrawal - .mockReturnValueOnce(mockWithdrawal) - .mockReturnValueOnce(null); - - const withdrawals = service.detectWithdrawals(); - - expect(withdrawals).toHaveLength(1); - expect(withdrawals[0]).toEqual(mockWithdrawal); - }); - - it('handles detection errors gracefully', () => { - mockDetectHyperLiquidWithdrawal.mockImplementation(() => { - throw new Error('Detection error'); - }); - - const withdrawals = service.detectWithdrawals(); - - expect(withdrawals).toEqual([]); - expect(mockDevLogger.log).toHaveBeenCalledWith( - 'Error detecting Arbitrum withdrawals:', - expect.any(Error), - ); - }); - - it('logs detected withdrawals', () => { - service.detectWithdrawals(); - - expect(mockDevLogger.log).toHaveBeenCalledWith( - 'Detected Arbitrum withdrawals:', - expect.objectContaining({ - count: 2, - withdrawals: expect.arrayContaining([ - expect.objectContaining({ - id: expect.any(String), - amount: expect.any(String), - txHash: expect.any(String), - timestamp: expect.any(Number), - }), - ]), - }), - ); - }); - }); - - describe('getWithdrawalHistory', () => { - const mockWithdrawals = [ - { - id: 'arbitrum-withdrawal-0x123', - timestamp: 1640995200000, - amount: '100', - txHash: '0x123', - from: '0xbridge', - to: '0xuser', - status: 'completed' as const, - blockNumber: '12345', - }, - ]; - - const mockHistoryItems = [ - { - id: 'history-1', - timestamp: 1640995200000, - type: 'withdrawal' as const, - amount: '100', - asset: 'USDC', - status: 'completed' as const, - txHash: '0x123', - details: { - source: 'arbitrum', - bridgeContract: '0x1234567890123456789012345678901234567890', - recipient: '0x9876543210987654321098765432109876543210', - blockNumber: '12345', - chainId: '42161', - synthetic: false, - }, - }, - ]; - - beforeEach(() => { - jest.spyOn(service, 'detectWithdrawals').mockReturnValue(mockWithdrawals); - mockTransformArbitrumWithdrawalsToHistoryItems.mockReturnValue( - mockHistoryItems, - ); - }); - - it('transforms withdrawals to history items', () => { - const history = service.getWithdrawalHistory(); - - expect(history).toEqual(mockHistoryItems); - expect( - mockTransformArbitrumWithdrawalsToHistoryItems, - ).toHaveBeenCalledWith(mockWithdrawals); - }); - - it('passes parameters to detectWithdrawals', () => { - const customAddress = '0xcustom'; - const customChainId = '0x66eee'; - - service.getWithdrawalHistory(customAddress, customChainId); - - expect(service.detectWithdrawals).toHaveBeenCalledWith( - customAddress, - customChainId, - ); - }); - }); - - describe('isOnArbitrum', () => { - it('returns true for Arbitrum mainnet', () => { - mockSelectChainId.mockReturnValue('0xa4b1'); - - const result = service.isOnArbitrum(); - - expect(result).toBe(true); - }); - - it('returns true for Arbitrum testnet', () => { - mockSelectChainId.mockReturnValue('0x66eee'); - - const result = service.isOnArbitrum(); - - expect(result).toBe(true); - }); - - it('returns false for other networks', () => { - mockSelectChainId.mockReturnValue('0x1'); - - const result = service.isOnArbitrum(); - - expect(result).toBe(false); - }); - - it('returns false when chain ID is null', () => { - mockSelectChainId.mockReturnValue( - null as unknown as SupportedCaipChainId, - ); - - const result = service.isOnArbitrum(); - - expect(result).toBe(false); - }); - }); -}); diff --git a/app/components/UI/Perps/services/ArbitrumWithdrawalService.ts b/app/components/UI/Perps/services/ArbitrumWithdrawalService.ts deleted file mode 100644 index 5714878ff5ad..000000000000 --- a/app/components/UI/Perps/services/ArbitrumWithdrawalService.ts +++ /dev/null @@ -1,164 +0,0 @@ -import Engine from '../../../../core/Engine'; -import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; -import { detectHyperLiquidWithdrawal } from '../utils/arbitrumWithdrawalDetection'; -import { transformArbitrumWithdrawalsToHistoryItems } from '../utils/arbitrumWithdrawalTransforms'; -import type { TransactionMeta } from '@metamask/transaction-controller'; -import type { UserHistoryItem } from '../controllers/types'; -import { selectChainId } from '../../../../selectors/networkController'; -import { store } from '../../../../store'; - -interface ArbitrumWithdrawal { - id: string; - timestamp: number; - amount: string; - txHash: string; - from: string; - to: string; - status: 'completed' | 'failed' | 'pending'; - blockNumber?: string; -} - -/** - * Service to detect HyperLiquid withdrawals from Arbitrum blockchain transactions - * - * This service can be used by non-React classes (like providers) to access - * blockchain transaction data and detect withdrawals. - */ -export class ArbitrumWithdrawalService { - /** - * Get all transactions from MetaMask's TransactionController - */ - private getTransactions(): TransactionMeta[] { - try { - const transactionController = Engine.context.TransactionController; - const transactions = transactionController.state.transactions || {}; - return Object.values(transactions); - } catch (error) { - DevLogger.log( - 'Error getting transactions from TransactionController:', - error, - ); - return []; - } - } - - /** - * Get current network chain ID - */ - private getCurrentChainId(): string | null { - try { - const state = store.getState(); - return selectChainId(state) || null; - } catch (error) { - DevLogger.log('Error getting current chain ID:', error); - return null; - } - } - - /** - * Get current selected address - */ - private getCurrentAddress(): string | null { - try { - const preferencesController = Engine.context.PreferencesController; - return preferencesController.state.selectedAddress || null; - } catch (error) { - DevLogger.log('Error getting current address:', error); - return null; - } - } - - /** - * Detect HyperLiquid withdrawals from Arbitrum transactions - * - * @param userAddress - Optional user address to filter by - * @param chainId - Optional chain ID to filter by - * @returns Array of detected withdrawals - */ - detectWithdrawals( - userAddress?: string, - chainId?: string, - ): ArbitrumWithdrawal[] { - try { - const transactions = this.getTransactions(); - const currentAddress = userAddress || this.getCurrentAddress(); - const currentChainId = chainId || this.getCurrentChainId(); - - if (!currentAddress || !currentChainId) { - DevLogger.log('Missing required data for withdrawal detection:', { - currentAddress, - currentChainId, - }); - return []; - } - - const withdrawals: ArbitrumWithdrawal[] = []; - - transactions.forEach((tx) => { - // Convert TransactionMeta to the expected format - const txForDetection = { - hash: tx.hash || '', - from: tx.txParams?.from, - to: tx.txParams?.to, - data: tx.txParams?.data, - chainId: tx.chainId, - time: tx.time, - status: tx.status, - blockNumber: tx.blockNumber, - }; - - const withdrawal = detectHyperLiquidWithdrawal( - txForDetection, - currentAddress, - currentChainId, - ); - if (withdrawal) { - withdrawals.push(withdrawal); - } - }); - - // Sort by timestamp descending (newest first) - withdrawals.sort((a, b) => b.timestamp - a.timestamp); - - DevLogger.log('Detected Arbitrum withdrawals:', { - count: withdrawals.length, - withdrawals: withdrawals.map((w) => ({ - id: w.id, - amount: w.amount, - txHash: w.txHash, - timestamp: w.timestamp, - })), - }); - - return withdrawals; - } catch (error) { - DevLogger.log('Error detecting Arbitrum withdrawals:', error); - return []; - } - } - - /** - * Get withdrawal history as UserHistoryItem array - * - * @param userAddress - Optional user address to filter by - * @param chainId - Optional chain ID to filter by - * @returns Array of UserHistoryItem for transaction history - */ - getWithdrawalHistory( - userAddress?: string, - chainId?: string, - ): UserHistoryItem[] { - const withdrawals = this.detectWithdrawals(userAddress, chainId); - return transformArbitrumWithdrawalsToHistoryItems(withdrawals); - } - - /** - * Check if current network is Arbitrum - * - * @returns True if on Arbitrum network - */ - isOnArbitrum(): boolean { - const chainId = this.getCurrentChainId(); - return chainId === '0xa4b1' || chainId === '0x66eee'; // Arbitrum mainnet or testnet - } -} diff --git a/app/components/UI/Perps/types/navigation.ts b/app/components/UI/Perps/types/navigation.ts index d32a67fcd268..02e458a4ec60 100644 --- a/app/components/UI/Perps/types/navigation.ts +++ b/app/components/UI/Perps/types/navigation.ts @@ -157,6 +157,23 @@ export interface PerpsNavigationParamList extends ParamListBase { marketPrice?: string; }; + // Activity view - Stack-based for proper back navigation + // Uses the same redirect params as the tab-based TRANSACTIONS_VIEW + PerpsActivity: { + /** + * Redirect to Perps transactions tab + */ + redirectToPerpsTransactions?: boolean; + /** + * Redirect to Orders tab + */ + redirectToOrders?: boolean; + /** + * Show back button in header for stack navigation + */ + showBackButton?: boolean; + }; + // Root perps view Perps: undefined; } diff --git a/app/components/UI/Perps/utils/arbitrumWithdrawalDetection.test.ts b/app/components/UI/Perps/utils/arbitrumWithdrawalDetection.test.ts deleted file mode 100644 index b0c4f6cf5965..000000000000 --- a/app/components/UI/Perps/utils/arbitrumWithdrawalDetection.test.ts +++ /dev/null @@ -1,437 +0,0 @@ -import { - parseUSDCTransferAmount, - parseERC20TransferRecipient, - isUSDCContractInteraction, - isHyperLiquidBridgeTransaction, - detectHyperLiquidWithdrawal, - getBridgeContractAddress, - getUSDCContractAddress, - ARBITRUM_MAINNET_CHAIN_ID, - ARBITRUM_TESTNET_CHAIN_ID, - HYPERLIQUID_BRIDGE_CONTRACTS, - USDC_CONTRACTS, - ERC20_TRANSFER_METHOD, -} from './arbitrumWithdrawalDetection'; -import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; - -// Mock DevLogger -jest.mock('../../../../core/SDKConnect/utils/DevLogger'); -const mockDevLogger = DevLogger as jest.Mocked; - -describe('arbitrumWithdrawalDetection', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('parseUSDCTransferAmount', () => { - it('returns null for empty or invalid input', () => { - expect(parseUSDCTransferAmount('')).toBeNull(); - expect(parseUSDCTransferAmount('0x')).toBeNull(); - expect(parseUSDCTransferAmount('invalid')).toBeNull(); - }); - - it('returns null for non-transfer method', () => { - const nonTransferData = '0x1234567890abcdef'; - expect(parseUSDCTransferAmount(nonTransferData)).toBeNull(); - }); - - it('returns null for invalid data length', () => { - const invalidLengthData = '0xa9059cbb1234567890abcdef'; - expect(parseUSDCTransferAmount(invalidLengthData)).toBeNull(); - }); - - it('parses USDC transfer amount correctly', () => { - // Transfer 100 USDC (100 * 1e6 = 100000000) - const transferData = - '0xa9059cbb00000000000000000000000012345678901234567890123456789012345678900000000000000000000000000000000000000000000000000000000005f5e100'; // 100 USDC in wei - - const result = parseUSDCTransferAmount(transferData); - expect(result).toBe('100'); - }); - - it('parses small USDC amounts correctly', () => { - // Transfer 0.5 USDC (0.5 * 1e6 = 500000) - const transferData = - '0xa9059cbb0000000000000000000000001234567890123456789012345678901234567890000000000000000000000000000000000000000000000000000000000007a120'; // 0.5 USDC in wei - - const result = parseUSDCTransferAmount(transferData); - expect(result).toBe('0.5'); - }); - - it('handles parsing errors gracefully', () => { - const invalidData = '0xa9059cbbinvalid_hex_data'; - - const result = parseUSDCTransferAmount(invalidData); - - expect(result).toBeNull(); - // The function doesn't log errors for invalid hex data, it just returns null - expect(mockDevLogger.log).not.toHaveBeenCalled(); - }); - }); - - describe('parseERC20TransferRecipient', () => { - it('returns null for empty or invalid input', () => { - expect(parseERC20TransferRecipient('')).toBeNull(); - expect(parseERC20TransferRecipient('0x')).toBeNull(); - expect(parseERC20TransferRecipient('invalid')).toBeNull(); - }); - - it('returns null for non-transfer method', () => { - const nonTransferData = '0x1234567890abcdef'; - expect(parseERC20TransferRecipient(nonTransferData)).toBeNull(); - }); - - it('returns null for invalid data length', () => { - const invalidLengthData = '0xa9059cbb1234567890abcdef'; - expect(parseERC20TransferRecipient(invalidLengthData)).toBeNull(); - }); - - it('parses recipient address correctly', () => { - const recipientAddress = '0x1234567890123456789012345678901234567890'; - const transferData = - '0xa9059cbb00000000000000000000000012345678901234567890123456789012345678900000000000000000000000000000000000000000000000000000000005f5e100'; // amount - - const result = parseERC20TransferRecipient(transferData); - expect(result).toBe(recipientAddress); - }); - - it('handles parsing errors gracefully', () => { - const invalidData = '0xa9059cbbinvalid_hex_data'; - - const result = parseERC20TransferRecipient(invalidData); - - expect(result).toBeNull(); - // The function doesn't log errors for invalid hex data, it just returns null - expect(mockDevLogger.log).not.toHaveBeenCalled(); - }); - }); - - describe('isUSDCContractInteraction', () => { - it('returns true for mainnet USDC contract', () => { - const result = isUSDCContractInteraction( - USDC_CONTRACTS.mainnet, - ARBITRUM_MAINNET_CHAIN_ID, - ); - expect(result).toBe(true); - }); - - it('returns true for testnet USDC contract', () => { - const result = isUSDCContractInteraction( - USDC_CONTRACTS.testnet, - ARBITRUM_TESTNET_CHAIN_ID, - ); - expect(result).toBe(true); - }); - - it('returns false for wrong chain', () => { - const result = isUSDCContractInteraction( - USDC_CONTRACTS.mainnet, - ARBITRUM_TESTNET_CHAIN_ID, - ); - expect(result).toBe(false); - }); - - it('returns false for different contract', () => { - const result = isUSDCContractInteraction( - '0x1234567890123456789012345678901234567890', - ARBITRUM_MAINNET_CHAIN_ID, - ); - expect(result).toBe(false); - }); - - it('handles case insensitive comparison', () => { - const result = isUSDCContractInteraction( - USDC_CONTRACTS.mainnet.toUpperCase(), - ARBITRUM_MAINNET_CHAIN_ID, - ); - expect(result).toBe(true); - }); - - it('handles undefined txTo', () => { - const result = isUSDCContractInteraction( - undefined as unknown as string, - ARBITRUM_MAINNET_CHAIN_ID, - ); - expect(result).toBe(false); - }); - }); - - describe('isHyperLiquidBridgeTransaction', () => { - it('returns true for mainnet bridge contract', () => { - const result = isHyperLiquidBridgeTransaction( - HYPERLIQUID_BRIDGE_CONTRACTS.mainnet, - ARBITRUM_MAINNET_CHAIN_ID, - ); - expect(result).toBe(true); - }); - - it('returns true for testnet bridge contract', () => { - const result = isHyperLiquidBridgeTransaction( - HYPERLIQUID_BRIDGE_CONTRACTS.testnet, - ARBITRUM_TESTNET_CHAIN_ID, - ); - expect(result).toBe(true); - }); - - it('returns false for wrong chain', () => { - const result = isHyperLiquidBridgeTransaction( - HYPERLIQUID_BRIDGE_CONTRACTS.mainnet, - ARBITRUM_TESTNET_CHAIN_ID, - ); - expect(result).toBe(false); - }); - - it('returns false for different contract', () => { - const result = isHyperLiquidBridgeTransaction( - '0x1234567890123456789012345678901234567890', - ARBITRUM_MAINNET_CHAIN_ID, - ); - expect(result).toBe(false); - }); - - it('handles case insensitive comparison', () => { - const result = isHyperLiquidBridgeTransaction( - HYPERLIQUID_BRIDGE_CONTRACTS.mainnet.toUpperCase(), - ARBITRUM_MAINNET_CHAIN_ID, - ); - expect(result).toBe(true); - }); - - it('handles undefined txFrom', () => { - const result = isHyperLiquidBridgeTransaction( - undefined as unknown as string, - ARBITRUM_MAINNET_CHAIN_ID, - ); - expect(result).toBe(false); - }); - }); - - describe('detectHyperLiquidWithdrawal', () => { - const mockUserAddress = '0x1234567890123456789012345678901234567890'; - const mockChainId = ARBITRUM_MAINNET_CHAIN_ID; - const mockBridgeContract = HYPERLIQUID_BRIDGE_CONTRACTS.mainnet; - - const createMockTransaction = (overrides = {}) => ({ - hash: '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', - from: mockBridgeContract, - to: mockUserAddress, - data: '0xa9059cbb00000000000000000000000012345678901234567890123456789012345678900000000000000000000000000000000000000000000000000000000005f5e100', // 100 USDC - chainId: mockChainId, - time: 1640995200000, - status: 'confirmed', - blockNumber: '12345', - ...overrides, - }); - - it('detects valid HyperLiquid withdrawal', () => { - const tx = createMockTransaction(); - - const result = detectHyperLiquidWithdrawal( - tx, - mockUserAddress, - mockChainId, - ); - - expect(result).toEqual({ - id: `arbitrum-withdrawal-${tx.hash}`, - timestamp: tx.time, - amount: '100', - txHash: tx.hash, - from: tx.from, - to: tx.to, - status: 'completed', - blockNumber: tx.blockNumber, - }); - }); - - it('returns null for wrong chain ID', () => { - const tx = createMockTransaction({ chainId: ARBITRUM_TESTNET_CHAIN_ID }); - - const result = detectHyperLiquidWithdrawal( - tx, - mockUserAddress, - mockChainId, - ); - - expect(result).toBeNull(); - }); - - it('returns null for transaction without data', () => { - const tx = createMockTransaction({ data: undefined }); - - const result = detectHyperLiquidWithdrawal( - tx, - mockUserAddress, - mockChainId, - ); - - expect(result).toBeNull(); - }); - - it('returns null for transaction with empty data', () => { - const tx = createMockTransaction({ data: '0x' }); - - const result = detectHyperLiquidWithdrawal( - tx, - mockUserAddress, - mockChainId, - ); - - expect(result).toBeNull(); - }); - - it('returns null for transaction not from bridge contract', () => { - const tx = createMockTransaction({ - from: '0x1234567890123456789012345678901234567890', - }); - - const result = detectHyperLiquidWithdrawal( - tx, - mockUserAddress, - mockChainId, - ); - - expect(result).toBeNull(); - }); - - it('returns null for transaction not to user address', () => { - const tx = createMockTransaction({ - to: '0x9876543210987654321098765432109876543210', // Different address - }); - - const result = detectHyperLiquidWithdrawal( - tx, - mockUserAddress, - mockChainId, - ); - - expect(result).toBeNull(); - }); - - it('returns null for non-USDC transfer', () => { - const tx = createMockTransaction({ data: '0x1234567890abcdef' }); - - const result = detectHyperLiquidWithdrawal( - tx, - mockUserAddress, - mockChainId, - ); - - expect(result).toBeNull(); - }); - - it('maps transaction status correctly', () => { - const testCases = [ - { status: 'confirmed', expected: 'completed' }, - { status: 'failed', expected: 'failed' }, - { status: 'pending', expected: 'pending' }, - { status: 'unknown', expected: 'pending' }, - ]; - - testCases.forEach(({ status, expected }) => { - const tx = createMockTransaction({ status }); - const result = detectHyperLiquidWithdrawal( - tx, - mockUserAddress, - mockChainId, - ); - - expect(result?.status).toBe(expected); - }); - }); - - it('uses current timestamp when time is missing', () => { - const tx = createMockTransaction({ time: undefined }); - const beforeCall = Date.now(); - - const result = detectHyperLiquidWithdrawal( - tx, - mockUserAddress, - mockChainId, - ); - - const afterCall = Date.now(); - expect(result?.timestamp).toBeGreaterThanOrEqual(beforeCall); - expect(result?.timestamp).toBeLessThanOrEqual(afterCall); - }); - - it('handles detection errors gracefully', () => { - // Create a transaction that would cause an error in the detection logic - const tx = { - hash: '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', - from: mockBridgeContract, - to: mockUserAddress, - data: '0xa9059cbbinvalid_hex_data', // This will cause parsing to fail - chainId: mockChainId, - time: 1640995200000, - status: 'confirmed', - blockNumber: '12345', - }; - - const result = detectHyperLiquidWithdrawal( - tx, - mockUserAddress, - mockChainId, - ); - - expect(result).toBeNull(); - // The function should return null for invalid data without logging errors - expect(mockDevLogger.log).not.toHaveBeenCalled(); - }); - }); - - describe('getBridgeContractAddress', () => { - it('returns mainnet bridge contract for mainnet chain', () => { - const result = getBridgeContractAddress(ARBITRUM_MAINNET_CHAIN_ID); - expect(result).toBe(HYPERLIQUID_BRIDGE_CONTRACTS.mainnet); - }); - - it('returns testnet bridge contract for testnet chain', () => { - const result = getBridgeContractAddress(ARBITRUM_TESTNET_CHAIN_ID); - expect(result).toBe(HYPERLIQUID_BRIDGE_CONTRACTS.testnet); - }); - - it('returns testnet bridge contract for unknown chain', () => { - const result = getBridgeContractAddress('0x123'); - expect(result).toBe(HYPERLIQUID_BRIDGE_CONTRACTS.testnet); - }); - }); - - describe('getUSDCContractAddress', () => { - it('returns mainnet USDC contract for mainnet chain', () => { - const result = getUSDCContractAddress(ARBITRUM_MAINNET_CHAIN_ID); - expect(result).toBe(USDC_CONTRACTS.mainnet); - }); - - it('returns testnet USDC contract for testnet chain', () => { - const result = getUSDCContractAddress(ARBITRUM_TESTNET_CHAIN_ID); - expect(result).toBe(USDC_CONTRACTS.testnet); - }); - - it('returns testnet USDC contract for unknown chain', () => { - const result = getUSDCContractAddress('0x123'); - expect(result).toBe(USDC_CONTRACTS.testnet); - }); - }); - - describe('constants', () => { - it('exports correct chain IDs', () => { - expect(ARBITRUM_MAINNET_CHAIN_ID).toBe('0xa4b1'); - expect(ARBITRUM_TESTNET_CHAIN_ID).toBe('0x66eee'); - }); - - it('exports correct ERC20 transfer method', () => { - expect(ERC20_TRANSFER_METHOD).toBe('0xa9059cbb'); - }); - - it('exports bridge contracts', () => { - expect(HYPERLIQUID_BRIDGE_CONTRACTS.mainnet).toBeDefined(); - expect(HYPERLIQUID_BRIDGE_CONTRACTS.testnet).toBeDefined(); - }); - - it('exports USDC contracts', () => { - expect(USDC_CONTRACTS.mainnet).toBeDefined(); - expect(USDC_CONTRACTS.testnet).toBeDefined(); - }); - }); -}); diff --git a/app/components/UI/Perps/utils/arbitrumWithdrawalDetection.ts b/app/components/UI/Perps/utils/arbitrumWithdrawalDetection.ts deleted file mode 100644 index c2bc5b0f7b89..000000000000 --- a/app/components/UI/Perps/utils/arbitrumWithdrawalDetection.ts +++ /dev/null @@ -1,232 +0,0 @@ -import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; - -// Arbitrum chain IDs -export const ARBITRUM_MAINNET_CHAIN_ID = '0xa4b1'; // 42161 -export const ARBITRUM_TESTNET_CHAIN_ID = '0x66eee'; // 421614 - -// HyperLiquid bridge contracts (from hyperLiquidConfig.ts) -export const HYPERLIQUID_BRIDGE_CONTRACTS = { - mainnet: '0x2df1c51e09aecf9cacb7bc98cb1742757f163df7', // HyperLiquid Arbitrum mainnet bridge - testnet: '0x08cfc1B6b2dCF36A1480b99353A354AA8AC56f89', // HyperLiquid Arbitrum testnet bridge -}; - -// USDC contract addresses on Arbitrum -export const USDC_CONTRACTS = { - mainnet: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', // Arbitrum USDC - testnet: '0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d', // Arbitrum Goerli USDC -}; - -// ERC20 Transfer method signature -export const ERC20_TRANSFER_METHOD = '0xa9059cbb'; // transfer(address,uint256) - -/** - * Parse USDC transfer amount from transaction data - * - * ERC20 transfer method: transfer(address,uint256) - * Data format: 0xa9059cbb + 32-byte address + 32-byte amount - * - * @param txData - Transaction data hex string - * @returns Amount in USDC (with 6 decimals) or null if not a transfer - */ -export const parseUSDCTransferAmount = (txData: string): string | null => { - try { - if (!txData || txData === '0x') { - return null; - } - - // Check if it's a transfer method - const methodId = txData.slice(0, 10); - if (methodId !== ERC20_TRANSFER_METHOD) { - return null; - } - - // Extract amount from data (position 74-138, 32 bytes) - const amountHex = txData.slice(74, 138); - if (amountHex.length !== 64) { - return null; - } - - // Convert hex to BigNumber and divide by USDC decimals (6) - const amountWei = BigInt('0x' + amountHex); - const amount = Number(amountWei) / 1e6; // USDC has 6 decimals - - return amount.toString(); - } catch (error) { - DevLogger.log('Error parsing USDC transfer amount:', error); - return null; - } -}; - -/** - * Parse recipient address from ERC20 transfer transaction data - * - * @param txData - Transaction data hex string - * @returns Recipient address or null if not a transfer - */ -export const parseERC20TransferRecipient = (txData: string): string | null => { - try { - if (!txData || txData === '0x') { - return null; - } - - // Check if it's a transfer method - const methodId = txData.slice(0, 10); - if (methodId !== ERC20_TRANSFER_METHOD) { - return null; - } - - // Extract recipient address from data (position 10-74, 32 bytes) - const addressHex = txData.slice(10, 74); - if (addressHex.length !== 64) { - return null; - } - - // Convert to address format (remove leading zeros) - const address = '0x' + addressHex.slice(24); // Last 20 bytes - - return address; - } catch (error) { - DevLogger.log('Error parsing ERC20 transfer recipient:', error); - return null; - } -}; - -/** - * Check if a transaction is interacting with USDC contract - * - * @param txTo - Transaction 'to' address - * @param chainId - Current chain ID - * @returns True if transaction is with USDC contract - */ -export const isUSDCContractInteraction = ( - txTo: string, - chainId: string, -): boolean => { - const usdcContract = - chainId === ARBITRUM_MAINNET_CHAIN_ID - ? USDC_CONTRACTS.mainnet - : USDC_CONTRACTS.testnet; - - return txTo?.toLowerCase() === usdcContract.toLowerCase(); -}; - -/** - * Check if a transaction is from HyperLiquid bridge contract - * - * @param txFrom - Transaction 'from' address - * @param chainId - Current chain ID - * @returns True if transaction is from HyperLiquid bridge - */ -export const isHyperLiquidBridgeTransaction = ( - txFrom: string, - chainId: string, -): boolean => { - const bridgeContract = - chainId === ARBITRUM_MAINNET_CHAIN_ID - ? HYPERLIQUID_BRIDGE_CONTRACTS.mainnet - : HYPERLIQUID_BRIDGE_CONTRACTS.testnet; - - return txFrom?.toLowerCase() === bridgeContract.toLowerCase(); -}; - -/** - * Detect if a transaction is a HyperLiquid withdrawal - * - * @param tx - Transaction metadata - * @param userAddress - User's wallet address - * @param chainId - Current chain ID - * @returns Withdrawal data or null if not a withdrawal - */ -export const detectHyperLiquidWithdrawal = ( - tx: { - hash: string; - from?: string; - to?: string; - data?: string; - chainId?: string; - time?: number; - status?: string; - blockNumber?: string; - }, - userAddress: string, - chainId: string, -): { - id: string; - timestamp: number; - amount: string; - txHash: string; - from: string; - to: string; - status: 'completed' | 'failed' | 'pending'; - blockNumber?: string; -} | null => { - try { - // Must be on Arbitrum - if (tx.chainId !== chainId) { - return null; - } - - // Must be a contract interaction (has data) - if (!tx.data || tx.data === '0x') { - return null; - } - - // Must be from HyperLiquid bridge contract - if (!isHyperLiquidBridgeTransaction(tx.from || '', chainId)) { - return null; - } - - // Must be to the current user's address - if (tx.to?.toLowerCase() !== userAddress.toLowerCase()) { - return null; - } - - // Must be a USDC transfer - const amount = parseUSDCTransferAmount(tx.data); - if (!amount) { - return null; - } - - // Create withdrawal record - return { - id: `arbitrum-withdrawal-${tx.hash}`, - timestamp: tx.time || Date.now(), - amount, - txHash: tx.hash, - from: tx.from || '', - to: tx.to || '', - status: - tx.status === 'confirmed' - ? 'completed' - : tx.status === 'failed' - ? 'failed' - : 'pending', - blockNumber: tx.blockNumber, - }; - } catch (error) { - DevLogger.log('Error detecting HyperLiquid withdrawal:', error); - return null; - } -}; - -/** - * Get the appropriate bridge contract address for the current network - * - * @param chainId - Current chain ID - * @returns Bridge contract address - */ -export const getBridgeContractAddress = (chainId: string): string => - chainId === ARBITRUM_MAINNET_CHAIN_ID - ? HYPERLIQUID_BRIDGE_CONTRACTS.mainnet - : HYPERLIQUID_BRIDGE_CONTRACTS.testnet; - -/** - * Get the appropriate USDC contract address for the current network - * - * @param chainId - Current chain ID - * @returns USDC contract address - */ -export const getUSDCContractAddress = (chainId: string): string => - chainId === ARBITRUM_MAINNET_CHAIN_ID - ? USDC_CONTRACTS.mainnet - : USDC_CONTRACTS.testnet; diff --git a/app/components/UI/Perps/utils/arbitrumWithdrawalTransforms.ts b/app/components/UI/Perps/utils/arbitrumWithdrawalTransforms.ts deleted file mode 100644 index 42f694bc812b..000000000000 --- a/app/components/UI/Perps/utils/arbitrumWithdrawalTransforms.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { UserHistoryItem } from '../controllers/types'; - -interface ArbitrumWithdrawal { - id: string; - timestamp: number; - amount: string; - txHash: string; - from: string; - to: string; - status: 'completed' | 'failed' | 'pending'; - blockNumber?: string; -} - -/** - * Transform Arbitrum withdrawal data into UserHistoryItem format - * - * @param withdrawal - Arbitrum withdrawal data - * @returns UserHistoryItem for transaction history - */ -export const transformArbitrumWithdrawalToHistoryItem = ( - withdrawal: ArbitrumWithdrawal, -): UserHistoryItem => ({ - id: withdrawal.id, - timestamp: withdrawal.timestamp, - type: 'withdrawal', - amount: withdrawal.amount, - asset: 'USDC', - txHash: withdrawal.txHash, - status: - withdrawal.status === 'completed' - ? 'completed' - : withdrawal.status === 'failed' - ? 'failed' - : 'pending', - details: { - source: 'arbitrum_blockchain', - bridgeContract: withdrawal.from, - recipient: withdrawal.to, - blockNumber: withdrawal.blockNumber, - chainId: '0xa4b1', // Arbitrum mainnet - synthetic: false, // This is real blockchain data - }, -}); - -/** - * Transform multiple Arbitrum withdrawals into UserHistoryItem array - * - * @param withdrawals - Array of Arbitrum withdrawal data - * @returns Array of UserHistoryItem for transaction history - */ -export const transformArbitrumWithdrawalsToHistoryItems = ( - withdrawals: ArbitrumWithdrawal[], -): UserHistoryItem[] => - withdrawals.map(transformArbitrumWithdrawalToHistoryItem); diff --git a/app/components/UI/Perps/utils/blockchainUtils.test.ts b/app/components/UI/Perps/utils/blockchainUtils.test.ts deleted file mode 100644 index 208a6e38d354..000000000000 --- a/app/components/UI/Perps/utils/blockchainUtils.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { getHyperliquidExplorerUrl } from './blockchainUtils'; - -describe('blockchainUtils', () => { - describe('getHyperliquidExplorerUrl', () => { - const testAddress = '0x1234567890abcdef1234567890abcdef12345678'; - - it('should generate correct mainnet explorer URL', () => { - const result = getHyperliquidExplorerUrl('mainnet', testAddress); - expect(result).toBe( - `https://app.hyperliquid.xyz/explorer/address/${testAddress}`, - ); - }); - - it('should generate correct testnet explorer URL', () => { - const result = getHyperliquidExplorerUrl('testnet', testAddress); - expect(result).toBe( - `https://app.hyperliquid-testnet.xyz/explorer/address/${testAddress}`, - ); - }); - - it('should handle empty address', () => { - const result = getHyperliquidExplorerUrl('mainnet', ''); - expect(result).toBe('https://app.hyperliquid.xyz/explorer/address/'); - }); - }); -}); diff --git a/app/components/UI/Perps/utils/blockchainUtils.ts b/app/components/UI/Perps/utils/blockchainUtils.ts deleted file mode 100644 index e0fd1918b714..000000000000 --- a/app/components/UI/Perps/utils/blockchainUtils.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Blockchain utilities for Hyperliquid network explorer URLs - */ - -/** - * Gets the full Hyperliquid explorer URL for an address - * @param network - Either "mainnet" or "testnet" - * @param address - The address to view in the explorer - * @returns The full explorer URL - */ -export const getHyperliquidExplorerUrl = ( - network: 'mainnet' | 'testnet', - address: string, -): string => { - const baseUrl = - network === 'testnet' - ? 'https://app.hyperliquid-testnet.xyz' - : 'https://app.hyperliquid.xyz'; - - return `${baseUrl}/explorer/address/${address}`; -}; diff --git a/app/components/UI/Perps/utils/transactionUtils.test.ts b/app/components/UI/Perps/utils/transactionUtils.test.ts deleted file mode 100644 index 2c7897acbea5..000000000000 --- a/app/components/UI/Perps/utils/transactionUtils.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { getUserFundingsListTimePeriod } from './transactionUtils'; - -describe('getUserFundingsListTimePeriod', () => { - beforeEach(() => { - // Mock Date.now to ensure consistent test results - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('should return timestamp for 7 days ago from current time', () => { - // Arrange - const mockCurrentTime = 1700000000000; // Fixed timestamp for testing - jest.setSystemTime(mockCurrentTime); - const expectedSevenDaysAgo = mockCurrentTime - 7 * 24 * 60 * 60 * 1000; - - // Act - const result = getUserFundingsListTimePeriod(); - - // Assert - expect(result).toBe(expectedSevenDaysAgo); - }); - - it('should return different values when called at different times', () => { - // Arrange - const firstTime = 1700000000000; - const secondTime = 1700000000000 + 1000; // 1 second later - - // Act - jest.setSystemTime(firstTime); - const firstResult = getUserFundingsListTimePeriod(); - - jest.setSystemTime(secondTime); - const secondResult = getUserFundingsListTimePeriod(); - - // Assert - expect(secondResult).toBe(firstResult + 1000); - }); - - it('should return a valid timestamp format', () => { - // Arrange - const mockCurrentTime = 1700000000000; - jest.setSystemTime(mockCurrentTime); - - // Act - const result = getUserFundingsListTimePeriod(); - - // Assert - expect(typeof result).toBe('number'); - expect(result).toBeGreaterThan(0); - expect(Number.isInteger(result)).toBe(true); - }); -}); diff --git a/app/components/UI/Perps/utils/transactionUtils.ts b/app/components/UI/Perps/utils/transactionUtils.ts deleted file mode 100644 index 418d9a0013aa..000000000000 --- a/app/components/UI/Perps/utils/transactionUtils.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Get the timestamp for 14 days ago from now - * Used for userFundingsListTimePeriod to fetch funding data from the last 14 days - * @returns Unix timestamp in milliseconds for 14 days ago - */ -export const getUserFundingsListTimePeriod = (): number => { - const now = Date.now(); - const sevenDaysAgo = now - 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds - return sevenDaysAgo; -}; diff --git a/app/components/UI/Predict/components/PredictBalance/PredictBalance.tsx b/app/components/UI/Predict/components/PredictBalance/PredictBalance.tsx index a7622c530a22..81f851ed76f6 100644 --- a/app/components/UI/Predict/components/PredictBalance/PredictBalance.tsx +++ b/app/components/UI/Predict/components/PredictBalance/PredictBalance.tsx @@ -85,7 +85,7 @@ const PredictBalance: React.FC = ({ onLayout }) => { if (isLoading) { return ( = ({ flexDirection={BoxFlexDirection.Row} alignItems={BoxAlignItems.Center} justifyContent={BoxJustifyContent.Between} - twClassName="w-full py-2 px-4" + twClassName="w-full pt-2 pb-4 px-4" style={{ backgroundColor: colors.background.default }} > { params: { marketId: mockMarket.id, entryPoint: PredictEventValues.ENTRY_POINT.PREDICT_FEED, + title: mockMarket.title, + image: mockMarket.image, }, }); }); diff --git a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx index 2385f4b2408e..38870c1ca995 100644 --- a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx +++ b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx @@ -148,6 +148,8 @@ const PredictMarketMultiple: React.FC = ({ params: { marketId: market.id, entryPoint, + title: market.title, + image: market.image, }, }); }} diff --git a/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.tsx b/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.tsx index 35a1a62099e7..11ea7207180a 100644 --- a/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.tsx +++ b/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.tsx @@ -108,7 +108,7 @@ const PredictMarketOutcome: React.FC = ({ alignItems={BoxAlignItems.Center} twClassName="flex-1 gap-3" > - + {getImageUrl() ? ( = ({ > {getTitle()} - {isClosed && outcomeToken && outcomeToken.price === 1 && ( - - Winner - - )} ${getVolumeDisplay()} {strings('predict.volume_abbreviated')} @@ -148,17 +139,35 @@ const PredictMarketOutcome: React.FC = ({ {isClosed && outcomeToken ? ( - + + + {outcomeToken.price === 1 + ? strings('predict.outcome_winner') + : strings('predict.outcome_loser')} + + {outcomeToken.price === 1 && ( + + )} + ) : ( { params: { marketId: mockMarket.id, entryPoint: PredictEventValues.ENTRY_POINT.PREDICT_FEED, + title: mockMarket.title, + image: mockMarket.image, }, }); }); diff --git a/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.tsx b/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.tsx index 5c9c35a1ea66..a0ee965da1d4 100644 --- a/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.tsx +++ b/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.tsx @@ -187,6 +187,8 @@ const PredictMarketSingle: React.FC = ({ params: { marketId: market.id, entryPoint, + title: market.title, + image: getImageUrl(), }, }); }} @@ -198,7 +200,7 @@ const PredictMarketSingle: React.FC = ({ alignItems={BoxAlignItems.Center} twClassName="flex-1 gap-3" > - + {getImageUrl() ? ( = ({ )} diff --git a/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.test.tsx b/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.test.tsx index 651937fe0c0b..f753cd212ad3 100644 --- a/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.test.tsx +++ b/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.test.tsx @@ -362,8 +362,12 @@ function setupMarketsWonCardTest( const ref = React.createRef<{ refresh: () => Promise }>(); + // Test address and account ID to use in state + const testAddress = '0x1234567890123456789012345678901234567890'; + const testAccountId = 'test-account-id'; + // Build claimable positions for Redux state - const claimablePositions = + const claimablePositionsArray = claimablePositionsOverrides.positions !== undefined ? (claimablePositionsOverrides.positions as unknown as PredictPosition[]) : props.totalClaimableAmount @@ -382,12 +386,30 @@ function setupMarketsWonCardTest( ] as unknown as PredictPosition[]) : []; - // Create Redux state + // Create Redux state with claimablePositions keyed by address const state = { engine: { backgroundState: { PredictController: { - claimablePositions, + claimablePositions: { + [testAddress]: claimablePositionsArray, + }, + }, + AccountsController: { + internalAccounts: { + selectedAccount: testAccountId, + accounts: { + [testAccountId]: { + id: testAccountId, + address: testAddress, + name: 'Test Account', + type: 'eip155:eoa' as const, + metadata: { + lastSelected: 0, + }, + }, + }, + }, }, }, }, @@ -869,5 +891,35 @@ describe('MarketsWonCard', () => { // Verify the callback is undefined expect(props.onClaimPress).toBeUndefined(); }); + + it('uses fallback address when selectedAddress is undefined', () => { + // Arrange - create state with undefined selected account + const ref = React.createRef<{ refresh: () => Promise }>(); + const stateWithNoAddress = { + engine: { + backgroundState: { + PredictController: { + claimablePositions: { + '0x0': [], + }, + }, + AccountsController: { + internalAccounts: { + selectedAccount: undefined, + accounts: {}, + }, + }, + }, + }, + }; + + // Act + const { getByTestId } = renderWithProvider(, { + state: stateWithNoAddress, + }); + + // Assert - component renders without crashing + expect(getByTestId('markets-won-card')).toBeDefined(); + }); }); }); diff --git a/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx b/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx index 0d793c1ecdf6..5cce8777f974 100644 --- a/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx +++ b/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx @@ -32,8 +32,9 @@ import { usePredictDeposit } from '../../hooks/usePredictDeposit'; import { useUnrealizedPnL } from '../../hooks/useUnrealizedPnL'; import { usePredictActionGuard } from '../../hooks/usePredictActionGuard'; import { POLYMARKET_PROVIDER_ID } from '../../providers/polymarket/constants'; -import { selectPredictClaimablePositions } from '../../selectors/predictController'; -import { PredictPosition, PredictPositionStatus } from '../../types'; +import { selectPredictWonPositions } from '../../selectors/predictController'; +import { selectSelectedInternalAccountAddress } from '../../../../../selectors/accountsController'; +import { PredictPosition } from '../../types'; import { PredictNavigationParamList } from '../../types/navigation'; import { formatPrice } from '../../utils/format'; import ButtonHero from '../../../../../component-library/components-temp/Buttons/ButtonHero'; @@ -71,8 +72,12 @@ const PredictPositionsHeader = forwardRef< loadOnMount: true, refreshOnFocus: true, }); + const selectedAddress = + useSelector(selectSelectedInternalAccountAddress) ?? '0x0'; const { isDepositPending } = usePredictDeposit(); - const claimablePositions = useSelector(selectPredictClaimablePositions); + const wonPositions = useSelector( + selectPredictWonPositions({ address: selectedAddress }), + ); const { unrealizedPnL, @@ -110,14 +115,6 @@ const PredictPositionsHeader = forwardRef< }, })); - const wonPositions = useMemo( - () => - claimablePositions.filter( - (position) => position.status === PredictPositionStatus.WON, - ), - [claimablePositions], - ); - const totalClaimableAmount = useMemo( () => wonPositions.reduce( diff --git a/app/components/UI/Predict/controllers/PredictController.test.ts b/app/components/UI/Predict/controllers/PredictController.test.ts index bde5ee5db0d6..9218056b96a1 100644 --- a/app/components/UI/Predict/controllers/PredictController.test.ts +++ b/app/components/UI/Predict/controllers/PredictController.test.ts @@ -17,7 +17,13 @@ import { } from '../../../../util/transaction-controller'; import { PolymarketProvider } from '../providers/polymarket/PolymarketProvider'; import type { OrderPreview } from '../providers/types'; -import { PredictClaimStatus, PredictWithdrawStatus, Side } from '../types'; +import { + PredictClaimStatus, + PredictPosition, + PredictPositionStatus, + PredictWithdrawStatus, + Side, +} from '../types'; import { getDefaultPredictControllerState, PredictController, @@ -100,6 +106,34 @@ function getRootMessenger(): RootMessenger { describe('PredictController', () => { let mockPolymarketProvider: jest.Mocked; + function createMockPosition( + overrides?: Partial, + ): PredictPosition { + return { + id: 'position-1', + providerId: 'polymarket', + marketId: 'market-1', + outcomeId: 'outcome-1', + outcome: 'Yes', + outcomeTokenId: 'token-1', + currentValue: 100, + title: 'Test Market', + icon: 'https://example.com/icon.png', + amount: 10, + price: 0.5, + status: PredictPositionStatus.OPEN, + size: 10, + outcomeIndex: 0, + percentPnl: 0, + cashPnl: 0, + claimable: false, + initialValue: 100, + avgPrice: 0.5, + endDate: '2025-12-31T23:59:59Z', + ...overrides, + }; + } + function createMockOrderPreview( overrides?: Partial, ): OrderPreview { @@ -1151,7 +1185,7 @@ describe('PredictController', () => { }); }); - it('get positions from multiple providers when no providerId specified', async () => { + it('defaults to polymarket provider when no providerId specified', async () => { await withController(async ({ controller }) => { const polymarketPositions = [ { @@ -1189,17 +1223,16 @@ describe('PredictController', () => { const result = await controller.getPositions({ address: '0x1234567890123456789012345678901234567890', - }); // No providerId + }); // No providerId - should default to polymarket - expect(result).toHaveLength(2); + expect(result).toHaveLength(1); expect(result).toEqual( expect.arrayContaining([ expect.objectContaining({ providerId: 'polymarket' }), - expect.objectContaining({ providerId: 'second-provider' }), ]), ); expect(mockPolymarketProvider.getPositions).toHaveBeenCalled(); - expect(mockSecondProvider.getPositions).toHaveBeenCalled(); + expect(mockSecondProvider.getPositions).not.toHaveBeenCalled(); }); }); @@ -1408,6 +1441,7 @@ describe('PredictController', () => { }; it('claim a single position successfully', async () => { + // Arrange const mockBatchId = 'claim-batch-1'; await withController(async ({ controller }) => { mockPolymarketProvider.getPositions = jest.fn().mockResolvedValue([ @@ -1424,11 +1458,14 @@ describe('PredictController', () => { (addTransactionBatch as jest.Mock).mockResolvedValue({ batchId: mockBatchId, }); + await controller.getPositions({ claimable: true }); + // Act const result = await controller.claimWithConfirmation({ providerId: 'polymarket', }); + // Assert expect(result.batchId).toBe(mockBatchId); expect(result.status).toBe(PredictClaimStatus.PENDING); expect(mockPolymarketProvider.prepareClaim).toHaveBeenCalledWith({ @@ -1442,6 +1479,7 @@ describe('PredictController', () => { }); it('claim multiple positions successfully using batch transaction', async () => { + // Arrange const mockBatchId = 'claim-batch-1'; await withController(async ({ controller }) => { mockPolymarketProvider.getPositions = jest.fn().mockResolvedValue([ @@ -1483,11 +1521,14 @@ describe('PredictController', () => { (addTransactionBatch as jest.Mock).mockResolvedValue({ batchId: mockBatchId, }); + await controller.getPositions({ claimable: true }); + // Act const result = await controller.claimWithConfirmation({ providerId: 'polymarket', }); + // Assert expect(result.batchId).toBe(mockBatchId); expect(result.status).toBe(PredictClaimStatus.PENDING); expect(addTransactionBatch).toHaveBeenCalled(); @@ -1505,6 +1546,7 @@ describe('PredictController', () => { }); it('handle general claim error', async () => { + // Arrange await withController(async ({ controller }) => { mockPolymarketProvider.getPositions = jest.fn().mockResolvedValue([ { @@ -1519,7 +1561,9 @@ describe('PredictController', () => { .mockImplementation(() => { throw new Error('Claim preparation failed'); }); + await controller.getPositions({ claimable: true }); + // Act & Assert await expect( controller.claimWithConfirmation({ providerId: 'polymarket', @@ -1529,6 +1573,7 @@ describe('PredictController', () => { }); it('return CANCELLED status when user denies transaction signature', async () => { + // Arrange await withController(async ({ controller }) => { const mockClaimablePositions = [ { @@ -1547,11 +1592,14 @@ describe('PredictController', () => { .mockImplementation(() => { throw new Error('User denied transaction signature'); }); + await controller.getPositions({ claimable: true }); + // Act const result = await controller.claimWithConfirmation({ providerId: 'polymarket', }); + // Assert expect(result.batchId).toBe('NA'); expect(result.chainId).toBe(0); expect(result.status).toBe(PredictClaimStatus.CANCELLED); @@ -1559,6 +1607,7 @@ describe('PredictController', () => { }); it('return CANCELLED status when user denial error is wrapped', async () => { + // Arrange await withController(async ({ controller }) => { const mockClaimablePositions = [ { @@ -1580,11 +1629,14 @@ describe('PredictController', () => { mockPolymarketProvider.prepareClaim = jest .fn() .mockResolvedValue(mockClaim); + await controller.getPositions({ claimable: true }); + // Act const result = await controller.claimWithConfirmation({ providerId: 'polymarket', }); + // Assert expect(result.batchId).toBe('NA'); expect(result.chainId).toBe(0); expect(result.status).toBe(PredictClaimStatus.CANCELLED); @@ -1592,9 +1644,12 @@ describe('PredictController', () => { }); it('throws error when no claimable positions found', async () => { + // Arrange await withController(async ({ controller }) => { mockPolymarketProvider.getPositions = jest.fn().mockResolvedValue([]); + await controller.getPositions({ claimable: true }); + // Act & Assert await expect( controller.claimWithConfirmation({ providerId: 'polymarket', @@ -1604,6 +1659,7 @@ describe('PredictController', () => { }); it('updates error state when claim fails', async () => { + // Arrange await withController(async ({ controller }) => { mockPolymarketProvider.getPositions = jest.fn().mockResolvedValue([ { @@ -1617,7 +1673,9 @@ describe('PredictController', () => { mockPolymarketProvider.prepareClaim = jest .fn() .mockRejectedValue(new Error(errorMessage)); + await controller.getPositions({ claimable: true }); + // Act & Assert await expect( controller.claimWithConfirmation({ providerId: 'polymarket', @@ -1630,6 +1688,7 @@ describe('PredictController', () => { }); it('throws error when network client not found', async () => { + // Arrange await withController(async ({ controller }) => { mockPolymarketProvider.getPositions = jest.fn().mockResolvedValue([ { @@ -1647,7 +1706,9 @@ describe('PredictController', () => { mockPolymarketProvider.prepareClaim = jest .fn() .mockResolvedValue(mockClaim); + await controller.getPositions({ claimable: true }); + // Act & Assert await expect( controller.claimWithConfirmation({ providerId: 'polymarket', @@ -1657,6 +1718,7 @@ describe('PredictController', () => { }); it('throws error when transaction batch returns no batchId', async () => { + // Arrange await withController(async ({ controller }) => { mockPolymarketProvider.getPositions = jest.fn().mockResolvedValue([ { @@ -1676,7 +1738,9 @@ describe('PredictController', () => { .mockReturnValue('mainnet'); (addTransactionBatch as jest.Mock).mockResolvedValue({}); + await controller.getPositions({ claimable: true }); + // Act & Assert await expect( controller.claimWithConfirmation({ providerId: 'polymarket', @@ -1688,6 +1752,7 @@ describe('PredictController', () => { }); it('throws error when prepareClaim returns no transactions', async () => { + // Arrange await withController(async ({ controller }) => { mockPolymarketProvider.getPositions = jest.fn().mockResolvedValue([ { @@ -1701,7 +1766,9 @@ describe('PredictController', () => { chainId: 1, transactions: [], }); + await controller.getPositions({ claimable: true }); + // Act & Assert await expect( controller.claimWithConfirmation({ providerId: 'polymarket', @@ -1711,6 +1778,7 @@ describe('PredictController', () => { }); it('throws error when prepareClaim returns no chainId', async () => { + // Arrange await withController(async ({ controller }) => { mockPolymarketProvider.getPositions = jest.fn().mockResolvedValue([ { @@ -1730,7 +1798,9 @@ describe('PredictController', () => { }, ], }); + await controller.getPositions({ claimable: true }); + // Act & Assert await expect( controller.claimWithConfirmation({ providerId: 'polymarket', @@ -1740,9 +1810,9 @@ describe('PredictController', () => { }); it('clears error state on successful claim', async () => { + // Arrange const mockBatchId = 'claim-batch-1'; await withController(async ({ controller }) => { - // Set initial error state controller.updateStateForTesting((state) => { state.lastError = 'Previous error'; }); @@ -1767,11 +1837,14 @@ describe('PredictController', () => { (addTransactionBatch as jest.Mock).mockResolvedValue({ batchId: mockBatchId, }); + await controller.getPositions({ claimable: true }); + // Act const result = await controller.claimWithConfirmation({ providerId: 'polymarket', }); + // Assert expect(result.batchId).toBe(mockBatchId); expect(controller.state.lastError).toBeNull(); expect(controller.state.lastUpdateTimestamp).toBeGreaterThan(0); @@ -3471,4 +3544,225 @@ describe('PredictController', () => { }); }); }); + + describe('confirmClaim', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('clears claimable positions from state after confirmation', async () => { + // Arrange + await withController(async ({ controller }) => { + const testAddress = '0x1234567890123456789012345678901234567890'; + const mockPositions = [ + createMockPosition({ + id: 'position-1', + status: PredictPositionStatus.WON, + currentValue: 100, + cashPnl: 50, + }), + createMockPosition({ + id: 'position-2', + status: PredictPositionStatus.WON, + currentValue: 200, + cashPnl: 100, + }), + ]; + + // Set up state with claimable positions + controller.updateStateForTesting((state) => { + state.claimablePositions[testAddress] = mockPositions; + }); + + mockPolymarketProvider.confirmClaim = jest.fn(); + + // Act + controller.confirmClaim({ providerId: 'polymarket' }); + + // Assert + expect(controller.state.claimablePositions[testAddress]).toEqual([]); + }); + }); + + it('calls provider confirmClaim with correct positions', async () => { + // Arrange + await withController(async ({ controller }) => { + const testAddress = '0x1234567890123456789012345678901234567890'; + const mockPositions = [ + createMockPosition({ + id: 'position-1', + status: PredictPositionStatus.WON, + currentValue: 100, + cashPnl: 50, + }), + ]; + + controller.updateStateForTesting((state) => { + state.claimablePositions[testAddress] = mockPositions; + }); + + mockPolymarketProvider.confirmClaim = jest.fn(); + + // Act + controller.confirmClaim({ providerId: 'polymarket' }); + + // Assert + expect(mockPolymarketProvider.confirmClaim).toHaveBeenCalledWith({ + positions: mockPositions, + signer: expect.objectContaining({ + address: testAddress, + }), + }); + }); + }); + + it('returns early when no claimable positions exist', async () => { + // Arrange + await withController(async ({ controller }) => { + const testAddress = '0x1234567890123456789012345678901234567890'; + + controller.updateStateForTesting((state) => { + state.claimablePositions[testAddress] = []; + }); + + mockPolymarketProvider.confirmClaim = jest.fn(); + + // Act + controller.confirmClaim({ providerId: 'polymarket' }); + + // Assert + expect(mockPolymarketProvider.confirmClaim).not.toHaveBeenCalled(); + }); + }); + + it('returns early when claimable positions undefined for address', async () => { + // Arrange + await withController(async ({ controller }) => { + controller.updateStateForTesting((state) => { + state.claimablePositions = {}; + }); + + mockPolymarketProvider.confirmClaim = jest.fn(); + + // Act + controller.confirmClaim({ providerId: 'polymarket' }); + + // Assert + expect(mockPolymarketProvider.confirmClaim).not.toHaveBeenCalled(); + }); + }); + + it('throws error when provider not available', async () => { + // Arrange + await withController(async ({ controller }) => { + // Act & Assert + expect(() => + controller.confirmClaim({ providerId: 'invalid-provider' }), + ).toThrow('Provider not available'); + }); + }); + + it('handles provider without confirmClaim method', async () => { + // Arrange + await withController(async ({ controller }) => { + const testAddress = '0x1234567890123456789012345678901234567890'; + const mockPositions = [ + createMockPosition({ + id: 'position-1', + status: PredictPositionStatus.WON, + currentValue: 100, + cashPnl: 50, + }), + ]; + + controller.updateStateForTesting((state) => { + state.claimablePositions[testAddress] = mockPositions; + }); + + // Remove confirmClaim method from provider + delete (mockPolymarketProvider as { confirmClaim?: unknown }) + .confirmClaim; + + // Act + controller.confirmClaim({ providerId: 'polymarket' }); + + // Assert - should not throw, state should still be cleared + expect(controller.state.claimablePositions[testAddress]).toEqual([]); + }); + }); + }); + + describe('getPositions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('defaults to polymarket provider when no providerId specified', async () => { + // Arrange + await withController(async ({ controller }) => { + const mockPositions = [ + { + id: 'position-1', + marketId: 'market-1', + providerId: 'polymarket', + status: PredictPositionStatus.OPEN, + currentValue: 100, + cashPnl: 0, + }, + ]; + + mockPolymarketProvider.getPositions = jest + .fn() + .mockResolvedValue(mockPositions); + + // Act + const result = await controller.getPositions({ + address: '0x1234567890123456789012345678901234567890', + }); + + // Assert + expect(result).toEqual(mockPositions); + expect(mockPolymarketProvider.getPositions).toHaveBeenCalled(); + }); + }); + + it('stores claimable positions keyed by address', async () => { + // Arrange + await withController(async ({ controller }) => { + const testAddress = '0x1234567890123456789012345678901234567890'; + const mockClaimablePositions = [ + createMockPosition({ + id: 'position-1', + status: PredictPositionStatus.WON, + currentValue: 100, + cashPnl: 50, + }), + createMockPosition({ + id: 'position-2', + status: PredictPositionStatus.WON, + currentValue: 200, + cashPnl: 100, + }), + ]; + + mockPolymarketProvider.getPositions = jest + .fn() + .mockResolvedValue(mockClaimablePositions); + + // Act + await controller.getPositions({ + address: testAddress, + claimable: true, + }); + + // Assert + expect(controller.state.claimablePositions[testAddress]).toHaveLength( + 2, + ); + expect(controller.state.claimablePositions[testAddress]).toEqual( + mockClaimablePositions, + ); + }); + }); + }); }); diff --git a/app/components/UI/Predict/controllers/PredictController.ts b/app/components/UI/Predict/controllers/PredictController.ts index c8afd253924e..03e5171d7fbf 100644 --- a/app/components/UI/Predict/controllers/PredictController.ts +++ b/app/components/UI/Predict/controllers/PredictController.ts @@ -48,6 +48,7 @@ import { PrepareDepositParams, PrepareWithdrawParams, PreviewOrderParams, + Signer, } from '../providers/types'; import { ClaimParams, @@ -83,8 +84,7 @@ export type PredictControllerState = { balances: { [providerId: string]: { [address: string]: number } }; // Claim management - // TODO: change to be per-account basis - claimablePositions: PredictPosition[]; + claimablePositions: { [address: string]: PredictPosition[] }; // Deposit management pendingDeposits: { [providerId: string]: { [address: string]: boolean } }; @@ -107,7 +107,7 @@ export const getDefaultPredictControllerState = (): PredictControllerState => ({ lastError: null, lastUpdateTimestamp: 0, balances: {}, - claimablePositions: [], + claimablePositions: {}, pendingDeposits: {}, withdrawTransaction: null, isOnboarded: {}, @@ -145,7 +145,7 @@ const metadata: StateMetadata = { persist: false, includeInDebugSnapshot: false, includeInStateLogs: false, - usedInUi: false, + usedInUi: true, }, pendingDeposits: { persist: false, @@ -351,6 +351,27 @@ export class PredictController extends BaseController< }; } + /** + * Get signer for the currently selected account + * @param address - Optionally specify the address to use + * @returns Signer object + * @private + */ + private getSigner(address?: string): Signer { + const { AccountsController, KeyringController } = Engine.context; + const selectedAddress = + address ?? AccountsController.getSelectedAccount().address; + return { + address: selectedAddress, + signTypedMessage: ( + _params: TypedMessageParams, + _version: SignTypedDataVersion, + ) => KeyringController.signTypedMessage(_params, _version), + signPersonalMessage: (_params: PersonalMessageParams) => + KeyringController.signPersonalMessage(_params), + }; + } + /** * Get available markets with optional filtering */ @@ -539,42 +560,29 @@ export class PredictController extends BaseController< */ async getPositions(params: GetPositionsParams): Promise { try { - const { address, providerId } = params; + const { address, providerId = 'polymarket' } = params; const { AccountsController } = Engine.context; const selectedAddress = address ?? AccountsController.getSelectedAccount().address; - const providerIds = providerId - ? [providerId] - : Array.from(this.providers.keys()); + const provider = this.providers.get(providerId); - if (providerIds.some((id) => !this.providers.has(id))) { + if (!provider) { throw new Error('Provider not available'); } - const allPositions = await Promise.all( - providerIds.map((id: string) => - this.providers.get(id)?.getPositions({ - ...params, - address: selectedAddress, - }), - ), - ); - - //TODO: We need to sort the positions after merging them - const positions = allPositions - .flat() - .filter( - (position): position is PredictPosition => position !== undefined, - ); + const positions = await provider.getPositions({ + ...params, + address: selectedAddress, + }); // Only update state if the provider call succeeded this.update((state) => { state.lastUpdateTimestamp = Date.now(); state.lastError = null; // Clear any previous errors if (params.claimable) { - state.claimablePositions = [...positions]; + state.claimablePositions[selectedAddress] = [...positions]; } }); @@ -928,17 +936,7 @@ export class PredictController extends BaseController< throw new Error('Provider not available'); } - const { AccountsController, KeyringController } = Engine.context; - const selectedAddress = AccountsController.getSelectedAccount().address; - const signer = { - address: selectedAddress, - signTypedMessage: ( - _params: TypedMessageParams, - _version: SignTypedDataVersion, - ) => KeyringController.signTypedMessage(_params, _version), - signPersonalMessage: (_params: PersonalMessageParams) => - KeyringController.signPersonalMessage(_params), - }; + const signer = this.getSigner(); return provider.previewOrder({ ...params, signer }); } catch (error) { @@ -973,17 +971,7 @@ export class PredictController extends BaseController< throw new Error('Provider not available'); } - const { AccountsController, KeyringController } = Engine.context; - const selectedAddress = AccountsController.getSelectedAccount().address; - const signer = { - address: selectedAddress, - signTypedMessage: ( - _params: TypedMessageParams, - _version: SignTypedDataVersion, - ) => KeyringController.signTypedMessage(_params, _version), - signPersonalMessage: (_params: PersonalMessageParams) => - KeyringController.signPersonalMessage(_params), - }; + const signer = this.getSigner(); // Track Predict Action Submitted (fire and forget) this.trackPredictOrderEvent({ @@ -1101,30 +1089,10 @@ export class PredictController extends BaseController< throw new Error('Provider not available'); } - const { AccountsController, KeyringController, NetworkController } = - Engine.context; - - // Get selected account - can fail if no account is selected - const selectedAccount = AccountsController.getSelectedAccount(); - if (!selectedAccount?.address) { - throw new Error('No account selected'); - } - const selectedAddress = selectedAccount.address; - - const signer = { - address: selectedAddress, - signTypedMessage: ( - params: TypedMessageParams, - version: SignTypedDataVersion, - ) => KeyringController.signTypedMessage(params, version), - signPersonalMessage: (params: PersonalMessageParams) => - KeyringController.signPersonalMessage(params), - }; + const signer = this.getSigner(); - // Get claimable positions - can fail if network request fails - const claimablePositions = await this.getPositions({ - claimable: true, - }); + // Get claimable positions from state + const claimablePositions = this.state.claimablePositions[signer.address]; if (!claimablePositions || claimablePositions.length === 0) { throw new Error('No claimable positions found'); @@ -1151,6 +1119,7 @@ export class PredictController extends BaseController< } // Find network client - can fail if chain is not supported + const { NetworkController } = Engine.context; const networkClientId = NetworkController.findNetworkClientIdByChainId( numberToHex(chainId), ); @@ -1232,6 +1201,31 @@ export class PredictController extends BaseController< } } + public confirmClaim({ + providerId = 'polymarket', + }: { + providerId: string; + }): void { + const provider = this.providers.get(providerId); + if (!provider) { + throw new Error('Provider not available'); + } + const signer = this.getSigner(); + const claimedPositions = this.state.claimablePositions[signer.address]; + if (!claimedPositions || claimedPositions.length === 0) { + return; + } + + this.providers.get(providerId)?.confirmClaim?.({ + positions: claimedPositions, + signer: this.getSigner(), + }); + + this.update((state) => { + state.claimablePositions[signer.address] = []; + }); + } + /** * Refresh eligibility status */ @@ -1288,33 +1282,16 @@ export class PredictController extends BaseController< throw new Error('Provider not available'); } - const { AccountsController, KeyringController, NetworkController } = - Engine.context; - try { - const selectedAccount = AccountsController.getSelectedAccount(); - if (!selectedAccount?.address) { - throw new Error('No account selected for deposit'); - } + const signer = this.getSigner(); // Clear any previous deposit transaction this.update((state) => { state.pendingDeposits[params.providerId] = { - [selectedAccount.address]: false, + [signer.address]: false, }; }); - const selectedAddress = selectedAccount.address; - const signer = { - address: selectedAddress, - signTypedMessage: ( - _params: TypedMessageParams, - _version: SignTypedDataVersion, - ) => KeyringController.signTypedMessage(_params, _version), - signPersonalMessage: (_params: PersonalMessageParams) => - KeyringController.signPersonalMessage(_params), - }; - const depositPreparation = await provider.prepareDeposit({ ...params, signer, @@ -1334,6 +1311,7 @@ export class PredictController extends BaseController< throw new Error('Chain ID not provided by deposit preparation'); } + const { NetworkController } = Engine.context; const networkClientId = NetworkController.findNetworkClientIdByChainId(chainId); @@ -1364,7 +1342,7 @@ export class PredictController extends BaseController< this.update((state) => { state.pendingDeposits[params.providerId] = { - [selectedAccount.address]: true, + [signer.address]: true, }; }); @@ -1479,18 +1457,8 @@ export class PredictController extends BaseController< throw new Error('Provider not available'); } - const { AccountsController, KeyringController, NetworkController } = - Engine.context; - const selectedAddress = AccountsController.getSelectedAccount().address; - const signer = { - address: selectedAddress, - signTypedMessage: ( - typedMessageParams: TypedMessageParams, - version: SignTypedDataVersion, - ) => KeyringController.signTypedMessage(typedMessageParams, version), - signPersonalMessage: (personalMessageParams: PersonalMessageParams) => - KeyringController.signPersonalMessage(personalMessageParams), - }; + const signer = this.getSigner(); + const { chainId, transaction, predictAddress } = await provider.prepareWithdraw({ ...params, @@ -1508,8 +1476,10 @@ export class PredictController extends BaseController< }; }); + const { NetworkController } = Engine.context; + const { batchId } = await addTransactionBatch({ - from: selectedAddress as Hex, + from: signer.address as Hex, origin: ORIGIN_METAMASK, networkClientId: NetworkController.findNetworkClientIdByChainId(chainId), @@ -1602,20 +1572,11 @@ export class PredictController extends BaseController< return; } - const { KeyringController, NetworkController } = Engine.context; - - const signer = { - address: request.transactionMeta.txParams.from, - signTypedMessage: ( - params: TypedMessageParams, - version: SignTypedDataVersion, - ) => KeyringController.signTypedMessage(params, version), - signPersonalMessage: (params: PersonalMessageParams) => - KeyringController.signPersonalMessage(params), - }; + const signer = this.getSigner(request.transactionMeta.txParams.from); const chainId = this.state.withdrawTransaction.chainId; + const { NetworkController } = Engine.context; const networkClientId = NetworkController.findNetworkClientIdByChainId( numberToHex(chainId), ); diff --git a/app/components/UI/Predict/hooks/usePredictClaimToasts.test.tsx b/app/components/UI/Predict/hooks/usePredictClaimToasts.test.tsx index c65d4c93637a..8683c11faab1 100644 --- a/app/components/UI/Predict/hooks/usePredictClaimToasts.test.tsx +++ b/app/components/UI/Predict/hooks/usePredictClaimToasts.test.tsx @@ -58,7 +58,9 @@ jest.mock('../../../../util/theme', () => ({ // Mock Engine jest.mock('../../../../core/Engine', () => ({ context: { - PredictController: {}, + PredictController: { + confirmClaim: jest.fn(), + }, }, controllerMessenger: { subscribe: jest.fn(), @@ -76,7 +78,9 @@ let mockState: any = { engine: { backgroundState: { PredictController: { - claimablePositions: [], + claimablePositions: { + [mockAccountAddress]: [], + }, }, AccountsController: { internalAccounts: { @@ -142,18 +146,36 @@ describe('usePredictClaimToasts', () => { engine: { backgroundState: { PredictController: { - claimablePositions: [ - { - id: '1', - status: PredictPositionStatus.WON, - currentValue: 100, - }, - { - id: '2', - status: PredictPositionStatus.WON, - currentValue: 50, + claimablePositions: { + [mockAccountAddress]: [ + { + id: '1', + status: PredictPositionStatus.WON, + currentValue: 100, + }, + { + id: '2', + status: PredictPositionStatus.WON, + currentValue: 50, + }, + ], + }, + }, + AccountsController: { + internalAccounts: { + selectedAccount: mockAccountId, + accounts: { + [mockAccountId]: { + id: mockAccountId, + address: mockAccountAddress, + name: 'Test Account', + type: 'eip155:eoa', + metadata: { + lastSelected: 0, + }, + }, }, - ], + }, }, }, }, @@ -407,7 +429,25 @@ describe('usePredictClaimToasts', () => { engine: { backgroundState: { PredictController: { - claimablePositions: [], + claimablePositions: { + [mockAccountAddress]: [], + }, + }, + AccountsController: { + internalAccounts: { + selectedAccount: mockAccountId, + accounts: { + [mockAccountId]: { + id: mockAccountId, + address: mockAccountAddress, + name: 'Test Account', + type: 'eip155:eoa', + metadata: { + lastSelected: 0, + }, + }, + }, + }, }, }, }, @@ -434,23 +474,41 @@ describe('usePredictClaimToasts', () => { engine: { backgroundState: { PredictController: { - claimablePositions: [ - { - id: '1', - status: PredictPositionStatus.WON, - currentValue: 100, - }, - { - id: '2', - status: PredictPositionStatus.LOST, - currentValue: 50, - }, - { - id: '3', - status: PredictPositionStatus.LOST, - currentValue: 75, + claimablePositions: { + [mockAccountAddress]: [ + { + id: '1', + status: PredictPositionStatus.WON, + currentValue: 100, + }, + { + id: '2', + status: PredictPositionStatus.LOST, + currentValue: 50, + }, + { + id: '3', + status: PredictPositionStatus.LOST, + currentValue: 75, + }, + ], + }, + }, + AccountsController: { + internalAccounts: { + selectedAccount: mockAccountId, + accounts: { + [mockAccountId]: { + id: mockAccountId, + address: mockAccountAddress, + name: 'Test Account', + type: 'eip155:eoa', + metadata: { + lastSelected: 0, + }, + }, }, - ], + }, }, }, }, diff --git a/app/components/UI/Predict/hooks/usePredictClaimToasts.tsx b/app/components/UI/Predict/hooks/usePredictClaimToasts.tsx index 0c99892a4669..bfdaea742205 100644 --- a/app/components/UI/Predict/hooks/usePredictClaimToasts.tsx +++ b/app/components/UI/Predict/hooks/usePredictClaimToasts.tsx @@ -2,12 +2,14 @@ import { TransactionType } from '@metamask/transaction-controller'; import { useMemo } from 'react'; import { useSelector } from 'react-redux'; import { strings } from '../../../../../locales/i18n'; -import { selectPredictClaimablePositions } from '../selectors/predictController'; -import { PredictPosition, PredictPositionStatus } from '../types'; +import { selectPredictWonPositions } from '../selectors/predictController'; +import { PredictPosition } from '../types'; import { formatPrice } from '../utils/format'; import { usePredictClaim } from './usePredictClaim'; import { usePredictPositions } from './usePredictPositions'; import { usePredictToasts } from './usePredictToasts'; +import Engine from '../../../../core/Engine'; +import { selectSelectedInternalAccountAddress } from '../../../../selectors/accountsController'; export const usePredictClaimToasts = () => { const { claim } = usePredictClaim(); @@ -16,13 +18,10 @@ export const usePredictClaimToasts = () => { loadOnMount: true, }); - const claimablePositions = useSelector(selectPredictClaimablePositions); - const wonPositions = useMemo( - () => - claimablePositions.filter( - (position) => position.status === PredictPositionStatus.WON, - ), - [claimablePositions], + const selectedAddress = + useSelector(selectSelectedInternalAccountAddress) ?? '0x0'; + const wonPositions = useSelector( + selectPredictWonPositions({ address: selectedAddress }), ); const totalClaimableAmount = useMemo( @@ -63,6 +62,9 @@ export const usePredictClaimToasts = () => { onRetry: claim, }, onConfirmed: () => { + Engine.context.PredictController.confirmClaim({ + providerId: 'polymarket', + }); loadPositions({ isRefresh: true }).catch(() => { // Ignore errors when refreshing positions }); diff --git a/app/components/UI/Predict/hooks/usePredictOrderPreview.ts b/app/components/UI/Predict/hooks/usePredictOrderPreview.ts index 9a49c99df9e2..8a398de4b5e2 100644 --- a/app/components/UI/Predict/hooks/usePredictOrderPreview.ts +++ b/app/components/UI/Predict/hooks/usePredictOrderPreview.ts @@ -32,6 +32,7 @@ export function usePredictOrderPreview( side, size, autoRefreshTimeout, + positionId, } = params; const calculatePreview = useCallback(async () => { @@ -57,6 +58,7 @@ export function usePredictOrderPreview( outcomeTokenId, side, size, + positionId, }); if (operationId === currentOperationRef.current && isMountedRef.current) { setPreview(p); @@ -102,6 +104,7 @@ export function usePredictOrderPreview( outcomeId, outcomeTokenId, side, + positionId, ]); const calculatePreviewRef = useRef(calculatePreview); diff --git a/app/components/UI/Predict/hooks/usePredictPositions.test.ts b/app/components/UI/Predict/hooks/usePredictPositions.test.ts index 514d38b9c999..1d115efde32b 100644 --- a/app/components/UI/Predict/hooks/usePredictPositions.test.ts +++ b/app/components/UI/Predict/hooks/usePredictPositions.test.ts @@ -27,6 +27,9 @@ jest.mock('react-redux', () => ({ jest.mock('../../../../selectors/accountsController', () => ({ selectSelectedInternalAccountAddress: jest.fn(), })); +jest.mock('../selectors/predictController', () => ({ + selectPredictClaimablePositionsByAddress: jest.fn(), +})); describe('usePredictPositions', () => { const mockGetPositions = jest.fn(); @@ -44,7 +47,8 @@ describe('usePredictPositions', () => { if (selector === selectSelectedInternalAccountAddress) { return '0x1234567890123456789012345678901234567890'; } - return undefined; + // Return empty array for claimable positions selector + return []; }); (usePredictTrading as jest.Mock).mockReturnValue({ getPositions: mockGetPositions, diff --git a/app/components/UI/Predict/hooks/usePredictPositions.ts b/app/components/UI/Predict/hooks/usePredictPositions.ts index 8b9c22564215..a6e045f16b49 100644 --- a/app/components/UI/Predict/hooks/usePredictPositions.ts +++ b/app/components/UI/Predict/hooks/usePredictPositions.ts @@ -7,6 +7,7 @@ import { usePredictTrading } from './usePredictTrading'; import { usePredictNetworkManagement } from './usePredictNetworkManagement'; import { useSelector } from 'react-redux'; import { selectSelectedInternalAccountAddress } from '../../../../selectors/accountsController'; +import { selectPredictClaimablePositionsByAddress } from '../selectors/predictController'; interface UsePredictPositionsOptions { /** @@ -72,8 +73,13 @@ export function usePredictPositions( const [isRefreshing, setIsRefreshing] = useState(false); const [error, setError] = useState(null); - const selectedInternalAccountAddress = useSelector( - selectSelectedInternalAccountAddress, + const selectedInternalAccountAddress = + useSelector(selectSelectedInternalAccountAddress) ?? '0x0'; + + const claimablePositions = useSelector( + selectPredictClaimablePositionsByAddress({ + address: selectedInternalAccountAddress, + }), ); const loadPositions = useCallback( @@ -196,7 +202,10 @@ export function usePredictPositions( }, [autoRefreshTimeout]); return { - positions, + // Get claimable positions from controller state if claimable is true. + // This will ensure that we can refresh claimable positions when the user + // performs a claim operation. + positions: claimable ? [...claimablePositions] : positions, isLoading, isRefreshing, error, diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts index 30e43c0fdac3..a740ca568ab0 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts @@ -26,6 +26,7 @@ jest.mock('../../../../../core/SDKConnect/utils/DevLogger', () => { import Engine from '../../../../../core/Engine'; import DevLogger from '../../../../../core/SDKConnect/utils/DevLogger'; import { + PredictPosition, PredictPositionStatus, PredictPriceHistoryInterval, Recurrence, @@ -687,6 +688,35 @@ describe('PolymarketProvider', () => { originalFetch; }); + // Helper function to create a mock PredictPosition + function createMockPosition( + overrides?: Partial, + ): PredictPosition { + return { + id: 'position-1', + providerId: 'polymarket', + marketId: 'market-1', + outcomeId: 'outcome-1', + outcome: 'Yes', + outcomeTokenId: 'token-1', + currentValue: 100, + title: 'Test Market', + icon: 'https://example.com/icon.png', + amount: 10, + price: 0.5, + status: PredictPositionStatus.OPEN, + size: 10, + outcomeIndex: 0, + percentPnl: 0, + cashPnl: 0, + claimable: false, + initialValue: 100, + avgPrice: 0.5, + endDate: '2025-12-31T23:59:59Z', + ...overrides, + }; + } + // Helper function to create a mock OrderPreview function createMockOrderPreview( overrides?: Partial, @@ -3209,4 +3239,614 @@ describe('PolymarketProvider', () => { expect(result).toEqual([]); }); }); + + describe('optimistic position updates', () => { + let originalFetch: typeof fetch | undefined; + + beforeEach(() => { + originalFetch = globalThis.fetch as typeof fetch | undefined; + jest.clearAllMocks(); + }); + + afterEach(() => { + (globalThis as unknown as { fetch: typeof fetch | undefined }).fetch = + originalFetch; + }); + + describe('confirmClaim', () => { + it('adds claimed positions to recently sold list', async () => { + // Arrange + const provider = createProvider(); + const mockAddress = '0x1234567890123456789012345678901234567890'; + const mockSigner = { + address: mockAddress, + signTypedMessage: jest.fn(), + signPersonalMessage: jest.fn(), + }; + const mockPositions = [ + createMockPosition({ + id: 'position-1', + status: PredictPositionStatus.WON, + currentValue: 100, + cashPnl: 50, + }), + createMockPosition({ + id: 'position-2', + status: PredictPositionStatus.WON, + currentValue: 200, + cashPnl: 100, + }), + ]; + + // Mock fetch for getPositions + (globalThis as unknown as { fetch: jest.Mock }).fetch = jest + .fn() + .mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue([ + { + id: 'position-1', + market: 'market-1', + size: '10', + value: '100', + }, + { + id: 'position-2', + market: 'market-1', + size: '20', + value: '200', + }, + ]), + }); + + mockComputeProxyAddress.mockReturnValue('0xproxy'); + mockParsePolymarketPositions.mockResolvedValue([ + { + id: 'position-1', + marketId: 'market-1', + status: PredictPositionStatus.WON, + currentValue: 100, + cashPnl: 50, + }, + { + id: 'position-2', + marketId: 'market-1', + status: PredictPositionStatus.WON, + currentValue: 200, + cashPnl: 100, + }, + ]); + + // Act + provider.confirmClaim({ positions: mockPositions, signer: mockSigner }); + + // Assert - subsequent getPositions should filter out claimed positions + const result = await provider.getPositions({ address: mockAddress }); + expect(result).toHaveLength(0); + }); + + it('handles single position claim', async () => { + // Arrange + const provider = createProvider(); + const mockAddress = '0x1234567890123456789012345678901234567890'; + const mockSigner = { + address: mockAddress, + signTypedMessage: jest.fn(), + signPersonalMessage: jest.fn(), + }; + const mockPosition = createMockPosition({ + id: 'position-1', + status: PredictPositionStatus.WON, + currentValue: 100, + cashPnl: 50, + }); + + (globalThis as unknown as { fetch: jest.Mock }).fetch = jest + .fn() + .mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue([ + { + id: 'position-1', + market: 'market-1', + size: '10', + value: '100', + }, + ]), + }); + + mockComputeProxyAddress.mockReturnValue('0xproxy'); + mockParsePolymarketPositions.mockResolvedValue([ + { + id: 'position-1', + marketId: 'market-1', + status: PredictPositionStatus.WON, + currentValue: 100, + cashPnl: 50, + }, + ]); + + // Act + provider.confirmClaim({ + positions: [mockPosition], + signer: mockSigner, + }); + + // Assert + const result = await provider.getPositions({ address: mockAddress }); + expect(result).toHaveLength(0); + }); + }); + + describe('getPositions with recently sold filtering', () => { + it('filters out recently sold positions when calling getPositions', async () => { + // Arrange + const provider = createProvider(); + const mockAddress = '0x1234567890123456789012345678901234567890'; + const mockSigner = { + address: mockAddress, + signTypedMessage: jest.fn(), + signPersonalMessage: jest.fn(), + }; + + // First, mark position-2 as sold + provider.confirmClaim({ + positions: [ + createMockPosition({ + id: 'position-2', + status: PredictPositionStatus.OPEN, + currentValue: 0, + cashPnl: 0, + }), + ], + signer: mockSigner, + }); + + // Mock fetch to return 3 positions + (globalThis as unknown as { fetch: jest.Mock }).fetch = jest + .fn() + .mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue([ + { + id: 'position-1', + market: 'market-1', + size: '10', + value: '100', + }, + { + id: 'position-2', + market: 'market-1', + size: '20', + value: '200', + }, + { + id: 'position-3', + market: 'market-1', + size: '30', + value: '300', + }, + ]), + }); + + mockComputeProxyAddress.mockReturnValue('0xproxy'); + mockParsePolymarketPositions.mockResolvedValue([ + { + id: 'position-1', + marketId: 'market-1', + providerId: 'polymarket', + }, + { + id: 'position-2', + marketId: 'market-1', + providerId: 'polymarket', + }, + { + id: 'position-3', + marketId: 'market-1', + providerId: 'polymarket', + }, + ]); + + // Act + const result = await provider.getPositions({ address: mockAddress }); + + // Assert - should return only 2 positions (position-2 filtered out) + expect(result).toHaveLength(2); + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'position-1' }), + expect.objectContaining({ id: 'position-3' }), + ]), + ); + expect(result).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'position-2' }), + ]), + ); + }); + + it('cleans up sold positions older than 5 minutes when adding new positions', async () => { + // Arrange + const provider = createProvider(); + const mockAddress = '0x1234567890123456789012345678901234567890'; + const mockSigner = { + address: mockAddress, + signTypedMessage: jest.fn(), + signPersonalMessage: jest.fn(), + }; + + // Save the original Date.now + const realDateNow = Date.now.bind(global.Date); + const sixMinutesAgo = realDateNow() - 6 * 60 * 1000; + + // Mock Date.now to return 6 minutes ago for the first confirmClaim + const dateNowStub = jest.fn(); + global.Date.now = dateNowStub; + dateNowStub.mockReturnValueOnce(sixMinutesAgo); + + // Mark a position as sold 6 minutes ago + provider.confirmClaim({ + positions: [ + createMockPosition({ + id: 'old-position', + status: PredictPositionStatus.OPEN, + currentValue: 0, + cashPnl: 0, + }), + ], + signer: mockSigner, + }); + + // Now make Date.now return current time + dateNowStub.mockImplementation(realDateNow); + + // Add a new position (this should trigger cleanup of old positions) + provider.confirmClaim({ + positions: [ + createMockPosition({ + id: 'new-sold-position', + status: PredictPositionStatus.OPEN, + currentValue: 0, + cashPnl: 0, + }), + ], + signer: mockSigner, + }); + + // Mock fetch to return positions + (globalThis as unknown as { fetch: jest.Mock }).fetch = jest + .fn() + .mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue([ + { + id: 'old-position', + market: 'market-1', + size: '10', + value: '100', + }, + { + id: 'new-sold-position', + market: 'market-1', + size: '15', + value: '150', + }, + { + id: 'visible-position', + market: 'market-1', + size: '20', + value: '200', + }, + ]), + }); + + mockComputeProxyAddress.mockReturnValue('0xproxy'); + mockParsePolymarketPositions.mockResolvedValue([ + { + id: 'old-position', + marketId: 'market-1', + providerId: 'polymarket', + }, + { + id: 'new-sold-position', + marketId: 'market-1', + providerId: 'polymarket', + }, + { + id: 'visible-position', + marketId: 'market-1', + providerId: 'polymarket', + }, + ]); + + // Act + const result = await provider.getPositions({ address: mockAddress }); + + // Assert - old position should NOT be filtered (cleaned up), new-sold-position SHOULD be filtered + expect(result).toHaveLength(2); + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'old-position' }), + expect.objectContaining({ id: 'visible-position' }), + ]), + ); + expect(result).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'new-sold-position' }), + ]), + ); + + // Cleanup + global.Date.now = realDateNow; + }); + + it('tracks multiple sold positions for same address', async () => { + // Arrange + const provider = createProvider(); + const mockAddress = '0x1234567890123456789012345678901234567890'; + const mockSigner = { + address: mockAddress, + signTypedMessage: jest.fn(), + signPersonalMessage: jest.fn(), + }; + + // Mark 3 positions as sold + provider.confirmClaim({ + positions: [ + createMockPosition({ + id: 'position-1', + status: PredictPositionStatus.OPEN, + currentValue: 0, + cashPnl: 0, + }), + createMockPosition({ + id: 'position-2', + status: PredictPositionStatus.OPEN, + currentValue: 0, + cashPnl: 0, + }), + createMockPosition({ + id: 'position-3', + status: PredictPositionStatus.OPEN, + currentValue: 0, + cashPnl: 0, + }), + ], + signer: mockSigner, + }); + + (globalThis as unknown as { fetch: jest.Mock }).fetch = jest + .fn() + .mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue([ + { id: 'position-1', market: 'market-1' }, + { id: 'position-2', market: 'market-1' }, + { id: 'position-3', market: 'market-1' }, + { id: 'position-4', market: 'market-1' }, + ]), + }); + + mockComputeProxyAddress.mockReturnValue('0xproxy'); + mockParsePolymarketPositions.mockResolvedValue([ + { id: 'position-1', marketId: 'market-1', providerId: 'polymarket' }, + { id: 'position-2', marketId: 'market-1', providerId: 'polymarket' }, + { id: 'position-3', marketId: 'market-1', providerId: 'polymarket' }, + { id: 'position-4', marketId: 'market-1', providerId: 'polymarket' }, + ]); + + // Act + const result = await provider.getPositions({ address: mockAddress }); + + // Assert - only position-4 should remain + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ id: 'position-4' }); + }); + + it('handles multiple addresses independently', async () => { + // Arrange + const provider = createProvider(); + const addressA = '0x1111111111111111111111111111111111111111'; + const addressB = '0x2222222222222222222222222222222222222222'; + + // Mark position-1 as sold for address A + provider.confirmClaim({ + positions: [ + createMockPosition({ + id: 'position-1', + status: PredictPositionStatus.OPEN, + currentValue: 0, + cashPnl: 0, + }), + ], + signer: { + address: addressA, + signTypedMessage: jest.fn(), + signPersonalMessage: jest.fn(), + }, + }); + + // Mark position-2 as sold for address B + provider.confirmClaim({ + positions: [ + createMockPosition({ + id: 'position-2', + status: PredictPositionStatus.OPEN, + currentValue: 0, + cashPnl: 0, + }), + ], + signer: { + address: addressB, + signTypedMessage: jest.fn(), + signPersonalMessage: jest.fn(), + }, + }); + + // Mock fetch for address A + (globalThis as unknown as { fetch: jest.Mock }).fetch = jest + .fn() + .mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue([ + { id: 'position-1', market: 'market-1' }, + { id: 'position-2', market: 'market-1' }, + ]), + }); + + mockComputeProxyAddress.mockReturnValue('0xproxy'); + mockParsePolymarketPositions.mockResolvedValue([ + { id: 'position-1', marketId: 'market-1', providerId: 'polymarket' }, + { id: 'position-2', marketId: 'market-1', providerId: 'polymarket' }, + ]); + + // Act - get positions for address A + const resultA = await provider.getPositions({ address: addressA }); + + // Assert - only position-2 should be returned (position-1 filtered for addressA) + expect(resultA).toHaveLength(1); + expect(resultA[0]).toMatchObject({ id: 'position-2' }); + }); + + it('returns all positions when no positions are marked as sold', async () => { + // Arrange + const provider = createProvider(); + const mockAddress = '0x1234567890123456789012345678901234567890'; + + (globalThis as unknown as { fetch: jest.Mock }).fetch = jest + .fn() + .mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue([ + { id: 'position-1', market: 'market-1' }, + { id: 'position-2', market: 'market-1' }, + { id: 'position-3', market: 'market-1' }, + { id: 'position-4', market: 'market-1' }, + { id: 'position-5', market: 'market-1' }, + ]), + }); + + mockComputeProxyAddress.mockReturnValue('0xproxy'); + mockParsePolymarketPositions.mockResolvedValue([ + { id: 'position-1', marketId: 'market-1', providerId: 'polymarket' }, + { id: 'position-2', marketId: 'market-1', providerId: 'polymarket' }, + { id: 'position-3', marketId: 'market-1', providerId: 'polymarket' }, + { id: 'position-4', marketId: 'market-1', providerId: 'polymarket' }, + { id: 'position-5', marketId: 'market-1', providerId: 'polymarket' }, + ]); + + // Act + const result = await provider.getPositions({ address: mockAddress }); + + // Assert + expect(result).toHaveLength(5); + }); + + it('handles empty sold positions list gracefully', async () => { + // Arrange + const provider = createProvider(); + const mockAddress = '0x1234567890123456789012345678901234567890'; + + (globalThis as unknown as { fetch: jest.Mock }).fetch = jest + .fn() + .mockResolvedValue({ + ok: true, + json: jest + .fn() + .mockResolvedValue([{ id: 'position-1', market: 'market-1' }]), + }); + + mockComputeProxyAddress.mockReturnValue('0xproxy'); + mockParsePolymarketPositions.mockResolvedValue([ + { id: 'position-1', marketId: 'market-1', providerId: 'polymarket' }, + ]); + + // Act + const result = await provider.getPositions({ address: mockAddress }); + + // Assert - no errors, returns all positions + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ id: 'position-1' }); + }); + }); + + describe('placeOrder with optimistic sold tracking', () => { + it('adds position to sold list when selling', async () => { + // Arrange + const { provider, mockSigner } = setupPlaceOrderTest(); + const preview = createMockOrderPreview({ + side: Side.SELL, + outcomeTokenId: 'token-123', + positionId: 'token-123', // positionId is required for tracking sold positions + }); + const orderParams = { + signer: mockSigner, + providerId: 'polymarket', + preview, + }; + + // Act + await provider.placeOrder(orderParams); + + // Assert - subsequent getPositions should filter out the sold position + (globalThis as unknown as { fetch: jest.Mock }).fetch = jest + .fn() + .mockResolvedValue({ + ok: true, + json: jest + .fn() + .mockResolvedValue([{ id: 'token-123', market: 'market-1' }]), + }); + + mockComputeProxyAddress.mockReturnValue('0xproxy'); + mockParsePolymarketPositions.mockResolvedValue([ + { id: 'token-123', marketId: 'market-1', providerId: 'polymarket' }, + ]); + + const positions = await provider.getPositions({ + address: mockSigner.address, + }); + expect(positions).toHaveLength(0); + }); + + it('does not add to sold list when buying', async () => { + // Arrange + const { provider, mockSigner } = setupPlaceOrderTest(); + const preview = createMockOrderPreview({ + side: Side.BUY, + outcomeTokenId: 'token-456', + }); + const orderParams = { + signer: mockSigner, + providerId: 'polymarket', + preview, + }; + + // Act + await provider.placeOrder(orderParams); + + // Assert - getPositions should not filter anything + (globalThis as unknown as { fetch: jest.Mock }).fetch = jest + .fn() + .mockResolvedValue({ + ok: true, + json: jest + .fn() + .mockResolvedValue([{ id: 'token-456', market: 'market-1' }]), + }); + + mockComputeProxyAddress.mockReturnValue('0xproxy'); + mockParsePolymarketPositions.mockResolvedValue([ + { id: 'token-456', marketId: 'market-1', providerId: 'polymarket' }, + ]); + + const positions = await provider.getPositions({ + address: mockSigner.address, + }); + expect(positions).toHaveLength(1); + expect(positions[0]).toMatchObject({ id: 'token-456' }); + }); + }); + }); }); diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts index 4340d5cd988b..5e8536157d44 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts @@ -93,6 +93,11 @@ export type SignTypedMessageFn = ( version: SignTypedDataVersion, ) => Promise; +interface RecentlySoldPosition { + positionId: string; + timestamp: number; +} + export class PolymarketProvider implements PredictProvider { readonly providerId = POLYMARKET_PROVIDER_ID; @@ -100,6 +105,7 @@ export class PolymarketProvider implements PredictProvider { #accountStateByAddress: Map = new Map(); #lastBuyOrderTimestampByAddress: Map = new Map(); #buyOrderInProgressByAddress: Map = new Map(); + #recentlySoldPositionsByAddress = new Map(); private static readonly FALLBACK_CATEGORY: PredictCategory = 'trending'; @@ -257,6 +263,28 @@ export class PolymarketProvider implements PredictProvider { } } + private addRecentlySoldPositions({ + address, + positionIds, + }: { + address: string; + positionIds: string[]; + }) { + // Delete anything older than 5 minutes to prevent + // list from growing too large + const recentlySoldPositions = ( + this.#recentlySoldPositionsByAddress.get(address) ?? [] + ).filter((soldPosition) => soldPosition.timestamp > Date.now() - 5 * 60000); + + recentlySoldPositions.push( + ...positionIds.map((positionId) => ({ + positionId, + timestamp: Date.now(), + })), + ); + this.#recentlySoldPositionsByAddress.set(address, recentlySoldPositions); + } + public async getPositions({ address, limit = 100, // todo: reduce this once we've decided on the pagination approach @@ -299,7 +327,19 @@ export class PolymarketProvider implements PredictProvider { positions: positionsData, }); - return parsedPositions; + // NOTE: Remove positions that were recently sold. This is a workaround for + // Polymarket's API taking some time to update positions + const soldPositions = + this.#recentlySoldPositionsByAddress.get(address) ?? []; + + const filteredPositions = parsedPositions.filter((position) => { + const isSold = soldPositions.some( + (soldPosition) => soldPosition.positionId === position.id, + ); + return !isSold; + }); + + return filteredPositions; } private async fetchActivity({ @@ -419,6 +459,7 @@ export class PolymarketProvider implements PredictProvider { fees, slippage, tickSize, + positionId, } = preview; if (side === Side.BUY) { @@ -542,6 +583,11 @@ export class PolymarketProvider implements PredictProvider { if (side === Side.BUY) { this.#lastBuyOrderTimestampByAddress.set(signer.address, Date.now()); + } else if (positionId) { + this.addRecentlySoldPositions({ + address: signer.address, + positionIds: [positionId], + }); } return { @@ -642,6 +688,19 @@ export class PolymarketProvider implements PredictProvider { } } + public confirmClaim({ + positions, + signer, + }: { + positions: PredictPosition[]; + signer: Signer; + }) { + this.addRecentlySoldPositions({ + address: signer.address, + positionIds: positions.map((position) => position.id), + }); + } + public async isEligible(): Promise { const { GEOBLOCK_API_ENDPOINT } = getPolymarketEndpoints(); let eligible = false; diff --git a/app/components/UI/Predict/providers/polymarket/utils.ts b/app/components/UI/Predict/providers/polymarket/utils.ts index 2af97c40487f..e75c2ef8976d 100644 --- a/app/components/UI/Predict/providers/polymarket/utils.ts +++ b/app/components/UI/Predict/providers/polymarket/utils.ts @@ -1197,6 +1197,7 @@ export const previewOrder = async ( outcomeTokenId, timestamp: new Date(book.timestamp).getTime(), side: Side.SELL, + positionId: params.positionId, sharePrice: bestPrice, maxAmountSpent: makerAmount, minAmountReceived: takerAmount, diff --git a/app/components/UI/Predict/providers/types.ts b/app/components/UI/Predict/providers/types.ts index 5a3760c53c2c..bc56d89480d2 100644 --- a/app/components/UI/Predict/providers/types.ts +++ b/app/components/UI/Predict/providers/types.ts @@ -58,7 +58,9 @@ export interface PreviewOrderParams { outcomeTokenId: string; side: Side; size: number; - signer?: Signer; + // For sell orders, we can store the position ID + // so we can perform optimistic updates + positionId?: string; } // Fees in US dollars @@ -97,6 +99,9 @@ export interface OrderPreview { negRisk: boolean; fees?: PredictFees; rateLimited?: boolean; + // For sell orders, we can store the position ID + // so we can perform optimistic updates + positionId?: string; } export type OrderResult = Result<{ @@ -124,12 +129,12 @@ export interface ClaimOrderResponse { } export interface GetPositionsParams { - address?: string; providerId?: string; - limit?: number; - offset?: number; + address?: string; claimable?: boolean; marketId?: string; + limit?: number; + offset?: number; } export interface PrepareDepositParams { @@ -210,13 +215,16 @@ export interface PredictProvider { }): Promise; // Order management - previewOrder(params: PreviewOrderParams): Promise; + previewOrder( + params: Omit & { signer: Signer }, + ): Promise; placeOrder( - params: PlaceOrderParams & { signer: Signer }, + params: Omit & { signer: Signer }, ): Promise; // Claim management prepareClaim(params: ClaimOrderParams): Promise; + confirmClaim?(params: { positions: PredictPosition[]; signer: Signer }): void; // Eligibility (Geo-Blocking) isEligible(): Promise; diff --git a/app/components/UI/Predict/selectors/predictController/index.test.ts b/app/components/UI/Predict/selectors/predictController/index.test.ts index 70afb3ba267b..52a743e4fb34 100644 --- a/app/components/UI/Predict/selectors/predictController/index.test.ts +++ b/app/components/UI/Predict/selectors/predictController/index.test.ts @@ -8,7 +8,7 @@ import { selectPredictBalances, selectPredictBalanceByAddress, } from './index'; -import { PredictPositionStatus } from '../../types'; +import { PredictPosition, PredictPositionStatus } from '../../types'; describe('Predict Controller Selectors', () => { describe('selectPredictControllerState', () => { @@ -96,30 +96,33 @@ describe('Predict Controller Selectors', () => { describe('selectPredictClaimablePositions', () => { it('returns claimable positions when they exist', () => { - const claimablePositions = [ - { - id: 'pos-1', - providerId: 'polymarket', - marketId: 'market-1', - outcomeId: 'outcome-1', - outcome: 'Yes', - outcomeTokenId: '123', - currentValue: 100, - title: 'Test Market', - icon: 'icon-url', - amount: 50, - price: 0.5, - status: PredictPositionStatus.WON, - size: 100, - outcomeIndex: 0, - percentPnl: 50, - cashPnl: 25, - claimable: true, - initialValue: 75, - avgPrice: 0.75, - endDate: '2024-12-31', - }, - ]; + const testAddress = '0x123'; + const claimablePositions = { + [testAddress]: [ + { + id: 'pos-1', + providerId: 'polymarket', + marketId: 'market-1', + outcomeId: 'outcome-1', + outcome: 'Yes', + outcomeTokenId: '123', + currentValue: 100, + title: 'Test Market', + icon: 'icon-url', + amount: 50, + price: 0.5, + status: PredictPositionStatus.WON, + size: 100, + outcomeIndex: 0, + percentPnl: 50, + cashPnl: 25, + claimable: true, + initialValue: 75, + avgPrice: 0.75, + endDate: '2024-12-31', + }, + ], + }; const mockState = { engine: { @@ -137,7 +140,7 @@ describe('Predict Controller Selectors', () => { expect(result).toEqual(claimablePositions); }); - it('returns empty array when claimable positions do not exist', () => { + it('returns empty object when claimable positions do not exist', () => { const mockState = { engine: { backgroundState: { @@ -151,10 +154,10 @@ describe('Predict Controller Selectors', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = selectPredictClaimablePositions(mockState as any); - expect(result).toEqual([]); + expect(result).toEqual({}); }); - it('returns empty array when PredictController state is undefined', () => { + it('returns empty object when PredictController state is undefined', () => { const mockState = { engine: { backgroundState: { @@ -166,58 +169,61 @@ describe('Predict Controller Selectors', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = selectPredictClaimablePositions(mockState as any); - expect(result).toEqual([]); + expect(result).toEqual({}); }); }); describe('selectPredictWonPositions', () => { it('filters positions with WON status', () => { - const claimablePositions = [ - { - id: 'pos-1', - providerId: 'polymarket', - marketId: 'market-1', - outcomeId: 'outcome-1', - outcome: 'Yes', - outcomeTokenId: '123', - currentValue: 100, - title: 'Test Market', - icon: 'icon-url', - amount: 50, - price: 0.5, - status: PredictPositionStatus.WON, - size: 100, - outcomeIndex: 0, - percentPnl: 50, - cashPnl: 25, - claimable: true, - initialValue: 75, - avgPrice: 0.75, - endDate: '2024-12-31', - }, - { - id: 'pos-2', - providerId: 'polymarket', - marketId: 'market-2', - outcomeId: 'outcome-2', - outcome: 'No', - outcomeTokenId: '456', - currentValue: 0, - title: 'Test Market 2', - icon: 'icon-url-2', - amount: 30, - price: 0.3, - status: PredictPositionStatus.LOST, - size: 100, - outcomeIndex: 1, - percentPnl: -100, - cashPnl: -30, - claimable: false, - initialValue: 30, - avgPrice: 0.3, - endDate: '2024-12-31', - }, - ]; + const testAddress = '0x123'; + const claimablePositions = { + [testAddress]: [ + { + id: 'pos-1', + providerId: 'polymarket', + marketId: 'market-1', + outcomeId: 'outcome-1', + outcome: 'Yes', + outcomeTokenId: '123', + currentValue: 100, + title: 'Test Market', + icon: 'icon-url', + amount: 50, + price: 0.5, + status: PredictPositionStatus.WON, + size: 100, + outcomeIndex: 0, + percentPnl: 50, + cashPnl: 25, + claimable: true, + initialValue: 75, + avgPrice: 0.75, + endDate: '2024-12-31', + }, + { + id: 'pos-2', + providerId: 'polymarket', + marketId: 'market-2', + outcomeId: 'outcome-2', + outcome: 'No', + outcomeTokenId: '456', + currentValue: 0, + title: 'Test Market 2', + icon: 'icon-url-2', + amount: 30, + price: 0.3, + status: PredictPositionStatus.LOST, + size: 100, + outcomeIndex: 1, + percentPnl: -100, + cashPnl: -30, + claimable: false, + initialValue: 30, + avgPrice: 0.3, + endDate: '2024-12-31', + }, + ], + }; const mockState = { engine: { @@ -230,7 +236,9 @@ describe('Predict Controller Selectors', () => { }; // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = selectPredictWonPositions(mockState as any); + const selector = selectPredictWonPositions({ address: testAddress }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = selector(mockState as any) as PredictPosition[]; expect(result).toHaveLength(1); expect(result[0].status).toBe(PredictPositionStatus.WON); @@ -238,30 +246,33 @@ describe('Predict Controller Selectors', () => { }); it('returns empty array when no positions have WON status', () => { - const claimablePositions = [ - { - id: 'pos-1', - providerId: 'polymarket', - marketId: 'market-1', - outcomeId: 'outcome-1', - outcome: 'Yes', - outcomeTokenId: '123', - currentValue: 0, - title: 'Test Market', - icon: 'icon-url', - amount: 50, - price: 0.5, - status: PredictPositionStatus.LOST, - size: 100, - outcomeIndex: 0, - percentPnl: -100, - cashPnl: -50, - claimable: false, - initialValue: 50, - avgPrice: 0.5, - endDate: '2024-12-31', - }, - ]; + const testAddress = '0x123'; + const claimablePositions = { + [testAddress]: [ + { + id: 'pos-1', + providerId: 'polymarket', + marketId: 'market-1', + outcomeId: 'outcome-1', + outcome: 'Yes', + outcomeTokenId: '123', + currentValue: 0, + title: 'Test Market', + icon: 'icon-url', + amount: 50, + price: 0.5, + status: PredictPositionStatus.LOST, + size: 100, + outcomeIndex: 0, + percentPnl: -100, + cashPnl: -50, + claimable: false, + initialValue: 50, + avgPrice: 0.5, + endDate: '2024-12-31', + }, + ], + }; const mockState = { engine: { @@ -273,25 +284,32 @@ describe('Predict Controller Selectors', () => { }, }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = selectPredictWonPositions(mockState as any); + const result = selectPredictWonPositions({ address: testAddress })( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockState as any, + ); expect(result).toEqual([]); }); it('returns empty array when claimable positions is empty', () => { + const testAddress = '0x123'; const mockState = { engine: { backgroundState: { PredictController: { - claimablePositions: [], + claimablePositions: { + [testAddress]: [], + }, }, }, }, }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = selectPredictWonPositions(mockState as any); + const result = selectPredictWonPositions({ address: testAddress })( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockState as any, + ); expect(result).toEqual([]); }); @@ -299,52 +317,55 @@ describe('Predict Controller Selectors', () => { describe('selectPredictWinFiat', () => { it('calculates total current value from winning positions', () => { - const claimablePositions = [ - { - id: 'pos-1', - providerId: 'polymarket', - marketId: 'market-1', - outcomeId: 'outcome-1', - outcome: 'Yes', - outcomeTokenId: '123', - currentValue: 100, - title: 'Test Market', - icon: 'icon-url', - amount: 50, - price: 0.5, - status: PredictPositionStatus.WON, - size: 100, - outcomeIndex: 0, - percentPnl: 50, - cashPnl: 25, - claimable: true, - initialValue: 75, - avgPrice: 0.75, - endDate: '2024-12-31', - }, - { - id: 'pos-2', - providerId: 'polymarket', - marketId: 'market-2', - outcomeId: 'outcome-2', - outcome: 'Yes', - outcomeTokenId: '456', - currentValue: 200, - title: 'Test Market 2', - icon: 'icon-url-2', - amount: 150, - price: 0.75, - status: PredictPositionStatus.WON, - size: 200, - outcomeIndex: 0, - percentPnl: 33.33, - cashPnl: 50, - claimable: true, - initialValue: 150, - avgPrice: 0.75, - endDate: '2024-12-31', - }, - ]; + const testAddress = '0x123'; + const claimablePositions = { + [testAddress]: [ + { + id: 'pos-1', + providerId: 'polymarket', + marketId: 'market-1', + outcomeId: 'outcome-1', + outcome: 'Yes', + outcomeTokenId: '123', + currentValue: 100, + title: 'Test Market', + icon: 'icon-url', + amount: 50, + price: 0.5, + status: PredictPositionStatus.WON, + size: 100, + outcomeIndex: 0, + percentPnl: 50, + cashPnl: 25, + claimable: true, + initialValue: 75, + avgPrice: 0.75, + endDate: '2024-12-31', + }, + { + id: 'pos-2', + providerId: 'polymarket', + marketId: 'market-2', + outcomeId: 'outcome-2', + outcome: 'Yes', + outcomeTokenId: '456', + currentValue: 200, + title: 'Test Market 2', + icon: 'icon-url-2', + amount: 150, + price: 0.75, + status: PredictPositionStatus.WON, + size: 200, + outcomeIndex: 0, + percentPnl: 33.33, + cashPnl: 50, + claimable: true, + initialValue: 150, + avgPrice: 0.75, + endDate: '2024-12-31', + }, + ], + }; const mockState = { engine: { @@ -356,54 +377,64 @@ describe('Predict Controller Selectors', () => { }, }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = selectPredictWinFiat(mockState as any); + const result = selectPredictWinFiat({ address: testAddress })( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockState as any, + ); expect(result).toBe(300); }); it('returns zero when no winning positions exist', () => { + const testAddress = '0x123'; const mockState = { engine: { backgroundState: { PredictController: { - claimablePositions: [], + claimablePositions: { + [testAddress]: [], + }, }, }, }, }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = selectPredictWinFiat(mockState as any); + const result = selectPredictWinFiat({ address: testAddress })( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockState as any, + ); expect(result).toBe(0); }); it('returns zero when only LOST positions exist', () => { - const claimablePositions = [ - { - id: 'pos-1', - providerId: 'polymarket', - marketId: 'market-1', - outcomeId: 'outcome-1', - outcome: 'Yes', - outcomeTokenId: '123', - currentValue: 0, - title: 'Test Market', - icon: 'icon-url', - amount: 50, - price: 0.5, - status: PredictPositionStatus.LOST, - size: 100, - outcomeIndex: 0, - percentPnl: -100, - cashPnl: -50, - claimable: false, - initialValue: 50, - avgPrice: 0.5, - endDate: '2024-12-31', - }, - ]; + const testAddress = '0x123'; + const claimablePositions = { + [testAddress]: [ + { + id: 'pos-1', + providerId: 'polymarket', + marketId: 'market-1', + outcomeId: 'outcome-1', + outcome: 'Yes', + outcomeTokenId: '123', + currentValue: 0, + title: 'Test Market', + icon: 'icon-url', + amount: 50, + price: 0.5, + status: PredictPositionStatus.LOST, + size: 100, + outcomeIndex: 0, + percentPnl: -100, + cashPnl: -50, + claimable: false, + initialValue: 50, + avgPrice: 0.5, + endDate: '2024-12-31', + }, + ], + }; const mockState = { engine: { @@ -415,8 +446,10 @@ describe('Predict Controller Selectors', () => { }, }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = selectPredictWinFiat(mockState as any); + const result = selectPredictWinFiat({ address: testAddress })( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockState as any, + ); expect(result).toBe(0); }); @@ -424,52 +457,55 @@ describe('Predict Controller Selectors', () => { describe('selectPredictWinPnl', () => { it('calculates total cash PnL from winning positions', () => { - const claimablePositions = [ - { - id: 'pos-1', - providerId: 'polymarket', - marketId: 'market-1', - outcomeId: 'outcome-1', - outcome: 'Yes', - outcomeTokenId: '123', - currentValue: 100, - title: 'Test Market', - icon: 'icon-url', - amount: 50, - price: 0.5, - status: PredictPositionStatus.WON, - size: 100, - outcomeIndex: 0, - percentPnl: 50, - cashPnl: 25, - claimable: true, - initialValue: 75, - avgPrice: 0.75, - endDate: '2024-12-31', - }, - { - id: 'pos-2', - providerId: 'polymarket', - marketId: 'market-2', - outcomeId: 'outcome-2', - outcome: 'Yes', - outcomeTokenId: '456', - currentValue: 200, - title: 'Test Market 2', - icon: 'icon-url-2', - amount: 150, - price: 0.75, - status: PredictPositionStatus.WON, - size: 200, - outcomeIndex: 0, - percentPnl: 33.33, - cashPnl: 50, - claimable: true, - initialValue: 150, - avgPrice: 0.75, - endDate: '2024-12-31', - }, - ]; + const testAddress = '0x123'; + const claimablePositions = { + [testAddress]: [ + { + id: 'pos-1', + providerId: 'polymarket', + marketId: 'market-1', + outcomeId: 'outcome-1', + outcome: 'Yes', + outcomeTokenId: '123', + currentValue: 100, + title: 'Test Market', + icon: 'icon-url', + amount: 50, + price: 0.5, + status: PredictPositionStatus.WON, + size: 100, + outcomeIndex: 0, + percentPnl: 50, + cashPnl: 25, + claimable: true, + initialValue: 75, + avgPrice: 0.75, + endDate: '2024-12-31', + }, + { + id: 'pos-2', + providerId: 'polymarket', + marketId: 'market-2', + outcomeId: 'outcome-2', + outcome: 'Yes', + outcomeTokenId: '456', + currentValue: 200, + title: 'Test Market 2', + icon: 'icon-url-2', + amount: 150, + price: 0.75, + status: PredictPositionStatus.WON, + size: 200, + outcomeIndex: 0, + percentPnl: 33.33, + cashPnl: 50, + claimable: true, + initialValue: 150, + avgPrice: 0.75, + endDate: '2024-12-31', + }, + ], + }; const mockState = { engine: { @@ -481,54 +517,64 @@ describe('Predict Controller Selectors', () => { }, }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = selectPredictWinPnl(mockState as any); + const result = selectPredictWinPnl({ address: testAddress })( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockState as any, + ); expect(result).toBe(75); }); it('returns zero when no winning positions exist', () => { + const testAddress = '0x123'; const mockState = { engine: { backgroundState: { PredictController: { - claimablePositions: [], + claimablePositions: { + [testAddress]: [], + }, }, }, }, }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = selectPredictWinPnl(mockState as any); + const result = selectPredictWinPnl({ address: testAddress })( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockState as any, + ); expect(result).toBe(0); }); it('calculates negative PnL when winning positions have negative cash PnL', () => { - const claimablePositions = [ - { - id: 'pos-1', - providerId: 'polymarket', - marketId: 'market-1', - outcomeId: 'outcome-1', - outcome: 'Yes', - outcomeTokenId: '123', - currentValue: 100, - title: 'Test Market', - icon: 'icon-url', - amount: 50, - price: 0.5, - status: PredictPositionStatus.WON, - size: 100, - outcomeIndex: 0, - percentPnl: -10, - cashPnl: -10, - claimable: true, - initialValue: 110, - avgPrice: 1.1, - endDate: '2024-12-31', - }, - ]; + const testAddress = '0x123'; + const claimablePositions = { + [testAddress]: [ + { + id: 'pos-1', + providerId: 'polymarket', + marketId: 'market-1', + outcomeId: 'outcome-1', + outcome: 'Yes', + outcomeTokenId: '123', + currentValue: 100, + title: 'Test Market', + icon: 'icon-url', + amount: 50, + price: 0.5, + status: PredictPositionStatus.WON, + size: 100, + outcomeIndex: 0, + percentPnl: -10, + cashPnl: -10, + claimable: true, + initialValue: 110, + avgPrice: 1.1, + endDate: '2024-12-31', + }, + ], + }; const mockState = { engine: { @@ -540,8 +586,10 @@ describe('Predict Controller Selectors', () => { }, }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = selectPredictWinPnl(mockState as any); + const result = selectPredictWinPnl({ address: testAddress })( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockState as any, + ); expect(result).toBe(-10); }); diff --git a/app/components/UI/Predict/selectors/predictController/index.ts b/app/components/UI/Predict/selectors/predictController/index.ts index 0f90865adbcb..61a74ae0e3e7 100644 --- a/app/components/UI/Predict/selectors/predictController/index.ts +++ b/app/components/UI/Predict/selectors/predictController/index.ts @@ -12,28 +12,37 @@ const selectPredictPendingDeposits = createSelector( const selectPredictClaimablePositions = createSelector( selectPredictControllerState, - (predictControllerState) => predictControllerState?.claimablePositions || [], + (predictControllerState) => predictControllerState?.claimablePositions || {}, ); -const selectPredictWonPositions = createSelector( - selectPredictClaimablePositions, - (claimablePositions) => - claimablePositions.filter( - (position) => position.status === PredictPositionStatus.WON, - ), -); +const selectPredictClaimablePositionsByAddress = ({ + address, +}: { + address: string; +}) => + createSelector( + selectPredictClaimablePositions, + (claimablePositions) => claimablePositions[address] || [], + ); -const selectPredictWinFiat = createSelector( - selectPredictWonPositions, - (winningPositions) => +const selectPredictWonPositions = ({ address }: { address: string }) => + createSelector( + selectPredictClaimablePositionsByAddress({ address }), + (claimablePositions) => + claimablePositions.filter( + (position) => position.status === PredictPositionStatus.WON, + ), + ); + +const selectPredictWinFiat = ({ address }: { address: string }) => + createSelector(selectPredictWonPositions({ address }), (winningPositions) => winningPositions.reduce((acc, position) => acc + position.currentValue, 0), -); + ); -const selectPredictWinPnl = createSelector( - selectPredictWonPositions, - (winningPositions) => +const selectPredictWinPnl = ({ address }: { address: string }) => + createSelector(selectPredictWonPositions({ address }), (winningPositions) => winningPositions.reduce((acc, position) => acc + position.cashPnl, 0), -); + ); const selectPredictBalances = createSelector( selectPredictControllerState, @@ -68,6 +77,7 @@ export { selectPredictControllerState, selectPredictPendingDeposits, selectPredictClaimablePositions, + selectPredictClaimablePositionsByAddress, selectPredictWonPositions, selectPredictWinFiat, selectPredictWinPnl, diff --git a/app/components/UI/Predict/types/navigation.ts b/app/components/UI/Predict/types/navigation.ts index e681d9c4a623..1b32e48bd26a 100644 --- a/app/components/UI/Predict/types/navigation.ts +++ b/app/components/UI/Predict/types/navigation.ts @@ -20,6 +20,8 @@ export interface PredictNavigationParamList extends ParamListBase { PredictMarketDetails: { marketId?: string; entryPoint?: PredictEntryPoint; + title?: string; + image?: string; }; PredictSellPreview: { market: PredictMarket; diff --git a/app/components/UI/Predict/utils/format.test.ts b/app/components/UI/Predict/utils/format.test.ts index ea895fd2807d..a9878aaa6464 100644 --- a/app/components/UI/Predict/utils/format.test.ts +++ b/app/components/UI/Predict/utils/format.test.ts @@ -3,6 +3,7 @@ import { formatPrice, formatAddress, formatCurrencyValue, + estimateLineCount, } from './format'; // Mock the formatWithThreshold utility @@ -10,6 +11,19 @@ jest.mock('../../../../util/assets', () => ({ formatWithThreshold: jest.fn(), })); +// Mock Dimensions from react-native +const mockDimensionsGet = jest.fn(() => ({ + width: 375, + height: 667, + scale: 2, + fontScale: 1, +})); +jest.mock('react-native', () => ({ + Dimensions: { + get: mockDimensionsGet, + }, +})); + import { formatWithThreshold } from '../../../../util/assets'; const mockFormatWithThreshold = formatWithThreshold as jest.MockedFunction< @@ -587,4 +601,205 @@ describe('format utils', () => { expect(result).toBe(expected); }); }); + + describe('estimateLineCount', () => { + beforeEach(() => { + mockDimensionsGet.mockReturnValue({ + width: 375, + height: 667, + scale: 2, + fontScale: 1, + }); + }); + + it('returns 1 for undefined text', () => { + const result = estimateLineCount(undefined); + + expect(result).toBe(1); + }); + + it('returns 1 for empty string', () => { + const result = estimateLineCount(''); + + expect(result).toBe(1); + }); + + it('returns 1 for short single-line text', () => { + const text = 'Short title'; + + const result = estimateLineCount(text); + + expect(result).toBe(1); + }); + + it('returns 1 for text that fits on single line', () => { + // Available width: 375 - 144 = 231px + // Chars per line: floor(231 / 8.5) = 27 chars + const text = 'Will Bitcoin reach $100k?'; + + const result = estimateLineCount(text); + + expect(result).toBe(1); + }); + + it('returns 2 for text that requires two lines', () => { + // Text that needs wrapping - needs to exceed ~27 characters per line with word boundaries + const text = + 'Will the cryptocurrency market continue to grow significantly next year?'; + + const result = estimateLineCount(text); + + expect(result).toBe(2); + }); + + it('returns 3 for text that requires three lines', () => { + const text = + 'Will the cryptocurrency decentralized blockchain market continue to grow significantly next year and reach unprecedented extraordinary heights with Bitcoin Ethereum?'; + + const result = estimateLineCount(text); + + expect(result).toBe(3); + }); + + it('calculates line count based on screen width for iPhone 14 Pro Max', () => { + mockDimensionsGet.mockReturnValue({ + width: 430, + height: 932, + scale: 3, + fontScale: 1, + }); + // Available width: 430 - 144 = 286px + // Chars per line: floor(286 / 8.5) = 33 chars + const text = + 'Will cryptocurrency blockchain decentralized markets continue to grow and expand globally with widespread mainstream adoption?'; + + const result = estimateLineCount(text); + + expect(result).toBe(2); + }); + + it('calculates line count based on screen width for iPhone SE', () => { + mockDimensionsGet.mockReturnValue({ + width: 375, + height: 667, + scale: 2, + fontScale: 1, + }); + // Available width: 375 - 144 = 231px + // Chars per line: floor(231 / 8.5) = 27 chars + const text = + 'Will cryptocurrency decentralized blockchain markets continue growing next year?'; + + const result = estimateLineCount(text); + + expect(result).toBe(2); + }); + + it('handles text with single very long word', () => { + const text = + 'Supercalifragilisticexpialidocious extraordinarily phenomenal unprecedented'; + + const result = estimateLineCount(text); + + expect(result).toBe(2); + }); + + it('handles text with multiple spaces between words', () => { + const text = + 'Will cryptocurrency blockchain decentralized markets continue growing next year indefinitely?'; + + const result = estimateLineCount(text); + + expect(result).toBe(2); + }); + + it('handles text starting with space', () => { + const text = + ' Will cryptocurrency blockchain decentralized markets continue to grow significantly next year?'; + + const result = estimateLineCount(text); + + expect(result).toBe(2); + }); + + it('handles text ending with space', () => { + const text = + 'Will cryptocurrency blockchain decentralized markets continue to grow significantly next year? '; + + const result = estimateLineCount(text); + + expect(result).toBe(2); + }); + + it('handles single character text', () => { + const text = 'A'; + + const result = estimateLineCount(text); + + expect(result).toBe(1); + }); + + it('handles text with special characters', () => { + const text = + 'Will BTC/ETH reach $100k/€90k during the upcoming fiscal year consistently?'; + + const result = estimateLineCount(text); + + expect(result).toBe(2); + }); + + it('handles text with numbers', () => { + const text = + '123456789 will this wrap to the next line with additional content about markets?'; + + const result = estimateLineCount(text); + + expect(result).toBe(2); + }); + + it.each([ + ['', 1], + ['A', 1], + ['Short', 1], + ['Will Bitcoin reach $100k?', 1], + [ + 'Will cryptocurrency blockchain decentralized markets continue to grow significantly next year?', + 2, + ], + [ + 'Will the cryptocurrency decentralized blockchain market continue to grow significantly next year and reach unprecedented extraordinary heights?', + 3, + ], + ])('estimates line count for text "%s" as %d lines', (text, expected) => { + const result = estimateLineCount(text); + + expect(result).toBe(expected); + }); + + it('handles text with exactly characters per line', () => { + // Long text that wraps + const text = + 'This text has exactly the right length to wrap to two complete lines with content'; + + const result = estimateLineCount(text); + + expect(result).toBe(2); + }); + + it('correctly wraps words at boundary', () => { + // Test word wrapping at exact boundary + mockDimensionsGet.mockReturnValue({ + width: 375, + height: 667, + scale: 2, + fontScale: 1, + }); + const text = + 'This is a test to check word boundary wrapping behavior correctly and accurately'; + + const result = estimateLineCount(text); + + expect(result).toBeGreaterThan(1); + }); + }); }); diff --git a/app/components/UI/Predict/utils/format.ts b/app/components/UI/Predict/utils/format.ts index 5dfce7322ffb..b14b2aec3643 100644 --- a/app/components/UI/Predict/utils/format.ts +++ b/app/components/UI/Predict/utils/format.ts @@ -1,3 +1,4 @@ +import { Dimensions } from 'react-native'; import { formatWithThreshold } from '../../../../util/assets'; import { PredictSeries, Recurrence } from '../types'; @@ -219,3 +220,43 @@ export const formatCurrencyValue = ( return formatted; }; + +/** + * Estimates the number of lines a title will occupy in the header + * Based on available width and average character width for HeadingMD variant + * HeadingMD: fontSize 18px, lineHeight 24px + */ +export const estimateLineCount = (text: string | undefined): number => { + if (!text) return 1; + + const screenWidth = Dimensions.get('window').width; + // Calculate available width: screen - horizontal padding - back button - icon - gaps + // 32px (horizontal padding) + 8px (px-1 on container) + 40px (back button) + 12px (gap) + 40px (icon) + 12px (gap) = ~144px + const usedWidth = 144; + const availableWidth = screenWidth - usedWidth; + + // HeadingMD font size is 18px with average character width of ~8.5px (accounting for proportional font) + const avgCharWidth = 8.5; + const charsPerLine = Math.floor(availableWidth / avgCharWidth); + + // Split text into words and simulate word wrapping + const words = text.split(' '); + let lines = 1; + let currentLineLength = 0; + + for (const word of words) { + const wordLength = word.length; + // Add 1 for space between words + const neededLength = + currentLineLength === 0 ? wordLength : currentLineLength + 1 + wordLength; + + if (neededLength > charsPerLine) { + lines++; + currentLineLength = wordLength; + } else { + currentLineLength = neededLength; + } + } + + return lines; +}; diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx index 9f74c5e4a457..75758bb77ae0 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx @@ -135,6 +135,14 @@ jest.mock('../../utils/format', () => ({ ? '0%' : `${value > 0 ? '+' : ''}${Math.abs(value).toFixed(2)}%`, ), + formatAddress: jest.fn( + (address: string) => `${address.slice(0, 6)}...${address.slice(-4)}`, + ), + estimateLineCount: jest.fn((text?: string) => { + if (!text) return 1; + // Simple mock implementation - returns 1 for short text, 2 for longer + return text.length > 50 ? 2 : 1; + }), })); jest.mock('../../hooks/usePredictMarket', () => ({ diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx index 9fb537462a47..5aef19bcc56d 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx @@ -24,7 +24,11 @@ import Routes from '../../../../../constants/navigation/Routes'; import { useTheme } from '../../../../../util/theme'; import { PredictNavigationParamList } from '../../types/navigation'; import { PredictEventValues } from '../../constants/eventNames'; -import { formatVolume, formatAddress } from '../../utils/format'; +import { + formatVolume, + formatAddress, + estimateLineCount, +} from '../../utils/format'; import Engine from '../../../../../core/Engine'; import { PredictMarketDetailsSelectorsIDs } from '../../../../../../e2e/selectors/Predict/Predict.selectors'; import { @@ -97,7 +101,7 @@ const PredictMarketDetails: React.FC = () => { const [isResolvedExpanded, setIsResolvedExpanded] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); - const { marketId, entryPoint } = route.params || {}; + const { marketId, entryPoint, title, image } = route.params || {}; const resolvedMarketId = marketId; const providerId = 'polymarket'; @@ -116,6 +120,11 @@ const PredictMarketDetails: React.FC = () => { enabled: Boolean(resolvedMarketId), }); + const titleLineCount = useMemo( + () => estimateLineCount(title ?? market?.title), + [title, market?.title], + ); + const claimable = market?.status === PredictMarketStatus.CLOSED; const { @@ -455,41 +464,48 @@ const PredictMarketDetails: React.FC = () => { const renderHeader = () => ( - - - - - {market?.image ? ( - + + - ) : ( - - )} + + + {image || market?.image ? ( + + ) : ( + + )} + - - - {market?.title || + = 2 ? undefined : BoxJustifyContent.Center + } + style={titleLineCount >= 2 ? tw.style('mt-[-5px]') : undefined} + > + + {title || + market?.title || (isMarketFetching ? strings('predict.loading') : '')} @@ -940,11 +956,15 @@ const PredictMarketDetails: React.FC = () => { outcome.tokens[1].price + ? TextColor.Default + : TextColor.Alternative + } > {outcome.tokens[0].price > outcome.tokens[1].price ? outcome.tokens[0].title @@ -953,6 +973,14 @@ const PredictMarketDetails: React.FC = () => { ? outcome.tokens[1].title : 'draw'} + {outcome.tokens[0].price > + outcome.tokens[1].price && ( + + )} diff --git a/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.tsx b/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.tsx index cf129eafc12b..47cc89c0d7ed 100644 --- a/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.tsx +++ b/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.tsx @@ -83,6 +83,7 @@ const PredictSellPreview = () => { outcomeTokenId: position.outcomeTokenId, side: Side.SELL, size: position.amount, + positionId: position.id, autoRefreshTimeout: 1000, }); diff --git a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.test.tsx b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.test.tsx index f7914e107cf5..72bf11dddd77 100644 --- a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.test.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.test.tsx @@ -195,6 +195,7 @@ const mockUseLimitsInitialValues: Partial> = { feeFixedRate: 1, quickAmounts: [100, 500, 1000], }, + isFetching: false, isAmountBelowMinimum: jest .fn() .mockImplementation((amount) => amount < MIN_LIMIT), diff --git a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx index c1900c38d82b..6c997375ec02 100644 --- a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx @@ -201,8 +201,13 @@ const BuildQuote = () => { queryGetCryptoCurrencies, } = useCryptoCurrencies(); - const { limits, isAmountBelowMinimum, isAmountAboveMaximum, isAmountValid } = - useLimits(); + const { + limits, + isFetching: isFetchingLimits, + isAmountBelowMinimum, + isAmountAboveMaximum, + isAmountValid, + } = useLimits(); useIntentAmount( setAmount, @@ -250,7 +255,6 @@ const BuildQuote = () => { ) { const newRegionCurrency = await queryDefaultFiatCurrency( selectedRegion.id, - selectedPaymentMethodId ? [selectedPaymentMethodId] : null, ); if (newRegionCurrency?.id) { setSelectedFiatCurrencyId(newRegionCurrency.id); @@ -418,10 +422,11 @@ const BuildQuote = () => { : undefined; const isFetching = + isFetchingRegions || + isFetchingFiatCurrency || isFetchingCryptoCurrencies || isFetchingPaymentMethods || - isFetchingFiatCurrency || - isFetchingRegions; + isFetchingLimits; const handleCancelPress = useCallback(() => { if (!selectedAsset?.network?.chainId) { @@ -894,20 +899,31 @@ const BuildQuote = () => { {isSell ? ( <> - - - {currentFiatCurrency?.symbol} - - + {isFetchingRegions || + isFetchingFiatCurrency || + !selectedFiatCurrencyId ? ( + + ) : ( + + + {currentFiatCurrency?.symbol} + + + )} ) : null} { onPress={handleAssetSelectorPress} /> - {isFetchingRegions || isFetchingCryptoCurrencies ? ( + {isFetchingRegions || + isFetchingFiatCurrency || + isFetchingCryptoCurrencies || + !selectedAsset?.id ? ( ) : ( { loading={ isFetchingRegions || isFetchingFiatCurrency || - isFetchingCryptoCurrencies + !selectedFiatCurrencyId } onCurrencyPress={isBuy ? handleFiatSelectorPress : undefined} /> @@ -1048,9 +1067,10 @@ const BuildQuote = () => { - + > + $ + 0 + - + onPress={[Function]} + testID="select-currency" + > + + + + USD + + + +  + + + + @@ -5767,10 +5858,8 @@ exports[`BuildQuote View Crypto Currency Data renders the loading page when cryp Update payment method - -  - + /> - - Credit or Debit Card - - - - - - - Change - - - -  - - + /> @@ -6011,45 +5996,30 @@ exports[`BuildQuote View Crypto Currency Data renders the loading page when cryp } > - - @@ -6430,7 +6400,7 @@ exports[`BuildQuote View Crypto Currency Data renders the loading page when cryp "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -6482,7 +6452,7 @@ exports[`BuildQuote View Crypto Currency Data renders the loading page when cryp "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -6534,7 +6504,7 @@ exports[`BuildQuote View Crypto Currency Data renders the loading page when cryp "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -6600,7 +6570,7 @@ exports[`BuildQuote View Crypto Currency Data renders the loading page when cryp "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -6652,7 +6622,7 @@ exports[`BuildQuote View Crypto Currency Data renders the loading page when cryp "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -6704,7 +6674,7 @@ exports[`BuildQuote View Crypto Currency Data renders the loading page when cryp "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -6770,7 +6740,7 @@ exports[`BuildQuote View Crypto Currency Data renders the loading page when cryp "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -6822,7 +6792,7 @@ exports[`BuildQuote View Crypto Currency Data renders the loading page when cryp "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -6874,7 +6844,7 @@ exports[`BuildQuote View Crypto Currency Data renders the loading page when cryp "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -6940,7 +6910,7 @@ exports[`BuildQuote View Crypto Currency Data renders the loading page when cryp "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -6994,7 +6964,7 @@ exports[`BuildQuote View Crypto Currency Data renders the loading page when cryp "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -7047,7 +7017,7 @@ exports[`BuildQuote View Crypto Currency Data renders the loading page when cryp { "alignItems": "center", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -8471,10 +8441,8 @@ exports[`BuildQuote View Fiat Currency Data renders the loading page when fiats You want to buy - - - - - - - - - - - - - + "overflow": "hidden", + "width": 40, + }, + undefined, + ] + } + /> - - Ethereum - + /> - - - - ETH - - - -  - - - + @@ -8824,28 +8608,39 @@ exports[`BuildQuote View Fiat Currency Data renders the loading page when fiats }, undefined, undefined, - undefined, - ] - } - > - + - Current balance - : - - 5.36385 ETH - ≈ $27.02 - + /> - -  - + /> - - Credit or Debit Card - - - - - - - Change - - - -  - - + /> @@ -9307,45 +8996,30 @@ exports[`BuildQuote View Fiat Currency Data renders the loading page when fiats } > - - @@ -9726,7 +9400,7 @@ exports[`BuildQuote View Fiat Currency Data renders the loading page when fiats "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -9778,7 +9452,7 @@ exports[`BuildQuote View Fiat Currency Data renders the loading page when fiats "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -9830,7 +9504,7 @@ exports[`BuildQuote View Fiat Currency Data renders the loading page when fiats "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -9896,7 +9570,7 @@ exports[`BuildQuote View Fiat Currency Data renders the loading page when fiats "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -9948,7 +9622,7 @@ exports[`BuildQuote View Fiat Currency Data renders the loading page when fiats "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -10000,7 +9674,7 @@ exports[`BuildQuote View Fiat Currency Data renders the loading page when fiats "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -10066,7 +9740,7 @@ exports[`BuildQuote View Fiat Currency Data renders the loading page when fiats "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -10118,7 +9792,7 @@ exports[`BuildQuote View Fiat Currency Data renders the loading page when fiats "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -10170,7 +9844,7 @@ exports[`BuildQuote View Fiat Currency Data renders the loading page when fiats "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -10236,7 +9910,7 @@ exports[`BuildQuote View Fiat Currency Data renders the loading page when fiats "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -10290,7 +9964,7 @@ exports[`BuildQuote View Fiat Currency Data renders the loading page when fiats "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -10343,7 +10017,7 @@ exports[`BuildQuote View Fiat Currency Data renders the loading page when fiats { "alignItems": "center", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -13070,7 +12744,7 @@ exports[`BuildQuote View Payment Method Data renders no icons if there are no pa "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -13122,7 +12796,7 @@ exports[`BuildQuote View Payment Method Data renders no icons if there are no pa "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -13174,7 +12848,7 @@ exports[`BuildQuote View Payment Method Data renders no icons if there are no pa "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -13240,7 +12914,7 @@ exports[`BuildQuote View Payment Method Data renders no icons if there are no pa "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -13292,7 +12966,7 @@ exports[`BuildQuote View Payment Method Data renders no icons if there are no pa "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -13344,7 +13018,7 @@ exports[`BuildQuote View Payment Method Data renders no icons if there are no pa "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -13410,7 +13084,7 @@ exports[`BuildQuote View Payment Method Data renders no icons if there are no pa "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -13462,7 +13136,7 @@ exports[`BuildQuote View Payment Method Data renders no icons if there are no pa "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -13514,7 +13188,7 @@ exports[`BuildQuote View Payment Method Data renders no icons if there are no pa "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -13580,7 +13254,7 @@ exports[`BuildQuote View Payment Method Data renders no icons if there are no pa "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -13634,7 +13308,7 @@ exports[`BuildQuote View Payment Method Data renders no icons if there are no pa "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -13687,7 +13361,7 @@ exports[`BuildQuote View Payment Method Data renders no icons if there are no pa { "alignItems": "center", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -15692,7 +15366,7 @@ exports[`BuildQuote View Payment Method Data renders the loading page when payme "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -15744,7 +15418,7 @@ exports[`BuildQuote View Payment Method Data renders the loading page when payme "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -15796,7 +15470,7 @@ exports[`BuildQuote View Payment Method Data renders the loading page when payme "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -15862,7 +15536,7 @@ exports[`BuildQuote View Payment Method Data renders the loading page when payme "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -15914,7 +15588,7 @@ exports[`BuildQuote View Payment Method Data renders the loading page when payme "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -15966,7 +15640,7 @@ exports[`BuildQuote View Payment Method Data renders the loading page when payme "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -16032,7 +15706,7 @@ exports[`BuildQuote View Payment Method Data renders the loading page when payme "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -16084,7 +15758,7 @@ exports[`BuildQuote View Payment Method Data renders the loading page when payme "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -16136,7 +15810,7 @@ exports[`BuildQuote View Payment Method Data renders the loading page when payme "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -16202,7 +15876,7 @@ exports[`BuildQuote View Payment Method Data renders the loading page when payme "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -16256,7 +15930,7 @@ exports[`BuildQuote View Payment Method Data renders the loading page when payme "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -16309,7 +15983,7 @@ exports[`BuildQuote View Payment Method Data renders the loading page when payme { "alignItems": "center", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -18656,7 +18330,7 @@ exports[`BuildQuote View Regions data renders the loading page when regions are "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -18708,7 +18382,7 @@ exports[`BuildQuote View Regions data renders the loading page when regions are "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -18760,7 +18434,7 @@ exports[`BuildQuote View Regions data renders the loading page when regions are "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -18826,7 +18500,7 @@ exports[`BuildQuote View Regions data renders the loading page when regions are "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -18878,7 +18552,7 @@ exports[`BuildQuote View Regions data renders the loading page when regions are "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -18930,7 +18604,7 @@ exports[`BuildQuote View Regions data renders the loading page when regions are "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -18996,7 +18670,7 @@ exports[`BuildQuote View Regions data renders the loading page when regions are "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -19048,7 +18722,7 @@ exports[`BuildQuote View Regions data renders the loading page when regions are "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -19100,7 +18774,7 @@ exports[`BuildQuote View Regions data renders the loading page when regions are "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -19166,7 +18840,7 @@ exports[`BuildQuote View Regions data renders the loading page when regions are "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -19220,7 +18894,7 @@ exports[`BuildQuote View Regions data renders the loading page when regions are "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -19273,7 +18947,7 @@ exports[`BuildQuote View Regions data renders the loading page when regions are { "alignItems": "center", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -21399,7 +21073,7 @@ exports[`BuildQuote View renders correctly 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -21451,7 +21125,7 @@ exports[`BuildQuote View renders correctly 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -21503,7 +21177,7 @@ exports[`BuildQuote View renders correctly 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -21569,7 +21243,7 @@ exports[`BuildQuote View renders correctly 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -21621,7 +21295,7 @@ exports[`BuildQuote View renders correctly 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -21673,7 +21347,7 @@ exports[`BuildQuote View renders correctly 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -21739,7 +21413,7 @@ exports[`BuildQuote View renders correctly 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -21791,7 +21465,7 @@ exports[`BuildQuote View renders correctly 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -21843,7 +21517,7 @@ exports[`BuildQuote View renders correctly 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -21909,7 +21583,7 @@ exports[`BuildQuote View renders correctly 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -21963,7 +21637,7 @@ exports[`BuildQuote View renders correctly 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -22016,7 +21690,7 @@ exports[`BuildQuote View renders correctly 1`] = ` { "alignItems": "center", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -24122,7 +23796,7 @@ exports[`BuildQuote View renders correctly 2`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -24174,7 +23848,7 @@ exports[`BuildQuote View renders correctly 2`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -24226,7 +23900,7 @@ exports[`BuildQuote View renders correctly 2`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -24292,7 +23966,7 @@ exports[`BuildQuote View renders correctly 2`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -24344,7 +24018,7 @@ exports[`BuildQuote View renders correctly 2`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -24396,7 +24070,7 @@ exports[`BuildQuote View renders correctly 2`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -24462,7 +24136,7 @@ exports[`BuildQuote View renders correctly 2`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -24514,7 +24188,7 @@ exports[`BuildQuote View renders correctly 2`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -24566,7 +24240,7 @@ exports[`BuildQuote View renders correctly 2`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -24632,7 +24306,7 @@ exports[`BuildQuote View renders correctly 2`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -24686,7 +24360,7 @@ exports[`BuildQuote View renders correctly 2`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -24739,7 +24413,7 @@ exports[`BuildQuote View renders correctly 2`] = ` { "alignItems": "center", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, diff --git a/app/components/UI/Ramp/Aggregator/hooks/useCryptoCurrencies.test.ts b/app/components/UI/Ramp/Aggregator/hooks/useCryptoCurrencies.test.ts index a97b562800c8..858b058ac754 100644 --- a/app/components/UI/Ramp/Aggregator/hooks/useCryptoCurrencies.test.ts +++ b/app/components/UI/Ramp/Aggregator/hooks/useCryptoCurrencies.test.ts @@ -100,7 +100,6 @@ describe('useCryptoCurrencies', () => { expect(useSDKMethod).toHaveBeenCalledWith( 'getCryptoCurrencies', 'test-region-id', - [], 'test-fiat-currency-id', ); }); @@ -123,7 +122,6 @@ describe('useCryptoCurrencies', () => { expect(useSDKMethod).toHaveBeenCalledWith( 'getSellCryptoCurrencies', 'test-region-id', - [], 'test-fiat-currency-id', ); }); diff --git a/app/components/UI/Ramp/Aggregator/hooks/useCryptoCurrencies.ts b/app/components/UI/Ramp/Aggregator/hooks/useCryptoCurrencies.ts index 87d75bb16d9a..82f4bbc8e5fd 100644 --- a/app/components/UI/Ramp/Aggregator/hooks/useCryptoCurrencies.ts +++ b/app/components/UI/Ramp/Aggregator/hooks/useCryptoCurrencies.ts @@ -34,7 +34,6 @@ export default function useCryptoCurrencies() { ] = useSDKMethod( isBuy ? 'getCryptoCurrencies' : 'getSellCryptoCurrencies', selectedRegion?.id, - [], // paymentMethodIds is passed as a wildcard to fetch all cryptocurrencies selectedFiatCurrencyId, ); diff --git a/app/components/UI/Ramp/Aggregator/hooks/useFiatCurrencies.test.ts b/app/components/UI/Ramp/Aggregator/hooks/useFiatCurrencies.test.ts index 49e50c830c83..b24b2b2e8e3d 100644 --- a/app/components/UI/Ramp/Aggregator/hooks/useFiatCurrencies.test.ts +++ b/app/components/UI/Ramp/Aggregator/hooks/useFiatCurrencies.test.ts @@ -47,13 +47,11 @@ describe('useFiatCurrencies', () => { expect(useSDKMethod).toHaveBeenCalledWith( 'getDefaultFiatCurrency', 'test-region-id', - [], ); expect(useSDKMethod).toHaveBeenCalledWith( 'getFiatCurrencies', 'test-region-id', - ['test-payment-method-id'], ); }); @@ -72,13 +70,11 @@ describe('useFiatCurrencies', () => { expect(useSDKMethod).toHaveBeenCalledWith( 'getDefaultSellFiatCurrency', 'test-region-id', - [], ); expect(useSDKMethod).toHaveBeenCalledWith( 'getSellFiatCurrencies', 'test-region-id', - ['test-payment-method-id'], ); }); @@ -119,6 +115,7 @@ describe('useFiatCurrencies', () => { queryGetFiatCurrencies: mockQueryGetFiatCurrencies, errorFiatCurrency: null, isFetchingFiatCurrency: true, + isFetchingFiatCurrencies: false, currentFiatCurrency: { id: 'test-fiat-currency-id-1' }, }); }); @@ -151,7 +148,8 @@ describe('useFiatCurrencies', () => { fiatCurrencies: null, queryGetFiatCurrencies: mockQueryGetFiatCurrencies, errorFiatCurrency: null, - isFetchingFiatCurrency: true, + isFetchingFiatCurrency: false, + isFetchingFiatCurrencies: true, currentFiatCurrency: { id: 'default-fiat-currency-id' }, }); }); @@ -193,6 +191,7 @@ describe('useFiatCurrencies', () => { queryGetFiatCurrencies: mockQueryGetFiatCurrencies, errorFiatCurrency: 'error-fetching-default-fiat-currency', isFetchingFiatCurrency: false, + isFetchingFiatCurrencies: false, currentFiatCurrency: { id: 'test-fiat-currency-id-1' }, }); }); @@ -226,6 +225,7 @@ describe('useFiatCurrencies', () => { queryGetFiatCurrencies: mockQueryGetFiatCurrencies, errorFiatCurrency: 'error-fetching-fiat-currencies', isFetchingFiatCurrency: false, + isFetchingFiatCurrencies: false, currentFiatCurrency: { id: 'default-fiat-currency-id' }, }); }); diff --git a/app/components/UI/Ramp/Aggregator/hooks/useFiatCurrencies.ts b/app/components/UI/Ramp/Aggregator/hooks/useFiatCurrencies.ts index d31c98b337ae..b35aeaa055cb 100644 --- a/app/components/UI/Ramp/Aggregator/hooks/useFiatCurrencies.ts +++ b/app/components/UI/Ramp/Aggregator/hooks/useFiatCurrencies.ts @@ -5,7 +5,6 @@ import useSDKMethod from './useSDKMethod'; export default function useFiatCurrencies() { const { selectedRegion, - selectedPaymentMethodId, selectedFiatCurrencyId, setSelectedFiatCurrencyId, isBuy, @@ -21,7 +20,6 @@ export default function useFiatCurrencies() { ] = useSDKMethod( isBuy ? 'getDefaultFiatCurrency' : 'getDefaultSellFiatCurrency', selectedRegion?.id, - [], ); const [ @@ -34,7 +32,6 @@ export default function useFiatCurrencies() { ] = useSDKMethod( isBuy ? 'getFiatCurrencies' : 'getSellFiatCurrencies', selectedRegion?.id, - selectedPaymentMethodId ? [selectedPaymentMethodId] : null, ); /** @@ -94,8 +91,8 @@ export default function useFiatCurrencies() { fiatCurrencies, queryGetFiatCurrencies, errorFiatCurrency: errorFiatCurrencies || errorDefaultFiatCurrency, - isFetchingFiatCurrency: - isFetchingFiatCurrencies || isFetchingDefaultFiatCurrency, + isFetchingFiatCurrency: isFetchingDefaultFiatCurrency, + isFetchingFiatCurrencies, currentFiatCurrency, }; } diff --git a/app/components/UI/Ramp/Aggregator/hooks/useLimits.ts b/app/components/UI/Ramp/Aggregator/hooks/useLimits.ts index 7c9c9ca5cc48..d42bcc863e73 100644 --- a/app/components/UI/Ramp/Aggregator/hooks/useLimits.ts +++ b/app/components/UI/Ramp/Aggregator/hooks/useLimits.ts @@ -10,7 +10,7 @@ const useLimits = () => { isBuy, } = useRampSDK(); - const [{ data: limits }] = useSDKMethod( + const [{ data: limits, isFetching }, queryGetLimits] = useSDKMethod( isBuy ? 'getLimits' : 'getSellLimits', selectedRegion?.id, selectedPaymentMethodId ? [selectedPaymentMethodId] : null, @@ -29,9 +29,11 @@ const useLimits = () => { return { limits, + isFetching, isAmountBelowMinimum, isAmountAboveMaximum, isAmountValid, + queryGetLimits, }; }; diff --git a/app/components/UI/Ramp/Aggregator/sdk/index.tsx b/app/components/UI/Ramp/Aggregator/sdk/index.tsx index 8973b85873db..97ef1bba3019 100644 --- a/app/components/UI/Ramp/Aggregator/sdk/index.tsx +++ b/app/components/UI/Ramp/Aggregator/sdk/index.tsx @@ -25,8 +25,6 @@ import { setFiatOrdersGetStartedAGG, setFiatOrdersRegionAGG, fiatOrdersRegionSelectorAgg, - fiatOrdersPaymentMethodSelectorAgg, - setFiatOrdersPaymentMethodAGG, networkShortNameSelector, fiatOrdersGetStartedSell, setFiatOrdersGetStartedSell, @@ -170,9 +168,6 @@ export const RampSDKProvider = ({ const selectedNetworkName = selectedNetworkNickname || selectedAggregatorNetworkName; - const INITIAL_PAYMENT_METHOD_ID = useSelector( - fiatOrdersPaymentMethodSelectorAgg, - ); const INITIAL_SELECTED_ASSET = null; const [rampType, setRampType] = useState(providerRampType ?? RampType.BUY); @@ -188,9 +183,9 @@ export const RampSDKProvider = ({ const caipChainId = getCaipChainIdFromCryptoCurrency(selectedAsset); const selectedAddress = useRampAccountAddress(caipChainId); - const [selectedPaymentMethodId, setSelectedPaymentMethodId] = useState( - INITIAL_PAYMENT_METHOD_ID, - ); + const [selectedPaymentMethodId, setSelectedPaymentMethodId] = useState< + string | null + >(null); const [selectedFiatCurrencyId, setSelectedFiatCurrencyId] = useState< string | null >(null); @@ -217,9 +212,8 @@ export const RampSDKProvider = ({ const setSelectedPaymentMethodIdCallback = useCallback( (paymentMethodId: Payment['id'] | null) => { setSelectedPaymentMethodId(paymentMethodId); - dispatch(setFiatOrdersPaymentMethodAGG(paymentMethodId)); }, - [dispatch], + [], ); const setSelectedAssetCallback = useCallback((asset: CryptoCurrency) => { diff --git a/app/components/UI/Ramp/Deposit/Views/AdditionalVerification/AdditionalVerification.tsx b/app/components/UI/Ramp/Deposit/Views/AdditionalVerification/AdditionalVerification.tsx index 3d22b8bcc10a..462b5c699ddb 100644 --- a/app/components/UI/Ramp/Deposit/Views/AdditionalVerification/AdditionalVerification.tsx +++ b/app/components/UI/Ramp/Deposit/Views/AdditionalVerification/AdditionalVerification.tsx @@ -41,7 +41,9 @@ const AdditionalVerification = () => { const { styles, theme } = useStyles(styleSheet, {}); - const { navigateToKycWebview } = useDepositRouting(); + const { navigateToKycWebview } = useDepositRouting({ + screenLocation: 'AdditionalVerification Screen', + }); React.useEffect(() => { navigation.setOptions( diff --git a/app/components/UI/Ramp/Deposit/Views/BuildQuote/BuildQuote.tsx b/app/components/UI/Ramp/Deposit/Views/BuildQuote/BuildQuote.tsx index d9bd1d7283f1..1e82aa82e200 100644 --- a/app/components/UI/Ramp/Deposit/Views/BuildQuote/BuildQuote.tsx +++ b/app/components/UI/Ramp/Deposit/Views/BuildQuote/BuildQuote.tsx @@ -98,7 +98,11 @@ const BuildQuote = () => { isFetching: isFetchingUserDetails, error: userDetailsError, fetchUserDetails, - } = useDepositUser(); + } = useDepositUser({ + screenLocation: 'BuildQuote Screen', + shouldTrackFetch: true, + fetchOnMount: true, + }); const { cryptoCurrencies, diff --git a/app/components/UI/Ramp/Deposit/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap index 2ecffd5d2a5e..0f1daeb00354 100644 --- a/app/components/UI/Ramp/Deposit/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap +++ b/app/components/UI/Ramp/Deposit/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap @@ -1163,7 +1163,7 @@ exports[`BuildQuote Component Continue button functionality displays error when "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1215,7 +1215,7 @@ exports[`BuildQuote Component Continue button functionality displays error when "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1267,7 +1267,7 @@ exports[`BuildQuote Component Continue button functionality displays error when "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1333,7 +1333,7 @@ exports[`BuildQuote Component Continue button functionality displays error when "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1385,7 +1385,7 @@ exports[`BuildQuote Component Continue button functionality displays error when "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1437,7 +1437,7 @@ exports[`BuildQuote Component Continue button functionality displays error when "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1503,7 +1503,7 @@ exports[`BuildQuote Component Continue button functionality displays error when "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1555,7 +1555,7 @@ exports[`BuildQuote Component Continue button functionality displays error when "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1607,7 +1607,7 @@ exports[`BuildQuote Component Continue button functionality displays error when "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1673,7 +1673,7 @@ exports[`BuildQuote Component Continue button functionality displays error when "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1725,7 +1725,7 @@ exports[`BuildQuote Component Continue button functionality displays error when "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -1778,7 +1778,7 @@ exports[`BuildQuote Component Continue button functionality displays error when { "alignItems": "center", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -3037,7 +3037,7 @@ exports[`BuildQuote Component Continue button functionality displays error when "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -3089,7 +3089,7 @@ exports[`BuildQuote Component Continue button functionality displays error when "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -3141,7 +3141,7 @@ exports[`BuildQuote Component Continue button functionality displays error when "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -3207,7 +3207,7 @@ exports[`BuildQuote Component Continue button functionality displays error when "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -3259,7 +3259,7 @@ exports[`BuildQuote Component Continue button functionality displays error when "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -3311,7 +3311,7 @@ exports[`BuildQuote Component Continue button functionality displays error when "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -3377,7 +3377,7 @@ exports[`BuildQuote Component Continue button functionality displays error when "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -3429,7 +3429,7 @@ exports[`BuildQuote Component Continue button functionality displays error when "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -3481,7 +3481,7 @@ exports[`BuildQuote Component Continue button functionality displays error when "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -3547,7 +3547,7 @@ exports[`BuildQuote Component Continue button functionality displays error when "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -3599,7 +3599,7 @@ exports[`BuildQuote Component Continue button functionality displays error when "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -3652,7 +3652,7 @@ exports[`BuildQuote Component Continue button functionality displays error when { "alignItems": "center", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -4911,7 +4911,7 @@ exports[`BuildQuote Component Continue button functionality displays error when "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -4963,7 +4963,7 @@ exports[`BuildQuote Component Continue button functionality displays error when "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -5015,7 +5015,7 @@ exports[`BuildQuote Component Continue button functionality displays error when "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -5081,7 +5081,7 @@ exports[`BuildQuote Component Continue button functionality displays error when "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -5133,7 +5133,7 @@ exports[`BuildQuote Component Continue button functionality displays error when "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -5185,7 +5185,7 @@ exports[`BuildQuote Component Continue button functionality displays error when "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -5251,7 +5251,7 @@ exports[`BuildQuote Component Continue button functionality displays error when "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -5303,7 +5303,7 @@ exports[`BuildQuote Component Continue button functionality displays error when "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -5355,7 +5355,7 @@ exports[`BuildQuote Component Continue button functionality displays error when "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -5421,7 +5421,7 @@ exports[`BuildQuote Component Continue button functionality displays error when "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -5473,7 +5473,7 @@ exports[`BuildQuote Component Continue button functionality displays error when "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -5526,7 +5526,7 @@ exports[`BuildQuote Component Continue button functionality displays error when { "alignItems": "center", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -6724,7 +6724,7 @@ exports[`BuildQuote Component Keypad Functionality displays converted token amou "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -6776,7 +6776,7 @@ exports[`BuildQuote Component Keypad Functionality displays converted token amou "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -6828,7 +6828,7 @@ exports[`BuildQuote Component Keypad Functionality displays converted token amou "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -6894,7 +6894,7 @@ exports[`BuildQuote Component Keypad Functionality displays converted token amou "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -6946,7 +6946,7 @@ exports[`BuildQuote Component Keypad Functionality displays converted token amou "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -6998,7 +6998,7 @@ exports[`BuildQuote Component Keypad Functionality displays converted token amou "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -7064,7 +7064,7 @@ exports[`BuildQuote Component Keypad Functionality displays converted token amou "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -7116,7 +7116,7 @@ exports[`BuildQuote Component Keypad Functionality displays converted token amou "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -7168,7 +7168,7 @@ exports[`BuildQuote Component Keypad Functionality displays converted token amou "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -7234,7 +7234,7 @@ exports[`BuildQuote Component Keypad Functionality displays converted token amou "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -7286,7 +7286,7 @@ exports[`BuildQuote Component Keypad Functionality displays converted token amou "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -7339,7 +7339,7 @@ exports[`BuildQuote Component Keypad Functionality displays converted token amou { "alignItems": "center", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -8537,7 +8537,7 @@ exports[`BuildQuote Component Keypad Functionality updates amount when keypad is "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -8589,7 +8589,7 @@ exports[`BuildQuote Component Keypad Functionality updates amount when keypad is "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -8641,7 +8641,7 @@ exports[`BuildQuote Component Keypad Functionality updates amount when keypad is "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -8707,7 +8707,7 @@ exports[`BuildQuote Component Keypad Functionality updates amount when keypad is "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -8759,7 +8759,7 @@ exports[`BuildQuote Component Keypad Functionality updates amount when keypad is "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -8811,7 +8811,7 @@ exports[`BuildQuote Component Keypad Functionality updates amount when keypad is "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -8877,7 +8877,7 @@ exports[`BuildQuote Component Keypad Functionality updates amount when keypad is "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -8929,7 +8929,7 @@ exports[`BuildQuote Component Keypad Functionality updates amount when keypad is "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -8981,7 +8981,7 @@ exports[`BuildQuote Component Keypad Functionality updates amount when keypad is "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -9047,7 +9047,7 @@ exports[`BuildQuote Component Keypad Functionality updates amount when keypad is "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -9099,7 +9099,7 @@ exports[`BuildQuote Component Keypad Functionality updates amount when keypad is "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -9152,7 +9152,7 @@ exports[`BuildQuote Component Keypad Functionality updates amount when keypad is { "alignItems": "center", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -10349,7 +10349,7 @@ exports[`BuildQuote Component Payment Method Selection does not open payment met "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -10401,7 +10401,7 @@ exports[`BuildQuote Component Payment Method Selection does not open payment met "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -10453,7 +10453,7 @@ exports[`BuildQuote Component Payment Method Selection does not open payment met "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -10519,7 +10519,7 @@ exports[`BuildQuote Component Payment Method Selection does not open payment met "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -10571,7 +10571,7 @@ exports[`BuildQuote Component Payment Method Selection does not open payment met "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -10623,7 +10623,7 @@ exports[`BuildQuote Component Payment Method Selection does not open payment met "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -10689,7 +10689,7 @@ exports[`BuildQuote Component Payment Method Selection does not open payment met "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -10741,7 +10741,7 @@ exports[`BuildQuote Component Payment Method Selection does not open payment met "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -10793,7 +10793,7 @@ exports[`BuildQuote Component Payment Method Selection does not open payment met "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -10859,7 +10859,7 @@ exports[`BuildQuote Component Payment Method Selection does not open payment met "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -10911,7 +10911,7 @@ exports[`BuildQuote Component Payment Method Selection does not open payment met "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -10964,7 +10964,7 @@ exports[`BuildQuote Component Payment Method Selection does not open payment met { "alignItems": "center", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -12255,7 +12255,7 @@ exports[`BuildQuote Component Payment Method Selection does not open payment met "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -12307,7 +12307,7 @@ exports[`BuildQuote Component Payment Method Selection does not open payment met "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -12359,7 +12359,7 @@ exports[`BuildQuote Component Payment Method Selection does not open payment met "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -12425,7 +12425,7 @@ exports[`BuildQuote Component Payment Method Selection does not open payment met "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -12477,7 +12477,7 @@ exports[`BuildQuote Component Payment Method Selection does not open payment met "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -12529,7 +12529,7 @@ exports[`BuildQuote Component Payment Method Selection does not open payment met "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -12595,7 +12595,7 @@ exports[`BuildQuote Component Payment Method Selection does not open payment met "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -12647,7 +12647,7 @@ exports[`BuildQuote Component Payment Method Selection does not open payment met "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -12699,7 +12699,7 @@ exports[`BuildQuote Component Payment Method Selection does not open payment met "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -12765,7 +12765,7 @@ exports[`BuildQuote Component Payment Method Selection does not open payment met "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -12817,7 +12817,7 @@ exports[`BuildQuote Component Payment Method Selection does not open payment met "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -12870,7 +12870,7 @@ exports[`BuildQuote Component Payment Method Selection does not open payment met { "alignItems": "center", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -14023,7 +14023,7 @@ exports[`BuildQuote Component Payment Method Selection does not show the duratio "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -14075,7 +14075,7 @@ exports[`BuildQuote Component Payment Method Selection does not show the duratio "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -14127,7 +14127,7 @@ exports[`BuildQuote Component Payment Method Selection does not show the duratio "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -14193,7 +14193,7 @@ exports[`BuildQuote Component Payment Method Selection does not show the duratio "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -14245,7 +14245,7 @@ exports[`BuildQuote Component Payment Method Selection does not show the duratio "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -14297,7 +14297,7 @@ exports[`BuildQuote Component Payment Method Selection does not show the duratio "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -14363,7 +14363,7 @@ exports[`BuildQuote Component Payment Method Selection does not show the duratio "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -14415,7 +14415,7 @@ exports[`BuildQuote Component Payment Method Selection does not show the duratio "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -14467,7 +14467,7 @@ exports[`BuildQuote Component Payment Method Selection does not show the duratio "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -14533,7 +14533,7 @@ exports[`BuildQuote Component Payment Method Selection does not show the duratio "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -14585,7 +14585,7 @@ exports[`BuildQuote Component Payment Method Selection does not show the duratio "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -14638,7 +14638,7 @@ exports[`BuildQuote Component Payment Method Selection does not show the duratio { "alignItems": "center", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -15836,7 +15836,7 @@ exports[`BuildQuote Component Payment Method Selection shows the right duration "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -15888,7 +15888,7 @@ exports[`BuildQuote Component Payment Method Selection shows the right duration "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -15940,7 +15940,7 @@ exports[`BuildQuote Component Payment Method Selection shows the right duration "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -16006,7 +16006,7 @@ exports[`BuildQuote Component Payment Method Selection shows the right duration "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -16058,7 +16058,7 @@ exports[`BuildQuote Component Payment Method Selection shows the right duration "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -16110,7 +16110,7 @@ exports[`BuildQuote Component Payment Method Selection shows the right duration "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -16176,7 +16176,7 @@ exports[`BuildQuote Component Payment Method Selection shows the right duration "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -16228,7 +16228,7 @@ exports[`BuildQuote Component Payment Method Selection shows the right duration "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -16280,7 +16280,7 @@ exports[`BuildQuote Component Payment Method Selection shows the right duration "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -16346,7 +16346,7 @@ exports[`BuildQuote Component Payment Method Selection shows the right duration "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -16398,7 +16398,7 @@ exports[`BuildQuote Component Payment Method Selection shows the right duration "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -16451,7 +16451,7 @@ exports[`BuildQuote Component Payment Method Selection shows the right duration { "alignItems": "center", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -17649,7 +17649,7 @@ exports[`BuildQuote Component Region Selection displays EUR currency when select "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -17701,7 +17701,7 @@ exports[`BuildQuote Component Region Selection displays EUR currency when select "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -17753,7 +17753,7 @@ exports[`BuildQuote Component Region Selection displays EUR currency when select "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -17819,7 +17819,7 @@ exports[`BuildQuote Component Region Selection displays EUR currency when select "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -17871,7 +17871,7 @@ exports[`BuildQuote Component Region Selection displays EUR currency when select "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -17923,7 +17923,7 @@ exports[`BuildQuote Component Region Selection displays EUR currency when select "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -17989,7 +17989,7 @@ exports[`BuildQuote Component Region Selection displays EUR currency when select "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -18041,7 +18041,7 @@ exports[`BuildQuote Component Region Selection displays EUR currency when select "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -18093,7 +18093,7 @@ exports[`BuildQuote Component Region Selection displays EUR currency when select "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -18159,7 +18159,7 @@ exports[`BuildQuote Component Region Selection displays EUR currency when select "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -18211,7 +18211,7 @@ exports[`BuildQuote Component Region Selection displays EUR currency when select "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -18264,7 +18264,7 @@ exports[`BuildQuote Component Region Selection displays EUR currency when select { "alignItems": "center", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -19462,7 +19462,7 @@ exports[`BuildQuote Component Region Selection displays default US region on ini "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -19514,7 +19514,7 @@ exports[`BuildQuote Component Region Selection displays default US region on ini "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -19566,7 +19566,7 @@ exports[`BuildQuote Component Region Selection displays default US region on ini "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -19632,7 +19632,7 @@ exports[`BuildQuote Component Region Selection displays default US region on ini "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -19684,7 +19684,7 @@ exports[`BuildQuote Component Region Selection displays default US region on ini "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -19736,7 +19736,7 @@ exports[`BuildQuote Component Region Selection displays default US region on ini "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -19802,7 +19802,7 @@ exports[`BuildQuote Component Region Selection displays default US region on ini "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -19854,7 +19854,7 @@ exports[`BuildQuote Component Region Selection displays default US region on ini "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -19906,7 +19906,7 @@ exports[`BuildQuote Component Region Selection displays default US region on ini "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -19972,7 +19972,7 @@ exports[`BuildQuote Component Region Selection displays default US region on ini "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -20024,7 +20024,7 @@ exports[`BuildQuote Component Region Selection displays default US region on ini "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -20077,7 +20077,7 @@ exports[`BuildQuote Component Region Selection displays default US region on ini { "alignItems": "center", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -21275,7 +21275,7 @@ exports[`BuildQuote Component Region Selection does not open region modal when r "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -21327,7 +21327,7 @@ exports[`BuildQuote Component Region Selection does not open region modal when r "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -21379,7 +21379,7 @@ exports[`BuildQuote Component Region Selection does not open region modal when r "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -21445,7 +21445,7 @@ exports[`BuildQuote Component Region Selection does not open region modal when r "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -21497,7 +21497,7 @@ exports[`BuildQuote Component Region Selection does not open region modal when r "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -21549,7 +21549,7 @@ exports[`BuildQuote Component Region Selection does not open region modal when r "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -21615,7 +21615,7 @@ exports[`BuildQuote Component Region Selection does not open region modal when r "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -21667,7 +21667,7 @@ exports[`BuildQuote Component Region Selection does not open region modal when r "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -21719,7 +21719,7 @@ exports[`BuildQuote Component Region Selection does not open region modal when r "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -21785,7 +21785,7 @@ exports[`BuildQuote Component Region Selection does not open region modal when r "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -21837,7 +21837,7 @@ exports[`BuildQuote Component Region Selection does not open region modal when r "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -21890,7 +21890,7 @@ exports[`BuildQuote Component Region Selection does not open region modal when r { "alignItems": "center", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -23181,7 +23181,7 @@ exports[`BuildQuote Component Region Selection does not open region modal when r "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -23233,7 +23233,7 @@ exports[`BuildQuote Component Region Selection does not open region modal when r "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -23285,7 +23285,7 @@ exports[`BuildQuote Component Region Selection does not open region modal when r "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -23351,7 +23351,7 @@ exports[`BuildQuote Component Region Selection does not open region modal when r "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -23403,7 +23403,7 @@ exports[`BuildQuote Component Region Selection does not open region modal when r "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -23455,7 +23455,7 @@ exports[`BuildQuote Component Region Selection does not open region modal when r "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -23521,7 +23521,7 @@ exports[`BuildQuote Component Region Selection does not open region modal when r "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -23573,7 +23573,7 @@ exports[`BuildQuote Component Region Selection does not open region modal when r "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -23625,7 +23625,7 @@ exports[`BuildQuote Component Region Selection does not open region modal when r "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -23691,7 +23691,7 @@ exports[`BuildQuote Component Region Selection does not open region modal when r "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -23743,7 +23743,7 @@ exports[`BuildQuote Component Region Selection does not open region modal when r "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -23796,7 +23796,7 @@ exports[`BuildQuote Component Region Selection does not open region modal when r { "alignItems": "center", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -24992,7 +24992,7 @@ exports[`BuildQuote Component Token Selection does not open token modal when cry "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -25044,7 +25044,7 @@ exports[`BuildQuote Component Token Selection does not open token modal when cry "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -25096,7 +25096,7 @@ exports[`BuildQuote Component Token Selection does not open token modal when cry "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -25162,7 +25162,7 @@ exports[`BuildQuote Component Token Selection does not open token modal when cry "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -25214,7 +25214,7 @@ exports[`BuildQuote Component Token Selection does not open token modal when cry "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -25266,7 +25266,7 @@ exports[`BuildQuote Component Token Selection does not open token modal when cry "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -25332,7 +25332,7 @@ exports[`BuildQuote Component Token Selection does not open token modal when cry "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -25384,7 +25384,7 @@ exports[`BuildQuote Component Token Selection does not open token modal when cry "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -25436,7 +25436,7 @@ exports[`BuildQuote Component Token Selection does not open token modal when cry "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -25502,7 +25502,7 @@ exports[`BuildQuote Component Token Selection does not open token modal when cry "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -25554,7 +25554,7 @@ exports[`BuildQuote Component Token Selection does not open token modal when cry "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -25607,7 +25607,7 @@ exports[`BuildQuote Component Token Selection does not open token modal when cry { "alignItems": "center", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -26896,7 +26896,7 @@ exports[`BuildQuote Component Token Selection does not open token modal when cry "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -26948,7 +26948,7 @@ exports[`BuildQuote Component Token Selection does not open token modal when cry "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -27000,7 +27000,7 @@ exports[`BuildQuote Component Token Selection does not open token modal when cry "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -27066,7 +27066,7 @@ exports[`BuildQuote Component Token Selection does not open token modal when cry "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -27118,7 +27118,7 @@ exports[`BuildQuote Component Token Selection does not open token modal when cry "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -27170,7 +27170,7 @@ exports[`BuildQuote Component Token Selection does not open token modal when cry "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -27236,7 +27236,7 @@ exports[`BuildQuote Component Token Selection does not open token modal when cry "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -27288,7 +27288,7 @@ exports[`BuildQuote Component Token Selection does not open token modal when cry "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -27340,7 +27340,7 @@ exports[`BuildQuote Component Token Selection does not open token modal when cry "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -27406,7 +27406,7 @@ exports[`BuildQuote Component Token Selection does not open token modal when cry "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -27458,7 +27458,7 @@ exports[`BuildQuote Component Token Selection does not open token modal when cry "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -27511,7 +27511,7 @@ exports[`BuildQuote Component Token Selection does not open token modal when cry { "alignItems": "center", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -28802,7 +28802,7 @@ exports[`BuildQuote Component User Details Error displays user details error ale "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -28854,7 +28854,7 @@ exports[`BuildQuote Component User Details Error displays user details error ale "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -28906,7 +28906,7 @@ exports[`BuildQuote Component User Details Error displays user details error ale "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -28972,7 +28972,7 @@ exports[`BuildQuote Component User Details Error displays user details error ale "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -29024,7 +29024,7 @@ exports[`BuildQuote Component User Details Error displays user details error ale "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -29076,7 +29076,7 @@ exports[`BuildQuote Component User Details Error displays user details error ale "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -29142,7 +29142,7 @@ exports[`BuildQuote Component User Details Error displays user details error ale "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -29194,7 +29194,7 @@ exports[`BuildQuote Component User Details Error displays user details error ale "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -29246,7 +29246,7 @@ exports[`BuildQuote Component User Details Error displays user details error ale "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -29312,7 +29312,7 @@ exports[`BuildQuote Component User Details Error displays user details error ale "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -29364,7 +29364,7 @@ exports[`BuildQuote Component User Details Error displays user details error ale "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -29417,7 +29417,7 @@ exports[`BuildQuote Component User Details Error displays user details error ale { "alignItems": "center", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -30615,7 +30615,7 @@ exports[`BuildQuote Component render matches snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -30667,7 +30667,7 @@ exports[`BuildQuote Component render matches snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -30719,7 +30719,7 @@ exports[`BuildQuote Component render matches snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -30785,7 +30785,7 @@ exports[`BuildQuote Component render matches snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -30837,7 +30837,7 @@ exports[`BuildQuote Component render matches snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -30889,7 +30889,7 @@ exports[`BuildQuote Component render matches snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -30955,7 +30955,7 @@ exports[`BuildQuote Component render matches snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -31007,7 +31007,7 @@ exports[`BuildQuote Component render matches snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -31059,7 +31059,7 @@ exports[`BuildQuote Component render matches snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -31125,7 +31125,7 @@ exports[`BuildQuote Component render matches snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -31177,7 +31177,7 @@ exports[`BuildQuote Component render matches snapshot 1`] = ` "alignItems": "center", "backgroundColor": "#3c4d9d0f", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, @@ -31230,7 +31230,7 @@ exports[`BuildQuote Component render matches snapshot 1`] = ` { "alignItems": "center", "borderRadius": 12, - "height": 40, + "height": 48, "justifyContent": "center", "paddingHorizontal": 16, }, diff --git a/app/components/UI/Ramp/Deposit/Views/EnterAddress/EnterAddress.tsx b/app/components/UI/Ramp/Deposit/Views/EnterAddress/EnterAddress.tsx index b1392066676f..5c45e20ad000 100644 --- a/app/components/UI/Ramp/Deposit/Views/EnterAddress/EnterAddress.tsx +++ b/app/components/UI/Ramp/Deposit/Views/EnterAddress/EnterAddress.tsx @@ -69,7 +69,9 @@ const EnterAddress = (): JSX.Element => { const stateInputRef = useRef(null); const postCodeInputRef = useRef(null); - const { routeAfterAuthentication } = useDepositRouting(); + const { routeAfterAuthentication } = useDepositRouting({ + screenLocation: 'EnterAddress Screen', + }); const initialFormData: AddressFormData = { addressLine1: previousFormData?.addressLine1 || '', diff --git a/app/components/UI/Ramp/Deposit/Views/KycProcessing/KycProcessing.tsx b/app/components/UI/Ramp/Deposit/Views/KycProcessing/KycProcessing.tsx index 0ee053257953..70c303ed139f 100644 --- a/app/components/UI/Ramp/Deposit/Views/KycProcessing/KycProcessing.tsx +++ b/app/components/UI/Ramp/Deposit/Views/KycProcessing/KycProcessing.tsx @@ -48,7 +48,9 @@ const KycProcessing = () => { const { quote } = useParams(); const trackEvent = useAnalytics(); - const { routeAfterAuthentication } = useDepositRouting(); + const { routeAfterAuthentication } = useDepositRouting({ + screenLocation: 'KycProcessing Screen', + }); const [{ data: kycForms, error: kycFormsError }] = useDepositSdkMethod( { diff --git a/app/components/UI/Ramp/Deposit/Views/Modals/WebviewModal/KycWebviewModal.tsx b/app/components/UI/Ramp/Deposit/Views/Modals/WebviewModal/KycWebviewModal.tsx index f1ffc33610a9..46888a3cba26 100644 --- a/app/components/UI/Ramp/Deposit/Views/Modals/WebviewModal/KycWebviewModal.tsx +++ b/app/components/UI/Ramp/Deposit/Views/Modals/WebviewModal/KycWebviewModal.tsx @@ -25,7 +25,9 @@ export const createKycWebviewModalNavigationDetails = function KycWebviewModal() { const { quote, workFlowRunId } = useParams(); - const { routeAfterAuthentication } = useDepositRouting(); + const { routeAfterAuthentication } = useDepositRouting({ + screenLocation: 'KycWebviewModal Screen', + }); const { idProofStatus } = useIdProofPolling(workFlowRunId, 1000, true, 0); diff --git a/app/components/UI/Ramp/Deposit/hooks/useDepositRouting.test.ts b/app/components/UI/Ramp/Deposit/hooks/useDepositRouting.test.ts index a4d9a69b45f2..838c69ad7a53 100644 --- a/app/components/UI/Ramp/Deposit/hooks/useDepositRouting.test.ts +++ b/app/components/UI/Ramp/Deposit/hooks/useDepositRouting.test.ts @@ -1123,7 +1123,10 @@ describe('useDepositRouting', () => { result.current.routeAfterAuthentication(mockQuote), ).resolves.not.toThrow(); - expect(mockTrackEvent).not.toHaveBeenCalled(); + expect(mockTrackEvent).not.toHaveBeenCalledWith( + 'RAMPS_KYC_STARTED', + expect.any(Object), + ); }); }); diff --git a/app/components/UI/Ramp/Deposit/hooks/useDepositRouting.ts b/app/components/UI/Ramp/Deposit/hooks/useDepositRouting.ts index 0a270d7af95e..481d5923eaa9 100644 --- a/app/components/UI/Ramp/Deposit/hooks/useDepositRouting.ts +++ b/app/components/UI/Ramp/Deposit/hooks/useDepositRouting.ts @@ -38,7 +38,12 @@ class LimitExceededError extends Error { } } -export const useDepositRouting = () => { +interface UseDepositRoutingConfig { + screenLocation: string; +} + +export const useDepositRouting = (config?: UseDepositRoutingConfig) => { + const { screenLocation = '' } = config || {}; const navigation = useNavigation(); const handleNewOrder = useHandleNewOrder(); const { @@ -49,7 +54,12 @@ export const useDepositRouting = () => { } = useDepositSDK(); const { themeAppearance, colors } = useTheme(); const trackEvent = useAnalytics(); - const { fetchUserDetails } = useDepositUser(); + + const { fetchUserDetails } = useDepositUser({ + screenLocation, + shouldTrackFetch: true, + fetchOnMount: false, + }); const [, getKycRequirement] = useDepositSdkMethod({ method: 'getKycRequirement', diff --git a/app/components/UI/Ramp/Deposit/hooks/useDepositUser.test.ts b/app/components/UI/Ramp/Deposit/hooks/useDepositUser.test.ts index 98cbb3d7cfb2..cc4b552da3a4 100644 --- a/app/components/UI/Ramp/Deposit/hooks/useDepositUser.test.ts +++ b/app/components/UI/Ramp/Deposit/hooks/useDepositUser.test.ts @@ -1,6 +1,6 @@ import { renderHook } from '@testing-library/react-native'; import { useDepositUser } from './useDepositUser'; -import { createMockSDKReturn } from '../testUtils/constants'; +import { createMockSDKReturn, MOCK_US_REGION } from '../testUtils/constants'; import { DepositSdkMethodQuery } from '../hooks/useDepositSdkMethod'; import { NativeRampsSdk } from '@consensys/native-ramps-sdk'; import type { AxiosError } from 'axios'; @@ -17,6 +17,9 @@ jest.mock('../sdk', () => ({ useDepositSDK: () => mockUseDepositSDK(), })); +const mockTrackEvent = jest.fn(); +jest.mock('../../hooks/useAnalytics', () => () => mockTrackEvent); + describe('useDepositUser', () => { const mockFetchUserDetails = jest.fn(); const mockUserDetails = { @@ -39,26 +42,14 @@ describe('useDepositUser', () => { error?: string | null; isFetching?: boolean; }) => { - mockUseDepositSdkMethod.mockImplementation((config) => { - if (config.throws) { - return [ - { - data: overrides?.data ?? null, - error: overrides?.error ?? null, - isFetching: overrides?.isFetching ?? false, - }, - mockFetchUserDetails, - ]; - } - return [ - { - data: overrides?.data ?? null, - error: overrides?.error ?? null, - isFetching: overrides?.isFetching ?? false, - }, - mockFetchUserDetails, - ]; - }); + mockUseDepositSdkMethod.mockImplementation(() => [ + { + data: overrides?.data ?? null, + error: overrides?.error ?? null, + isFetching: overrides?.isFetching ?? false, + }, + mockFetchUserDetails, + ]); }; beforeEach(() => { @@ -70,6 +61,7 @@ describe('useDepositUser', () => { createMockSDKReturn({ isAuthenticated: false, logoutFromProvider: mockLogoutFromProvider, + selectedRegion: MOCK_US_REGION, }), ); @@ -81,12 +73,10 @@ describe('useDepositUser', () => { mockUseDepositSDK.mockReturnValue( createMockSDKReturn({ isAuthenticated: true, + selectedRegion: MOCK_US_REGION, }), ); - mockUseDepositSdkMethod.mockReturnValue([ - { data: mockUserDetails, error: null, isFetching: false }, - mockFetchUserDetails, - ]); + setupMockSdkMethod({ data: mockUserDetails }); const { result } = renderHook(() => useDepositUser()); @@ -99,12 +89,10 @@ describe('useDepositUser', () => { mockUseDepositSDK.mockReturnValue( createMockSDKReturn({ isAuthenticated: false, + selectedRegion: MOCK_US_REGION, }), ); - mockUseDepositSdkMethod.mockReturnValue([ - { data: mockUserDetails, error: null, isFetching: false }, - mockFetchUserDetails, - ]); + setupMockSdkMethod({ data: mockUserDetails }); const { result } = renderHook(() => useDepositUser()); @@ -128,18 +116,17 @@ describe('useDepositUser', () => { expect(typeof result.current.fetchUserDetails).toBe('function'); }); }); - describe('authentication-based fetching', () => { it('fetches user details when authenticated and no user details exist', () => { mockUseDepositSDK.mockReturnValue( createMockSDKReturn({ isAuthenticated: true, - logoutFromProvider: mockLogoutFromProvider, + selectedRegion: MOCK_US_REGION, }), ); setupMockSdkMethod(); - renderHook(() => useDepositUser()); + renderHook(() => useDepositUser({ fetchOnMount: true })); expect(mockFetchUserDetails).toHaveBeenCalled(); }); @@ -148,9 +135,10 @@ describe('useDepositUser', () => { mockUseDepositSDK.mockReturnValue( createMockSDKReturn({ isAuthenticated: false, - logoutFromProvider: mockLogoutFromProvider, + selectedRegion: MOCK_US_REGION, }), ); + setupMockSdkMethod({ data: mockUserDetails }); renderHook(() => useDepositUser()); @@ -205,7 +193,7 @@ describe('useDepositUser', () => { mockUseDepositSDK.mockReturnValue( createMockSDKReturn({ isAuthenticated: true, - logoutFromProvider: mockLogoutFromProvider, + selectedRegion: MOCK_US_REGION, }), ); setupMockSdkMethod({ isFetching: true }); @@ -219,10 +207,11 @@ describe('useDepositUser', () => { it('returns error state when API fails', () => { const mockError = 'Failed to fetch user details'; + mockUseDepositSDK.mockReturnValue( createMockSDKReturn({ isAuthenticated: true, - logoutFromProvider: mockLogoutFromProvider, + selectedRegion: MOCK_US_REGION, }), ); setupMockSdkMethod({ error: mockError }); @@ -243,7 +232,9 @@ describe('useDepositUser', () => { ); setupMockSdkMethod(); - const { rerender } = renderHook(() => useDepositUser()); + const { rerender } = renderHook(() => + useDepositUser({ fetchOnMount: true }), + ); rerender({}); rerender({}); @@ -251,27 +242,138 @@ describe('useDepositUser', () => { }); }); - describe('fetchUserDetails', () => { - it('returns user details when successful', async () => { + describe('config options', () => { + it('fetches user details on mount when fetchOnMount is enabled', () => { mockUseDepositSDK.mockReturnValue( createMockSDKReturn({ isAuthenticated: true, - logoutFromProvider: mockLogoutFromProvider, + selectedRegion: MOCK_US_REGION, + }), + ); + setupMockSdkMethod(); + + renderHook(() => useDepositUser({ fetchOnMount: true })); + + expect(mockFetchUserDetails).toHaveBeenCalledTimes(1); + }); + + it('does not fetch on mount when fetchOnMount is disabled', () => { + mockUseDepositSDK.mockReturnValue( + createMockSDKReturn({ + isAuthenticated: true, + selectedRegion: MOCK_US_REGION, + }), + ); + setupMockSdkMethod(); + + renderHook(() => useDepositUser({ fetchOnMount: false })); + + expect(mockFetchUserDetails).not.toHaveBeenCalled(); + }); + + it('does not fetch on mount when not authenticated', () => { + mockUseDepositSDK.mockReturnValue( + createMockSDKReturn({ + isAuthenticated: false, + selectedRegion: MOCK_US_REGION, + }), + ); + setupMockSdkMethod(); + + renderHook(() => useDepositUser({ fetchOnMount: true })); + + expect(mockFetchUserDetails).not.toHaveBeenCalled(); + }); + }); + + describe('analytics tracking', () => { + it('tracks RAMPS_USER_DETAILS_FETCHED when shouldTrackFetch is enabled', async () => { + mockUseDepositSDK.mockReturnValue( + createMockSDKReturn({ + isAuthenticated: true, + selectedRegion: MOCK_US_REGION, + }), + ); + mockFetchUserDetails.mockResolvedValue(mockUserDetails); + setupMockSdkMethod({ data: mockUserDetails }); + + const { result } = renderHook(() => + useDepositUser({ + shouldTrackFetch: true, + screenLocation: 'TestScreen', }), ); + await result.current.fetchUserDetails(); + + expect(mockTrackEvent).toHaveBeenCalledWith( + 'RAMPS_USER_DETAILS_FETCHED', + { + logged_in: true, + region: 'US', + location: 'TestScreen', + }, + ); + }); + + it('does not track analytics when shouldTrackFetch is disabled', async () => { + mockUseDepositSDK.mockReturnValue( + createMockSDKReturn({ + isAuthenticated: true, + selectedRegion: MOCK_US_REGION, + }), + ); mockFetchUserDetails.mockResolvedValue(mockUserDetails); setupMockSdkMethod(); - const { result } = renderHook(() => useDepositUser()); + const { result } = renderHook(() => + useDepositUser({ + shouldTrackFetch: false, + }), + ); - const userDetails = await result.current.fetchUserDetails(); + await result.current.fetchUserDetails(); - expect(userDetails).toEqual(mockUserDetails); - expect(mockFetchUserDetails).toHaveBeenCalled(); + expect(mockTrackEvent).not.toHaveBeenCalled(); }); - it('logs out but does not throw on 401 error', async () => { + it('uses selectedRegion isoCode when userDetails has no country', async () => { + const userDetailsWithoutAddress = { + firstName: 'John', + lastName: 'Doe', + }; + + mockUseDepositSDK.mockReturnValue( + createMockSDKReturn({ + isAuthenticated: true, + selectedRegion: MOCK_US_REGION, + }), + ); + mockFetchUserDetails.mockResolvedValue(userDetailsWithoutAddress); + setupMockSdkMethod({ data: userDetailsWithoutAddress }); + + const { result } = renderHook(() => + useDepositUser({ + shouldTrackFetch: true, + screenLocation: 'TestScreen', + }), + ); + + await result.current.fetchUserDetails(); + + expect(mockTrackEvent).toHaveBeenCalledWith( + 'RAMPS_USER_DETAILS_FETCHED', + { + logged_in: true, + region: 'US', + location: 'TestScreen', + }, + ); + }); + }); + + describe('error handling', () => { + it('logs out when receiving 401 error', async () => { const error401 = Object.assign(new Error('Unauthorized'), { status: 401, }) as AxiosError; @@ -280,9 +382,9 @@ describe('useDepositUser', () => { createMockSDKReturn({ isAuthenticated: true, logoutFromProvider: mockLogoutFromProvider, + selectedRegion: MOCK_US_REGION, }), ); - mockFetchUserDetails.mockRejectedValue(error401); setupMockSdkMethod(); @@ -302,9 +404,9 @@ describe('useDepositUser', () => { createMockSDKReturn({ isAuthenticated: true, logoutFromProvider: mockLogoutFromProvider, + selectedRegion: MOCK_US_REGION, }), ); - mockFetchUserDetails.mockRejectedValue(networkError); setupMockSdkMethod({ data: mockUserDetails }); diff --git a/app/components/UI/Ramp/Deposit/hooks/useDepositUser.ts b/app/components/UI/Ramp/Deposit/hooks/useDepositUser.ts index 82f05fcf1c5c..cf601345bc14 100644 --- a/app/components/UI/Ramp/Deposit/hooks/useDepositUser.ts +++ b/app/components/UI/Ramp/Deposit/hooks/useDepositUser.ts @@ -3,9 +3,22 @@ import { useDepositSdkMethod } from './useDepositSdkMethod'; import { useDepositSDK } from '../sdk'; import type { AxiosError } from 'axios'; import Logger from '../../../../../util/Logger'; +import useAnalytics from '../../hooks/useAnalytics'; -export function useDepositUser() { - const { isAuthenticated, logoutFromProvider } = useDepositSDK(); +export interface UseDepositUserConfig { + screenLocation?: string; + shouldTrackFetch?: boolean; + fetchOnMount?: boolean; +} + +export function useDepositUser(config?: UseDepositUserConfig) { + const { + screenLocation = '', + shouldTrackFetch = false, + fetchOnMount = false, + } = config || {}; + const { isAuthenticated, logoutFromProvider, selectedRegion } = + useDepositSDK(); const [{ data: userDetails, error, isFetching }, fetchUserDetails] = useDepositSdkMethod({ @@ -14,22 +27,51 @@ export function useDepositUser() { throws: true, }); + const trackEvent = useAnalytics(); + const fetchUserDetailsCallback = useCallback(async () => { try { const result = await fetchUserDetails(); + if (shouldTrackFetch) { + trackEvent('RAMPS_USER_DETAILS_FETCHED', { + logged_in: true, + region: result?.address?.countryCode || selectedRegion?.isoCode || '', + location: screenLocation, + }); + } return result; } catch (error) { if ((error as AxiosError).status === 401) { + if (shouldTrackFetch) { + trackEvent('RAMPS_USER_DETAILS_FETCHED', { + logged_in: false, + region: selectedRegion?.isoCode || '', + location: screenLocation, + }); + } Logger.log('useDepositUser: 401 error, clearing authentication'); await logoutFromProvider(false); } else { throw error; } } - }, [fetchUserDetails, logoutFromProvider]); + }, [ + trackEvent, + fetchUserDetails, + logoutFromProvider, + shouldTrackFetch, + selectedRegion, + screenLocation, + ]); useEffect(() => { - if (isAuthenticated && !userDetails && !isFetching && !error) { + if ( + fetchOnMount && + isAuthenticated && + !userDetails && + !isFetching && + !error + ) { fetchUserDetailsCallback(); } }, [ @@ -38,6 +80,7 @@ export function useDepositUser() { fetchUserDetailsCallback, isFetching, error, + fetchOnMount, ]); return { diff --git a/app/components/UI/Ramp/Deposit/testUtils/constants.ts b/app/components/UI/Ramp/Deposit/testUtils/constants.ts index 93ce53ae3206..f98ab77f312d 100644 --- a/app/components/UI/Ramp/Deposit/testUtils/constants.ts +++ b/app/components/UI/Ramp/Deposit/testUtils/constants.ts @@ -11,6 +11,7 @@ import { NativeTransakUserDetailsKycDetails, } from '@consensys/native-ramps-sdk'; import { IconName } from '../../../../../component-library/components/Icons/Icon'; +import type { DepositSDK } from '../sdk'; export const MOCK_US_REGION: DepositRegion = { isoCode: 'US', @@ -259,8 +260,17 @@ export const MOCK_BANK_DETAILS_ORDER = { }, }; -export const createMockSDKReturn = (overrides = {}) => ({ +export const createMockSDKReturn = (overrides = {}): DepositSDK => ({ + sdk: undefined, + sdkError: undefined, + providerApiKey: null, isAuthenticated: false, + authToken: undefined, + setAuthToken: jest.fn().mockResolvedValue(true), + logoutFromProvider: jest.fn().mockResolvedValue(undefined), + checkExistingToken: jest.fn().mockResolvedValue(false), + getStarted: false, + setGetStarted: jest.fn(), selectedWalletAddress: '0x1234567890123456789012345678901234567890', selectedRegion: MOCK_US_REGION, setSelectedRegion: jest.fn(), diff --git a/app/components/UI/Ramp/Deposit/types/analytics.ts b/app/components/UI/Ramp/Deposit/types/analytics.ts index 0420389a0b97..15a7a1528544 100644 --- a/app/components/UI/Ramp/Deposit/types/analytics.ts +++ b/app/components/UI/Ramp/Deposit/types/analytics.ts @@ -212,7 +212,11 @@ interface RampsPaymentMethodAdded { user_id?: string; payment_method_id: string; } - +interface RampsUserDetailsFetched { + logged_in: boolean; + region: string; + location: string; +} export interface AnalyticsEvents { RAMPS_BUTTON_CLICKED: RampsButtonClicked; RAMPS_DEPOSIT_CASH_BUTTON_CLICKED: RampsDepositCashButtonClicked; @@ -235,4 +239,5 @@ export interface AnalyticsEvents { RAMPS_TRANSACTION_FAILED: RampsTransactionFailed; RAMPS_KYC_APPLICATION_FAILED: RampsKycApplicationFailed; RAMPS_KYC_APPLICATION_APPROVED: RampsKycApplicationApproved; + RAMPS_USER_DETAILS_FETCHED: RampsUserDetailsFetched; } diff --git a/app/components/Views/ActivityView/index.js b/app/components/Views/ActivityView/index.js index abc278e7939d..0b3dffb0abe9 100644 --- a/app/components/Views/ActivityView/index.js +++ b/app/components/Views/ActivityView/index.js @@ -18,7 +18,13 @@ import Avatar, { AvatarVariant, } from '../../../component-library/components/Avatars/Avatar'; import ButtonBase from '../../../component-library/components/Buttons/Button/foundation/ButtonBase'; -import { IconName } from '../../../component-library/components/Icons/Icon'; +import ButtonIcon, { + ButtonIconSizes, +} from '../../../component-library/components/Buttons/ButtonIcon'; +import { + IconName, + IconColor, +} from '../../../component-library/components/Icons/Icon'; import TextComponent, { getFontFamily, TextVariant, @@ -71,6 +77,19 @@ const createStyles = (params) => { wrapper: { flex: 1, }, + headerWithBackButton: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + backgroundColor: colors.background.default, + }, + headerBackButton: { + marginRight: 12, + }, + headerTitleContainer: { + flex: 1, + }, controlButtonOuterWrapper: { flexDirection: 'row', width: '100%', @@ -199,21 +218,35 @@ const ActivityView = () => { } }; + const handleBackPress = useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack(); + } + }, [navigation]); + + const showBackButton = params.showBackButton || false; + useEffect( () => { const title = 'activity_view.title'; - navigation.setOptions( - getTransactionsNavbarOptions( - title, - colors, - navigation, - selectedAddress, - openAccountSelector, - ), - ); + if (!showBackButton) { + navigation.setOptions( + getTransactionsNavbarOptions( + title, + colors, + navigation, + selectedAddress, + openAccountSelector, + ), + ); + } else { + navigation.setOptions({ + headerShown: false, + }); + } }, /* eslint-disable-next-line */ - [navigation, colors, selectedAddress, openAccountSelector], + [navigation, colors, selectedAddress, openAccountSelector, showBackButton], ); const renderTabBar = () => ; @@ -262,11 +295,30 @@ const ActivityView = () => { return ( - - - {strings('transactions_view.title')} - - + {showBackButton ? ( + + + + + + + {strings('transactions_view.title')} + + + + ) : ( + + + {strings('transactions_view.title')} + + + )} {!(isPerpsTabActive || isOrdersTabActive || isPredictTabActive) && ( diff --git a/app/components/Views/ActivityView/index.test.tsx b/app/components/Views/ActivityView/index.test.tsx index 425b0745a036..3a043f4ebfaa 100644 --- a/app/components/Views/ActivityView/index.test.tsx +++ b/app/components/Views/ActivityView/index.test.tsx @@ -31,6 +31,7 @@ const mockNavigation = { navigate: jest.fn(), setOptions: jest.fn(), goBack: jest.fn(), + canGoBack: jest.fn(() => true), reset: jest.fn(), dangerouslyGetParent: () => ({ pop: jest.fn(), @@ -42,9 +43,14 @@ jest.mock('../../hooks/useCurrentNetworkInfo', () => ({ useCurrentNetworkInfo: jest.fn(), })); +const mockRoute = { + params: {}, +}; + jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), useNavigation: () => mockNavigation, + useRoute: () => mockRoute, })); jest.mock('../../../core/Engine', () => ({ @@ -400,4 +406,81 @@ describe('ActivityView', () => { }); }); }); + + describe('back button behavior', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('displays back button when showBackButton param is true', () => { + mockRoute.params = { showBackButton: true }; + + const { getByTestId } = renderComponent(mockInitialState); + + expect(getByTestId('activity-view-back-button')).toBeTruthy(); + }); + + it('hides back button when showBackButton param is false', () => { + mockRoute.params = { showBackButton: false }; + + const { queryByTestId } = renderComponent(mockInitialState); + + expect(queryByTestId('activity-view-back-button')).toBeNull(); + }); + + it('hides back button when showBackButton param is undefined', () => { + mockRoute.params = {}; + + const { queryByTestId } = renderComponent(mockInitialState); + + expect(queryByTestId('activity-view-back-button')).toBeNull(); + }); + + it('calls navigation.goBack when back button is pressed', () => { + mockRoute.params = { showBackButton: true }; + + const { getByTestId } = renderComponent(mockInitialState); + const backButton = getByTestId('activity-view-back-button'); + + fireEvent.press(backButton); + + expect(mockNavigation.goBack).toHaveBeenCalledTimes(1); + }); + + it('does not call navigation.goBack when canGoBack returns false', () => { + mockRoute.params = { showBackButton: true }; + mockNavigation.canGoBack.mockReturnValueOnce(false); + + const { getByTestId } = renderComponent(mockInitialState); + const backButton = getByTestId('activity-view-back-button'); + + fireEvent.press(backButton); + + expect(mockNavigation.goBack).not.toHaveBeenCalled(); + }); + + it('hides default header when showBackButton is true', () => { + mockRoute.params = { showBackButton: true }; + + renderComponent(mockInitialState); + + expect(mockNavigation.setOptions).toHaveBeenCalledWith({ + headerShown: false, + }); + }); + + it('shows default header when showBackButton is false', () => { + mockRoute.params = { showBackButton: false }; + + renderComponent(mockInitialState); + + expect(mockNavigation.setOptions).toHaveBeenCalledWith( + expect.objectContaining({ + headerTitle: expect.any(Function), + headerLeft: null, + headerRight: expect.any(Function), + }), + ); + }); + }); }); diff --git a/app/components/Views/AddressSelector/AddressSelector.test.tsx b/app/components/Views/AddressSelector/AddressSelector.test.tsx index 1ff02e8fc791..b2ad4cc64e5c 100644 --- a/app/components/Views/AddressSelector/AddressSelector.test.tsx +++ b/app/components/Views/AddressSelector/AddressSelector.test.tsx @@ -12,9 +12,14 @@ import { AddressSelectorParams } from './AddressSelector.types'; import { setReloadAccounts } from '../../../actions/accounts'; import Engine from '../../../core/Engine'; import { + ARBITRUM_DISPLAY_NAME, BASE_DISPLAY_NAME, + BNB_DISPLAY_NAME, LINEA_MAINNET_DISPLAY_NAME, MAINNET_DISPLAY_NAME, + OPTIMISM_DISPLAY_NAME, + POLYGON_DISPLAY_NAME, + SEI_DISPLAY_NAME, } from '../../../core/Engine/constants'; jest.mock('../../../core/Engine', () => ({ @@ -132,6 +137,11 @@ describe('AccountSelector', () => { expect(networkNames).toEqual([ MAINNET_DISPLAY_NAME, + BNB_DISPLAY_NAME, + SEI_DISPLAY_NAME, + POLYGON_DISPLAY_NAME, + OPTIMISM_DISPLAY_NAME, + ARBITRUM_DISPLAY_NAME, LINEA_MAINNET_DISPLAY_NAME, BASE_DISPLAY_NAME, ]); diff --git a/app/components/Views/AddressSelector/__snapshots__/AddressSelector.test.tsx.snap b/app/components/Views/AddressSelector/__snapshots__/AddressSelector.test.tsx.snap index e794a30386e8..f40403e3ea8a 100644 --- a/app/components/Views/AddressSelector/__snapshots__/AddressSelector.test.tsx.snap +++ b/app/components/Views/AddressSelector/__snapshots__/AddressSelector.test.tsx.snap @@ -894,6 +894,911 @@ exports[`AccountSelector renders correctly and matches snapshot 1`] = ` "flexDirection": "row", } } + > + + + + + + + + BNB Chain + + + 0x4FeC2...fdcB5 + + + + + + + + + + + + + + + + + + + + Sei + + + 0x4FeC2...fdcB5 + + + + + + + + + + + + + + + + + + + + Polygon + + + 0x4FeC2...fdcB5 + + + + + + + + + + + + + + + + + + + + OP + + + 0x4FeC2...fdcB5 + + + + + + + + + + + + + + + + + + + + Arbitrum + + + 0x4FeC2...fdcB5 + + + + + + + + + + + + `; -exports[`OnboardingSuccess route params successFlow is IMPORT_FROM_SEED_PHRASE adds networks to the network controller 1`] = ` - - - - - - - - - - Your wallet is ready! - - - - - - Done - - - - - Manage default settings - - - - - -`; - exports[`OnboardingSuccess route params successFlow is IMPORT_FROM_SEED_PHRASE fails to add networks to the network controller but should render the component 1`] = ` { expect(toJSON()).toMatchSnapshot(); }); - it('adds networks to the network controller', async () => { - const { toJSON } = renderWithProvider(); - expect(toJSON()).toMatchSnapshot(); - - // wait for the useEffect side-effect to call addNetwork - await waitFor(() => { - expect(Engine.context.NetworkController.addNetwork).toHaveBeenCalled(); - expect( - Engine.context.TokenBalancesController.updateBalances, - ).toHaveBeenCalled(); - expect( - Engine.context.TokenListController.fetchTokenList, - ).toHaveBeenCalled(); - expect( - Engine.context.TokenDetectionController.detectTokens, - ).toHaveBeenCalled(); - expect( - Engine.context.AccountTrackerController.refresh, - ).toHaveBeenCalled(); - expect( - Engine.context.TokenRatesController.updateExchangeRatesByChainId, - ).toHaveBeenCalled(); - expect( - Engine.context.CurrencyRateController.updateExchangeRate, - ).toHaveBeenCalled(); - }); - }); - it('fails to add networks to the network controller but should render the component', async () => { ( Engine.context.NetworkController.addNetwork as jest.Mock diff --git a/app/components/Views/OnboardingSuccess/index.tsx b/app/components/Views/OnboardingSuccess/index.tsx index 753423d6b07a..f2a2c316cba9 100644 --- a/app/components/Views/OnboardingSuccess/index.tsx +++ b/app/components/Views/OnboardingSuccess/index.tsx @@ -1,7 +1,6 @@ -import React, { useCallback, useEffect, useLayoutEffect, useMemo } from 'react'; +import React, { useCallback, useLayoutEffect, useMemo } from 'react'; import { View, TouchableOpacity } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { RpcEndpointType } from '@metamask/network-controller'; import Button, { ButtonSize, ButtonVariants, @@ -26,13 +25,8 @@ import importAdditionalAccounts from '../../../util/importAdditionalAccounts'; import createStyles from './index.styles'; import OnboardingSuccessEndAnimation from './OnboardingSuccessEndAnimation/index'; import { ONBOARDING_SUCCESS_FLOW } from '../../../constants/onboarding'; -import Logger from '../../../util/Logger'; import Engine from '../../../core/Engine/Engine'; -import { CHAIN_IDS } from '@metamask/transaction-controller'; -import { PopularList } from '../../../util/networks/customNetworks'; -import { useDispatch } from 'react-redux'; -import { onboardNetworkAction } from '../../../actions/onboardNetwork'; import { isMultichainAccountsState2Enabled } from '../../../multichain-accounts/remote-feature-flag'; import { discoverAccounts } from '../../../multichain-accounts/discovery'; @@ -150,200 +144,10 @@ export const OnboardingSuccess = () => { const navigation = useNavigation(); const route = useRoute(); const params = route.params as { successFlow: ONBOARDING_SUCCESS_FLOW }; - const dispatch = useDispatch(); const successFlow = params?.successFlow; - const nextScreen = ResetNavigationToHome; - useEffect(() => { - const addSingleNetwork = async ( - network: (typeof PopularList)[number], - controllers: typeof Engine.context, - ): Promise<{ - chainId: `0x${string}`; - networkClientId: string | null; - } | null> => { - try { - await controllers.NetworkController.addNetwork({ - chainId: network.chainId, - blockExplorerUrls: [network.rpcPrefs.blockExplorerUrl], - defaultRpcEndpointIndex: 0, - defaultBlockExplorerUrlIndex: 0, - name: network.nickname, - nativeCurrency: network.ticker, - rpcEndpoints: [ - { - url: network.rpcUrl, - failoverUrls: network.failoverRpcUrls, - name: network.nickname, - type: RpcEndpointType.Custom, - }, - ], - }); - - const networkClientId = - await controllers.NetworkController.findNetworkClientIdByChainId( - network.chainId, - ); - - dispatch(onboardNetworkAction(network.chainId)); - - return { - chainId: network.chainId, - networkClientId: networkClientId || null, - }; - } catch (error) { - Logger.error( - error as Error, - `Failed to add network ${network.nickname}`, - ); - return null; - } - }; - - const fetchTokenListSafely = async ( - chainId: `0x${string}`, - controller: typeof Engine.context.TokenListController, - ): Promise => { - try { - await controller.fetchTokenList(chainId); - } catch (error) { - Logger.error( - error as Error, - `Failed to fetch token list for ${chainId}`, - ); - } - }; - - const updateBalancesSafely = async ( - chainId: `0x${string}`, - controller: typeof Engine.context.TokenBalancesController, - ): Promise => { - try { - await controller.updateBalances({ chainIds: [chainId] }); - } catch (error) { - Logger.error( - error as Error, - `Failed to update balances for ${chainId}`, - ); - } - }; - - const updateRatesSafely = async ( - chainId: `0x${string}`, - ticker: string, - controller: typeof Engine.context.TokenRatesController, - ): Promise => { - try { - await controller.updateExchangeRatesByChainId([ - { chainId, nativeCurrency: ticker }, - ]); - } catch (error) { - Logger.error(error as Error, `Failed to update rates for ${chainId}`); - } - }; - - const performBatchOperations = async ( - addedChainIds: `0x${string}`[], - networkClientIds: string[], - selectedNetworks: (typeof PopularList)[number][], - controllers: typeof Engine.context, - ): Promise => { - if (addedChainIds.length === 0) return; - - try { - // Batch fetch token lists for all chains - await Promise.all( - addedChainIds.map((chainId) => - fetchTokenListSafely(chainId, controllers.TokenListController), - ), - ); - - // Batch detect tokens for all chains - await controllers.TokenDetectionController.detectTokens({ - chainIds: addedChainIds, - }); - - // Batch update balances for all chains - await Promise.all( - addedChainIds.map((chainId) => - updateBalancesSafely(chainId, controllers.TokenBalancesController), - ), - ); - - // Batch update currency rates - const tickers = addedChainIds.map( - (chainId) => - selectedNetworks.find((network) => network.chainId === chainId) - ?.ticker || 'ETH', - ); - await controllers.CurrencyRateController.updateExchangeRate(tickers); - - // Batch update rates for all chains - await Promise.all( - addedChainIds.map((chainId) => { - const ticker = - selectedNetworks.find((network) => network.chainId === chainId) - ?.ticker || 'ETH'; - return updateRatesSafely( - chainId, - ticker, - controllers.TokenRatesController, - ); - }), - ); - - // Batch refresh account tracker for all network clients - if (networkClientIds.length > 0) { - await controllers.AccountTrackerController.refresh(networkClientIds); - } - } catch (error) { - Logger.error(error as Error, 'Failed during batch operations'); - } - }; - - const addNetworks = async (): Promise => { - const chainIdsToAdd: `0x${string}`[] = [ - CHAIN_IDS.ARBITRUM, - CHAIN_IDS.BSC, - CHAIN_IDS.OPTIMISM, - CHAIN_IDS.POLYGON, - ]; - - const selectedNetworks = PopularList.filter((network) => - chainIdsToAdd.includes(network.chainId), - ); - - const controllers = Engine.context; - const addedChainIds: `0x${string}`[] = []; - const networkClientIds: string[] = []; - - // Add all networks sequentially - for (const network of selectedNetworks) { - const result = await addSingleNetwork(network, controllers); - if (result) { - addedChainIds.push(result.chainId); - if (result.networkClientId) { - networkClientIds.push(result.networkClientId); - } - } - } - - // Perform batch operations on successfully added networks - await performBatchOperations( - addedChainIds, - networkClientIds, - selectedNetworks, - controllers, - ); - }; - - addNetworks().catch((error) => { - Logger.error(error, 'Error adding networks'); - }); - }, [dispatch]); - return ( { // Then the onPress handler is called expect(onPressMock).toHaveBeenCalled(); }); + + it('uses fallback address when selectedAddress is undefined', () => { + // Arrange - state with no selected account address + const stateWithNoAddress = merge( + {}, + simpleSendTransactionControllerMock, + transactionApprovalControllerMock, + otherControllersMock, + { + engine: { + backgroundState: { + AccountsController: { + internalAccounts: { + selectedAccount: undefined, + }, + }, + }, + }, + }, + ); + + // Act + const { getByTestId } = renderWithProvider( + , + { state: stateWithNoAddress }, + ); + + // Assert - component renders without crashing + expect(getByTestId('predict-claim-footer')).toBeDefined(); + }); }); diff --git a/app/components/Views/confirmations/components/predict-confirmations/predict-claim-footer/predict-claim-footer.tsx b/app/components/Views/confirmations/components/predict-confirmations/predict-claim-footer/predict-claim-footer.tsx index 8332d340906b..aaaa1c9a7b4f 100644 --- a/app/components/Views/confirmations/components/predict-confirmations/predict-claim-footer/predict-claim-footer.tsx +++ b/app/components/Views/confirmations/components/predict-confirmations/predict-claim-footer/predict-claim-footer.tsx @@ -18,6 +18,7 @@ import { Box } from '../../../../../UI/Box/Box'; import { PredictClaimConfirmationSelectorsIDs } from '../../../../../../../e2e/selectors/Predict/Predict.selectors'; import styleSheet from './predict-claim-footer.styles'; import { selectPredictWonPositions } from '../../../../../UI/Predict/selectors/predictController'; +import { selectSelectedInternalAccountAddress } from '../../../../../../selectors/accountsController'; export interface PredictClaimFooterProps { onPress: () => void; @@ -25,7 +26,13 @@ export interface PredictClaimFooterProps { export function PredictClaimFooter({ onPress }: PredictClaimFooterProps) { const { styles } = useStyles(styleSheet, {}); - const wonPositions = useSelector(selectPredictWonPositions); + const selectedAddress = + useSelector(selectSelectedInternalAccountAddress) ?? '0x0'; + const wonPositions = useSelector( + selectPredictWonPositions({ + address: selectedAddress, + }), + ); const positionIcons = wonPositions.map((position) => ({ imageSource: { uri: position.icon }, diff --git a/app/components/Views/confirmations/hooks/metrics/usePredictClaimConfirmationMetrics.ts b/app/components/Views/confirmations/hooks/metrics/usePredictClaimConfirmationMetrics.ts index 78f08c32a54b..cdac6d9482fe 100644 --- a/app/components/Views/confirmations/hooks/metrics/usePredictClaimConfirmationMetrics.ts +++ b/app/components/Views/confirmations/hooks/metrics/usePredictClaimConfirmationMetrics.ts @@ -10,11 +10,22 @@ import { useTransactionMetadataRequest } from '../transactions/useTransactionMet export function usePredictClaimConfirmationMetrics() { const dispatch = useDispatch(); - const { id: transactionId } = useTransactionMetadataRequest() ?? { id: '' }; - const winPositions = useSelector(selectPredictWonPositions); + const txMeta = useTransactionMetadataRequest(); + const transactionId = txMeta?.id ?? ''; + const fromAddress = txMeta?.txParams?.from ?? '0x0'; - const predict_claim_value_usd = useSelector(selectPredictWinFiat); - const predict_pnl = useSelector(selectPredictWinPnl); + const winPositions = useSelector( + selectPredictWonPositions({ + address: fromAddress, + }), + ); + + const predict_claim_value_usd = useSelector( + selectPredictWinFiat({ address: fromAddress }), + ); + const predict_pnl = useSelector( + selectPredictWinPnl({ address: fromAddress }), + ); const predict_market_title = useMemo( () => winPositions.map((p) => p.title), diff --git a/app/constants/deeplinks.ts b/app/constants/deeplinks.ts index 78ed9320eab4..1fc6c9393239 100644 --- a/app/constants/deeplinks.ts +++ b/app/constants/deeplinks.ts @@ -13,6 +13,7 @@ export enum PROTOCOLS { } export enum ACTIONS { + RAMP = 'ramp', ENABLE_CARD_BUTTON = 'enable-card-button', DAPP = 'dapp', SEND = 'send', diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index 0837d0004ceb..92fbaf597b5b 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -258,6 +258,7 @@ const Routes = { HIP3_DEBUG: 'PerpsHIP3Debug', TPSL: 'PerpsTPSL', PNL_HERO_CARD: 'PerpsPnlHeroCard', + ACTIVITY: 'PerpsActivity', // Stack-based activity view for proper back navigation MODALS: { ROOT: 'PerpsModals', QUOTE_EXPIRED_MODAL: 'PerpsQuoteExpiredModal', diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index 8a07dd008551..0bf8bd832819 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -283,6 +283,7 @@ enum EVENT_NAME { RAMPS_KYC_APPLICATION_FAILED = 'Ramps KYC Application Failed', RAMPS_KYC_APPLICATION_APPROVED = 'Ramps KYC Application Approved', RAMPS_PAYMENT_METHOD_ADDED = 'Ramps Payment Method Added', + RAMPS_USER_DETAILS_FETCHED = 'Ramps User Details Fetched', ACCOUNTS = 'Accounts', DAPP_VIEW = 'Dapp View', @@ -964,6 +965,9 @@ const events = { RAMPS_PAYMENT_METHOD_ADDED: generateOpt( EVENT_NAME.RAMPS_PAYMENT_METHOD_ADDED, ), + RAMPS_USER_DETAILS_FETCHED: generateOpt( + EVENT_NAME.RAMPS_USER_DETAILS_FETCHED, + ), FORCE_UPGRADE_UPDATE_NEEDED_PROMPT_VIEWED: generateOpt( EVENT_NAME.FORCE_UPGRADE_UPDATE_NEEDED_PROMPT_VIEWED, diff --git a/app/core/Analytics/MetaMetrics.test.ts b/app/core/Analytics/MetaMetrics.test.ts index 72d70ed3c93a..09ca8f00dad6 100644 --- a/app/core/Analytics/MetaMetrics.test.ts +++ b/app/core/Analytics/MetaMetrics.test.ts @@ -19,6 +19,8 @@ import { import { MetricsEventBuilder } from './MetricsEventBuilder'; import { segmentPersistor } from './SegmentPersistor'; import { createClient } from '@segment/analytics-react-native'; +import { validate } from 'uuid'; +import { isHexAddress } from '@metamask/utils'; jest.mock('../../store/storage-wrapper'); const mockGet = jest.fn(); @@ -627,7 +629,7 @@ describe('MetaMetrics', () => { describe('Ids', () => { it('is returned from StorageWrapper when instance not configured', async () => { - const UUID = '00000000-0000-0000-0000-000000000000'; + const UUID = '12345678-1234-4234-b234-123456789012'; mockGet.mockImplementation(async (key: string) => key === METAMETRICS_ID ? UUID : '', ); @@ -637,7 +639,7 @@ describe('MetaMetrics', () => { }); it('is returned from memory when instance configured', async () => { - const testID = '00000000-0000-0000-0000-000000000000'; + const testID = '12345678-1234-4234-b234-123456789012'; mockGet.mockImplementation(async () => testID); const metaMetrics = TestMetaMetrics.getInstance(); expect(await metaMetrics.configure()).toBeTruthy(); @@ -649,9 +651,11 @@ describe('MetaMetrics', () => { expect(StorageWrapper.getItem).not.toHaveBeenCalled(); }); - it('uses Mixpanel ID if it is set', async () => { - const mixPanelUUID = '00000000-0000-0000-0000-000000000000'; - mockGet.mockImplementation(async () => mixPanelUUID); + it('uses Mixpanel ID if it is set and is valid hex address', async () => { + const mixPanelHexAddress = '0x1234567890123456789012345678901234567890'; + mockGet.mockImplementation(async (key: string) => + key === MIXPANEL_METAMETRICS_ID ? mixPanelHexAddress : '', + ); const metaMetrics = TestMetaMetrics.getInstance(); expect(await metaMetrics.configure()).toBeTruthy(); @@ -661,14 +665,62 @@ describe('MetaMetrics', () => { ); expect(StorageWrapper.setItem).toHaveBeenCalledWith( METAMETRICS_ID, - mixPanelUUID, + mixPanelHexAddress, ); expect(StorageWrapper.getItem).not.toHaveBeenCalledWith(METAMETRICS_ID); - expect(await metaMetrics.getMetaMetricsId()).toEqual(mixPanelUUID); + expect(await metaMetrics.getMetaMetricsId()).toEqual(mixPanelHexAddress); + expect(isHexAddress(mixPanelHexAddress)).toBe(true); + }); + + it('uses Mixpanel ID with uppercase letters after converting to lowercase', async () => { + const mixPanelHexAddressUppercase = + '0X1234567890ABCDEF123456789012345678901234'; + const expectedLowercase = mixPanelHexAddressUppercase.toLowerCase(); + mockGet.mockImplementation(async (key: string) => + key === MIXPANEL_METAMETRICS_ID ? mixPanelHexAddressUppercase : '', + ); + const metaMetrics = TestMetaMetrics.getInstance(); + expect(await metaMetrics.configure()).toBeTruthy(); + + const metricsId = await metaMetrics.getMetaMetricsId(); + + expect(StorageWrapper.getItem).toHaveBeenNthCalledWith( + 3, + MIXPANEL_METAMETRICS_ID, + ); + expect(StorageWrapper.setItem).toHaveBeenCalledWith( + METAMETRICS_ID, + mixPanelHexAddressUppercase, + ); + expect(metricsId).toEqual(mixPanelHexAddressUppercase); + expect(isHexAddress(expectedLowercase)).toBe(true); + }); + + it('ignores Mixpanel ID if it is not a valid hex address', async () => { + const invalidMixpanelId = '00000000-0000-0000-0000-000000000000'; + mockGet.mockImplementation(async (key: string) => + key === MIXPANEL_METAMETRICS_ID ? invalidMixpanelId : '', + ); + const metaMetrics = TestMetaMetrics.getInstance(); + expect(await metaMetrics.configure()).toBeTruthy(); + + const metricsId = await metaMetrics.getMetaMetricsId(); + + expect(StorageWrapper.getItem).toHaveBeenNthCalledWith( + 3, + MIXPANEL_METAMETRICS_ID, + ); + expect(StorageWrapper.getItem).toHaveBeenNthCalledWith(4, METAMETRICS_ID); + expect(metricsId).not.toEqual(invalidMixpanelId); + expect(validate(metricsId as string)).toBe(true); + expect(StorageWrapper.setItem).toHaveBeenCalledWith( + METAMETRICS_ID, + metricsId, + ); }); it('uses Metametrics ID if it is set', async () => { - const UUID = '00000000-0000-0000-0000-000000000000'; + const UUID = '12345678-1234-4234-b234-123456789012'; mockGet.mockImplementation(async (key: string) => key === METAMETRICS_ID ? UUID : '', ); @@ -731,6 +783,175 @@ describe('MetaMetrics', () => { expect(metricsId2).not.toEqual(''); expect(metricsId).not.toEqual(metricsId2); }); + + describe('corrupted ID validation', () => { + it('regenerates new ID when stored ID is JSON-stringified empty string', async () => { + mockGet.mockImplementation(async (key: string) => + key === METAMETRICS_ID ? '""' : '', + ); + const metaMetrics = TestMetaMetrics.getInstance(); + + await metaMetrics.configure(); + + const metricsId = await metaMetrics.getMetaMetricsId(); + expect(metricsId).not.toEqual('""'); + expect(metricsId).not.toEqual(''); + expect(validate(metricsId as string)).toBe(true); + expect(StorageWrapper.setItem).toHaveBeenCalledWith( + METAMETRICS_ID, + metricsId, + ); + }); + + it('regenerates new ID when stored ID is too short', async () => { + mockGet.mockImplementation(async (key: string) => + key === METAMETRICS_ID ? 'abc' : '', + ); + const metaMetrics = TestMetaMetrics.getInstance(); + + await metaMetrics.configure(); + + const metricsId = await metaMetrics.getMetaMetricsId(); + expect(metricsId).not.toEqual('abc'); + expect(validate(metricsId as string)).toBe(true); + expect(StorageWrapper.setItem).toHaveBeenCalledWith( + METAMETRICS_ID, + metricsId, + ); + }); + + it('regenerates new ID when stored ID is "null" string', async () => { + mockGet.mockImplementation(async (key: string) => + key === METAMETRICS_ID ? 'null' : '', + ); + const metaMetrics = TestMetaMetrics.getInstance(); + + await metaMetrics.configure(); + + const metricsId = await metaMetrics.getMetaMetricsId(); + expect(metricsId).not.toEqual('null'); + expect(validate(metricsId as string)).toBe(true); + }); + + it('regenerates new ID when stored ID is "undefined" string', async () => { + mockGet.mockImplementation(async (key: string) => + key === METAMETRICS_ID ? 'undefined' : '', + ); + const metaMetrics = TestMetaMetrics.getInstance(); + + await metaMetrics.configure(); + + const metricsId = await metaMetrics.getMetaMetricsId(); + expect(metricsId).not.toEqual('undefined'); + expect(validate(metricsId as string)).toBe(true); + }); + + it('regenerates new ID when stored ID has invalid UUID format', async () => { + mockGet.mockImplementation(async (key: string) => + key === METAMETRICS_ID ? 'not-a-valid-uuid-format' : '', + ); + const metaMetrics = TestMetaMetrics.getInstance(); + + await metaMetrics.configure(); + + const metricsId = await metaMetrics.getMetaMetricsId(); + expect(metricsId).not.toEqual('not-a-valid-uuid-format'); + // casting for testing + expect(validate(metricsId as unknown as string)).toBe(true); + }); + + it('regenerates new ID when stored ID is NIL UUID (all zeros)', async () => { + const nilUUID = '00000000-0000-0000-0000-000000000000'; + mockGet.mockImplementation(async (key: string) => + key === METAMETRICS_ID ? nilUUID : '', + ); + const metaMetrics = TestMetaMetrics.getInstance(); + + await metaMetrics.configure(); + + const metricsId = await metaMetrics.getMetaMetricsId(); + expect(metricsId).not.toEqual(nilUUID); + expect(validate(metricsId as string)).toBe(true); + expect(StorageWrapper.setItem).toHaveBeenCalledWith( + METAMETRICS_ID, + metricsId, + ); + }); + + it('accepts valid UUIDv4 format', async () => { + const validUUID = '12345678-1234-4234-a234-123456789012'; + mockGet.mockImplementation(async (key: string) => + key === METAMETRICS_ID ? validUUID : '', + ); + const metaMetrics = TestMetaMetrics.getInstance(); + + await metaMetrics.configure(); + + const metricsId = await metaMetrics.getMetaMetricsId(); + expect(metricsId).toEqual(validUUID); + expect(StorageWrapper.setItem).not.toHaveBeenCalledWith( + METAMETRICS_ID, + expect.anything(), + ); + }); + + it('regenerates new ID when stored ID is version 1 UUID', async () => { + // Example UUIDv1 format: time-based + const uuidV1 = '12345678-1234-1234-a234-123456789012'; + mockGet.mockImplementation(async (key: string) => + key === METAMETRICS_ID ? uuidV1 : '', + ); + const metaMetrics = TestMetaMetrics.getInstance(); + + await metaMetrics.configure(); + + const metricsId = await metaMetrics.getMetaMetricsId(); + expect(metricsId).not.toEqual(uuidV1); + expect(validate(metricsId as string)).toBe(true); + expect(StorageWrapper.setItem).toHaveBeenCalledWith( + METAMETRICS_ID, + metricsId, + ); + }); + + it('regenerates new ID when stored ID is version 3 UUID', async () => { + // Example UUIDv3 format: MD5-based + const uuidV3 = '12345678-1234-3234-a234-123456789012'; + mockGet.mockImplementation(async (key: string) => + key === METAMETRICS_ID ? uuidV3 : '', + ); + const metaMetrics = TestMetaMetrics.getInstance(); + + await metaMetrics.configure(); + + const metricsId = await metaMetrics.getMetaMetricsId(); + expect(metricsId).not.toEqual(uuidV3); + expect(validate(metricsId as string)).toBe(true); + expect(StorageWrapper.setItem).toHaveBeenCalledWith( + METAMETRICS_ID, + metricsId, + ); + }); + + it('regenerates new ID when stored ID is version 5 UUID', async () => { + // Example UUIDv5 format: SHA1-based + const uuidV5 = '12345678-1234-5234-a234-123456789012'; + mockGet.mockImplementation(async (key: string) => + key === METAMETRICS_ID ? uuidV5 : '', + ); + const metaMetrics = TestMetaMetrics.getInstance(); + + await metaMetrics.configure(); + + const metricsId = await metaMetrics.getMetaMetricsId(); + expect(metricsId).not.toEqual(uuidV5); + expect(validate(metricsId as string)).toBe(true); + expect(StorageWrapper.setItem).toHaveBeenCalledWith( + METAMETRICS_ID, + metricsId, + ); + }); + }); }); describe('Delete regulation', () => { diff --git a/app/core/Analytics/MetaMetrics.ts b/app/core/Analytics/MetaMetrics.ts index e179c164b448..c495d8497ec8 100644 --- a/app/core/Analytics/MetaMetrics.ts +++ b/app/core/Analytics/MetaMetrics.ts @@ -33,7 +33,7 @@ import { ISegmentClient, ITrackingEvent, } from './MetaMetrics.types'; -import { v4 as uuidv4 } from 'uuid'; +import { v4 as uuidv4, validate, version } from 'uuid'; import { Config } from '@segment/analytics-react-native/lib/typescript/src/types'; import generateDeviceAnalyticsMetaData from '../../util/metrics/DeviceAnalyticsMetaData/generateDeviceAnalyticsMetaData'; import generateUserSettingsAnalyticsMetaData from '../../util/metrics/UserSettingsAnalyticsMetaData/generateUserProfileAnalyticsMetaData'; @@ -41,6 +41,7 @@ import { isE2E } from '../../util/test/utils'; import MetaMetricsPrivacySegmentPlugin from './MetaMetricsPrivacySegmentPlugin'; import MetaMetricsTestUtils from './MetaMetricsTestUtils'; import { segmentPersistor } from './SegmentPersistor'; +import { isHexAddress } from '@metamask/utils'; /** * MetaMetrics using Segment as the analytics provider. @@ -287,7 +288,7 @@ class MetaMetrics implements IMetaMetrics { /** * Retrieve the analytics user ID from references * - * Generates a new ID if none is found + * Generates a new ID if none is found or if the stored ID is corrupted * * @returns Promise containing the user ID */ @@ -298,7 +299,7 @@ class MetaMetrics implements IMetaMetrics { // this same ID should be retrieved from preferences and reused. // look for a legacy ID from MixPanel integration and use it const legacyId = await StorageWrapper.getItem(MIXPANEL_METAMETRICS_ID); - if (legacyId) { + if (legacyId && isHexAddress(legacyId.toLowerCase())) { this.metametricsId = legacyId; await StorageWrapper.setItem(METAMETRICS_ID, legacyId); return legacyId; @@ -307,7 +308,19 @@ class MetaMetrics implements IMetaMetrics { // look for a new Metametics ID and use it or generate a new one const metametricsId: string | undefined = await StorageWrapper.getItem(METAMETRICS_ID); - if (!metametricsId) { + + // This catches '""', 'null', 'undefined', and other corruptions + if ( + !metametricsId || + !validate(metametricsId) || + version(metametricsId) !== 4 + ) { + if (metametricsId) { + // Log corruption for monitoring + Logger.log( + `MetaMetrics: Corrupted metaMetricsId detected and regenerated. Invalid value: ${metametricsId}`, + ); + } // keep the id format compatible with MixPanel but base it on a UUIDv4 this.metametricsId = uuidv4(); await StorageWrapper.setItem(METAMETRICS_ID, this.metametricsId); @@ -874,7 +887,7 @@ class MetaMetrics implements IMetaMetrics { * * @returns the current MetaMetrics ID */ - getMetaMetricsId = async (): Promise => + getMetaMetricsId = async (): Promise => this.metametricsId ?? (await this.#getMetaMetricsId()); } diff --git a/app/core/Analytics/MetaMetrics.types.ts b/app/core/Analytics/MetaMetrics.types.ts index 8bcd12a2b394..f7a4c6097898 100644 --- a/app/core/Analytics/MetaMetrics.types.ts +++ b/app/core/Analytics/MetaMetrics.types.ts @@ -74,7 +74,7 @@ export interface IMetaMetrics { configure(): Promise; - getMetaMetricsId(): Promise; + getMetaMetricsId(): Promise; } /** diff --git a/app/core/DeeplinkManager/CoreLinkNormalizer.test.ts b/app/core/DeeplinkManager/CoreLinkNormalizer.test.ts new file mode 100644 index 000000000000..499aaf544434 --- /dev/null +++ b/app/core/DeeplinkManager/CoreLinkNormalizer.test.ts @@ -0,0 +1,194 @@ +import { CoreLinkNormalizer } from './CoreLinkNormalizer'; +import { CoreUniversalLink } from './types/CoreUniversalLink'; +import AppConstants from '../AppConstants'; + +const { MM_IO_UNIVERSAL_LINK_HOST } = AppConstants; + +describe('CoreLinkNormalizer', () => { + const mockTimestamp = 1234567890; + + beforeEach(() => { + jest.spyOn(Date, 'now').mockReturnValue(mockTimestamp); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('normalization', () => { + it('normalizes basic metamask:// links', () => { + const url = 'metamask://swap'; + const source = 'test'; + + const result = CoreLinkNormalizer.normalize(url, source); + + expect(result.protocol).toBe('metamask'); + expect(result.action).toBe('swap'); + expect(result.isValid).toBe(true); + expect(result.isSupportedAction).toBe(true); + }); + + it('normalizes https:// universal links', () => { + const url = `https://${MM_IO_UNIVERSAL_LINK_HOST}/send`; + const source = 'browser'; + + const result = CoreLinkNormalizer.normalize(url, source); + + expect(result.protocol).toBe('https'); + expect(result.action).toBe('send'); + expect(result.host).toBe(MM_IO_UNIVERSAL_LINK_HOST); + expect(result.isValid).toBe(true); + }); + + it('normalizes universal links with paths and parameters', () => { + const url = `https://${MM_IO_UNIVERSAL_LINK_HOST}/dapp/app.uniswap.org?chain=1`; + const source = 'deep-link'; + + const result = CoreLinkNormalizer.normalize(url, source); + + expect(result.protocol).toBe('https'); + expect(result.action).toBe('dapp'); + expect(result.params.chain).toBe('1'); + expect(result.params.dappPath).toBe('dapp/app.uniswap.org?chain=1'); + }); + + it('extracts SDK parameters', () => { + const url = 'metamask://connect?channelId=123&comm=socket&pubkey=abc&v=2'; + const source = 'sdk'; + + const result = CoreLinkNormalizer.normalize(url, source); + + expect(result.action).toBe('connect'); + expect(result.params.channelId).toBe('123'); + expect(result.params.comm).toBe('socket'); + expect(result.params.pubkey).toBe('abc'); + expect(result.params.v).toBe('2'); + expect(result.requiresAuth).toBe(false); + }); + + it('identifies auth-required actions', () => { + const url = 'metamask://send?to=0x123'; + const source = 'test'; + + const result = CoreLinkNormalizer.normalize(url, source); + + expect(result.action).toBe('send'); + expect(result.requiresAuth).toBe(true); + }); + + it('extracts ramp actions with paths', () => { + const url = `https://${MM_IO_UNIVERSAL_LINK_HOST}/buy-crypto?amount=100¤cy=USD`; + const source = 'ramp'; + + const result = CoreLinkNormalizer.normalize(url, source); + + expect(result.action).toBe('buy-crypto'); + expect(result.params.rampPath).toBe('buy-crypto?amount=100¤cy=USD'); + expect(result.params.amount).toBe('100'); + expect(result.params.currency).toBe('USD'); + }); + + it('extracts perps actions', () => { + const url = `https://${MM_IO_UNIVERSAL_LINK_HOST}/perps-asset/ETH-USD`; + const source = 'perps'; + + const result = CoreLinkNormalizer.normalize(url, source); + + expect(result.action).toBe('perps-asset'); + expect(result.params.perpsPath).toBe('perps-asset/ETH-USD'); + }); + + it('defaults to home action', () => { + const url = `https://${MM_IO_UNIVERSAL_LINK_HOST}/`; + const source = 'test'; + + const result = CoreLinkNormalizer.normalize(url, source); + + expect(result.action).toBe('home'); + }); + + it('labels invalid URLs as invalid', () => { + const url = 'not-a-valid-url'; + const source = 'test'; + + const result = CoreLinkNormalizer.normalize(url, source); + + expect(result.isValid).toBe(false); + expect(result.isSupportedAction).toBe(false); + }); + + it('filters out null and empty parameters', () => { + const url = 'metamask://swap?from=ETH&to=&amount=null&empty='; + const source = 'test'; + + const result = CoreLinkNormalizer.normalize(url, source); + + expect(result.params.from).toBe('ETH'); + expect(result.params.to).toBeUndefined(); + expect(result.params.amount).toBeUndefined(); + expect(result.params.empty).toBeUndefined(); + }); + + it('extracts create-account action correctly', () => { + const url = 'metamask://create-account?name=NewAccount'; + const source = 'test'; + + const result = CoreLinkNormalizer.normalize(url, source); + + expect(result.action).toBe('create-account'); + expect(result.params.createAccountPath).toBe( + 'create-account?name=NewAccount', + ); + expect(result.requiresAuth).toBe(true); + }); + }); + + describe('toMetaMaskProtocol', () => { + it('converts https links to metamask protocol', () => { + const link: CoreUniversalLink = { + protocol: 'https', + host: MM_IO_UNIVERSAL_LINK_HOST, + action: 'swap', + params: { from: 'ETH', to: 'DAI' }, + source: 'test', + timestamp: mockTimestamp, + originalUrl: `https://${MM_IO_UNIVERSAL_LINK_HOST}/swap?from=ETH&to=DAI`, + normalizedUrl: `https://${MM_IO_UNIVERSAL_LINK_HOST}/swap?from=ETH&to=DAI`, + isValid: true, + isSupportedAction: true, + isPrivateLink: false, + requiresAuth: false, + }; + + const result = CoreLinkNormalizer.toMetaMaskProtocol(link); + + expect(result).toBe('metamask://swap?from=ETH&to=DAI'); + }); + }); + + describe('isSupportedDeeplink', () => { + it('returns true for supported deeplinks', () => { + const url = 'metamask://swap'; + + const result = CoreLinkNormalizer.isSupportedDeeplink(url); + + expect(result).toBe(true); + }); + + it('returns false for unsupported actions', () => { + const url = 'metamask://unknown-action'; + + const result = CoreLinkNormalizer.isSupportedDeeplink(url); + + expect(result).toBe(false); + }); + + it('returns false for invalid URLs', () => { + const url = 'not-a-url'; + + const result = CoreLinkNormalizer.isSupportedDeeplink(url); + + expect(result).toBe(false); + }); + }); +}); diff --git a/app/core/DeeplinkManager/CoreLinkNormalizer.ts b/app/core/DeeplinkManager/CoreLinkNormalizer.ts new file mode 100644 index 000000000000..77421b746330 --- /dev/null +++ b/app/core/DeeplinkManager/CoreLinkNormalizer.ts @@ -0,0 +1,336 @@ +/** + * CoreLinkNormalizer - Unified deep link normalization + * + * Converts various deep link formats into a standardized CoreUniversalLink format + */ +import { PROTOCOLS, ACTIONS } from '../../constants/deeplinks'; +import AppConstants from '../AppConstants'; +import { + CoreUniversalLink, + CoreLinkParams, + DEFAULT_ACTION, + RAMP_ACTIONS, + PERPS_ACTIONS, + AUTH_REQUIRED_ACTIONS, + SUPPORTED_PROTOCOLS, +} from './types/CoreUniversalLink'; + +const { HTTPS, METAMASK, DAPP, HTTP } = PROTOCOLS; + +export class CoreLinkNormalizer { + private static readonly ACTION_PATH_MAP: Record< + string, + keyof CoreLinkParams + > = { + [ACTIONS.RAMP]: 'rampPath', + [ACTIONS.PERPS]: 'perpsPath', + [ACTIONS.SWAP]: 'swapPath', + [ACTIONS.DAPP]: 'dappPath', + [ACTIONS.SEND]: 'sendPath', + [ACTIONS.REWARDS]: 'rewardsPath', + [ACTIONS.HOME]: 'homePath', + [ACTIONS.ONBOARDING]: 'onboardingPath', + [ACTIONS.CREATE_ACCOUNT]: 'createAccountPath', + [ACTIONS.DEPOSIT]: 'depositCashPath', + }; + + /** + * Normalize a deep link URL into a CoreUniversalLink + * @param url - The URL to normalize + * @param source - The source of the deep link (e.g., 'qr-code', 'browser', etc.) + * @returns Normalized CoreUniversalLink object + */ + static normalize(url: string, source: string): CoreUniversalLink { + const timestamp = Date.now(); + + try { + // Clean and validate URL + const cleanedUrl = this.cleanUrl(url); + const urlObj = new URL(cleanedUrl); + + // Extract protocol + const protocol = this.extractProtocol(urlObj); + + // Extract action and params from original URL + const action = this.extractAction(urlObj, protocol); + const params = this.extractParams(urlObj, action); + + // Convert metamask:// to https:// for normalizedUrl + const processedUrl = this.convertToHttpsIfNeeded(cleanedUrl, urlObj); + const processedUrlObj = new URL(processedUrl); + + // Check if action is supported + const isSupportedAction = this.isSupportedAction(action); + + // Build normalized representation + return { + protocol, + host: + protocol === 'metamask' + ? undefined + : processedUrlObj.hostname || undefined, + action, + params, + source, + timestamp, + originalUrl: url, + normalizedUrl: processedUrl, + isValid: true, + isSupportedAction, + isPrivateLink: false, // Will be determined by signature verification + requiresAuth: AUTH_REQUIRED_ACTIONS.includes(action), + }; + } catch (_error) { + return { + protocol: 'https', + action: '', + params: {}, + source, + timestamp, + originalUrl: url, + normalizedUrl: url, + isValid: false, + isSupportedAction: false, + isPrivateLink: false, + requiresAuth: false, + }; + } + } + + /** + * Convert a CoreUniversalLink to metamask:// protocol + * @param link - The CoreUniversalLinkv (object) to convert + * @returns URL string with metamask:// protocol + */ + static toMetaMaskProtocol(link: CoreUniversalLink): string { + if (link.protocol === 'metamask') { + return link.originalUrl; + } + + const { action, params } = link; + const queryParams = this.buildQueryString(params); + + return `${METAMASK}://${action}${queryParams ? '?' + queryParams : ''}`; + } + + /** + * Check if a URL is a supported deep link + * @param url - The URL (string) to check + * @returns boolean indicating if the link is supported + */ + static isSupportedDeeplink(url: string): boolean { + try { + const normalizedLink = this.normalize(url, 'validation'); + return normalizedLink.isValid && normalizedLink.isSupportedAction; + } catch { + return false; + } + } + + /** + * Build a deep link URL from parameters + * @param protocol - The protocol to use + * @param action - The action to perform + * @param params - Optional parameters + * @returns Constructed deep link URL + */ + static buildDeeplink( + protocol: CoreUniversalLink['protocol'], + action: string, + params?: Partial, + ): string { + const queryString = params ? this.buildQueryString(params) : ''; + + if (protocol === 'metamask') { + return `${METAMASK}://${action}${queryString ? '?' + queryString : ''}`; + } + + const host = AppConstants.MM_IO_UNIVERSAL_LINK_HOST; + return `${HTTPS}://${host}/${action}${queryString ? '?' + queryString : ''}`; + } + + /** + * Private helper methods + */ + + private static cleanUrl(url: string): string { + // Remove dapp protocol prefix handling + return url + .replace(`${DAPP}/${HTTPS}://`, `${DAPP}/`) + .replace(`${DAPP}/${HTTP}://`, `${DAPP}/`); + } + + private static extractProtocol(urlObj: URL): CoreUniversalLink['protocol'] { + const protocol = urlObj.protocol.replace(':', ''); + const isSupportedProtocol = SUPPORTED_PROTOCOLS.includes(protocol); + return isSupportedProtocol + ? (protocol as CoreUniversalLink['protocol']) + : 'https'; + } + + private static convertToHttpsIfNeeded(url: string, urlObj: URL): string { + if (urlObj.protocol === `${METAMASK}:`) { + return url.replace( + `${METAMASK}://`, + `${HTTPS}://${AppConstants.MM_IO_UNIVERSAL_LINK_HOST}/`, + ); + } + return url; + } + + private static extractAction(urlObj: URL, protocol: string): string { + // For metamask:// URLs, the action is the hostname + if (protocol === 'metamask' && urlObj.hostname) { + return urlObj.hostname; + } + + // For https:// URLs, extract from pathname + const pathSegments = urlObj.pathname.split('/').filter(Boolean); + return pathSegments[0] || DEFAULT_ACTION; + } + + private static extractParams(urlObj: URL, action: string): CoreLinkParams { + // Parse query parameters + const queryParams = this.parseQueryString(urlObj); + + // Extract action-specific path + const actionPath = this.extractActionPath(urlObj, action); + + // Merge with action-specific paths + const params: CoreLinkParams = { + ...queryParams, + ...this.getActionSpecificParams(action, actionPath), + }; + + params.hr = String(params.hr) === '1'; + + // Clean up message parameter + if (params.message) { + params.message = params.message.replace(/ /g, '+'); + } + + return params; + } + + private static removeFalsyParams( + searchParams: URLSearchParams, + ): Partial { + const params: Partial = {}; + searchParams.forEach((value, key) => { + // URLSearchParams values are always strings, so only check string falsy values + switch (value) { + case '': + case 'null': + case 'undefined': + // Don't add to params object (effectively filtering it out) + break; + default: + params[key] = value; + break; + } + }); + return params; + } + + private static parseQueryString(urlObj: URL): Partial { + const { searchParams } = urlObj; + const searchParamKeys = [...searchParams.keys()]; + if (searchParamKeys.length === 0) { + return {}; + } + + try { + // Filter out null/undefined values and convert to proper types + const cleaned = this.removeFalsyParams(searchParams); + + return cleaned; + } catch { + return {}; + } + } + + private static extractActionPath(urlObj: URL, action: string): string { + const pathSegments = urlObj.pathname.split('/').filter(Boolean); + + // Remove the action from path segments + const actionIndex = pathSegments.indexOf(action); + if (actionIndex >= 0) { + pathSegments.splice(0, actionIndex + 1); + } + + // Reconstruct path without action + const path = pathSegments.join('/'); + const query = urlObj.search; + + let output = action; + if (path) { + output += `/${path}`; + } + if (query) { + output += query; + } + return output; + } + + private static getActionSpecificParams( + action: string, + actionPath: string, + ): Partial { + const params: Partial = {}; + + // note that ramp and perps actions are special cases because they have + // multiple actions associated with them + if (RAMP_ACTIONS.includes(action)) { + params.rampPath = actionPath; + } else if (PERPS_ACTIONS.includes(action)) { + params.perpsPath = actionPath; + } else { + const pathKey = this.ACTION_PATH_MAP[action]; + if (pathKey) { + params[pathKey] = actionPath; + } + } + + return params; + } + + private static buildQueryString(params: Partial): string { + const filteredParams: Record = {}; + + const pathKeys = [...Object.values(this.ACTION_PATH_MAP)]; + // Filter out action-specific paths and empty values + + Object.entries(params).forEach(([key, value]) => { + if ( + // exclude action-specific paths and empty values + !pathKeys.includes(key) && + value !== null && + value !== undefined && + value !== '' + ) { + if (key === 'hr' && typeof value === 'boolean') { + // Only include hr parameter if it's true + if (value) { + filteredParams[key] = '1'; + } + // If false, omit from URL entirely (default is false anyway) + } else { + filteredParams[key] = String(value); + } + } + }); + + // Use native URLSearchParams instead of qs + if (Object.keys(filteredParams).length === 0) { + return ''; + } + + const searchParams = new URLSearchParams(filteredParams); + return searchParams.toString(); + } + + private static isSupportedAction(action: string): boolean { + const allActions = Object.values(ACTIONS) as string[]; + return allActions.includes(action); + } +} diff --git a/app/core/DeeplinkManager/ParseManager/extractURLParams.ts b/app/core/DeeplinkManager/ParseManager/extractURLParams.ts index f6595b5a8997..19823a24a947 100644 --- a/app/core/DeeplinkManager/ParseManager/extractURLParams.ts +++ b/app/core/DeeplinkManager/ParseManager/extractURLParams.ts @@ -50,7 +50,7 @@ function extractURLParams(url: string) { utm_campaign: '', utm_term: '', utm_content: '', - hr: false, // string 1 means true + hr: false, }; if (urlObj.query.length) { @@ -59,7 +59,11 @@ function extractURLParams(url: string) { const parsedParams = qs.parse(urlObj.query.substring(1), { arrayLimit: 99, }); - params = { ...params, ...parsedParams, hr: parsedParams.hr === '1' }; + params = { + ...params, + ...parsedParams, + hr: parsedParams.hr === '1', + }; if (params.message) { params.message = params.message?.replace(/ /g, '+'); diff --git a/app/core/DeeplinkManager/ParseManager/handleMetaMaskDeeplink.ts b/app/core/DeeplinkManager/ParseManager/handleMetaMaskDeeplink.ts index 7a4b2c84447f..c2d3324717f3 100644 --- a/app/core/DeeplinkManager/ParseManager/handleMetaMaskDeeplink.ts +++ b/app/core/DeeplinkManager/ParseManager/handleMetaMaskDeeplink.ts @@ -46,7 +46,7 @@ export function handleMetaMaskDeeplink({ Routes.MODAL.ROOT_MODAL_FLOW, { screen: Routes.SDK.RETURN_TO_DAPP_NOTIFICATION, - hideReturnToApp: params.hr, + hideReturnToApp: !!params.hr, }, ); } else if (params.channelId) { diff --git a/app/core/DeeplinkManager/ParseManager/utils/verifySignature.test.ts b/app/core/DeeplinkManager/ParseManager/utils/verifySignature.test.ts index 3ddcf1d4215d..d4fe0569f49e 100644 --- a/app/core/DeeplinkManager/ParseManager/utils/verifySignature.test.ts +++ b/app/core/DeeplinkManager/ParseManager/utils/verifySignature.test.ts @@ -263,5 +263,212 @@ describe('verifySignature', () => { 'https://example.com:8080/deep/path?a=1&b=2&c=3', ); }); + + describe('with sig_params', () => { + it('includes only parameters listed in sig_params for verification', async () => { + const validSignature = Buffer.from(new Array(64).fill(0)).toString( + 'base64', + ); + const url = new URL( + `https://example.com?param1=value1¶m2=value2¶m3=value3&sig_params=param1,param2&sig=${validSignature}`, + ); + + mockSubtle.verify.mockResolvedValue(true); + + const result = await verifyDeeplinkSignature(url); + + expect(result).toBe(VALID); + const verifyCall = mockSubtle.verify.mock.calls[0]; + const dataBuffer = verifyCall[3] as Uint8Array; // data is 4th param ([3]) + const canonicalUrl = new TextDecoder().decode(dataBuffer); + + // Should include param1, param2, and sig_params itself, but NOT param3 + expect(canonicalUrl).toBe( + 'https://example.com/?param1=value1¶m2=value2&sig_params=param1%2Cparam2', + ); + }); + + it('includes sig_params itself in canonical URL for verification', async () => { + const validSignature = Buffer.from(new Array(64).fill(0)).toString( + 'base64', + ); + const url = new URL( + `https://example.com?channel=someValue&sig_params=channel&sig=${validSignature}`, + ); + + mockSubtle.verify.mockResolvedValue(true); + + const result = await verifyDeeplinkSignature(url); + + expect(result).toBe(VALID); + const verifyCall = mockSubtle.verify.mock.calls[0]; + const dataBuffer = verifyCall[3] as Uint8Array; // 4th param ([3]) is data + const canonicalUrl = new TextDecoder().decode(dataBuffer); + + // Should include both channel AND sig_params + expect(canonicalUrl).toBe( + 'https://example.com/?channel=someValue&sig_params=channel', + ); + }); + + it('handles empty sig_params correctly (legacy behavior)', async () => { + const validSignature = Buffer.from(new Array(64).fill(0)).toString( + 'base64', + ); + const url = new URL( + `https://example.com?param1=value1&sig_params=&sig=${validSignature}`, + ); + + mockSubtle.verify.mockResolvedValue(true); + + const result = await verifyDeeplinkSignature(url); + + expect(result).toBe(VALID); + const verifyCall = mockSubtle.verify.mock.calls[0]; + const dataBuffer = verifyCall[3] as Uint8Array; + const canonicalUrl = new TextDecoder().decode(dataBuffer); + + // Should only include sig_params itself (empty value) + expect(canonicalUrl).toBe('https://example.com/?sig_params='); + }); + + it('ignores parameters not listed in sig_params', async () => { + const validSignature = Buffer.from(new Array(64).fill(0)).toString( + 'base64', + ); + const url = new URL( + `https://example.com?keep=yes&ignore1=no&ignore2=no&sig_params=keep&sig=${validSignature}`, + ); + + mockSubtle.verify.mockResolvedValue(true); + + const result = await verifyDeeplinkSignature(url); + + expect(result).toBe(VALID); + const verifyCall = mockSubtle.verify.mock.calls[0]; + const dataBuffer = verifyCall[3] as Uint8Array; + const canonicalUrl = new TextDecoder().decode(dataBuffer); + + // Should only include 'keep' and sig_params + expect(canonicalUrl).toBe( + 'https://example.com/?keep=yes&sig_params=keep', + ); + }); + + it('handles multiple parameters in sig_params with proper sorting', async () => { + const validSignature = Buffer.from(new Array(64).fill(0)).toString( + 'base64', + ); + const url = new URL( + `https://example.com?zebra=last&alpha=first&middle=center&sig_params=zebra,alpha,middle&sig=${validSignature}`, + ); + + mockSubtle.verify.mockResolvedValue(true); + + const result = await verifyDeeplinkSignature(url); + + expect(result).toBe(VALID); + const verifyCall = mockSubtle.verify.mock.calls[0]; + const dataBuffer = verifyCall[3] as Uint8Array; + const canonicalUrl = new TextDecoder().decode(dataBuffer); + + // Should be alphabetically sorted + expect(canonicalUrl).toBe( + 'https://example.com/?alpha=first&middle=center&sig_params=zebra%2Calpha%2Cmiddle&zebra=last', + ); + }); + + it('handles missing parameters referenced in sig_params gracefully', async () => { + const validSignature = Buffer.from(new Array(64).fill(0)).toString( + 'base64', + ); + const url = new URL( + `https://example.com?param1=value1&sig_params=param1,param2,param3&sig=${validSignature}`, + ); + + mockSubtle.verify.mockResolvedValue(true); + + const result = await verifyDeeplinkSignature(url); + + expect(result).toBe(VALID); + const verifyCall = mockSubtle.verify.mock.calls[0]; + const dataBuffer = verifyCall[3] as Uint8Array; + const canonicalUrl = new TextDecoder().decode(dataBuffer); + + // Should only include param1 (exists) and sig_params, not param2 or param3 + expect(canonicalUrl).toBe( + 'https://example.com/?param1=value1&sig_params=param1%2Cparam2%2Cparam3', + ); + }); + + it('preserves backward compatibility for URLs without sig_params', async () => { + const validSignature = Buffer.from(new Array(64).fill(0)).toString( + 'base64', + ); + const url = new URL( + `https://example.com?param1=value1¶m2=value2&sig=${validSignature}`, + ); + + mockSubtle.verify.mockResolvedValue(true); + + const result = await verifyDeeplinkSignature(url); + + expect(result).toBe(VALID); + const verifyCall = mockSubtle.verify.mock.calls[0]; + const dataBuffer = verifyCall[3] as Uint8Array; + const canonicalUrl = new TextDecoder().decode(dataBuffer); + + // Should include all params except sig (old behavior) + expect(canonicalUrl).toBe( + 'https://example.com/?param1=value1¶m2=value2', + ); + }); + + it('handles parameters with special characters in sig_params', async () => { + const validSignature = Buffer.from(new Array(64).fill(0)).toString( + 'base64', + ); + const url = new URL( + `https://example.com?param%20name=value%20here&other=test&sig_params=param%20name&sig=${validSignature}`, + ); + + mockSubtle.verify.mockResolvedValue(true); + + const result = await verifyDeeplinkSignature(url); + + expect(result).toBe(VALID); + const verifyCall = mockSubtle.verify.mock.calls[0]; + const dataBuffer = verifyCall[3] as Uint8Array; + const canonicalUrl = new TextDecoder().decode(dataBuffer); + + // Should handle URL encoding properly + expect(canonicalUrl).toBe( + 'https://example.com/?param+name=value+here&sig_params=param+name', + ); + }); + + it('handles sig_params with trailing commas', async () => { + const validSignature = Buffer.from(new Array(64).fill(0)).toString( + 'base64', + ); + const url = new URL( + `https://example.com?param1=value1¶m2=value2&sig_params=param1,param2,&sig=${validSignature}`, + ); + + mockSubtle.verify.mockResolvedValue(true); + + const result = await verifyDeeplinkSignature(url); + + expect(result).toBe(VALID); + const verifyCall = mockSubtle.verify.mock.calls[0]; + const dataBuffer = verifyCall[3] as Uint8Array; + const canonicalUrl = new TextDecoder().decode(dataBuffer); + + // empty string from trailing comma should be removed + expect(canonicalUrl).toBe( + 'https://example.com/?param1=value1¶m2=value2&sig_params=param1%2Cparam2%2C', + ); + }); + }); }); }); diff --git a/app/core/DeeplinkManager/ParseManager/utils/verifySignature.ts b/app/core/DeeplinkManager/ParseManager/utils/verifySignature.ts index b9848f635017..158782591676 100644 --- a/app/core/DeeplinkManager/ParseManager/utils/verifySignature.ts +++ b/app/core/DeeplinkManager/ParseManager/utils/verifySignature.ts @@ -34,12 +34,36 @@ function getKeyData() { function canonicalize(url: URL): string { const params = new URLSearchParams(url.searchParams); - params.delete('sig'); + const canonicalParams = new URLSearchParams(); + + // If sig_params is present, only include the + // parameters listed in it for sig verification + if (params.has('sig_params')) { + const stringifiedSigParams = params.get('sig_params') || ''; + + // Filter to only valid, existing params with non-null values + stringifiedSigParams.split(',').forEach((paramName) => { + if (!paramName) return; // Skip empty strings + + const value = params.get(paramName); // can be string or null + if (value !== null) { + // remove null + canonicalParams.set(paramName, value); + } + }); + + canonicalParams.set('sig_params', stringifiedSigParams); + canonicalParams.sort(); + const queryString = canonicalParams.toString(); + return url.origin + url.pathname + (queryString ? `?${queryString}` : ''); + } + + // Fallback to old behavior for URLs without sig_params + params.delete('sig'); params.sort(); const queryString = params.toString(); - const fullUrl = url.origin + url.pathname + (queryString ? `?${queryString}` : ''); diff --git a/app/core/DeeplinkManager/types/CoreUniversalLink.ts b/app/core/DeeplinkManager/types/CoreUniversalLink.ts new file mode 100644 index 000000000000..b6fdd7d327dc --- /dev/null +++ b/app/core/DeeplinkManager/types/CoreUniversalLink.ts @@ -0,0 +1,141 @@ +/** + * Core types and interfaces for unified deep link handling + */ + +import { ACTIONS } from '../../../constants/deeplinks'; + +/** + * Parameters that can be extracted from deep links + */ +export interface CoreLinkParams { + // Navigation params + uri?: string; + redirect?: string; + + // SDK params + channelId?: string; + comm?: string; + pubkey?: string; + scheme?: string; + v?: string; + rpc?: string; + sdkVersion?: string; + message?: string; + originatorInfo?: string; + request?: string; + + // Attribution params + attributionId?: string; + utm_source?: string; + utm_medium?: string; + utm_campaign?: string; + utm_term?: string; + utm_content?: string; + + // Account params + account?: string; // Format: "address@chainId" + + // UI control params + hr?: boolean; // Hide Return to App button + + // Action-specific paths (populated by normalizer) + rampPath?: string; + swapPath?: string; + dappPath?: string; + sendPath?: string; + perpsPath?: string; + rewardsPath?: string; + homePath?: string; + onboardingPath?: string; + createAccountPath?: string; + depositCashPath?: string; + perpsMarketsPath?: string; + + // Additional dynamic params + [key: string]: string | boolean | undefined; +} + +/** + * Normalized representation of a universal link + */ +export interface CoreUniversalLink { + // Core properties + protocol: 'metamask' | 'https' | 'http' | 'wc' | 'ethereum' | 'dapp'; + host?: string; + action: string; + params: CoreLinkParams; + + // Metadata + source: string; + timestamp: number; + originalUrl: string; + normalizedUrl: string; + + // Validation + isValid: boolean; + isSupportedAction: boolean; + isPrivateLink: boolean; + requiresAuth: boolean; +} + +/** + * Actions that require authentication/signature verification + */ +export const AUTH_REQUIRED_ACTIONS: string[] = [ + ACTIONS.SEND, + ACTIONS.APPROVE, + ACTIONS.PAYMENT, + ACTIONS.CREATE_ACCOUNT, +] as const; + +/** + * MetaMask SDK specific actions + */ +export const SDK_ACTIONS: string[] = [ + ACTIONS.ANDROID_SDK, + ACTIONS.CONNECT, + ACTIONS.MMSDK, +] as const; + +/** + * Actions that should bypass the deep link modal + */ +export const WHITELISTED_ACTIONS: string[] = [ACTIONS.WC] as const; + +/** + * Ramp-related actions + */ +export const RAMP_ACTIONS: string[] = [ + ACTIONS.BUY, + ACTIONS.BUY_CRYPTO, + ACTIONS.SELL, + ACTIONS.SELL_CRYPTO, + ACTIONS.DEPOSIT, + ACTIONS.RAMP, +] as const; + +/** + * Perpetuals-related actions + */ +export const PERPS_ACTIONS: string[] = [ + ACTIONS.PERPS, + ACTIONS.PERPS_MARKETS, + ACTIONS.PERPS_ASSET, +] as const; + +/** + * Supported protocol types + */ +export const SUPPORTED_PROTOCOLS: string[] = [ + 'metamask', + 'https', + 'http', + 'wc', + 'ethereum', + 'dapp', +] as const; + +/** + * Default action when none is specified + */ +export const DEFAULT_ACTION: string = ACTIONS.HOME; diff --git a/app/core/Engine/Engine.test.ts b/app/core/Engine/Engine.test.ts index c23bcfea5f73..f1b8c4ddf619 100644 --- a/app/core/Engine/Engine.test.ts +++ b/app/core/Engine/Engine.test.ts @@ -226,7 +226,7 @@ describe('Engine', () => { lastError: null, lastUpdateTimestamp: 0, balances: {}, - claimablePositions: [], + claimablePositions: {}, pendingDeposits: {}, withdrawTransaction: null, isOnboarded: {}, diff --git a/app/core/Engine/Engine.ts b/app/core/Engine/Engine.ts index c282cdbce6ae..ac260a923b84 100644 --- a/app/core/Engine/Engine.ts +++ b/app/core/Engine/Engine.ts @@ -276,7 +276,7 @@ export class Engine { qrKeyringScanner: this.qrKeyringScanner, codefiTokenApiV2, }; - + // @ts-expect-error - metametrics id is required, this will be addressed on a follow up PR const { controllersByName } = initModularizedControllers({ controllerInitFunctions: { ErrorReportingService: errorReportingServiceInit, diff --git a/app/core/Engine/controllers/currency-rate-controller/currency-rate-controller-init.ts b/app/core/Engine/controllers/currency-rate-controller/currency-rate-controller-init.ts index 3b11a6cf1884..7f5070e8c193 100644 --- a/app/core/Engine/controllers/currency-rate-controller/currency-rate-controller-init.ts +++ b/app/core/Engine/controllers/currency-rate-controller/currency-rate-controller-init.ts @@ -24,7 +24,8 @@ export const currencyRateControllerInit: ControllerInitFunction< CurrencyRateController, CurrencyRateMessenger > = (request) => { - const { controllerMessenger, persistedState, getState } = request; + const { controllerMessenger, persistedState, getState, codefiTokenApiV2 } = + request; // Get the persisted state or use default state const persistedCurrencyRateState = @@ -52,6 +53,7 @@ export const currencyRateControllerInit: ControllerInitFunction< currencyRates: normalizedCurrencyRates, }, useExternalServices: () => selectBasicFunctionalityEnabled(getState()), + tokenPricesService: codefiTokenApiV2, }); return { controller }; diff --git a/app/core/Engine/controllers/multichain-assets-controller/multichain-assets-controller-init.test.ts b/app/core/Engine/controllers/multichain-assets-controller/multichain-assets-controller-init.test.ts index 3db6a6213640..6470f4d1e9be 100644 --- a/app/core/Engine/controllers/multichain-assets-controller/multichain-assets-controller-init.test.ts +++ b/app/core/Engine/controllers/multichain-assets-controller/multichain-assets-controller-init.test.ts @@ -62,6 +62,7 @@ describe('multichain assets controller init', () => { ], }, }, + allIgnoredAssets: {}, }; // Update mock with initial state diff --git a/app/core/Engine/controllers/network-controller-init.ts b/app/core/Engine/controllers/network-controller-init.ts index cd3b2e4f8dab..e6a22323702a 100644 --- a/app/core/Engine/controllers/network-controller-init.ts +++ b/app/core/Engine/controllers/network-controller-init.ts @@ -51,6 +51,22 @@ export function getInitialNetworkControllerState(persistedState: { ChainId['base-mainnet'] ].rpcEndpoints[0].failoverUrls = getFailoverUrlsForInfuraNetwork('base-mainnet'); + initialNetworkControllerState.networkConfigurationsByChainId[ + ChainId['arbitrum-mainnet'] + ].rpcEndpoints[0].failoverUrls = + getFailoverUrlsForInfuraNetwork('arbitrum-mainnet'); + initialNetworkControllerState.networkConfigurationsByChainId[ + ChainId['bsc-mainnet'] + ].rpcEndpoints[0].failoverUrls = + getFailoverUrlsForInfuraNetwork('bsc-mainnet'); + initialNetworkControllerState.networkConfigurationsByChainId[ + ChainId['optimism-mainnet'] + ].rpcEndpoints[0].failoverUrls = + getFailoverUrlsForInfuraNetwork('optimism-mainnet'); + initialNetworkControllerState.networkConfigurationsByChainId[ + ChainId['polygon-mainnet'] + ].rpcEndpoints[0].failoverUrls = + getFailoverUrlsForInfuraNetwork('polygon-mainnet'); // Update default popular network names initialNetworkControllerState.networkConfigurationsByChainId[ @@ -62,6 +78,21 @@ export function getInitialNetworkControllerState(persistedState: { initialNetworkControllerState.networkConfigurationsByChainId[ ChainId['base-mainnet'] ].name = 'Base'; + initialNetworkControllerState.networkConfigurationsByChainId[ + ChainId['arbitrum-mainnet'] + ].name = 'Arbitrum'; + initialNetworkControllerState.networkConfigurationsByChainId[ + ChainId['bsc-mainnet'] + ].name = 'BNB Chain'; + initialNetworkControllerState.networkConfigurationsByChainId[ + ChainId['optimism-mainnet'] + ].name = 'OP'; + initialNetworkControllerState.networkConfigurationsByChainId[ + ChainId['polygon-mainnet'] + ].name = 'Polygon'; + initialNetworkControllerState.networkConfigurationsByChainId[ + ChainId['sei-mainnet'] + ].name = 'Sei'; } return initialNetworkControllerState; diff --git a/app/core/Engine/controllers/predict-controller/index.test.ts b/app/core/Engine/controllers/predict-controller/index.test.ts index 0f439ea6b58a..cf8a672bfdd9 100644 --- a/app/core/Engine/controllers/predict-controller/index.test.ts +++ b/app/core/Engine/controllers/predict-controller/index.test.ts @@ -67,7 +67,7 @@ describe('predict controller init', () => { lastError: null, lastUpdateTimestamp: Date.now(), balances: {}, - claimablePositions: [], + claimablePositions: {}, pendingDeposits: {}, withdrawTransaction: null, isOnboarded: {}, diff --git a/app/core/Engine/controllers/remote-feature-flag-controller-init.test.ts b/app/core/Engine/controllers/remote-feature-flag-controller-init.test.ts index 9526f3eaf9ba..ca76e1b426ef 100644 --- a/app/core/Engine/controllers/remote-feature-flag-controller-init.test.ts +++ b/app/core/Engine/controllers/remote-feature-flag-controller-init.test.ts @@ -9,6 +9,11 @@ import { getRemoteFeatureFlagControllerMessenger } from '../messengers/remote-fe import { ExtendedMessenger } from '../../ExtendedMessenger'; import { buildControllerInitRequestMock } from '../utils/test-utils'; import { MOCK_ANY_NAMESPACE, MockAnyNamespace } from '@metamask/messenger'; +import Logger from '../../../util/Logger'; + +jest.mock('../../../util/Logger', () => ({ + log: jest.fn(), +})); jest.mock('@metamask/remote-feature-flag-controller', () => ({ ...jest.requireActual('@metamask/remote-feature-flag-controller'), @@ -100,4 +105,35 @@ describe('remoteFeatureFlagControllerInit', () => { controllerMock.mock.results[0].value.updateRemoteFeatureFlags, ).not.toHaveBeenCalled(); }); + + it('logs success message when feature flags update successfully', async () => { + const initRequestMock = getInitRequestMock(); + + remoteFeatureFlagControllerInit(initRequestMock); + + await new Promise(process.nextTick); + + expect(Logger.log).toHaveBeenCalledWith('Feature flags updated'); + }); + + it('logs error message when feature flags update fails', async () => { + const initRequestMock = getInitRequestMock(); + const mockError = new Error('Network error'); + const controllerMock = jest.mocked(RemoteFeatureFlagController); + controllerMock.mockImplementationOnce( + () => + ({ + updateRemoteFeatureFlags: jest.fn().mockRejectedValue(mockError), + }) as unknown as RemoteFeatureFlagController, + ); + + remoteFeatureFlagControllerInit(initRequestMock); + + await new Promise(process.nextTick); + + expect(Logger.log).toHaveBeenCalledWith( + 'Feature flags update failed: ', + mockError, + ); + }); }); diff --git a/app/core/Engine/controllers/remote-feature-flag-controller-init.ts b/app/core/Engine/controllers/remote-feature-flag-controller-init.ts index 74ac59987703..03d7098e22d0 100644 --- a/app/core/Engine/controllers/remote-feature-flag-controller-init.ts +++ b/app/core/Engine/controllers/remote-feature-flag-controller-init.ts @@ -31,7 +31,7 @@ export const remoteFeatureFlagControllerInit: ControllerInitFunction< messenger: controllerMessenger, state: persistedState.RemoteFeatureFlagController, disabled, - getMetaMetricsId: () => metaMetricsId ?? '', + getMetaMetricsId: () => metaMetricsId, clientConfigApiService: new ClientConfigApiService({ fetch, config: { @@ -53,7 +53,7 @@ export const remoteFeatureFlagControllerInit: ControllerInitFunction< .then(() => { Logger.log('Feature flags updated'); }) - .catch((error) => Logger.log(error)); + .catch((error) => Logger.log('Feature flags update failed: ', error)); } return { diff --git a/app/core/Engine/messengers/account-tracker-controller-messenger.ts b/app/core/Engine/messengers/account-tracker-controller-messenger.ts index 59457486aead..00da73d014ce 100644 --- a/app/core/Engine/messengers/account-tracker-controller-messenger.ts +++ b/app/core/Engine/messengers/account-tracker-controller-messenger.ts @@ -34,9 +34,10 @@ export function getAccountTrackerControllerMessenger( ], events: [ 'AccountsController:selectedEvmAccountChange', - 'AccountsController:selectedAccountChange', 'TransactionController:transactionConfirmed', 'TransactionController:unapprovedTransactionAdded', + 'NetworkController:networkAdded', + 'KeyringController:unlock', ], messenger, }); diff --git a/app/core/Engine/messengers/defi-positions-controller-messenger/defi-positions-controller-messenger.ts b/app/core/Engine/messengers/defi-positions-controller-messenger/defi-positions-controller-messenger.ts index 8e3d8c566a45..7c4195dd90b6 100644 --- a/app/core/Engine/messengers/defi-positions-controller-messenger/defi-positions-controller-messenger.ts +++ b/app/core/Engine/messengers/defi-positions-controller-messenger/defi-positions-controller-messenger.ts @@ -26,12 +26,11 @@ export function getDeFiPositionsControllerMessenger( parent: rootMessenger, }); rootMessenger.delegate({ - actions: ['AccountsController:listAccounts'], + actions: ['AccountTreeController:getAccountsFromSelectedAccountGroup'], events: [ - 'KeyringController:unlock', 'KeyringController:lock', 'TransactionController:transactionConfirmed', - 'AccountsController:accountAdded', + 'AccountTreeController:selectedAccountGroupChange', ], messenger, }); @@ -56,7 +55,6 @@ export function getDeFiPositionsControllerInitMessenger( }); rootMessenger.delegate({ actions: ['RemoteFeatureFlagController:getState'], - events: [], messenger, }); return messenger; diff --git a/app/core/Engine/types.ts b/app/core/Engine/types.ts index fc607c54b189..67abfc082199 100644 --- a/app/core/Engine/types.ts +++ b/app/core/Engine/types.ts @@ -909,8 +909,9 @@ export type ControllerInitRequest< /** * The MetaMetrics ID to use for tracking. + * This is always provided at runtime and should not be undefined. */ - metaMetricsId?: string; + metaMetricsId: string; ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) /** @@ -975,7 +976,7 @@ export interface InitModularizedControllersFunctionRequest { existingControllersByName?: Partial; getGlobalChainId: () => Hex; getState: () => RootState; - metaMetricsId?: string; + metaMetricsId: string; initialKeyringState?: KeyringControllerState | null; qrKeyringScanner: QrKeyringDeferredPromiseBridge; codefiTokenApiV2: CodefiTokenPricesServiceV2; diff --git a/app/core/Engine/utils/test-utils.ts b/app/core/Engine/utils/test-utils.ts index 837b235c84b3..13eefedb3caa 100644 --- a/app/core/Engine/utils/test-utils.ts +++ b/app/core/Engine/utils/test-utils.ts @@ -22,6 +22,7 @@ export function buildControllerInitRequestMock( controllerMessenger: controllerMessenger as unknown as ControllerMessenger, getController: jest.fn(), getGlobalChainId: jest.fn(), + metaMetricsId: 'mock-meta-metrics-id', getState: jest.fn(), initMessenger: jest.fn() as unknown as void, qrKeyringScanner: jest.fn() as unknown as QrKeyringDeferredPromiseBridge, diff --git a/app/selectors/assets/assets-list.test.ts b/app/selectors/assets/assets-list.test.ts index 53cfa143c65a..283ca3f41bce 100644 --- a/app/selectors/assets/assets-list.test.ts +++ b/app/selectors/assets/assets-list.test.ts @@ -261,6 +261,7 @@ const mockState = ({ ], }, }, + allIgnoredAssets: {}, }, MultichainBalancesController: { balances: { @@ -617,6 +618,7 @@ describe('selectSortedAssetsBySelectedAccountGroup', () => { units: [{ name: 'TRON', symbol: 'TRX', decimals: 6 }], }, }, + allIgnoredAssets: {}, }, MultichainBalancesController: { balances: { @@ -874,6 +876,7 @@ describe('selectTronResourcesBySelectedAccountGroup', () => { units: [{ name: 'TRON', symbol: 'TRX', decimals: 6 }], }, }, + allIgnoredAssets: {}, }, MultichainBalancesController: { balances: { @@ -950,6 +953,7 @@ describe('selectTronResourcesBySelectedAccountGroup', () => { ], }, }, + allIgnoredAssets: {}, }, MultichainBalancesController: { balances: { diff --git a/app/selectors/assets/assets-list.ts b/app/selectors/assets/assets-list.ts index dc5add731454..76916ad62d89 100644 --- a/app/selectors/assets/assets-list.ts +++ b/app/selectors/assets/assets-list.ts @@ -49,6 +49,7 @@ export const selectAssetsBySelectedAccountGroup = createDeepEqualSelector( let multichainState = { accountsAssets: {}, assetsMetadata: {}, + allIgnoredAssets: {}, balances: {}, conversionRates: {}, }; diff --git a/app/selectors/assets/balances.test.ts b/app/selectors/assets/balances.test.ts index f983a4dd413c..ed07f6b31b44 100644 --- a/app/selectors/assets/balances.test.ts +++ b/app/selectors/assets/balances.test.ts @@ -174,6 +174,11 @@ const makeState = (overrides: Record = {}) => ({ }, }, }, + MultichainAssetsController: { + accountsAssets: {}, + assetsMetadata: {}, + allIgnoredAssets: {}, + }, TokensController: { allTokens: { '0x1': { @@ -229,6 +234,11 @@ describe('assets balance and balance change selectors (mobile)', () => { TokenRatesController: { marketData: {} }, MultichainAssetsRatesController: { conversionRates: {} }, MultichainBalancesController: { balances: {} }, + MultichainAssetsController: { + accountsAssets: {}, + assetsMetadata: {}, + allIgnoredAssets: {}, + }, TokensController: { allTokens: {}, allIgnoredTokens: {}, @@ -530,7 +540,7 @@ describe('assets balance and balance change selectors (mobile)', () => { // Verify calculateBalanceForAllWallets was called with proper enabledNetworkMap expect(mockCalculateBalanceForAllWallets).toHaveBeenCalledTimes(1); const enabledNetworkMap = - mockCalculateBalanceForAllWallets.mock.calls[0][8]; + mockCalculateBalanceForAllWallets.mock.calls[0][9]; // Should include mainnet networks only expect(enabledNetworkMap).toEqual({ diff --git a/app/selectors/assets/balances.ts b/app/selectors/assets/balances.ts index e9528ca92479..36430fdba95f 100644 --- a/app/selectors/assets/balances.ts +++ b/app/selectors/assets/balances.ts @@ -11,6 +11,7 @@ import { calculateBalanceChangeForAllWallets, calculateBalanceChangeForAccountGroup, type BalanceChangePeriod, + MultichainAssetsControllerState, } from '@metamask/assets-controllers'; import type { AccountTreeControllerState } from '@metamask/account-tree-controller'; import type { AccountsControllerState } from '@metamask/accounts-controller'; @@ -33,6 +34,9 @@ import { import { selectMultichainBalances, selectMultichainAssetsRates, + selectMultichainAssetsAllIgnoredAssets, + selectMultichainAssets, + selectMultichainAssetsMetadata, } from '../multichain/multichain'; import { selectTokenMarketData } from '../tokenRatesController'; import { selectAllTokenBalances } from '../tokenBalancesController'; @@ -98,6 +102,23 @@ const selectMultichainBalancesStateForBalances = createSelector( ({ balances }) as MultichainBalancesControllerState, ); +const selectMultichainAssetsControllerStateForBalances = createSelector( + [ + selectMultichainAssets, + selectMultichainAssetsMetadata, + selectMultichainAssetsAllIgnoredAssets, + ], + ( + accountsAssets, + assetsMetadata, + allIgnoredAssets, + ): MultichainAssetsControllerState => ({ + accountsAssets, + assetsMetadata, + allIgnoredAssets, + }), +); + const selectMultichainAssetsRatesStateForBalances = createSelector( [selectMultichainAssetsRates], (conversionRates): MultichainAssetsRatesControllerState => @@ -131,6 +152,7 @@ export const selectBalanceForAllWallets = createSelector( selectTokenRatesStateForBalances, selectMultichainAssetsRatesStateForBalances, selectMultichainBalancesStateForBalances, + selectMultichainAssetsControllerStateForBalances, selectTokensStateForBalances, selectCurrencyRateStateForBalances, selectEnabledNetworksByNamespace, @@ -142,6 +164,7 @@ export const selectBalanceForAllWallets = createSelector( tokenRatesState: TokenRatesControllerState, multichainRatesState: MultichainAssetsRatesControllerState, multichainBalancesState: MultichainBalancesControllerState, + multichainAssetsControllerState: MultichainAssetsControllerState, tokensState: TokensControllerState, currencyRateState: CurrencyRateState, enabledNetworkMap: Record> | undefined, @@ -153,6 +176,7 @@ export const selectBalanceForAllWallets = createSelector( tokenRatesState, multichainRatesState, multichainBalancesState, + multichainAssetsControllerState, tokensState, currencyRateState, enabledNetworkMap, @@ -167,6 +191,7 @@ export const selectBalanceForAllWalletsAndChains = createSelector( selectTokenRatesStateForBalances, selectMultichainAssetsRatesStateForBalances, selectMultichainBalancesStateForBalances, + selectMultichainAssetsControllerStateForBalances, selectTokensStateForBalances, selectCurrencyRateStateForBalances, ], @@ -177,6 +202,7 @@ export const selectBalanceForAllWalletsAndChains = createSelector( tokenRatesState, multichainRatesState, multichainBalancesState, + multichainAssetsControllerState, tokensState, currencyRateState, ) => @@ -187,6 +213,7 @@ export const selectBalanceForAllWalletsAndChains = createSelector( tokenRatesState, multichainRatesState, multichainBalancesState, + multichainAssetsControllerState, tokensState, currencyRateState, undefined, @@ -266,6 +293,7 @@ export const selectAccountGroupBalanceForEmptyState = createSelector( selectTokenRatesStateForBalances, selectMultichainAssetsRatesStateForBalances, selectMultichainBalancesStateForBalances, + selectMultichainAssetsControllerStateForBalances, selectTokensStateForBalances, selectCurrencyRateStateForBalances, ], @@ -278,6 +306,7 @@ export const selectAccountGroupBalanceForEmptyState = createSelector( tokenRatesState, multichainRatesState, multichainBalancesState, + multichainAssetsControllerState, tokensState, currencyRateState, ) => { @@ -338,6 +367,7 @@ export const selectAccountGroupBalanceForEmptyState = createSelector( tokenRatesState, multichainRatesState, multichainBalancesState, + multichainAssetsControllerState, tokensState, currencyRateState, enabledNetworkMap, @@ -380,6 +410,7 @@ export const selectBalanceChangeForAllWallets = (period: BalanceChangePeriod) => selectTokenRatesStateForBalances, selectMultichainAssetsRatesStateForBalances, selectMultichainBalancesStateForBalances, + selectMultichainAssetsControllerStateForBalances, selectTokensStateForBalances, selectCurrencyRateStateForBalances, selectEnabledNetworksByNamespace, @@ -391,6 +422,7 @@ export const selectBalanceChangeForAllWallets = (period: BalanceChangePeriod) => tokenRatesState, multichainRatesState, multichainBalancesState, + multichainAssetsControllerState, tokensState, currencyRateState, enabledNetworkMap, @@ -402,6 +434,7 @@ export const selectBalanceChangeForAllWallets = (period: BalanceChangePeriod) => tokenRatesState, multichainRatesState, multichainBalancesState, + multichainAssetsControllerState, tokensState, currencyRateState, enabledNetworkMap, @@ -422,6 +455,7 @@ export const selectBalanceChangeByAccountGroup = ( selectTokenRatesStateForBalances, selectMultichainAssetsRatesStateForBalances, selectMultichainBalancesStateForBalances, + selectMultichainAssetsControllerStateForBalances, selectTokensStateForBalances, selectCurrencyRateStateForBalances, selectEnabledNetworksByNamespace, @@ -433,6 +467,7 @@ export const selectBalanceChangeByAccountGroup = ( tokenRatesState, multichainRatesState, multichainBalancesState, + multichainAssetsControllerState, tokensState, currencyRateState, enabledNetworkMap, @@ -444,6 +479,7 @@ export const selectBalanceChangeByAccountGroup = ( tokenRatesState, multichainRatesState, multichainBalancesState, + multichainAssetsControllerState, tokensState, currencyRateState, enabledNetworkMap, @@ -474,6 +510,7 @@ export const selectBalanceChangeBySelectedAccountGroup = ( selectTokenRatesStateForBalances, selectMultichainAssetsRatesStateForBalances, selectMultichainBalancesStateForBalances, + selectMultichainAssetsControllerStateForBalances, selectTokensStateForBalances, selectCurrencyRateStateForBalances, selectEnabledNetworksByNamespace, @@ -486,6 +523,7 @@ export const selectBalanceChangeBySelectedAccountGroup = ( tokenRatesState, multichainRatesState, multichainBalancesState, + multichainAssetsControllerState, tokensState, currencyRateState, enabledNetworkMap, @@ -500,6 +538,7 @@ export const selectBalanceChangeBySelectedAccountGroup = ( tokenRatesState, multichainRatesState, multichainBalancesState, + multichainAssetsControllerState, tokensState, currencyRateState, enabledNetworkMap, diff --git a/app/selectors/defiPositionsController.test.ts b/app/selectors/defiPositionsController.test.ts index 90548373766f..af4b04b8058b 100644 --- a/app/selectors/defiPositionsController.test.ts +++ b/app/selectors/defiPositionsController.test.ts @@ -6,7 +6,7 @@ import { } from './defiPositionsController'; describe('defiPositionsController selectors', () => { - const mockAddress = '0x1234567890123456789012345678901234567890'; + const mockAddress1 = '0x1234567890123456789012345678901234567890'; const mockAddress2 = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; const mockDefiPositions = { @@ -42,38 +42,69 @@ describe('defiPositionsController selectors', () => { }, }; - const createMockState = ( - defiPositions: Record> = {}, - selectedAddress: string | undefined = mockAddress, - enabledNetworks: Record> = {}, - ): RootState => + const createMockState = ({ + selectedAccountGroup, + defiPositions = {}, + enabledNetworks = {}, + }: { + selectedAccountGroup: + | 'entropy:wallet0/0' + | 'entropy:wallet0/1' + | 'entropy:wallet0/btc-only'; + defiPositions?: Record | null>; + enabledNetworks?: Record>; + }): RootState => ({ engine: { backgroundState: { DeFiPositionsController: { allDeFiPositions: defiPositions, }, - AccountsController: { - internalAccounts: selectedAddress - ? { - selectedAccount: `account-${selectedAddress}`, - accounts: { - [`account-${selectedAddress}`]: { - address: selectedAddress, - id: `account-${selectedAddress}`, - metadata: { - name: 'Account 1', - keyring: { type: 'HD Key Tree' }, - }, - methods: [], - type: 'eip155:eoa', + AccountTreeController: { + accountTree: { + selectedAccountGroup, + wallets: { + 'entropy:wallet0': { + id: 'entropy:wallet0', + type: 'Entropy', + groups: { + 'entropy:wallet0/0': { + id: 'entropy:wallet0/0', + accounts: [`account-${mockAddress1}`], + }, + 'entropy:wallet0/1': { + id: 'entropy:wallet0/1', + accounts: [`account-${mockAddress2}`], + }, + 'entropy:wallet0/btc-only': { + id: 'entropy:wallet0/btc-only', + accounts: [`account-btc`], }, }, - } - : { - selectedAccount: undefined, - accounts: {}, }, + }, + }, + }, + AccountsController: { + internalAccounts: { + accounts: { + [`account-${mockAddress1}`]: { + id: `account-${mockAddress1}`, + address: mockAddress1, + scopes: ['eip155:0'], + }, + [`account-${mockAddress2}`]: { + id: `account-${mockAddress2}`, + address: mockAddress2, + scopes: ['eip155:0'], + }, + [`account-btc`]: { + id: `account-btc`, + address: 'btc', + scopes: ['bip122:0'], + }, + }, + }, }, NetworkEnablementController: { enabledNetworkMap: enabledNetworks, @@ -83,303 +114,128 @@ describe('defiPositionsController selectors', () => { }) as unknown as RootState; describe('selectDeFiPositionsByAddress', () => { - it('should return defi positions for the selected address', () => { + it('returns defi positions for the selected address', () => { const state = createMockState({ - [mockAddress]: mockDefiPositions, + selectedAccountGroup: 'entropy:wallet0/0', + defiPositions: { + [mockAddress1]: mockDefiPositions, + }, }); const result = selectDeFiPositionsByAddress(state); - expect(result).toEqual(mockDefiPositions); + expect(result).toStrictEqual(mockDefiPositions); }); - it('should return undefined when no positions exist for the selected address', () => { + it('returns undefined when no positions exist for the selected address', () => { const state = createMockState({ - [mockAddress2]: mockDefiPositions, + selectedAccountGroup: 'entropy:wallet0/0', + defiPositions: { + [mockAddress2]: mockDefiPositions, + }, }); const result = selectDeFiPositionsByAddress(state); expect(result).toBeUndefined(); }); - it('should return undefined when selected address is undefined', () => { - const state = createMockState({}, undefined); - const result = selectDeFiPositionsByAddress(state); - expect(result).toBeUndefined(); - }); - - it('should handle empty allDeFiPositions object', () => { - const state = createMockState({}); - const result = selectDeFiPositionsByAddress(state); - expect(result).toBeUndefined(); - }); - - it('should return correct positions when switching between addresses', () => { - const state1 = createMockState({ - [mockAddress]: mockDefiPositions, - [mockAddress2]: { - '0x1': { - protocolId: 'protocol4', - positions: [{ id: 'pos4', balance: '4000', token: 'ETH' }], - }, - }, - }); - - const result1 = selectDeFiPositionsByAddress(state1); - expect(result1).toEqual(mockDefiPositions); - - const state2 = createMockState( - { - [mockAddress]: mockDefiPositions, - [mockAddress2]: { - '0x1': { - protocolId: 'protocol4', - positions: [{ id: 'pos4', balance: '4000', token: 'ETH' }], - }, - }, - }, - mockAddress2, - ); - - const result2 = selectDeFiPositionsByAddress(state2); - expect(result2).toEqual({ - '0x1': { - protocolId: 'protocol4', - positions: [{ id: 'pos4', balance: '4000', token: 'ETH' }], - }, + it('returns empty object when there is no evm account in the selected account group', () => { + const state = createMockState({ + selectedAccountGroup: 'entropy:wallet0/btc-only', }); + const result = selectDeFiPositionsByAddress(state); + expect(result).toStrictEqual({}); }); }); describe('selectDefiPositionsByEnabledNetworks', () => { - it('should return positions only for enabled networks', () => { - const state = createMockState( - { - [mockAddress]: mockDefiPositions, + it('returns positions only for enabled networks', () => { + const state = createMockState({ + selectedAccountGroup: 'entropy:wallet0/0', + defiPositions: { + [mockAddress1]: mockDefiPositions, }, - mockAddress, - { + enabledNetworks: { [KnownCaipNamespace.Eip155]: { '0x1': true, '0x89': false, '0xa86a': true, }, }, - ); + }); const result = selectDefiPositionsByEnabledNetworks(state); - expect(result).toEqual({ + expect(result).toStrictEqual({ '0x1': mockDefiPositions['0x1'], '0xa86a': mockDefiPositions['0xa86a'], }); expect(result?.['0x89']).toBeUndefined(); }); - it('should return empty object when no networks are enabled', () => { - const state = createMockState( - { - [mockAddress]: mockDefiPositions, - }, - mockAddress, - { - [KnownCaipNamespace.Eip155]: { - '0x1': false, - '0x89': false, - '0xa86a': false, - }, - }, - ); + it('returns empty object when there is no evm account in the selected account group', () => { + const state = createMockState({ + selectedAccountGroup: 'entropy:wallet0/btc-only', + }); const result = selectDefiPositionsByEnabledNetworks(state); - expect(result).toEqual({}); - }); - - it('should return empty object when selected address is undefined', () => { - // Create state with defi positions but no selected address - const stateWithNoSelectedAccount = { - engine: { - backgroundState: { - DeFiPositionsController: { - allDeFiPositions: { - [mockAddress]: mockDefiPositions, - }, - }, - AccountsController: { - internalAccounts: { - selectedAccount: '', // Empty string to ensure no account is selected - accounts: {}, - }, - }, - NetworkEnablementController: { - enabledNetworkMap: { - [KnownCaipNamespace.Eip155]: { - '0x1': true, - '0x89': true, - }, - }, - }, - }, - }, - } as unknown as RootState; - const result = selectDefiPositionsByEnabledNetworks( - stateWithNoSelectedAccount, - ); - // When selectedAddress is undefined, selector should return empty object - expect(result).toEqual({}); + expect(result).toStrictEqual({}); }); - it('should return empty object when no positions exist for address', () => { - const state = createMockState({}, mockAddress, { - [KnownCaipNamespace.Eip155]: { - '0x1': true, - '0x89': true, - }, + it('returns undefined when no positions exist for the selected address', () => { + const state = createMockState({ + selectedAccountGroup: 'entropy:wallet0/0', }); const result = selectDefiPositionsByEnabledNetworks(state); - expect(result).toEqual({}); - }); - - it('should handle all networks enabled', () => { - const state = createMockState( - { - [mockAddress]: mockDefiPositions, - }, - mockAddress, - { - [KnownCaipNamespace.Eip155]: { - '0x1': true, - '0x89': true, - '0xa86a': true, - }, - }, - ); - - const result = selectDefiPositionsByEnabledNetworks(state); - expect(result).toEqual(mockDefiPositions); + expect(result).toBeUndefined(); }); - it('should filter out positions for chains not in enabled networks', () => { - const state = createMockState( - { - [mockAddress]: mockDefiPositions, - }, - mockAddress, - { - [KnownCaipNamespace.Eip155]: { - '0x1': true, - // '0x89' not in enabled networks - // '0xa86a' not in enabled networks - }, + it('returns null when that is the value stored for that address', () => { + const state = createMockState({ + selectedAccountGroup: 'entropy:wallet0/0', + defiPositions: { + [mockAddress1]: null, }, - ); - - const result = selectDefiPositionsByEnabledNetworks(state); - expect(result).toEqual({ - '0x1': mockDefiPositions['0x1'], }); - }); - - it('should handle empty enabled networks map', () => { - const state = createMockState( - { - [mockAddress]: mockDefiPositions, - }, - mockAddress, - { - // Empty enabled networks - need at least empty EIP155 namespace - [KnownCaipNamespace.Eip155]: {}, - }, - ); const result = selectDefiPositionsByEnabledNetworks(state); - expect(result).toEqual({}); + expect(result).toBeNull(); }); - it('should handle missing EIP155 namespace in enabled networks', () => { - const state = createMockState( - { - [mockAddress]: mockDefiPositions, + it('returns empty object when there are no evm networks', () => { + const state = createMockState({ + selectedAccountGroup: 'entropy:wallet0/0', + defiPositions: { + [mockAddress1]: mockDefiPositions, }, - mockAddress, - { - // No EIP155 namespace - should return empty object instead of throwing - [KnownCaipNamespace.Solana]: { - 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': true, + enabledNetworks: { + [KnownCaipNamespace.Bip122]: { + 'bip122:0': true, }, }, - ); + }); - // The selector should return an empty object when EIP155 namespace is missing const result = selectDefiPositionsByEnabledNetworks(state); - expect(result).toEqual({}); + expect(result).toStrictEqual({}); }); - it('should correctly filter when some networks are enabled and some are not', () => { - const extendedPositions = { - ...mockDefiPositions, - '0x38': { - protocolId: 'protocol4', - positions: [{ id: 'pos4', balance: '4000', token: 'BNB' }], - }, - '0xfa': { - protocolId: 'protocol5', - positions: [{ id: 'pos5', balance: '5000', token: 'FTM' }], - }, - }; - - const state = createMockState( - { - [mockAddress]: extendedPositions, + it('returns empty object when no evm networks are enabled', () => { + const state = createMockState({ + selectedAccountGroup: 'entropy:wallet0/0', + defiPositions: { + [mockAddress1]: mockDefiPositions, }, - mockAddress, - { + enabledNetworks: { [KnownCaipNamespace.Eip155]: { - '0x1': true, + '0x1': false, '0x89': false, - '0xa86a': true, - '0x38': false, - '0xfa': true, + '0xa86a': false, }, }, - ); - - const result = selectDefiPositionsByEnabledNetworks(state); - expect(result).toEqual({ - '0x1': extendedPositions['0x1'], - '0xa86a': extendedPositions['0xa86a'], - '0xfa': extendedPositions['0xfa'], }); - expect(result?.['0x89']).toBeUndefined(); - expect(result?.['0x38']).toBeUndefined(); - }); - - it('should return positions for chains that exist in both defi positions and enabled networks', () => { - const state = createMockState( - { - [mockAddress]: { - '0x1': mockDefiPositions['0x1'], - '0x89': mockDefiPositions['0x89'], - }, - }, - mockAddress, - { - [KnownCaipNamespace.Eip155]: { - '0x1': true, - '0x89': true, - '0xa86a': true, // enabled but no positions - '0x38': true, // enabled but no positions - }, - }, - ); const result = selectDefiPositionsByEnabledNetworks(state); - expect(result).toEqual({ - '0x1': mockDefiPositions['0x1'], - '0x89': mockDefiPositions['0x89'], - }); - expect(result?.['0xa86a']).toBeUndefined(); - expect(result?.['0x38']).toBeUndefined(); + expect(result).toStrictEqual({}); }); }); }); diff --git a/app/selectors/defiPositionsController.ts b/app/selectors/defiPositionsController.ts index 2f9dbc73f884..4ec550febb3c 100644 --- a/app/selectors/defiPositionsController.ts +++ b/app/selectors/defiPositionsController.ts @@ -3,51 +3,72 @@ import { DeFiPositionsControllerState } from '@metamask/assets-controllers'; import { NetworkEnablementControllerState } from '@metamask/network-enablement-controller'; import { RootState } from '../reducers'; import { createDeepEqualSelector } from './util'; -import { selectLastSelectedEvmAccount } from './accountsController'; import { selectEnabledNetworksByNamespace } from './networkEnablementController'; +import { selectSelectedInternalAccountByScope } from './multichainAccounts/accounts'; +import { EVM_SCOPE } from '../components/UI/Earn/constants/networks'; + +const NO_DATA: NonNullable< + DeFiPositionsControllerState['allDeFiPositions'][string] +> = {}; const selectDeFiPositionsControllerState = (state: RootState) => state?.engine?.backgroundState?.DeFiPositionsController; +/** + * @deprecated This selector is deprecated and will be removed in a future release. + * Use selectDefiPositionsByEnabledNetworks instead. + */ export const selectDeFiPositionsByAddress = createDeepEqualSelector( selectDeFiPositionsControllerState, - selectLastSelectedEvmAccount, + selectSelectedInternalAccountByScope, ( defiPositionsControllerState: DeFiPositionsControllerState, - _eoaAccounts: ReturnType, - ): DeFiPositionsControllerState['allDeFiPositions'][string] | undefined => - defiPositionsControllerState?.allDeFiPositions[ - _eoaAccounts?.address as Hex - ], + selectedInternalAccountByScope: ReturnType< + typeof selectSelectedInternalAccountByScope + >, + ): DeFiPositionsControllerState['allDeFiPositions'][string] | undefined => { + const selectedEvmAccount = selectedInternalAccountByScope(EVM_SCOPE); + + if (!selectedEvmAccount) { + return NO_DATA; + } + + return defiPositionsControllerState?.allDeFiPositions[ + selectedEvmAccount.address + ]; + }, ); export const selectDefiPositionsByEnabledNetworks = createDeepEqualSelector( selectDeFiPositionsControllerState, - selectLastSelectedEvmAccount, + selectSelectedInternalAccountByScope, selectEnabledNetworksByNamespace, ( defiPositionsControllerState: DeFiPositionsControllerState, - _eoaAccounts: ReturnType, + selectedInternalAccountByScope: ReturnType< + typeof selectSelectedInternalAccountByScope + >, enabledNetworks: NetworkEnablementControllerState['enabledNetworkMap'], ): DeFiPositionsControllerState['allDeFiPositions'][string] | undefined => { - if (!_eoaAccounts) { - return {}; + const selectedEvmAccount = selectedInternalAccountByScope(EVM_SCOPE); + if (!selectedEvmAccount) { + return NO_DATA; } const defiPositionByAddress = - defiPositionsControllerState.allDeFiPositions[ - _eoaAccounts?.address as Hex - ] ?? {}; + defiPositionsControllerState?.allDeFiPositions[ + selectedEvmAccount.address + ]; - if (Object.keys(defiPositionByAddress).length === 0) { - return {}; + if (defiPositionByAddress == null) { + return defiPositionByAddress; } const defiPositionByEnabledNetworks = enabledNetworks[KnownCaipNamespace.Eip155]; if (!defiPositionByEnabledNetworks) { - return {}; + return NO_DATA; } const enabledChainIdsSet = new Set( @@ -57,7 +78,7 @@ export const selectDefiPositionsByEnabledNetworks = createDeepEqualSelector( ); if (enabledChainIdsSet.size === 0) { - return {}; + return NO_DATA; } const filteredDefiPositionByAddress = Object.keys(defiPositionByAddress) diff --git a/app/selectors/multichain/multichain.ts b/app/selectors/multichain/multichain.ts index 36c42438bc00..a1a1279d239f 100644 --- a/app/selectors/multichain/multichain.ts +++ b/app/selectors/multichain/multichain.ts @@ -159,14 +159,26 @@ export const selectMultichainTransactions = createDeepEqualSelector( multichainTransactionsControllerState.nonEvmTransactions, ); -// TODO: refactor this file to use createDeepEqualSelector -export function selectMultichainAssets(state: RootState) { - return state.engine.backgroundState.MultichainAssetsController.accountsAssets; -} +const selectMultichainAssetsControllerState = (state: RootState) => + state.engine.backgroundState.MultichainAssetsController; -export function selectMultichainAssetsMetadata(state: RootState) { - return state.engine.backgroundState.MultichainAssetsController.assetsMetadata; -} +export const selectMultichainAssets = createDeepEqualSelector( + selectMultichainAssetsControllerState, + (multichainAssetsControllerState) => + multichainAssetsControllerState.accountsAssets, +); + +export const selectMultichainAssetsMetadata = createDeepEqualSelector( + selectMultichainAssetsControllerState, + (multichainAssetsControllerState) => + multichainAssetsControllerState.assetsMetadata, +); + +export const selectMultichainAssetsAllIgnoredAssets = createDeepEqualSelector( + selectMultichainAssetsControllerState, + (multichainAssetsControllerState) => + multichainAssetsControllerState.allIgnoredAssets, +); function selectMultichainAssetsRatesState(state: RootState) { return state.engine.backgroundState.MultichainAssetsRatesController diff --git a/app/util/logs/__snapshots__/index.test.ts.snap b/app/util/logs/__snapshots__/index.test.ts.snap index f7a5c0c51ef6..e89d9959812f 100644 --- a/app/util/logs/__snapshots__/index.test.ts.snap +++ b/app/util/logs/__snapshots__/index.test.ts.snap @@ -137,6 +137,7 @@ exports[`logs :: generateStateLogs Sanitized SeedlessOnboardingController State }, "MultichainAssetsController": { "accountsAssets": {}, + "allIgnoredAssets": {}, "assetsMetadata": {}, }, "MultichainAssetsRatesController": { @@ -290,6 +291,81 @@ exports[`logs :: generateStateLogs Sanitized SeedlessOnboardingController State }, ], }, + "0x38": { + "blockExplorerUrls": [], + "chainId": "0x38", + "defaultRpcEndpointIndex": 0, + "name": "BNB Chain", + "nativeCurrency": "BNB", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "bsc-mainnet", + "type": "infura", + "url": "https://bsc-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0x531": { + "blockExplorerUrls": [], + "chainId": "0x531", + "defaultRpcEndpointIndex": 0, + "name": "Sei", + "nativeCurrency": "SEI", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "sei-mainnet", + "type": "infura", + "url": "https://sei-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0x89": { + "blockExplorerUrls": [], + "chainId": "0x89", + "defaultRpcEndpointIndex": 0, + "name": "Polygon", + "nativeCurrency": "POL", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "polygon-mainnet", + "type": "infura", + "url": "https://polygon-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0xa": { + "blockExplorerUrls": [], + "chainId": "0xa", + "defaultRpcEndpointIndex": 0, + "name": "OP", + "nativeCurrency": "ETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "optimism-mainnet", + "type": "infura", + "url": "https://optimism-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0xa4b1": { + "blockExplorerUrls": [], + "chainId": "0xa4b1", + "defaultRpcEndpointIndex": 0, + "name": "Arbitrum", + "nativeCurrency": "ETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "arbitrum-mainnet", + "type": "infura", + "url": "https://arbitrum-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, "0xaa36a7": { "blockExplorerUrls": [], "chainId": "0xaa36a7", @@ -358,6 +434,11 @@ exports[`logs :: generateStateLogs Sanitized SeedlessOnboardingController State "0x18c6": false, "0x2105": true, "0x279f": false, + "0x38": true, + "0x531": true, + "0x89": true, + "0xa": true, + "0xa4b1": true, "0xaa36a7": false, "0xe705": false, "0xe708": true, @@ -806,6 +887,7 @@ exports[`logs :: generateStateLogs generates a valid json export 1`] = ` }, "MultichainAssetsController": { "accountsAssets": {}, + "allIgnoredAssets": {}, "assetsMetadata": {}, }, "MultichainAssetsRatesController": { @@ -959,6 +1041,81 @@ exports[`logs :: generateStateLogs generates a valid json export 1`] = ` }, ], }, + "0x38": { + "blockExplorerUrls": [], + "chainId": "0x38", + "defaultRpcEndpointIndex": 0, + "name": "BNB Chain", + "nativeCurrency": "BNB", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "bsc-mainnet", + "type": "infura", + "url": "https://bsc-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0x531": { + "blockExplorerUrls": [], + "chainId": "0x531", + "defaultRpcEndpointIndex": 0, + "name": "Sei", + "nativeCurrency": "SEI", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "sei-mainnet", + "type": "infura", + "url": "https://sei-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0x89": { + "blockExplorerUrls": [], + "chainId": "0x89", + "defaultRpcEndpointIndex": 0, + "name": "Polygon", + "nativeCurrency": "POL", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "polygon-mainnet", + "type": "infura", + "url": "https://polygon-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0xa": { + "blockExplorerUrls": [], + "chainId": "0xa", + "defaultRpcEndpointIndex": 0, + "name": "OP", + "nativeCurrency": "ETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "optimism-mainnet", + "type": "infura", + "url": "https://optimism-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, + "0xa4b1": { + "blockExplorerUrls": [], + "chainId": "0xa4b1", + "defaultRpcEndpointIndex": 0, + "name": "Arbitrum", + "nativeCurrency": "ETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "arbitrum-mainnet", + "type": "infura", + "url": "https://arbitrum-mainnet.infura.io/v3/{infuraProjectId}", + }, + ], + }, "0xaa36a7": { "blockExplorerUrls": [], "chainId": "0xaa36a7", @@ -1027,6 +1184,11 @@ exports[`logs :: generateStateLogs generates a valid json export 1`] = ` "0x18c6": false, "0x2105": true, "0x279f": false, + "0x38": true, + "0x531": true, + "0x89": true, + "0xa": true, + "0xa4b1": true, "0xaa36a7": false, "0xe705": false, "0xe708": true, diff --git a/app/util/test/ganache.js b/app/util/test/ganache.js index 55165b22bf38..26cdcbbab1cc 100644 --- a/app/util/test/ganache.js +++ b/app/util/test/ganache.js @@ -53,7 +53,7 @@ export default class Ganache { return balanceFormatted; } - async quit() { + async stop() { if (!this._server) { throw new Error('Server not running yet'); } diff --git a/app/util/test/initial-background-state.json b/app/util/test/initial-background-state.json index 640e661ac8c3..2d79a288820f 100644 --- a/app/util/test/initial-background-state.json +++ b/app/util/test/initial-background-state.json @@ -63,7 +63,12 @@ }, "NetworkController": { "selectedNetworkClientId": "mainnet", - "networksMetadata": { "mainnet": { "status": "unknown", "EIPS": {} } }, + "networksMetadata": { + "mainnet": { + "status": "unknown", + "EIPS": {} + } + }, "networkConfigurationsByChainId": { "0x1": { "blockExplorerUrls": [], @@ -112,6 +117,81 @@ } ] }, + "0x38": { + "blockExplorerUrls": [], + "chainId": "0x38", + "defaultRpcEndpointIndex": 0, + "name": "BNB Chain", + "nativeCurrency": "BNB", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "bsc-mainnet", + "type": "infura", + "url": "https://bsc-mainnet.infura.io/v3/{infuraProjectId}" + } + ] + }, + "0x531": { + "blockExplorerUrls": [], + "chainId": "0x531", + "defaultRpcEndpointIndex": 0, + "name": "Sei", + "nativeCurrency": "SEI", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "sei-mainnet", + "type": "infura", + "url": "https://sei-mainnet.infura.io/v3/{infuraProjectId}" + } + ] + }, + "0x89": { + "blockExplorerUrls": [], + "chainId": "0x89", + "defaultRpcEndpointIndex": 0, + "name": "Polygon", + "nativeCurrency": "POL", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "polygon-mainnet", + "type": "infura", + "url": "https://polygon-mainnet.infura.io/v3/{infuraProjectId}" + } + ] + }, + "0xa": { + "blockExplorerUrls": [], + "chainId": "0xa", + "defaultRpcEndpointIndex": 0, + "name": "OP", + "nativeCurrency": "ETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "optimism-mainnet", + "type": "infura", + "url": "https://optimism-mainnet.infura.io/v3/{infuraProjectId}" + } + ] + }, + "0xa4b1": { + "blockExplorerUrls": [], + "chainId": "0xa4b1", + "defaultRpcEndpointIndex": 0, + "name": "Arbitrum", + "nativeCurrency": "ETH", + "rpcEndpoints": [ + { + "failoverUrls": [], + "networkClientId": "arbitrum-mainnet", + "type": "infura", + "url": "https://arbitrum-mainnet.infura.io/v3/{infuraProjectId}" + } + ] + }, "0xaa36a7": { "blockExplorerUrls": [], "chainId": "0xaa36a7", @@ -188,6 +268,11 @@ "0x18c6": false, "0x2105": true, "0x279f": false, + "0x38": true, + "0x531": true, + "0x89": true, + "0xa": true, + "0xa4b1": true, "0xaa36a7": false, "0xe705": false, "0xe708": true @@ -551,7 +636,8 @@ }, "MultichainAssetsController": { "accountsAssets": {}, - "assetsMetadata": {} + "assetsMetadata": {}, + "allIgnoredAssets": {} }, "BridgeController": { "assetExchangeRates": {}, @@ -646,7 +732,10 @@ "eligibility": {}, "lastError": null, "lastUpdateTimestamp": 0, + "balances": {}, + "claimablePositions": {}, "pendingDeposits": {}, + "withdrawTransaction": null, "isOnboarded": {} } } diff --git a/appwright/tests/performance/onboarding/new-wallet-account-creation.spec.js b/appwright/tests/performance/onboarding/new-wallet-account-creation.spec.js index ba5f9072135c..1a316d4f05d3 100644 --- a/appwright/tests/performance/onboarding/new-wallet-account-creation.spec.js +++ b/appwright/tests/performance/onboarding/new-wallet-account-creation.spec.js @@ -51,12 +51,10 @@ test('Account creation after fresh install', async ({ await CreateNewWalletScreen.tapSubmitButton(); await CreateNewWalletScreen.tapRemindMeLater(); - await SkipAccountSecurityModal.isVisible(); - await SkipAccountSecurityModal.proceedWithoutWalletSecure(); await MetaMetricsScreen.isScreenTitleVisible(); - await MetaMetricsScreen.tapIAgreeButton(); + await MetaMetricsScreen.tapContinueButton(); await OnboardingSucessScreen.isVisible(); await OnboardingSucessScreen.tapDone(); diff --git a/docs/perps/perps-architecture.md b/docs/perps/perps-architecture.md new file mode 100644 index 000000000000..9b5674bbf880 --- /dev/null +++ b/docs/perps/perps-architecture.md @@ -0,0 +1,471 @@ +# Perps Architecture + +## Overview + +The Perps feature enables perpetual futures trading in MetaMask Mobile. This document provides a high-level architectural overview of the codebase structure, key patterns, and references to detailed documentation. + +**Location**: `app/components/UI/Perps/` + +## Quick Navigation + +- **[Connection Architecture](./perps-connection-architecture.md)** - Connection lifecycle, reconnection logic, WebSocket management +- **[Screen Documentation](./perps-screens.md)** - Detailed view documentation +- **[Sentry Integration](./perps-sentry-reference.md)** - Error tracking and monitoring +- **[MetaMetrics Events](./perps-metametrics-reference.md)** - Analytics events +- **[Protocol Documentation](./hyperliquid/)** - HyperLiquid protocol specifics + +## Layer Architecture + +The Perps system uses a layered architecture where each layer has clear responsibilities: + +```mermaid +graph TD + UI[UI Components] -->|consume| Hooks[React Hooks] + Hooks -->|subscribe to| Streams[Stream Manager] + Streams -->|coordinate with| Connection[Connection Manager] + Connection -->|orchestrates| Controller[Perps Controller] + Controller -->|manages| Provider[Protocol Provider] + Provider -->|communicates with| Protocol[HyperLiquid API] + + Controller -->|stores data in| Redux[Redux State] + Hooks -->|read from| Redux + + style Streams fill:#e1f5ff + style Connection fill:#e1f5ff +``` + +### Layer Responsibilities + +| Layer | Purpose | Examples | +| ---------------------- | ------------------------------------------------- | -------------------------------------------------- | +| **UI Components** | Presentational components, user interactions | PerpsOrderView, PerpsMarketList, PerpsPositionCard | +| **React Hooks** | Data access, business logic, state management | usePerpsTrading, usePerpsMarkets, useLivePrices | +| **Stream Manager** | WebSocket subscription management, real-time data | PerpsStreamManager, component-level throttling | +| **Connection Manager** | Connection lifecycle, reconnection orchestration | PerpsConnectionManager (singleton) | +| **Perps Controller** | Business logic, provider management, Redux state | PerpsController (Redux controller) | +| **Protocol Provider** | Exchange-specific API implementation | HyperLiquidProvider (REST + WebSocket) | + +**See [perps-connection-architecture.md](./perps-connection-architecture.md) for detailed connection flow.** + +## Directory Structure + +``` +/Perps +├── components/ - Reusable UI components +├── Views/ - Main screen-level components +├── hooks/ - React hooks for data access and logic +│ └── stream/ - WebSocket subscription hooks (real-time data) +├── controllers/ - Business logic and Redux state +│ └── providers/ - Protocol-specific implementations +├── providers/ - React context providers +├── services/ - External integrations (WebSocket, HTTP, wallet) +├── utils/ - Pure utility functions +├── types/ - TypeScript type definitions +├── constants/ - Configuration values +├── contexts/ - React contexts +├── selectors/ - Redux selectors by domain +├── styles/ - Shared style utilities +├── Debug/ - Developer tools +├── animations/ - Rive animation files +└── __mocks__/ - Test mocks and fixtures +``` + +### Components + +Reusable UI components organized by feature: + +- **Display Components**: LivePriceDisplay, PerpsAmountDisplay, PerpsBadge, PerpsProgressBar, PerpsLoader +- **Form Components**: PerpsSlider, PerpsOrderTypeBottomSheet, PerpsLeverageBottomSheet, PerpsLimitPriceBottomSheet +- **Card Components**: PerpsCard, PerpsPositionCard, PerpsOpenOrderCard, PerpsMarketStatisticsCard +- **List Components**: PerpsMarketList, PerpsRecentActivityList, PerpsWatchlistMarkets +- **Modal Components**: PerpsCancelAllOrdersModal, PerpsCloseAllPositionsModal, PerpsGTMModal +- **Header Components**: PerpsHomeHeader, PerpsMarketHeader, PerpsOrderHeader, PerpsTabControlBar +- **Navigation**: PerpsNavigationCard, PerpsMarketTabs +- **Tooltips**: PerpsBottomSheetTooltip (with content registry), PerpsNotificationTooltip +- **Charts**: TradingViewChart, PerpsCandlestickChartIntervalSelector, FundingCountdown +- **Developer Tools**: PerpsDeveloperOptionsSection + +### Views + +Main screen-level components representing full pages: + +- **PerpsTabView** - Main tab container with navigation +- **PerpsHomeView** - Landing/dashboard screen +- **PerpsMarketListView** - Market browser with search/filters +- **PerpsMarketDetailsView** - Individual market with chart +- **PerpsOrderView** - Order entry form +- **PerpsPositionsView** - Active positions list +- **PerpsClosePositionView** - Single position close flow +- **PerpsCloseAllPositionsView** - Close all positions flow +- **PerpsCancelAllOrdersView** - Cancel all orders flow +- **PerpsTPSLView** - Take profit/stop loss management +- **PerpsTransactionsView** - Transaction history +- **PerpsWithdrawView** - Withdrawal flow +- **PerpsHeroCardView** - Hero/banner cards +- **PerpsEmptyState** - Empty state screens +- **PerpsRedirect** - Routing/redirect logic +- **HIP3DebugView** - Developer debug interface + +**See [perps-screens.md](./perps-screens.md) for detailed view documentation.** + +### Hooks + +React hooks organized by category: + +#### Controller Access + +- `usePerpsTrading` - Trading operations (place/cancel/close) +- `usePerpsDeposit` - Deposit flow +- `usePerpsDepositQuote` - Deposit quotes +- `usePerpsMarkets` - Market data +- `usePerpsNetwork` - Network configuration +- `usePerpsWithdrawQuote` - Withdrawal quotes + +#### State Management + +- `usePerpsAccount` - Redux account state +- `usePerpsConnection` - Connection provider context +- `usePerpsPositions` - Position list +- `usePerpsNetworkConfig` - Network state +- `usePerpsOpenOrders` - Open orders list + +#### Live Data (Stream Architecture) + +- `useLivePrices` - Real-time prices with component-level throttling +- `usePerpsLiveAccount` - Account state updates +- `usePerpsLiveFills` - Order fill notifications +- `usePerpsLiveOrders` - Order updates +- `usePerpsLivePositions` - Position updates +- `usePerpsTopOfBook` - Top-of-book data +- `usePerpsPositionData` - Position data aggregation + +#### Calculations + +- `usePerpsLiquidationPrice` - Liquidation price calculation +- `usePerpsOrderFees` - Fee calculation +- `useMinimumOrderAmount` - Minimum order calculation +- `usePerpsMarketData` - Market-specific data +- `usePerpsMarketStats` - Market statistics +- `usePerpsFunding` - Funding rate data + +#### Validation + +- `usePerpsOrderValidation` - Order validation (protocol + UI rules) +- `usePerpsClosePositionValidation` - Close validation +- `useWithdrawValidation` - Withdrawal validation + +#### Form Management + +- `usePerpsOrderForm` - Order form state +- `usePerpsOrderExecution` - Order execution flow +- `usePerpsClosePosition` - Close position flow +- `usePerpsTPSLForm` - TP/SL form management +- `usePerpsTPSLUpdate` - TP/SL updates + +#### UI Utilities + +- `useColorPulseAnimation` - Price change animations +- `useBalanceComparison` - Balance comparison +- `useHasExistingPosition` - Position existence check +- `useStableArray` - Array reference stability +- `usePerpsNavigation` - Navigation utilities +- `usePerpsToasts` - Toast notifications + +#### Assets/Tokens + +- `usePerpsAssetsMetadata` - Asset metadata +- `usePerpsPaymentTokens` - Payment tokens +- `useWithdrawTokens` - Withdrawal tokens + +#### Monitoring & Tracking + +- `usePerpsEventTracking` - Analytics events +- `usePerpsDataMonitor` - Data monitoring +- `usePerpsMeasurement` - Performance measurement +- `usePerpsDepositStatus` - Deposit status tracking +- `usePerpsWithdrawStatus` - Withdrawal status tracking + +### Controllers + +Business logic and Redux state management: + +- **PerpsController** (`controllers/PerpsController.ts`) - Main controller managing providers, orders, positions, market data +- **PerpsProvider** (`controllers/providers/HyperLiquidProvider.ts`) - HyperLiquid protocol implementation +- **Selectors** (`controllers/selectors.ts`) - Redux state selectors +- **Error Codes** (`controllers/perpsErrorCodes.ts`) - Error code definitions + +### Services + +External integrations and infrastructure: + +- **HyperLiquidClientService** - HTTP client for REST API +- **HyperLiquidSubscriptionService** - WebSocket subscription management +- **HyperLiquidWalletService** - Wallet operations +- **PerpsConnectionManager** - Connection lifecycle orchestration (singleton) + +### Providers + +React context providers: + +- **PerpsConnectionProvider** - Connection state and methods for UI +- **PerpsStreamManager** - WebSocket stream management with caching +- **PerpsOrderContext** - Order form context + +### Utils + +Pure utility functions organized by domain: + +- **Calculations**: orderCalculations, positionCalculations, pnlCalculations +- **Formatting**: formatUtils, amountConversion, textUtils +- **Validation**: hyperLiquidValidation, tpslValidation +- **Transforms**: marketDataTransform, transactionTransforms, arbitrumWithdrawalTransforms +- **Market Utils**: marketUtils, marketHours, sortMarkets +- **Error Handling**: perpsErrorHandler, translatePerpsError +- **Protocol**: hyperLiquidAdapter, hyperLiquidOrderBookProcessor +- **Blockchain**: idUtils, tokenIconUtils + +## Key Patterns + +### Validation Flow + +Protocol validation (provider) → UI validation (hook) → Display errors (component) + +```typescript +// Provider validates protocol rules +provider.validateOrder(order) // throws if invalid + +// Hook adds UI-specific rules +usePerpsOrderValidation(orderParams) // returns { isValid, errors } + +// Component displays errors +{errors.amount && {errors.amount}} +``` + +### Data Flow + +Controller → Redux Store → Hooks → Components + +```typescript +// Controller fetches and stores +await controller.getAccountState() // updates Redux + +// Hook reads from Redux +const account = usePerpsAccount() // subscribes to Redux + +// Component renders +{account.balance} +``` + +### Real-time Updates + +WebSocket → Stream Manager → Hooks → Components + +```typescript +// Stream Manager maintains single WebSocket connection +streamManager.subscribeToPrices(['BTC', 'ETH']) + +// Hook throttles updates at component level +const prices = useLivePrices({ + symbols: ['BTC', 'ETH'], + throttleMs: 2000, // 2s updates +}) + +// Component renders with throttled data +{prices.BTC?.price} +``` + +**See [perps-connection-architecture.md](./perps-connection-architecture.md) for WebSocket architecture details.** + +### Form Management + +Component input → Hook state → Validation → Controller action + +```typescript +// Component captures input + + +// Hook manages form state +const { amount, setAmount, errors } = usePerpsOrderForm() + +// Hook validates +const validation = usePerpsOrderValidation({ amount, ... }) + +// Hook executes when valid +if (validation.isValid) { + await controller.placeOrder(params) +} +``` + +## Stream Architecture + +**Single WebSocket connections shared across all components with component-level debouncing.** + +### Benefits + +- **90% fewer WebSocket connections** - One subscription per data type (not per component) +- **No subscription interference** - Each component controls its own update rate +- **Component-level control** - Different throttle rates for different views +- **Instant first render** - Pre-warmed connections provide cached data immediately +- **Zero parent re-renders** - Updates go directly to subscribers + +### How It Works + +1. **PerpsConnectionManager** pre-warms critical subscriptions on connection +2. **PerpsStreamManager** maintains single WebSocket subscriptions with reference counting +3. **Stream Hooks** provide component-level throttling: + +```typescript +// Order view: stable prices (10s throttle) +const prices = useLivePrices({ symbols: ['BTC'], throttleMs: 10000 }); + +// Market list: responsive updates (2s throttle) +const prices = useLivePrices({ symbols: allSymbols, throttleMs: 2000 }); + +// Charts: near real-time (100ms throttle) +const prices = useLivePrices({ symbols: ['BTC'], throttleMs: 100 }); +``` + +4. **Shared cache** ensures instant data availability for all subscribers + +**See [perps-connection-architecture.md](./perps-connection-architecture.md) for detailed stream architecture.** + +## Quick Reference + +| Need | Use Hook | Use Component | +| -------------- | -------------------------------------------- | ------------------------- | +| Place order | `usePerpsTrading` + `usePerpsOrderExecution` | PerpsOrderView | +| Validate order | `usePerpsOrderValidation` | - | +| Get prices | `useLivePrices` | LivePriceDisplay | +| Manage form | `usePerpsOrderForm` | - | +| Calculate fees | `usePerpsOrderFees` | PerpsFeesDisplay | +| Check position | `useHasExistingPosition` | - | +| Close position | `usePerpsClosePosition` + validation | PerpsClosePositionView | +| Get account | `usePerpsAccount` | - | +| Deposit funds | `usePerpsDeposit` | PerpsMarketBalanceActions | +| Withdraw funds | `usePerpsWithdrawQuote` + validation | PerpsWithdrawView | +| Show market | - | PerpsMarketDetailsView | +| List markets | `usePerpsMarkets` | PerpsMarketListView | + +## Error Handling + +Perps uses a multi-layered error handling approach: + +1. **Provider Layer** - Protocol-specific errors, logs to Sentry +2. **Controller Layer** - Business logic errors, updates Redux, logs to Sentry +3. **Manager Layer** - Connection errors, sets local state, logs to DevLogger +4. **Hook Layer** - Exposes errors to UI +5. **Component Layer** - Displays errors to user + +**See [perps-sentry-reference.md](./perps-sentry-reference.md) for error tracking details.** + +## Analytics + +All user interactions are tracked via MetaMetrics events: + +- Trading actions (orders, closes, cancels) +- Market interactions (views, searches, filters) +- Connection events (connect, disconnect, errors) +- Deposit/withdrawal flows + +**See [perps-metametrics-reference.md](./perps-metametrics-reference.md) for complete event catalog.** + +## Development Guidelines + +### Adding a New Hook + +1. Determine category (Controller Access, State Management, Live Data, etc.) +2. Follow naming convention: `usePerps[Feature][Action]` +3. Keep single responsibility +4. Add comprehensive tests +5. Document in this file + +### Adding a New Component + +1. Create in appropriate subdirectory under `components/` +2. Include `.styles.ts` file for styles +3. Add tests in `__tests__/` subdirectory +4. Export from component directory's `index.ts` +5. Use existing shared components where possible + +### Adding a New View + +1. Create in `Views/` directory +2. Follow naming: `Perps[Feature]View` +3. Use hooks for data access (not direct controller calls) +4. Add to navigation in `routes/index.tsx` +5. Document in [perps-screens.md](./perps-screens.md) + +### Before Committing + +```bash +# Format code +npx prettier --write 'app/components/UI/Perps/**/*.{ts,tsx}' + +# Check for errors +npx eslint app/components/UI/Perps/**/*.{ts,tsx} + +# Run tests +npx jest app/components/UI/Perps/ --no-coverage +``` + +## Testing + +- **Test Coverage**: ~95% across hooks, components, and utilities +- **Test Location**: Co-located `__tests__/` directories or `.test.ts` files +- **Mock System**: Centralized mocks in `__mocks__/` directory + +Key testing utilities: + +- `perpsHooksMocks.ts` - Mock hooks +- `perpsComponentMocks.ts` - Mock components +- `providerMocks.ts` - Mock providers +- `streamHooksMocks.ts` - Mock stream hooks + +## Code Quality + +The codebase maintains high quality standards: + +- **Test Coverage**: ~95% across hooks, components, and utilities +- **Architecture**: Tight cohesion with 59% of files used only internally +- **Patterns**: Consistent use of hooks, components, and utilities +- **Documentation**: Comprehensive inline and external documentation + +## Protocol Integration + +Currently integrated with HyperLiquid protocol: + +- **REST API** - Account queries, order placement, market data +- **WebSocket** - Real-time prices, order fills, position updates +- **Wallet Integration** - Ethereum signing for orders + +**See [hyperliquid/](./hyperliquid/) directory for protocol-specific documentation.** + +## Migration Notes + +### HIP-3 Upgrade (Nov 2024) + +Major protocol upgrade with webData3 migration: + +- Single WebSocket connection for positions + orders +- Improved performance and reliability +- See HIP3DebugView for debugging tools + +### Stream Architecture (Oct 2024) + +Migrated from per-component subscriptions to shared streams: + +- Old: `usePerpsPrices` (deprecated) +- New: `useLivePrices` with component-level throttling +- 90% reduction in WebSocket connections + +## Additional Resources + +- **[Perps Screens](./perps-screens.md)** - Detailed view documentation +- **[Connection Architecture](./perps-connection-architecture.md)** - Connection management deep dive +- **[Sentry Integration](./perps-sentry-reference.md)** - Error tracking +- **[MetaMetrics Events](./perps-metametrics-reference.md)** - Analytics events +- **[HyperLiquid Docs](./hyperliquid/)** - Protocol documentation + +## Questions? + +For architecture questions or contributions, refer to the specific documentation linked above or consult the team. diff --git a/docs/perps/perps-screens.md b/docs/perps/perps-screens.md new file mode 100644 index 000000000000..5f08dfdd613e --- /dev/null +++ b/docs/perps/perps-screens.md @@ -0,0 +1,936 @@ +# Perps Screens & Views Documentation + +Complete architectural reference for all 16 Perps screens in MetaMask Mobile. + +## Table of Contents + +1. [PerpsTabView](#perpstabview) - Main container +2. [PerpsHomeView](#perpshomeview) - Landing screen +3. [PerpsMarketListView](#perpsmarketlistview) - Market browser +4. [PerpsMarketDetailsView](#perpsmarketdetailsview) - Market detail +5. [PerpsOrderView](#perpsorderview) - Order entry +6. [PerpsPositionsView](#perpspositionsview) - Positions list +7. [PerpsClosePositionView](#perpsclosepositio nview) - Close position +8. [PerpsCloseAllPositionsView](#perpsclosealpositionsview) - Close all +9. [PerpsCancelAllOrdersView](#perpcancelallordersview) - Cancel all +10. [PerpsTPSLView](#perpstpslview) - TP/SL management +11. [PerpsTransactionsView](#perpstransactionsview) - Transaction history +12. [PerpsWithdrawView](#perpswithdrawview) - Withdrawal +13. [PerpsHeroCardView](#perpsherocardview) - Hero cards +14. [PerpsEmptyState](#perpsemptystate) - Empty states +15. [PerpsRedirect](#perpsredirect) - Routing logic +16. [HIP3DebugView](#hip3debugview) - Debug tools + +--- + +## PerpsTabView + +**Location:** `app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx` + +### Purpose & User Journey + +Main container view for Perps trading interface. Orchestrates all Perps screens within a tab-based structure. Acts as the root component when user selects Perps from main wallet tabs. + +### Key Components Used + +- `PerpsNavigation` - React Navigation stack navigator configuration +- Screen components (dynamically rendered based on active route) + +### Hooks Consumed + +- None directly (orchestration level) + +### Data Flow + +- Receives navigation props from parent (Wallet component) +- Routes all Perps navigation through React Navigation stack +- No Redux state mutations + +### Navigation + +- Entry point: User taps "Perps" tab in wallet +- Destinations: All other Perps screens (HomeView, MarketDetails, OrderView, etc.) +- Exit: User switches to different wallet tab + +--- + +## PerpsHomeView + +**Location:** `app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx` + +### Purpose & User Journey + +Landing screen for Perps trading. Displays aggregated trading overview including positions, open orders, watchlist markets, and recent activity. Single entry point to all trading actions. + +### Key Components Used + +| Component | Purpose | Location | +| ---------------------------- | --------------------------------------- | ------------------------------------- | +| `PerpsMarketBalanceActions` | Balance & deposit section | `components/` | +| `PerpsCard` | Featured trading card | `components/` | +| `PerpsWatchlistMarkets` | User watchlist | `components/PerpsWatchlistMarkets/` | +| `PerpsMarketTypeSection` | Market categories (Crypto/Stocks/Forex) | `components/` | +| `PerpsRecentActivityList` | Recent trades & orders | `components/PerpsRecentActivityList/` | +| `PerpsHomeHeader` | Header with balance display | `components/` | +| `PerpsCloseAllPositionsView` | Modal: Close all positions | `Views/PerpsCloseAllPositionsView/` | +| `PerpsCancelAllOrdersView` | Modal: Cancel all orders | `Views/PerpsCancelAllOrdersView/` | + +### Hooks Consumed + +| Hook | Purpose | +| ----------------------- | -------------------------------------------- | +| `usePerpsHomeData` | Fetches positions, orders, markets, activity | +| `usePerpsNavigation` | Centralized navigation routing | +| `usePerpsMeasurement` | Performance tracking | +| `usePerpsEventTracking` | Analytics events | + +### Data Flow + +``` +Redux + WebSocket (via usePerpsHomeData) + ↓ +Positions, Orders, Markets (real-time) + ↓ +PerpsHomeView renders sections + ↓ +User navigates to detail screens or executes close-all/cancel-all +``` + +### Navigation + +- **From:** Perps tab selection from wallet +- **To:** + - PerpsMarketDetailsView (tap market) + - PerpsOrderView (new trade) + - PerpsCloseAllPositionsView (modal) + - PerpsCancelAllOrdersView (modal) +- **Analytics:** Tracks screen view with source (main button or deep link) + +--- + +## PerpsMarketListView + +**Location:** `app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx` + +### Purpose & User Journey + +Browsable market list with search, sorting, filtering by market type. User discovers new markets and filters by asset class (Crypto/Stocks/Commodities/Forex). + +### Key Components Used + +| Component | Purpose | +| -------------------------------------------- | ----------------------------------- | +| `PerpsMarketList` | Virtualized market list (FlashList) | +| `PerpsMarketFiltersBar` | Asset type filter tabs | +| `PerpsMarketSortFieldBottomSheet` | Sort options modal | +| `PerpsStocksCommoditiesBottomSheet` | Sub-filter for stocks/commodities | +| `PerpsMarketListHeader` | Header with search | +| `PerpsMarketBalanceActions` | Balance section | +| `PerpsMarketListView.PerpsMarketRowSkeleton` | Loading skeleton | + +### Hooks Consumed + +| Hook | Purpose | +| ------------------------ | ------------------------------------------- | +| `usePerpsMarketListView` | All market filtering, sorting, search logic | +| `usePerpsMeasurement` | Performance tracking | +| `usePerpsEventTracking` | Analytics | +| `usePerpsNavigation` | Navigation to market details | + +### Data Flow + +``` +usePerpsMarketListView hook: + ├─ Fetches all markets + ├─ Filters by: search, type (crypto/stocks/forex), favorites + ├─ Sorts by: price change, volume, interest + └─ Returns: filteredMarkets[], marketCounts + +User interactions: + ├─ Search → real-time filter + ├─ Sort → reorder list + ├─ Type filter → category filter + └─ Tap market → navigate to PerpsMarketDetailsView +``` + +### Navigation + +- **From:** PerpsHomeView, back buttons +- **To:** PerpsMarketDetailsView (tap market row) +- **Modal dialogs:** Sort/filter options + +--- + +## PerpsMarketDetailsView + +**Location:** `app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx` + +### Purpose & User Journey + +Detailed market view with TradingView chart, market stats, and trading interface. User analyzes price action and executes trades for a single market. + +### Key Components Used + +| Component | Purpose | +| --------------------------- | ---------------------------------- | +| `PerpsMarketHeader` | Title, price, 24h change | +| `TradingViewChart` | Chart with multiple timeframes | +| `PerpsCandlePeriodSelector` | Candle period (1m, 5m, 1h, 4h, 1d) | +| `PerpsMarketTabs` | Info/Orders/Positions tabs | +| `PerpsNavigationCard` | Quick action buttons | +| `PerpsOICapWarning` | OI capacity warning | +| `PerpsMarketHoursBanner` | Trading hours status | +| `PerpsMarketBalanceActions` | Balance info | + +### Hooks Consumed + +| Hook | Purpose | +| ------------------------------------------- | ------------------------------- | +| `usePerpsPositionData` | Fetch position for this market | +| `usePerpsMarketStats` | Market statistics (funding, OI) | +| `useHasExistingPosition` | Check if user has position | +| `usePerpsOICap` | OI cap checking | +| `usePerpsDataMonitor` | Data consistency monitoring | +| `usePerpsMeasurement` | Performance tracking | +| `usePerpsLiveOrders`, `usePerpsLiveAccount` | Real-time updates | + +### Data Flow + +``` +Route params: { market: PerpsMarketData } + ↓ +usePerpsMarketStats → Statistics +usePerpsPositionData → Existing position +usePerpsDataMonitor → Data consistency + ↓ +Render: Chart + Stats + Tabs + ↓ +User actions: + ├─ Trade → PerpsOrderView + ├─ Manage position → PerpsClosePositionView or PerpsTPSLView + └─ View orders → Market orders tab +``` + +### Navigation + +- **From:** PerpsMarketListView (tap market) +- **To:** + - PerpsOrderView (new order button) + - PerpsClosePositionView (close existing) + - PerpsTPSLView (TP/SL settings) + +--- + +## PerpsOrderView + +**Location:** `app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx` + +### Purpose & User Journey + +Order placement interface. User specifies trade parameters: direction (long/short), amount (USD or size), leverage, and optional limit price. Final review before execution. + +### Key Components Used + +| Component | Purpose | +| ---------------------------- | ---------------------------------- | +| `PerpsOrderHeader` | Market info (asset, price, change) | +| `PerpsAmountDisplay` | USD amount display/input | +| `PerpsSlider` | Leverage/amount slider | +| `PerpsFeesDisplay` | Estimated fees breakdown | +| `PerpsLimitPriceBottomSheet` | Limit price input modal | + +### Hooks Consumed + +| Hook | Purpose | +| ----------------------- | --------------------- | +| `usePerpsOrderForm` | Form state management | +| `usePerpsOrderFees` | Fee calculation | +| `usePerpsRewards` | Rewards & discounts | +| `usePerpsValidation` | Form validation | +| `usePerpsLivePrices` | Real-time price feed | +| `usePerpsMeasurement` | Performance tracking | +| `usePerpsEventTracking` | Analytics | + +### Data Flow + +``` +Route params: + ├─ market: PerpsMarketData + ├─ orderType: 'market' | 'limit' + └─ initialLeverage?: number + +Form state: + ├─ amount (USD) + ├─ leverage + ├─ orderType + ├─ limitPrice (if limit order) + └─ direction (long/short) + +usePerpsOrderFees: + ├─ Calculates trading fee + ├─ Applies fee discount + └─ Shows rewards + +User action: + ├─ Adjust amount → slider or keypad + ├─ Set leverage → numeric input + ├─ Set limit price → modal + └─ Confirm → Execute order +``` + +### Navigation + +- **From:** PerpsMarketDetailsView (Trade button) +- **To:** PerpsTPSLView (optional, after order placed) +- **Back:** Returns to market details + +--- + +## PerpsPositionsView + +**Location:** `app/components/UI/Perps/Views/PerpsPositionsView/PerpsPositionsView.tsx` + +### Purpose & User Journey + +List of all open positions. User views position details, total P&L, and can initiate close or TP/SL updates. + +### Key Components Used + +| Component | Purpose | +| ------------------- | --------------------------- | +| `PerpsPositionCard` | Individual position card | +| Utility functions | PnL calculation, formatting | + +### Hooks Consumed + +| Hook | Purpose | +| ----------------------- | ----------------------------- | +| `usePerpsLivePositions` | Fetch all positions real-time | +| `usePerpsLiveAccount` | Account state (margin, etc.) | + +### Data Flow + +``` +usePerpsLivePositions (WebSocket): + └─ Returns: positions[], isInitialLoading + +Calculate: + ├─ Total unrealized P&L + ├─ Total margin used + └─ Position count + +Render: + ├─ Positions list + ├─ Total P&L summary + └─ Per-position action buttons +``` + +### Navigation + +- **From:** Perps tab or PerpsHomeView +- **To:** + - PerpsClosePositionView (close position) + - PerpsTPSLView (set TP/SL) + - PerpsMarketDetailsView (view market) + +--- + +## PerpsClosePositionView + +**Location:** `app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx` + +### Purpose & User Journey + +Interface to close existing position (fully or partially). User specifies close amount/percentage and optional limit price. Shows estimated fees and receive amount. + +### Key Components Used + +| Component | Purpose | +| ---------------------------- | -------------------------------- | +| `PerpsOrderHeader` | Position info | +| `PerpsAmountDisplay` | Close amount display | +| `PerpsSlider` | Close percentage slider | +| `PerpsCloseSummary` | Fee and receive amount breakdown | +| `PerpsLimitPriceBottomSheet` | Limit price for limit orders | + +### Hooks Consumed + +| Hook | Purpose | +| --------------------------------- | ------------------------ | +| `usePerpsClosePosition` | Close position execution | +| `usePerpsClosePositionValidation` | Validation logic | +| `usePerpsOrderFees` | Fee calculation | +| `usePerpsRewards` | Rewards calculation | +| `usePerpsLivePrices` | Real-time prices | +| `usePerpsMeasurement` | Performance tracking | + +### Data Flow + +``` +Route params: { position: Position } + +State: + ├─ closePercentage (0-100) + ├─ closeAmountUSD (for keypad input) + ├─ orderType ('market' | 'limit') + └─ limitPrice (optional) + +Calculations: + ├─ closeAmount = position.size * (closePercentage / 100) + ├─ closingValue = positionValue * (closePercentage / 100) + ├─ effectivePnL = calculated based on effective price + └─ receiveAmount = margin + pnl - fees + +User action: Confirm → handleClosePosition() +``` + +### Navigation + +- **From:** PerpsPositionsView or PerpsMarketDetailsView +- **To:** PerpsMarketDetailsView (after close) +- **Modal:** Limit price bottom sheet + +--- + +## PerpsCloseAllPositionsView + +**Location:** `app/components/UI/Perps/Views/PerpsCloseAllPositionsView/PerpsCloseAllPositionsView.tsx` + +### Purpose & User Journey + +Modal/bottom sheet to close all open positions at once. Shows summary of total margin, P&L, and fees. Confirms user intent before mass execution. + +### Key Components Used + +| Component | Purpose | +| ------------------- | --------------------- | +| `BottomSheet` | Modal container | +| `PerpsCloseSummary` | Fee breakdown summary | + +### Hooks Consumed + +| Hook | Purpose | +| ------------------------------ | ---------------------- | +| `usePerpsLivePositions` | Fetch all positions | +| `usePerpsLivePrice` | Price data for calc | +| `usePerpsCloseAllCalculations` | Aggregate calculations | +| `usePerpsCloseAllPositions` | Execution hook | +| `usePerpsToasts` | Success/error feedback | +| `usePerpsEventTracking` | Analytics | + +### Data Flow + +``` +Mount: + ├─ Fetch positions + ├─ Fetch prices + └─ Calculate aggregates + +Calculations (usePerpsCloseAllCalculations): + ├─ totalMargin + ├─ totalPnl + ├─ totalFees + ├─ feeDiscounts + └─ rewards + +User action: Confirm → usePerpsCloseAllPositions() → Loop through and close all +``` + +### Navigation + +- **From:** PerpsHomeView (modal action) or navigation stack +- **To:** Back to PerpsHomeView (modal close) +- **Integration:** Can be embedded as external sheet ref or standalone route + +--- + +## PerpsCancelAllOrdersView + +**Location:** `app/components/UI/Perps/Views/PerpsCancelAllOrdersView/PerpsCancelAllOrdersView.tsx` + +### Purpose & User Journey + +Modal to cancel all pending orders at once. Shows list count and confirmation. Useful for clearing market without closing positions. + +### Key Components Used + +| Component | Purpose | +| ------------- | --------------- | +| `BottomSheet` | Modal container | + +### Hooks Consumed + +| Hook | Purpose | +| ------------------------- | --------------------------------- | +| `usePerpsLiveOrders` | Fetch all orders (excludes TP/SL) | +| `usePerpsCancelAllOrders` | Execution hook | +| `usePerpsToasts` | Feedback | +| `usePerpsEventTracking` | Analytics | + +### Data Flow + +``` +Mount: + ├─ Fetch orders (hideTpSl: true) + └─ Show count + +User action: Confirm → Loop through and cancel all + +Result: + ├─ Show success toast + ├─ Close modal + └─ Refresh orders +``` + +### Navigation + +- **From:** PerpsHomeView (modal action) +- **To:** Back to PerpsHomeView +- **Pattern:** Similar to PerpsCloseAllPositionsView + +--- + +## PerpsTPSLView + +**Location:** `app/components/UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.tsx` + +### Purpose & User Journey + +Full-screen editor for Take Profit and Stop Loss price levels. Supports entry by price or percentage (ROE). Shows expected profit/loss. Used for new orders or position management. + +### Key Components Used + +| Component | Purpose | +| -------------------------------------------- | ------------------------ | +| `Keypad` | Numeric input for prices | +| Utility: `formatPerpsFiat`, `PRICE_RANGES_*` | Display formatting | + +### Hooks Consumed + +| Hook | Purpose | +| -------------------------- | --------------------------- | +| `usePerpsTPSLForm` | All form state & validation | +| `usePerpsLivePrices` | Real-time market price | +| `usePerpsLiquidationPrice` | Calculate liquidation level | +| `usePerpsEventTracking` | Analytics | + +### Data Flow + +``` +Route params: + ├─ asset (market) + ├─ direction (long/short) + ├─ position (optional) + ├─ leverage + ├─ orderType ('market' | 'limit') + └─ onConfirm callback + +Form state (usePerpsTPSLForm): + ├─ takeProfitPrice & percentage + ├─ stopLossPrice & percentage + ├─ validation errors + └─ expected P&L + +Pricing: + ├─ Use live price if available + ├─ Fall back to entry price for existing position + └─ Use limit price for limit orders + +User action: Confirm → onConfirm(tpPrice, slPrice, trackingData) +``` + +### Navigation + +- **From:** PerpsOrderView or PerpsMarketDetailsView +- **To:** Previous screen (back navigation) +- **Full screen:** SafeAreaView-based navigation + +--- + +## PerpsTransactionsView + +**Location:** `app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.tsx` + +### Purpose & User Journey + +Historical transaction log with filterable tabs: Trades, Orders, Funding, Deposits/Withdrawals. Pull-to-refresh supported. User reviews trading history. + +### Key Components Used + +| Component | Purpose | +| --------------------------- | ----------------------------------- | +| `FlashList` | Virtualized list (high performance) | +| `PerpsTransactionItem` | Individual transaction card | +| `PerpsTransactionsSkeleton` | Loading state | +| Tab buttons | Filter by transaction type | + +### Hooks Consumed + +| Hook | Purpose | +| ---------------------------- | ---------------------- | +| `usePerpsTransactionHistory` | Fetch all transactions | +| `usePerpsConnection` | Connection state | +| `usePerpsMeasurement` | Performance tracking | + +### Data Flow + +``` +usePerpsTransactionHistory: + └─ Fetch: trades, orders, funding, deposits/withdrawals + +Grouping: + ├─ Group by date + └─ Flatten for FlashList + +Filtering: + ├─ User selects tab (Trades/Orders/Funding/Deposits) + └─ Filter transactions by type + +Navigation: + ├─ Tap trade → PerpsPositionTransactionView + ├─ Tap order → PerpsOrderTransactionView + ├─ Tap funding → PerpsFundingTransactionView + └─ Deposits show inline (no detail view) +``` + +### Navigation + +- **From:** Perps tab or PerpsHomeView +- **To:** Transaction detail views (type-specific) +- **Pull-to-refresh:** Reloads all transaction data + +--- + +## PerpsWithdrawView + +**Location:** `app/components/UI/Perps/Views/PerpsWithdrawView/PerpsWithdrawView.tsx` + +### Purpose & User Journey + +Withdrawal flow to move USDC from Perps account back to mainchain wallet. User enters amount, sees fees, and confirms. Immediate navigation on confirm. + +### Key Components Used + +| Component | Purpose | +| ------------------------- | ------------------ | +| `Keypad` | Numeric input | +| `AvatarToken` | USDC token display | +| `Badge` | Network badge | +| `PerpsBottomSheetTooltip` | Info tooltips | +| `KeyValueRow` | Fee/time display | + +### Hooks Consumed + +| Hook | Purpose | +| ----------------------- | --------------------------- | +| `usePerpsLiveAccount` | Get available balance | +| `usePerpsWithdrawQuote` | Fee calculation | +| `useWithdrawValidation` | Validation (min/max) | +| `useWithdrawTokens` | Get destination token/chain | +| `usePerpsEventTracking` | Analytics | +| `usePerpsMeasurement` | Performance | + +### Data Flow + +``` +Mount: + ├─ Fetch account balance + ├─ Fetch destination token (USDC on Arbitrum) + └─ Display available balance + +User input: + ├─ Enter amount via keypad + ├─ Or tap 10/25/50/Max percentage + └─ Validation: min $10, max available + +Confirm: + ├─ Call controller.withdraw() + ├─ Navigate back immediately + └─ Async execution with toast feedback + +Result: + ├─ Success/error toast + └─ Balance update via WebSocket +``` + +### Navigation + +- **From:** PerpsHomeView (deposit button) +- **To:** Back to PerpsHomeView (immediate) +- **Modal state:** Percentage buttons disappear when amount entered + +--- + +## PerpsHeroCardView + +**Location:** `app/components/UI/Perps/Views/PerpsHeroCardView/PerpsHeroCardView.tsx` + +### Purpose & User Journey + +Celebratory card carousel for profitable positions. User can swipe through 4 themed cards, customize with optional referral code, and share to social media. + +### Key Components Used + +| Component | Purpose | +| ------------------------ | --------------------- | +| `ScrollableTabView` | Card carousel (swipe) | +| `react-native-view-shot` | Capture card image | +| `react-native-share` | Share to social apps | +| `RewardsReferralCodeTag` | Referral code display | +| `PerpsTokenLogo` | Market asset logo | + +### Hooks Consumed + +| Hook | Purpose | +| ------------------------------------ | ------------------------ | +| `usePerpsEventTracking` | Share analytics | +| `usePerpsToasts` | Share feedback | +| Redux selector: `selectReferralCode` | Get user's referral code | + +### Data Flow + +``` +Route params: { position: Position, marketPrice?: string } + +Data used: + ├─ position.unrealizedPnl (ROE calculation) + ├─ position.leverage + ├─ position.entryPrice + ├─ marketPrice (for mark price display) + └─ position.coin (asset symbol) + +Carousel: + ├─ 4 PNL character images + ├─ Swipe to change + └─ Dots indicator + +Share: + ├─ Capture current card as image + ├─ Include referral code if available + ├─ Send via Share sheet + └─ Track success/failure +``` + +### Navigation + +- **From:** PerpsHomeView (position share button) +- **To:** Share sheet or back to home +- **Analytics:** Track card view, share attempts + +--- + +## PerpsEmptyState + +**Location:** `app/components/UI/Perps/Views/PerpsEmptyState/PerpsEmptyState.tsx` + +### Purpose & User Journey + +Reusable empty state component shown when no positions exist. Encourages user to start trading. + +### Key Components Used + +| Component | Purpose | +| --------------- | ------------------------- | +| `TabEmptyState` | Base empty state layout | +| Image assets | Theme-aware illustrations | + +### Hooks Consumed + +| Hook | Purpose | +| ------------------- | --------------------- | +| `useAssetFromTheme` | Theme-specific images | +| `useTailwind` | Styling | + +### Data Flow + +``` +Props: { onActionPress?, testID? } + +Render: + ├─ Theme image (light/dark) + ├─ "Start trading" message + └─ CTA button (optional) + +Action: + └─ onActionPress() → Navigate to market list +``` + +### Navigation + +- **From:** PerpsPositionsView or PerpsHomeView (when empty) +- **To:** PerpsMarketListView (action button) + +--- + +## PerpsRedirect + +**Location:** `app/components/UI/Perps/Views/PerpsRedirect.tsx` + +### Purpose & User Journey + +Initialization route that connects to Perps controller, initializes WebSocket, and redirects to home. User never sees this screen in normal flow (only during initialization). + +### Key Components Used + +| Component | Purpose | +| ------------- | ----------------------------- | +| `PerpsLoader` | Full-screen loading indicator | + +### Hooks Consumed + +| Hook | Purpose | +| -------------------- | ------------------------ | +| `usePerpsConnection` | Monitor connection state | + +### Data Flow + +``` +Mount: + ├─ Check if connected & initialized + ├─ If not: show loader + └─ If yes: redirect to home + +Redirect: + ├─ Navigate to Routes.WALLET.HOME + ├─ Wait for navigation complete (delay needed) + ├─ setParams to select perps tab + └─ Tab selection triggers PerpsTabView +``` + +### Navigation + +- **From:** Deep link or initial Perps tab selection +- **To:** PerpsHomeView (or PerpsTabView container) +- **Status messages:** "Initializing Perps" → "Connecting" → redirect + +--- + +## HIP3DebugView + +**Location:** `app/components/UI/Perps/Debug/HIP3DebugView.tsx` + +### Purpose & User Journey + +Development-only debug interface for testing HyperLiquid HIP-3 multi-DEX feature. Tests DEX selection, market loading, transfers between DEXs, and order placement with auto-transfer. + +### Key Components Used + +| Component | Purpose | +| -------------------------------------------- | -------------------- | +| `DevLogger` | Debug output console | +| Native UI: buttons, text, activity indicator | Basic controls | + +### Hooks Consumed + +None directly (uses direct provider calls) + +### Data Flow + +``` +Provider access: + ├─ Engine.context.PerpsController.getActiveProvider() + └─ Cast to HyperLiquidProvider + +Test workflows: + 1. Load available DEXs + 2. Load markets for selected DEX + 3. Check account balances per DEX + 4. Manual transfer to/from DEX + 5. Test order with auto-transfer + 6. Test close with auto-transfer back + +Output: DevLogger console (accessible via DevLogger UI) +``` + +### Features + +| Feature | Purpose | Input | +| --------------------- | ----------------------------------------------- | ---------------------------------- | +| DEX Selector | Choose which DEX to test | Dropdown from available HIP-3 DEXs | +| Market Selector | Choose market on DEX | Dropdown filtered by selected DEX | +| Balance Check | View aggregated balances | Button (logs to console) | +| Manual Transfer → DEX | Transfer $10 from main to selected DEX | Button | +| Manual Transfer ← DEX | Transfer all from selected DEX back to main | Button (reset) | +| Place Order | $11 order with auto-transfer if needed | Button | +| Close Position | Close first position on DEX, auto-transfer back | Button | + +### Navigation + +- **From:** Developer menu or deep link (dev builds only) +- **To:** Only accessible in `__DEV__` mode +- **Visibility:** Returns "Debug tools unavailable" in production builds + +--- + +## Transaction Detail Views + +Also included in PerpsTransactionsView folder (referenced from main transactions view): + +### PerpsFundingTransactionView + +Shows detailed funding rate transaction with cumulative funding data. + +### PerpsOrderTransactionView + +Shows order details: status (pending/filled/canceled), price, size, fees. + +### PerpsPositionTransactionView + +Shows position trade details: entry price, P&L realized, fees paid. + +--- + +## Architecture Summary + +### Data Layer + +All views consume real-time data via: + +1. **WebSocket streams** (via hooks): + - `usePerpsLivePrices` - Price updates + - `usePerpsLivePositions` - Position updates + - `usePerpsLiveOrders` - Order updates + - `usePerpsLiveAccount` - Balance updates + +2. **Controller methods** (async): + - `usePerpsOrderFees` - Fee calculations + - `usePerpsMarketData` - Market metadata + - `usePerpsTransactionHistory` - Historical data + +3. **Redux** (selectors): + - User preferences + - Cached state + - Referral code + +### Navigation Pattern + +``` +Wallet Tab (PerpsTabView) + ↓ +PerpsHomeView (entry point) + ├→ PerpsMarketListView (browse) + │ └→ PerpsMarketDetailsView (view market) + │ ├→ PerpsOrderView (trade) + │ └→ PerpsClosePositionView (close) + ├→ PerpsPositionsView (manage) + │ ├→ PerpsClosePositionView + │ └→ PerpsTPSLView (TP/SL) + ├→ PerpsTransactionsView (history) + │ └→ Detail views (trade/order/funding) + ├→ PerpsWithdrawView (withdraw) + └→ PerpsHeroCardView (share card) +``` + +### Performance Patterns + +- **Throttled prices:** 1000ms for close position, 500ms for TP/SL +- **Virtualized lists:** FlashList in PerpsTransactionsView +- **Lazy loading:** Markets load on demand in market list +- **Performance tracking:** usePerpsMeasurement hook tracks screen load times + +### State Management + +- **Ephemeral:** Form inputs, UI state (focused input, etc.) +- **Cached:** Market data, transaction history +- **Real-time:** Prices, positions, orders, balances +- **Persisted:** User preferences (chart candle period, etc.) diff --git a/e2e/api-mocking/MockServerE2E.ts b/e2e/api-mocking/MockServerE2E.ts new file mode 100644 index 000000000000..6c9ace070370 --- /dev/null +++ b/e2e/api-mocking/MockServerE2E.ts @@ -0,0 +1,373 @@ +// eslint-disable-next-line @typescript-eslint/no-shadow +import { getLocal, Headers, Mockttp } from 'mockttp'; +import { ALLOWLISTED_HOSTS, ALLOWLISTED_URLS } from './mock-e2e-allowlist'; +import { createLogger, LogLevel } from '../framework/logger'; +import { + MockApiEndpoint, + MockEventsObject, + Resource, + ServerStatus, + TestSpecificMock, +} from '../framework/index'; +import { + findMatchingPostEvent, + processPostRequestBody, +} from './helpers/mockHelpers'; +import { getLocalHost } from '../framework/fixtures/FixtureUtils'; + +const logger = createLogger({ + name: 'MockServer', + level: LogLevel.INFO, +}); +interface LiveRequest { + url: string; + method: string; + timestamp: string; +} + +export interface InternalMockServer extends Mockttp { + _liveRequests?: LiveRequest[]; +} + +const isUrlAllowed = (url: string): boolean => { + try { + if (ALLOWLISTED_URLS.includes(url)) { + return true; + } + + const parsedUrl = new URL(url); + const hostname = parsedUrl.hostname; + + if (parsedUrl.protocol === 'data:') { + return true; + } + + return ALLOWLISTED_HOSTS.some((allowedHost) => { + if (allowedHost.startsWith('*.')) { + const domain = allowedHost.slice(2); + return hostname === domain || hostname.endsWith(`.${domain}`); + } + return hostname === allowedHost; + }); + } catch (error) { + logger.warn('Invalid URL:', url); + return false; + } +}; + +const handleDirectFetch = async ( + url: string, + method: string, + headers: Headers, + requestBody?: string, +): Promise<{ statusCode: number; body: string }> => { + try { + const fetchHeaders: HeadersInit = {}; + for (const [key, value] of Object.entries(headers)) { + if (value) { + fetchHeaders[key] = Array.isArray(value) ? value[0] : value; + } + } + + const response = await global.fetch(url, { + method, + headers: fetchHeaders, + body: ['POST', 'PUT', 'PATCH'].includes(method) ? requestBody : undefined, + }); + + const responseBody = await response.text(); + return { statusCode: response.status, body: responseBody }; + } catch (error) { + logger.error('Error forwarding request:', url, error); + return { + statusCode: 500, + body: JSON.stringify({ error: 'Failed to forward request' }), + }; + } +}; + +export default class MockServerE2E implements Resource { + _serverPort: number; + _serverStatus: ServerStatus = ServerStatus.STOPPED; + private _server: InternalMockServer | null = null; + private _events: MockEventsObject; + private _testSpecificMock?: TestSpecificMock; + + constructor(params: { + events: MockEventsObject; + port: number; + testSpecificMock?: TestSpecificMock; + }) { + this._events = params.events; + this._serverPort = params.port; + this._testSpecificMock = params.testSpecificMock; + } + + isStarted(): boolean { + return this._serverStatus === ServerStatus.STARTED; + } + + getServerPort(): number { + return this._serverPort; + } + + getServerStatus(): ServerStatus { + return this._serverStatus; + } + + get getServerUrl(): string { + return `http://${getLocalHost()}:${this._serverPort}`; + } + + get server(): InternalMockServer { + if (!this._server) { + throw new Error('Mock server not started'); + } + return this._server; + } + + async start(): Promise { + if (this._serverStatus === ServerStatus.STARTED) { + logger.debug('Mock server already started'); + return; + } + + const mockServer = getLocal() as InternalMockServer; + mockServer._liveRequests = []; + + try { + await mockServer.start(this._serverPort); + } catch (error) { + logger.error( + `Failed to start mock server on port ${this._serverPort}: ${error}`, + ); + throw new Error( + `Failed to start mock server on port ${this._serverPort}: ${error}`, + ); + } + + logger.debug( + `Mockttp server running at http://${getLocalHost()}:${this._serverPort}`, + ); + + await mockServer + .forGet('/health-check') + .thenReply(200, 'Mock server is running'); + await mockServer + .forGet( + /^http:\/\/(localhost|127\.0\.0\.1|10\.0\.2\.2)(:\\d+)?\/favicon\.ico$/, + ) + .thenReply(200, 'favicon.ico'); + + if (this._testSpecificMock) { + logger.info('Applying testSpecificMock function (takes precedence)'); + await this._testSpecificMock(mockServer); + } + + await mockServer + .forAnyRequest() + .matching((request) => request.path.startsWith('/proxy')) + .thenCallback(async (request) => { + const urlEndpoint = new URL(request.url).searchParams.get('url'); + if (!urlEndpoint) { + return { + statusCode: 400, + body: JSON.stringify({ error: 'Missing url parameter' }), + }; + } + + const method = request.method; + + let requestBodyText: string | undefined; + let requestBodyJson: unknown; + if (method === 'POST') { + try { + requestBodyText = await request.body.getText(); + if (requestBodyText) { + try { + requestBodyJson = JSON.parse(requestBodyText); + } catch (e) { + requestBodyJson = undefined; + } + } + } catch (e) { + requestBodyText = undefined; + } + } + + const methodEvents = this._events[method] || []; + const candidateEvents = methodEvents.filter( + (event: MockApiEndpoint) => { + const eventUrl = event.urlEndpoint; + if (!eventUrl) return false; + if (event.urlEndpoint instanceof RegExp) { + return event.urlEndpoint.test(urlEndpoint); + } + const eventUrlStr = String(eventUrl); + return ( + urlEndpoint === eventUrlStr || urlEndpoint.startsWith(eventUrlStr) + ); + }, + ); + + let matchingEvent: MockApiEndpoint | undefined; + if (candidateEvents.length > 0) { + if (method === 'POST') { + matchingEvent = findMatchingPostEvent( + candidateEvents, + requestBodyJson, + ); + } else { + matchingEvent = candidateEvents[0]; + } + } + + if (matchingEvent) { + logger.info(`Mocking ${method} request to: ${urlEndpoint}`); + logger.info(`Response status: ${matchingEvent.responseCode}`); + logger.debug('Response:', matchingEvent.response); + if (method === 'POST' && matchingEvent.requestBody) { + const result = processPostRequestBody( + requestBodyText, + matchingEvent.requestBody, + { ignoreFields: matchingEvent.ignoreFields || [] }, + ); + + if (!result.matches) { + return { + statusCode: result.error === 'Missing request body' ? 400 : 404, + body: JSON.stringify({ + error: result.error, + expected: matchingEvent.requestBody, + received: result.requestBodyJson, + }), + }; + } + } + + return { + statusCode: matchingEvent.responseCode, + body: JSON.stringify(matchingEvent.response), + }; + } + + const updatedUrl = + device.getPlatform() === 'android' + ? urlEndpoint.replace('localhost', '127.0.0.1') + : urlEndpoint; + + if (!isUrlAllowed(updatedUrl)) { + const errorMessage = `Request going to live server: ${updatedUrl}`; + logger.warn(errorMessage); + mockServer._liveRequests?.push({ + url: updatedUrl, + method, + timestamp: new Date().toISOString(), + }); + } else if (ALLOWLISTED_URLS.includes(updatedUrl)) { + logger.warn(`Allowed URL: ${updatedUrl}`); + if (method === 'POST') { + logger.warn(`Request Body: ${requestBodyText}`); + } + } + + return handleDirectFetch( + updatedUrl, + method, + request.headers, + method === 'POST' ? requestBodyText : undefined, + ); + }); + + await mockServer.forUnmatchedRequest().thenCallback(async (request) => { + if (!isUrlAllowed(request.url)) { + const errorMessage = `Request going to live server: ${request.url}`; + logger.warn(errorMessage); + mockServer._liveRequests?.push({ + url: request.url, + method: request.method, + timestamp: new Date().toISOString(), + }); + } else if (ALLOWLISTED_URLS.includes(request.url)) { + logger.warn(`Allowed URL: ${request.url}`); + if (request.method === 'POST') { + logger.warn(`Request Body: ${await request.body.getText()}`); + } + } + + return handleDirectFetch( + request.url, + request.method, + request.headers, + await request.body.getText(), + ); + }); + + this._server = mockServer; + this._serverStatus = ServerStatus.STARTED; + } + + async stop(): Promise { + logger.info('Mock server shutting down'); + if (!this._server) { + this._serverStatus = ServerStatus.STOPPED; + return; + } + + try { + await this._server.stop(); + } catch (error) { + logger.error('Error stopping mock server:', error); + } finally { + this._server = null; + this._serverStatus = ServerStatus.STOPPED; + } + } + + validateLiveRequests(): void { + const mockServer = this._server; + if (!mockServer?._liveRequests || mockServer._liveRequests.length === 0) { + return; + } + + const uniqueRequests = Array.from( + new Map( + mockServer._liveRequests.map((req) => [ + `${req.method} ${req.url}`, + req, + ]), + ).values(), + ); + + const requestsSummary = uniqueRequests + .map( + (req, index) => + `${index + 1}. [${req.method}] ${req.url} (${req.timestamp})`, + ) + .join('\n'); + + const totalCount = mockServer._liveRequests.length; + const uniqueCount = uniqueRequests.length; + const message = + `Test made ${totalCount} unmocked request(s) (${uniqueCount} unique):\n${requestsSummary}\n\n` + + "Check your test-specific mocks or add them to the default mocks.\n You can also add the URL to the allowlist if it's a known live request."; + logger.error(message); + throw new Error(message); + } + + private _sanitizeJson(value: unknown, ignoreFields: string[]): unknown { + if (Array.isArray(value)) { + return value.map((item) => this._sanitizeJson(item, ignoreFields)); + } + if (value && typeof value === 'object') { + const obj = value as Record; + const result: Record = {}; + for (const [key, val] of Object.entries(obj)) { + if (ignoreFields.includes(key)) continue; + result[key] = this._sanitizeJson(val, ignoreFields); + } + return result; + } + return value; + } +} diff --git a/e2e/api-mocking/mock-e2e-allowlist.ts b/e2e/api-mocking/mock-e2e-allowlist.ts index d2c6cd059be4..550eebc1c616 100644 --- a/e2e/api-mocking/mock-e2e-allowlist.ts +++ b/e2e/api-mocking/mock-e2e-allowlist.ts @@ -53,4 +53,5 @@ export const ALLOWLISTED_URLS = [ 'https://acl.execution.metamask.io/latest/registry.json', 'https://acl.execution.metamask.io/latest/signature.json', 'https://signature-insights.api.cx.metamask.io/v1/signature?chainId=0x1', + 'https://price.api.cx.metamask.io/v1/exchange-rates?baseCurrency=usd', ]; diff --git a/e2e/api-mocking/mock-responses/infura-mocks.ts b/e2e/api-mocking/mock-responses/infura-mocks.ts index 8bb5d43a6159..c0286458aa4f 100644 --- a/e2e/api-mocking/mock-responses/infura-mocks.ts +++ b/e2e/api-mocking/mock-responses/infura-mocks.ts @@ -65,6 +65,7 @@ const createInfuraMocks = () => { 'starknet-goerli.infura.io', 'starknet-sepolia.infura.io', 'ipfs.infura.io', + 'sei-mainnet.infura.io', ]; endpoints.forEach((endpoint) => { diff --git a/e2e/api-mocking/mock-server.ts b/e2e/api-mocking/mock-server.ts deleted file mode 100644 index 4af4068837cd..000000000000 --- a/e2e/api-mocking/mock-server.ts +++ /dev/null @@ -1,339 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/no-shadow -import { getLocal, Headers, Mockttp } from 'mockttp'; -import { ALLOWLISTED_HOSTS, ALLOWLISTED_URLS } from './mock-e2e-allowlist'; -import { createLogger, LogLevel } from '../framework/logger'; -import { - findMatchingPostEvent, - processPostRequestBody, -} from './helpers/mockHelpers'; -import { - MockApiEndpoint, - MockEventsObject, - TestSpecificMock, -} from '../framework/index'; - -// Creates a logger with INFO level as the mockServer produces too much noise -// Change this to DEBUG as needed -const logger = createLogger({ - name: 'MockServer', - level: LogLevel.INFO, -}); - -interface LiveRequest { - url: string; - method: string; - timestamp: string; -} - -interface MockServer extends Mockttp { - _liveRequests?: LiveRequest[]; -} - -/** - * Utility function to handle direct fetch requests - */ -const handleDirectFetch = async ( - url: string, - method: string, - headers: Headers, - requestBody?: string, -): Promise<{ statusCode: number; body: string }> => { - try { - // Convert mockttp headers to satisfy fetch API requirements - const fetchHeaders: HeadersInit = {}; - for (const [key, value] of Object.entries(headers)) { - if (value) { - fetchHeaders[key] = Array.isArray(value) ? value[0] : value; - } - } - - const response = await global.fetch(url, { - method, - headers: fetchHeaders, - body: ['POST', 'PUT', 'PATCH'].includes(method) ? requestBody : undefined, - }); - - const responseBody = await response.text(); - - return { - statusCode: response.status, - body: responseBody, - }; - } catch (error) { - logger.error('Error forwarding request:', url, error); - return { - statusCode: 500, - body: JSON.stringify({ error: 'Failed to forward request' }), - }; - } -}; - -/** - * Utility function to check if a URL is allowed - */ -const isUrlAllowed = (url: string): boolean => { - try { - // First check if the exact URL is in the allowed URLs list - if (ALLOWLISTED_URLS.includes(url)) { - return true; - } - - // Then check if the hostname is in the allowed hosts list - const parsedUrl = new URL(url); - const hostname = parsedUrl.hostname; - - // Allow data URLs, e.g. for decoding base64 - if (parsedUrl.protocol === 'data:') { - return true; - } - - return ALLOWLISTED_HOSTS.some((allowedHost) => { - // Support exact match or wildcard subdomains (e.g., "*.example.com") - if (allowedHost.startsWith('*.')) { - const domain = allowedHost.slice(2); - return hostname === domain || hostname.endsWith(`.${domain}`); - } - return hostname === allowedHost; - }); - } catch (error) { - logger.warn('Invalid URL:', url); - return false; - } -}; - -// Using shared port utilities from FixtureUtils - -/** - * Starts the mock server and sets up mock events. - */ -export const startMockServer = async ( - events: MockEventsObject, - port: number, - testSpecificMock?: TestSpecificMock, -): Promise => { - const mockServer = getLocal() as MockServer; - - // Track live requests - const liveRequests: LiveRequest[] = []; - mockServer._liveRequests = liveRequests; - - try { - await mockServer.start(port); - } catch (error) { - // If starting fails, log the error and throw it - logger.error(`Failed to start mock server on port ${port}: ${error}`); - throw new Error(`Failed to start mock server on port ${port}: ${error}`); - } - - logger.info(`Mockttp server running at http://localhost:${port}`); - - await mockServer - .forGet('/health-check') - .thenReply(200, 'Mock server is running'); - - await mockServer - .forGet( - /^http:\/\/(localhost|127\.0\.0\.1|10\.0\.2\.2)(:\d+)?\/favicon\.ico$/, - ) - .thenReply(200, 'favicon.ico'); - - // Apply test-specific mocks first (takes precedence) - if (testSpecificMock) { - logger.info('Applying testSpecificMock function (takes precedence)'); - await testSpecificMock(mockServer); - } - - // Set up the main proxy handler (fallback logic) - await mockServer - .forAnyRequest() - .matching((request) => request.path.startsWith('/proxy')) - .thenCallback(async (request) => { - const urlEndpoint = new URL(request.url).searchParams.get('url'); - if (!urlEndpoint) { - return { - statusCode: 400, - body: JSON.stringify({ error: 'Missing url parameter' }), - }; - } - const method = request.method; - // Read the body ONCE for POST requests to avoid stream exhaustion - let requestBodyText: string | undefined; - let requestBodyJson: unknown; - if (method === 'POST') { - try { - requestBodyText = await request.body.getText(); - if (requestBodyText) { - try { - requestBodyJson = JSON.parse(requestBodyText); - } catch (e) { - requestBodyJson = undefined; - } - } - } catch (e) { - requestBodyText = undefined; - } - } - - // Find matching mock event - const methodEvents = events[method] || []; - const candidateEvents = methodEvents.filter((event: MockApiEndpoint) => { - const eventUrl = event.urlEndpoint; - if (!eventUrl) return false; - if (event.urlEndpoint instanceof RegExp) { - return event.urlEndpoint.test(urlEndpoint); - } - // Support exact match and prefix (partial) match to avoid leaking keys in tests - const eventUrlStr = String(eventUrl); - return ( - urlEndpoint === eventUrlStr || urlEndpoint.startsWith(eventUrlStr) - ); - }); - - let matchingEvent: MockApiEndpoint | undefined; - - if (candidateEvents.length > 0) { - if (method === 'POST') { - // Use the extracted logic for POST request matching - matchingEvent = - findMatchingPostEvent(candidateEvents, requestBodyJson) || - undefined; - } else { - // Non-POST requests: first candidate by URL - matchingEvent = candidateEvents[0]; - } - } - - if (matchingEvent) { - logger.info(`Mocking ${method} request to: ${urlEndpoint}`); - logger.info(`Response status: ${matchingEvent.responseCode}`); - logger.debug('Response:', matchingEvent.response); - // For POST requests, verify the request body if specified - if (method === 'POST' && matchingEvent.requestBody) { - const result = processPostRequestBody( - requestBodyText, - matchingEvent.requestBody, - { ignoreFields: matchingEvent.ignoreFields || [] }, - ); - - if (!result.matches) { - return { - statusCode: result.error === 'Missing request body' ? 400 : 404, - body: JSON.stringify({ - error: result.error, - expected: matchingEvent.requestBody, - received: result.requestBodyJson, - }), - }; - } - } - - return { - statusCode: matchingEvent.responseCode, - body: JSON.stringify(matchingEvent.response), - }; - } - - // If no matching mock found, check if URL is allowed before passing through - const updatedUrl = - device.getPlatform() === 'android' - ? urlEndpoint.replace('localhost', '127.0.0.1') - : urlEndpoint; - - // Check if the URL is in the allowed list - if (!isUrlAllowed(updatedUrl)) { - const errorMessage = `Request going to live server: ${updatedUrl}`; - logger.warn(errorMessage); - liveRequests.push({ - url: updatedUrl, - method, - timestamp: new Date().toISOString(), - }); - } else if (ALLOWLISTED_URLS.includes(updatedUrl)) { - // Explicit debug to help with debugging in CI - logger.warn(`Allowed URL: ${updatedUrl}`); - if (method === 'POST') { - logger.warn(`Request Body: ${requestBodyText}`); - } - } - - return handleDirectFetch( - updatedUrl, - method, - request.headers, - method === 'POST' ? requestBodyText : undefined, - ); - }); - - // In case any other requests are made, check allowed list before passing through - await mockServer.forUnmatchedRequest().thenCallback(async (request) => { - // Check if the URL is in the allowed list - if (!isUrlAllowed(request.url)) { - const errorMessage = `Request going to live server: ${request.url}`; - logger.warn(errorMessage); - liveRequests.push({ - url: request.url, - method: request.method, - timestamp: new Date().toISOString(), - }); - } else if (ALLOWLISTED_URLS.includes(request.url)) { - // Explicit debug to help with debugging in CI - logger.warn(`Allowed URL: ${request.url}`); - if (request.method === 'POST') { - logger.warn(`Request Body: ${await request.body.getText()}`); - } - } - - return handleDirectFetch( - request.url, - request.method, - request.headers, - await request.body.getText(), - ); - }); - - return mockServer; -}; - -/** - * Validates that no unexpected live requests were made - */ -export const validateLiveRequests = (mockServer: MockServer): void => { - if (mockServer._liveRequests && mockServer._liveRequests.length > 0) { - // Get unique requests by method + URL combination - const uniqueRequests = Array.from( - new Map( - mockServer._liveRequests.map((req) => [ - `${req.method} ${req.url}`, - req, - ]), - ).values(), - ); - - const requestsSummary = uniqueRequests - .map( - (req, index) => - `${index + 1}. [${req.method}] ${req.url} (${req.timestamp})`, - ) - .join('\n'); - - const totalCount = mockServer._liveRequests.length; - const uniqueCount = uniqueRequests.length; - const message = - `Test made ${totalCount} unmocked request(s) (${uniqueCount} unique):\n${requestsSummary}\n\n` + - "Check your test-specific mocks or add them to the default mocks.\n You can also add the URL to the allowlist if it's a known live request."; - logger.error(message); - throw new Error(message); - } -}; - -/** - * Stops the mock server. - */ -export const stopMockServer = async (mockServer: Mockttp): Promise => { - logger.info('Mock server shutting down'); - try { - await mockServer.stop(); - } catch (error) { - logger.error('Error stopping mock server:', error); - } -}; diff --git a/e2e/create-static-server.js b/e2e/create-static-server.js deleted file mode 100644 index c51c10813112..000000000000 --- a/e2e/create-static-server.js +++ /dev/null @@ -1,29 +0,0 @@ -/* eslint-disable import/no-nodejs-modules */ -import http from 'http'; -import path from 'path'; -import serveHandler from 'serve-handler'; - -const createStaticServer = function (rootDirectory) { - return http.createServer((request, response) => { - if (request.url.startsWith('/node_modules/')) { - request.url = request.url.substr(14); - return serveHandler(request, response, { - directoryListing: false, - public: path.resolve('./node_modules'), - }); - } - - // Handle test-dapp-multichain URLs by removing the prefix - // The multichain test dapp resources are referenced with /test-dapp-multichain/ prefix in its HTML - if (request.url.startsWith('/test-dapp-multichain/')) { - request.url = request.url.slice('/test-dapp-multichain'.length); - } - - return serveHandler(request, response, { - directoryListing: false, - public: rootDirectory, - }); - }); -}; - -export default createStaticServer; diff --git a/e2e/framework/DappServer.ts b/e2e/framework/DappServer.ts new file mode 100644 index 000000000000..31fd2fef8caf --- /dev/null +++ b/e2e/framework/DappServer.ts @@ -0,0 +1,140 @@ +/* eslint-disable import/no-nodejs-modules */ +import { createLogger, Resource, ServerStatus } from '.'; +import http from 'http'; +import serveHandler from 'serve-handler'; +import { getLocalHost } from './fixtures/FixtureUtils'; +import { DappVariants } from './Constants'; +import path from 'path'; + +const logger = createLogger({ + name: 'DappServer', +}); + +export default class DappServer implements Resource { + private _serverPort: number; + private _serverStatus: ServerStatus = ServerStatus.STOPPED; + private _server: http.Server | undefined; + private _rootDirectory: string; + private dappVariant: DappVariants; + + constructor({ + port, + rootDirectory, + dappVariant, + }: { + port: number; + rootDirectory: string; + dappVariant: DappVariants; + }) { + this.dappVariant = dappVariant; + this._rootDirectory = rootDirectory; + this._serverPort = port; + } + + async stop(): Promise { + logger.debug( + `Stopping dapp server ${this.dappVariant} on port ${this._serverPort}`, + ); + if ( + this._serverStatus === ServerStatus.STARTED && + this._server?.listening + ) { + await new Promise((resolve, reject) => { + this._server?.close((error) => { + if (error) { + return reject(error); + } + return resolve(); + }); + }); + } + this._serverStatus = ServerStatus.STOPPED; + logger.debug( + `Dapp server ${this.dappVariant} stopped on port ${this._serverPort}`, + ); + } + + async start(): Promise { + logger.debug( + `Starting dapp server ${this.dappVariant} on port ${this._serverPort}`, + ); + if (this._serverStatus === ServerStatus.STARTED) { + logger.debug( + `Dapp server ${this.dappVariant} already started on port ${this._serverPort}`, + ); + return; + } + + return new Promise((resolve, reject) => { + this._server = http.createServer( + async ( + request: http.IncomingMessage, + response: http.ServerResponse, + ) => { + if (!request.url) { + response.statusCode = 404; + response.end('Not Found'); + return; + } + + if (request.url.startsWith('/node_modules/')) { + request.url = request.url.substr(14); + const nodeModulesDir = path.resolve( + __dirname, + '../../node_modules', + ); + return serveHandler(request, response, { + directoryListing: false, + public: nodeModulesDir, + }); + } + + // Handle test-dapp-multichain URLs by removing the prefix + // The multichain test dapp resources are referenced with /test-dapp-multichain/ prefix in its HTML + if (request.url.startsWith('/test-dapp-multichain/')) { + request.url = request.url.slice('/test-dapp-multichain'.length); + } + + return serveHandler(request, response, { + directoryListing: false, + public: this._rootDirectory, + }); + }, + ); + + this._server.once('error', (error) => { + logger.error( + `Failed to start dapp server ${this.dappVariant} on port ${this._serverPort}: ${String( + error, + )}`, + ); + this._serverStatus = ServerStatus.STOPPED; + reject(error); + }); + + this._server.listen(this._serverPort, () => { + this._serverStatus = ServerStatus.STARTED; + logger.debug( + `Dapp server ${this.dappVariant} started on port ${this._serverPort}`, + ); + resolve(); + }); + }); + } + + isStarted(): boolean { + return this._serverStatus === ServerStatus.STARTED; + } + + getServerPort(): number { + return this._serverPort; + } + + getServerStatus(): ServerStatus { + return this._serverStatus; + } + + get getServerUrl(): string { + return `http://${getLocalHost()}:${this._serverPort}`; + } +} diff --git a/e2e/framework/fixtures/CommandQueueServer.ts b/e2e/framework/fixtures/CommandQueueServer.ts index f0203e5c3650..51f4211f6c2f 100644 --- a/e2e/framework/fixtures/CommandQueueServer.ts +++ b/e2e/framework/fixtures/CommandQueueServer.ts @@ -1,7 +1,7 @@ import { getCommandQueueServerPort, getLocalHost } from './FixtureUtils'; import Koa, { Context } from 'koa'; import { createLogger } from '../logger'; -import { CommandType } from '../types'; +import { CommandType, Resource, ServerStatus } from '../types'; const logger = createLogger({ name: 'CommandQueueServer', @@ -18,16 +18,17 @@ export interface CommandQueueItem { args: Record; } -class CommandQueueServer { +class CommandQueueServer implements Resource { private _app: Koa; private _server: ReturnType | undefined; private _queue: CommandQueueItem[]; - private _port: number; + _serverPort: number; + _serverStatus: ServerStatus = ServerStatus.STOPPED; constructor() { this._app = new Koa(); this._queue = []; - this._port = getCommandQueueServerPort(); + this._serverPort = getCommandQueueServerPort(); this._app.use(async (ctx: Context) => { // Middleware to handle requests ctx.set('Access-Control-Allow-Origin', '*'); @@ -52,40 +53,85 @@ class CommandQueueServer { }); } + isStarted(): boolean { + return this._serverStatus === ServerStatus.STARTED; + } + + getServerPort(): number { + return this._serverPort; + } + + getServerStatus(): ServerStatus { + return this._serverStatus; + } + // Start the fixture server - async start() { + async start(): Promise { + if (this._serverStatus === ServerStatus.STARTED) { + logger.debug('The command queue server has already been started'); + return; + } + const options = { host: getLocalHost(), - port: this._port, + port: this._serverPort, exclusive: true, }; return new Promise((resolve, reject) => { - logger.debug('Starting command queue server on port', this._port); + logger.debug('Starting command queue server on port', this._serverPort); this._server = this._app.listen(options); if (!this._server) { logger.error( '❌ Failed to start command queue server on port', - this._port, + this._serverPort, ); - throw new Error('Failed to start command queue server'); + reject(new Error('Failed to start command queue server')); + return; } - this._server.once('error', reject); - this._server.once('listening', resolve); + let onError: ((err: Error) => void) | null = null; + let onListening: (() => void) | null = null; + onError = (err: Error) => { + if (onListening) { + this._server?.removeListener('listening', onListening); + } + logger.error( + '❌ Failed to start command queue server on port', + this._serverPort, + err, + ); + this._serverStatus = ServerStatus.STOPPED; + try { + this._server?.close(); + } catch (e) { + // ignore cleanup errors + } + this._server = undefined; + reject(err); + }; + onListening = () => { + if (onError) { + this._server?.removeListener('error', onError); + } + this._serverStatus = ServerStatus.STARTED; + resolve(); + }; + this._server.once('error', onError); + this._server.once('listening', onListening); }); } // Stop the fixture server - async stop() { + async stop(): Promise { if (!this._server) { return; } await new Promise((resolve, reject) => { - logger.debug('Stopping command queue server on port', this._port); + logger.debug('Stopping command queue server on port', this._serverPort); if (!this._server) { logger.error( '❌ Failed to stop command queue server on port', - this._port, + this._serverPort, ); throw new Error('Failed to stop command queue server'); } @@ -93,7 +139,9 @@ class CommandQueueServer { this._server.once('error', reject); this._server.once('close', resolve); this._server = undefined; + this._serverStatus = ServerStatus.STOPPED; }); + logger.debug('Command queue server stopped on port', this._serverPort); } addToQueue(item: CommandQueueItem) { diff --git a/e2e/framework/fixtures/FixtureHelper.ts b/e2e/framework/fixtures/FixtureHelper.ts index ed157dc82039..0c55b3340050 100644 --- a/e2e/framework/fixtures/FixtureHelper.ts +++ b/e2e/framework/fixtures/FixtureHelper.ts @@ -3,26 +3,18 @@ import FixtureServer from './FixtureServer'; import { AnvilManager, Hardfork } from '../../seeder/anvil-manager'; import Ganache from '../../../app/util/test/ganache'; - import GanacheSeeder from '../../../app/util/test/ganache-seeder'; import axios from 'axios'; -import createStaticServer from '../../create-static-server'; import { getFixturesServerPort, getLocalTestDappPort, getMockServerPort, - getCommandQueueServerPort, } from './FixtureUtils'; import Utilities from '../../framework/Utilities'; import TestHelpers from '../../helpers'; -import { - startMockServer, - stopMockServer, - validateLiveRequests, -} from '../../api-mocking/mock-server'; +import MockServerE2E from '../../api-mocking/MockServerE2E'; import { setupRemoteFeatureFlagsMock } from '../../api-mocking/helpers/remoteFeatureFlagsHelper'; import { AnvilSeeder } from '../../seeder/anvil-seeder'; -import http from 'http'; import { LocalNodeConfig, LocalNodeOptionsInput, @@ -40,37 +32,14 @@ import ContractAddressRegistry from '../../../app/util/test/contract-address-reg import FixtureBuilder from './FixtureBuilder'; import { createLogger } from '../logger'; import { mockNotificationServices } from '../../specs/notifications/utils/mocks'; -import { type Mockttp } from 'mockttp'; import { DEFAULT_MOCKS } from '../../api-mocking/mock-responses/defaults'; import CommandQueueServer from './CommandQueueServer'; +import DappServer from '../DappServer'; const logger = createLogger({ name: 'FixtureHelper', }); -const FIXTURE_SERVER_URL = `http://localhost:${getFixturesServerPort()}/state.json`; -const COMMAND_QUEUE_SERVER_URL = `http://localhost:${getCommandQueueServerPort()}/queue.json`; - -// checks if server has already been started -const isFixtureServerStarted = async () => { - try { - const response = await axios.get(FIXTURE_SERVER_URL); - return response.status === 200; - } catch (error) { - return false; - } -}; - -// checks if command queue server has already been started -const isCommandQueueServerStarted = async () => { - try { - const response = await axios.get(COMMAND_QUEUE_SERVER_URL); - return response.status === 200; - } catch (error) { - return false; - } -}; - /** * Handles the dapps by starting the servers and listening to the ports. * @param dapps - The dapps to start. @@ -78,7 +47,7 @@ const isCommandQueueServerStarted = async () => { */ async function handleDapps( dapps: DappOptions[], - dappServer: http.Server[], + dappServer: DappServer[], ): Promise { logger.debug( `Starting dapps: ${dapps.map((dapp) => dapp.dappVariant).join(', ')}`, @@ -89,24 +58,34 @@ async function handleDapps( switch (dapp.dappVariant) { case DappVariants.TEST_DAPP: dappServer.push( - createStaticServer( - dapp.dappPath || TestDapps[DappVariants.TEST_DAPP].dappPath, - ), + new DappServer({ + port: dappBasePort + i, + rootDirectory: + dapp.dappPath || TestDapps[DappVariants.TEST_DAPP].dappPath, + dappVariant: DappVariants.TEST_DAPP, + }), ); break; case DappVariants.MULTICHAIN_TEST_DAPP: dappServer.push( - createStaticServer( - dapp.dappPath || + new DappServer({ + port: dappBasePort + i, + rootDirectory: + dapp.dappPath || TestDapps[DappVariants.MULTICHAIN_TEST_DAPP].dappPath, - ), + dappVariant: DappVariants.MULTICHAIN_TEST_DAPP, + }), ); break; case DappVariants.SOLANA_TEST_DAPP: dappServer.push( - createStaticServer( - dapp.dappPath || TestDapps[DappVariants.SOLANA_TEST_DAPP].dappPath, - ), + new DappServer({ + port: dappBasePort + i, + rootDirectory: + dapp.dappPath || + TestDapps[DappVariants.SOLANA_TEST_DAPP].dappPath, + dappVariant: DappVariants.SOLANA_TEST_DAPP, + }), ); break; default: @@ -114,11 +93,7 @@ async function handleDapps( `Unsupported dapp variant: '${dapp.dappVariant}'. Cannot start the server.`, ); } - dappServer[i].listen(`${dappBasePort + i}`); - await new Promise((resolve, reject) => { - dappServer[i].on('listening', resolve); - dappServer[i].on('error', reject); - }); + await dappServer[i].start(); } } @@ -249,7 +224,7 @@ async function handleLocalNodeCleanup(localNodes: LocalNode[]): Promise { ); for (const node of localNodes) { if (node) { - await node.quit(); + await node.stop(); } } } @@ -261,22 +236,13 @@ async function handleLocalNodeCleanup(localNodes: LocalNode[]): Promise { */ async function handleDappCleanup( dapps: DappOptions[], - dappServer: http.Server[], + dappServer: DappServer[], ): Promise { logger.debug( `Stopping dapps: ${dapps.map((dapp) => dapp.dappVariant).join(', ')}`, ); for (let i = 0; i < dapps.length; i++) { - if (dappServer[i]?.listening) { - await new Promise((resolve, reject) => { - dappServer[i].close((error) => { - if (error) { - return reject(error); - } - return resolve(); - }); - }); - } + await dappServer[i].stop(); } } @@ -298,8 +264,10 @@ export const loadFixture = async ( const state = fixture || new FixtureBuilder({ onboarding: true }).build(); await fixtureServer.loadJsonState(state, null); // Checks if state is loaded - logger.debug(`Loading fixture into fixture server: ${FIXTURE_SERVER_URL}`); - const response = await axios.get(FIXTURE_SERVER_URL); + logger.debug( + `Loading fixture into fixture server: ${fixtureServer.getServerUrl}`, + ); + const response = await axios.get(fixtureServer.getServerUrl); // Throws if state is not properly loaded if (response.status !== 200) { @@ -308,64 +276,20 @@ export const loadFixture = async ( } }; -// Start the fixture server -export const startFixtureServer = async (fixtureServer: FixtureServer) => { - if (await isFixtureServerStarted()) { - logger.debug('The fixture server has already been started'); - return; - } - - try { - await fixtureServer.start(); - logger.debug('The fixture server is started'); - } catch (err) { - logger.error('Fixture server error:', err); - } -}; - -// Stop the fixture server -export const stopFixtureServer = async (fixtureServer: FixtureServer) => { - if (!(await isFixtureServerStarted())) { - logger.debug('The fixture server has already been stopped'); - return; - } - await fixtureServer.stop(); - logger.debug('The fixture server is stopped'); -}; - -// Start the command queue server -export const startCommandQueueServer = async ( - commandQueueServer: CommandQueueServer, -) => { - if (await isCommandQueueServerStarted()) { - logger.debug('The command queue server has already been started'); - return; - } - - await commandQueueServer.start(); - logger.debug('The command queue server is started'); -}; - -// Stop the command queue server -export const stopCommandQueueServer = async ( - commandQueueServer: CommandQueueServer, -) => { - await commandQueueServer.stop(); - logger.debug('The command queue server is stopped'); -}; - export const createMockAPIServer = async ( testSpecificMock?: TestSpecificMock, ): Promise<{ - mockServer: Mockttp; + mockServerInstance: MockServerE2E; mockServerPort: number; }> => { const mockServerPort = getMockServerPort(); - const mockServer = await startMockServer( - DEFAULT_MOCKS, - mockServerPort, - testSpecificMock, // Applied First, so any test-specific mocks take precedence - ); + const mockServerInstance = new MockServerE2E({ + events: DEFAULT_MOCKS, + port: mockServerPort, + testSpecificMock, + }); + await mockServerInstance.start(); + const mockServer = mockServerInstance.server; if (testSpecificMock) { logger.debug( @@ -386,7 +310,7 @@ export const createMockAPIServer = async ( logger.debug(`Mocked endpoints: ${endpoints.length}`); return { - mockServer, + mockServerInstance, mockServerPort, }; }; @@ -430,7 +354,7 @@ export async function withFixtures( // Prepare android devices for testing to avoid having this in all tests await TestHelpers.reverseServerPort(); - const { mockServer, mockServerPort } = + const { mockServerInstance, mockServerPort } = await createMockAPIServer(testSpecificMock); // Handle local nodes @@ -440,7 +364,7 @@ export async function withFixtures( localNodes = await handleLocalNodes(localNodeOptions); } - const dappServer: http.Server[] = []; + const dappServer: DappServer[] = []; const fixtureServer = new FixtureServer(); const commandQueueServer = new CommandQueueServer(); @@ -479,14 +403,14 @@ export async function withFixtures( } // Start fixture server - await startFixtureServer(fixtureServer); + await fixtureServer.start(); await loadFixture(fixtureServer, { fixture: resolvedFixture }); logger.debug( 'The fixture server is started, and the initial state is successfully loaded.', ); if (useCommandQueueServer) { - await startCommandQueueServer(commandQueueServer); + await commandQueueServer.start(); } // Due to the fact that the app was already launched on `init.js`, it is necessary to // launch into a fresh installation of the app to apply the new fixture loaded perviously. @@ -496,7 +420,7 @@ export async function withFixtures( delete: true, launchArgs: { fixtureServerPort: `${getFixturesServerPort()}`, - commandQueueServerPort: `${getCommandQueueServerPort()}`, + commandQueueServerPort: `${commandQueueServer.getServerPort()}`, detoxURLBlacklistRegex: Utilities.BlacklistURLs, mockServerPort: `${mockServerPort}`, ...(launchArgs || {}), @@ -508,7 +432,7 @@ export async function withFixtures( await testSuite({ contractRegistry, - mockServer, + mockServer: mockServerInstance.server, localNodes, commandQueueServer, }); @@ -522,7 +446,7 @@ export async function withFixtures( try { // Pass the mockServer to the endTestfn if it exists as we may want // to capture events before cleanup - await endTestfn({ mockServer }); + await endTestfn({ mockServer: mockServerInstance.server }); } catch (endTestError) { logger.error('Error in endTestfn:', endTestError); cleanupErrors.push(endTestError as Error); @@ -548,34 +472,41 @@ export async function withFixtures( } } - if (mockServer) { + // Clean up the mock server + if (mockServerInstance?.isStarted()) { try { - await stopMockServer(mockServer); + await mockServerInstance.stop(); } catch (cleanupError) { logger.error('Error during mock server cleanup:', cleanupError); cleanupErrors.push(cleanupError as Error); } } - try { - await stopFixtureServer(fixtureServer); - } catch (cleanupError) { - logger.error('Error during fixture server cleanup:', cleanupError); - cleanupErrors.push(cleanupError as Error); - } - - if (useCommandQueueServer) { + // Clean up the fixture server + if (fixtureServer?.isStarted()) { try { - await stopCommandQueueServer(commandQueueServer); + await fixtureServer.stop(); } catch (cleanupError) { - logger.error( - 'Error during command queue server cleanup:', - cleanupError, - ); + logger.error('Error during fixture server cleanup:', cleanupError); cleanupErrors.push(cleanupError as Error); } } + // Clean up the command queue server + if (useCommandQueueServer) { + if (commandQueueServer?.isStarted()) { + try { + await commandQueueServer.stop(); + } catch (cleanupError) { + logger.error( + 'Error during command queue server cleanup:', + cleanupError, + ); + cleanupErrors.push(cleanupError as Error); + } + } + } + if (!skipReactNativeReload) { try { // Force reload React Native to stop any lingering timers @@ -589,7 +520,7 @@ export async function withFixtures( try { // Validate live requests - validateLiveRequests(mockServer); + mockServerInstance.validateLiveRequests(); } catch (cleanupError) { logger.error('Error during live request validation:', cleanupError); cleanupErrors.push(cleanupError as Error); diff --git a/e2e/framework/fixtures/FixtureServer.ts b/e2e/framework/fixtures/FixtureServer.ts index 87079040f6f0..f4680606f469 100644 --- a/e2e/framework/fixtures/FixtureServer.ts +++ b/e2e/framework/fixtures/FixtureServer.ts @@ -3,6 +3,7 @@ import Koa, { Context } from 'koa'; import { isObject, mapValues } from 'lodash'; import FixtureBuilder from './FixtureBuilder'; import { createLogger } from '../logger'; +import { Resource, ServerStatus } from '../types'; const logger = createLogger({ name: 'FixtureServer', @@ -82,15 +83,19 @@ function performStateSubstitutions( ); } -class FixtureServer { +class FixtureServer implements Resource { private _app: Koa; private _stateMap: Map; - private _server: ReturnType | undefined; + private _server: ReturnType | null; + _serverPort: number; + _serverStatus: ServerStatus = ServerStatus.STOPPED; constructor() { this._app = new Koa(); this._stateMap = new Map([[DEFAULT_STATE_KEY, Object.create(null)]]); - + this._serverPort = getFixturesServerPort(); + this._server = null; + this._serverStatus = ServerStatus.STOPPED; this._app.use(async (ctx: Context) => { // Middleware to handle requests ctx.set('Access-Control-Allow-Origin', '*'); @@ -106,39 +111,118 @@ class FixtureServer { }); } + /** + * Get the status of the fixture server + * @returns The status of the fixture server + */ + get serverStatus() { + return this._serverStatus; + } + + /** + * + * @returns Whether the fixture server is started + */ + isStarted(): boolean { + return this._serverStatus === ServerStatus.STARTED; + } + + /** + * Get the port the fixture server is running on + * @returns The port the fixture server is running on + */ + getServerPort(): number { + return this._serverPort; + } + + /** + * Get the status of the fixture server + * @returns The status of the fixture server + */ + getServerStatus(): ServerStatus { + return this._serverStatus; + } + + /** + * Get the URL of the fixture server + * @returns + */ + get getServerUrl(): string { + return `http://${getLocalHost()}:${this._serverPort}/state.json`; + } + // Start the fixture server - async start() { + async start(): Promise { + if (this._serverStatus === ServerStatus.STARTED) { + logger.debug('The fixture server has already been started'); + return; + } + const options = { host: getLocalHost(), - port: getFixturesServerPort(), + port: this._serverPort, exclusive: true, }; return new Promise((resolve, reject) => { - logger.debug('Starting fixture server...'); + logger.debug(`Starting fixture server on port ${this._serverPort}`); this._server = this._app.listen(options); if (!this._server) { throw new Error('Failed to start fixture server'); } - this._server.once('error', reject); - this._server.once('listening', resolve); + let onError: ((err: unknown) => void) | null = null; + let onListening: (() => void) | null = null; + onError = (err: unknown) => { + if (onListening) { + this._server?.removeListener('listening', onListening); + } + reject(err); + }; + onListening = () => { + if (onError) { + this._server?.removeListener('error', onError); + } + this._serverStatus = ServerStatus.STARTED; + resolve(undefined); + }; + this._server.once('error', onError); + this._server.once('listening', onListening); }); } // Stop the fixture server - async stop() { - if (!this._server) { + async stop(): Promise { + logger.debug(`Stopping fixture server on port ${this._serverPort}`); + if (this._serverStatus === ServerStatus.STOPPED || !this._server) { + logger.debug('The fixture server has already been stopped'); return; } await new Promise((resolve, reject) => { - logger.debug('Stopping fixture server...'); - if (!this._server) { - throw new Error('Failed to stop fixture server'); + const serverRef = this._server; + if (!serverRef) { + this._serverStatus = ServerStatus.STOPPED; + resolve(undefined); + return; } - this._server.close(); - this._server.once('error', reject); - this._server.once('close', resolve); - this._server = undefined; + let onError: ((err: unknown) => void) | null = null; + let onClose: (() => void) | null = null; + onError = (err: unknown) => { + if (onClose) { + serverRef.removeListener('close', onClose); + } + reject(err); + }; + onClose = () => { + if (onError) { + serverRef.removeListener('error', onError); + } + this._server = null; + this._serverStatus = ServerStatus.STOPPED; + resolve(undefined); + }; + serverRef.once('error', onError); + serverRef.once('close', onClose); + serverRef.close(); }); } // Load JSON state into the server diff --git a/e2e/framework/types.ts b/e2e/framework/types.ts index 74429ebe8f25..9822b2cd3b4b 100644 --- a/e2e/framework/types.ts +++ b/e2e/framework/types.ts @@ -76,6 +76,24 @@ export interface RampsRegion { detected: boolean; } +export enum ServerStatus { + STOPPED = 'stopped', + STARTED = 'started', +} + +/** + * Interface representing a resource that can be started and stopped. + * Examples: FixtureServer, MockServer, CommandQueueServer, etc. + */ +export interface Resource { + stop(): Promise; + start(): Promise; + isStarted(): boolean; + getServerPort(): number; + getServerStatus(): ServerStatus; + getServerUrl?: string; +} + // Fixtures and Local Node Types // Available local node types export enum LocalNodeType { diff --git a/e2e/seeder/anvil-manager.ts b/e2e/seeder/anvil-manager.ts index 76b2715eade2..fd869783572b 100644 --- a/e2e/seeder/anvil-manager.ts +++ b/e2e/seeder/anvil-manager.ts @@ -5,7 +5,7 @@ import fs from 'fs'; import path from 'path'; import { createAnvilClients } from './anvil-clients'; import { AnvilPort } from '../framework/fixtures/FixtureUtils'; -import { AnvilNodeOptions } from '../framework/types'; +import { AnvilNodeOptions, ServerStatus, Resource } from '../framework/types'; import { createLogger } from '../framework/logger'; const logger = createLogger({ @@ -73,18 +73,11 @@ export const defaultOptions = { * Manages an Anvil Ethereum development server instance * @class */ -class AnvilManager { +class AnvilManager implements Resource { private server: AnvilType | undefined; private serverPort: number | undefined; private anvilBinary: string | undefined; - - /** - * Check if the Anvil server is running - * @returns {boolean} True if the server is running, false otherwise - */ - isRunning(): boolean { - return this.server !== undefined; - } + serverStatus: ServerStatus = ServerStatus.STOPPED; // Using shared port utilities from FixtureUtils @@ -190,6 +183,7 @@ class AnvilManager { await this.server.start(); logger.debug(`Server started successfully on port ${port}`); + this.serverStatus = ServerStatus.STARTED; } catch (error) { logger.error(`Failed to start server on port ${port}:`, error); @@ -217,6 +211,7 @@ class AnvilManager { logger.debug( `Server started successfully on alternative port ${alternativePort}`, ); + this.serverStatus = ServerStatus.STARTED; return; } catch (retryError) { logger.error( @@ -228,6 +223,7 @@ class AnvilManager { this.server = undefined; this.serverPort = undefined; + this.serverStatus = ServerStatus.STOPPED; throw error; } } @@ -313,17 +309,19 @@ class AnvilManager { * @throws {Error} If server is not running * @throws {Error} If server fails to stop */ - async quit(): Promise { - if (!this.server) { + async stop(): Promise { + if (this.serverStatus !== ServerStatus.STARTED) { logger.debug('Anvil server not running in this instance.'); + this.serverStatus = ServerStatus.STOPPED; return; } try { const port = this.serverPort || AnvilPort(); logger.debug(`Stopping Anvil server on port ${port}...`); - await this.server.stop(); + await this.server?.stop(); logger.debug(`Anvil server stopped on port ${port}`); + this.serverStatus = ServerStatus.STOPPED; } catch (e) { logger.error(`Error stopping server: ${e}`); throw e; @@ -332,5 +330,21 @@ class AnvilManager { this.serverPort = undefined; } } + + /** + * Check if the Anvil server is running + * @returns {boolean} True if the server is running, false otherwise + */ + isStarted(): boolean { + return this.serverStatus === ServerStatus.STARTED; + } + + getServerPort(): number { + return this.serverPort ?? 0; + } + + getServerStatus(): ServerStatus { + return this.serverStatus; + } } export { AnvilManager }; diff --git a/e2e/selectors/wallet/WalletActionsBottomSheet.selectors.ts b/e2e/selectors/wallet/WalletActionsBottomSheet.selectors.ts index 6221e63bb191..8ef99908a3bc 100644 --- a/e2e/selectors/wallet/WalletActionsBottomSheet.selectors.ts +++ b/e2e/selectors/wallet/WalletActionsBottomSheet.selectors.ts @@ -3,6 +3,7 @@ export const WalletActionsBottomSheetSelectorsIDs = { RECEIVE_BUTTON: 'wallet-receive-action', SWAP_BUTTON: 'wallet-actions-bottom-sheet-swap-button', BUY_BUTTON: 'wallet-buy-action', + BUY_UNIFIED_BUTTON: 'wallet-buy-unified-action', SELL_BUTTON: 'wallet-sell-action', DEPOSIT_BUTTON: 'wallet-deposit-action', BRIDGE_BUTTON: 'wallet-bridge-button', diff --git a/e2e/specs/confirmations-redesigned/signatures/alert-system.spec.ts b/e2e/specs/confirmations-redesigned/signatures/alert-system.spec.ts index f685ba9c55fc..4a1eba5f71c9 100644 --- a/e2e/specs/confirmations-redesigned/signatures/alert-system.spec.ts +++ b/e2e/specs/confirmations-redesigned/signatures/alert-system.spec.ts @@ -35,7 +35,6 @@ const typedSignRequestBody = { ], '0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3', ], - origin: getTestDappLocalUrl(), }; describe(SmokeConfirmationsRedesigned('Alert System - Signature'), () => { @@ -83,7 +82,7 @@ describe(SmokeConfirmationsRedesigned('Alert System - Signature'), () => { await setupMockPostRequest( mockServer, securityAlertsUrl('0xaa36a7'), - typedSignRequestBody, + { ...typedSignRequestBody, origin: getTestDappLocalUrl() }, SECURITY_ALERTS_BENIGN_RESPONSE, { statusCode: 201, @@ -115,7 +114,7 @@ describe(SmokeConfirmationsRedesigned('Alert System - Signature'), () => { await setupMockPostRequest( mockServer, 'https://security-alerts.api.cx.metamask.io/validate/0xaa36a7', - typedSignRequestBody, + { ...typedSignRequestBody, origin: getTestDappLocalUrl() }, { block: 20733277, result_type: 'Malicious', @@ -175,7 +174,7 @@ describe(SmokeConfirmationsRedesigned('Alert System - Signature'), () => { await setupMockPostRequest( mockServer, 'https://security-alerts.api.cx.metamask.io/validate/0xaa36a7', - typedSignRequestBody, + { ...typedSignRequestBody, origin: getTestDappLocalUrl() }, { error: 'Internal Server Error', message: 'An unexpected error occurred on the server.', diff --git a/e2e/specs/confirmations/signatures/security-alert-signatures.mock.spec.ts b/e2e/specs/confirmations/signatures/security-alert-signatures.mock.spec.ts index 7bc4d637bd2e..28802365a63c 100644 --- a/e2e/specs/confirmations/signatures/security-alert-signatures.mock.spec.ts +++ b/e2e/specs/confirmations/signatures/security-alert-signatures.mock.spec.ts @@ -34,7 +34,6 @@ const typedSignRequestBody = { ], '0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3', ], - origin: getTestDappLocalUrl(), }; describe(SmokeConfirmationsRedesigned('Security Alert API - Signature'), () => { @@ -81,7 +80,7 @@ describe(SmokeConfirmationsRedesigned('Security Alert API - Signature'), () => { await setupMockPostRequest( mockServer, securityAlertsUrl('0xaa36a7'), - typedSignRequestBody, + { ...typedSignRequestBody, origin: getTestDappLocalUrl() }, SECURITY_ALERTS_BENIGN_RESPONSE, { statusCode: 201, @@ -113,7 +112,7 @@ describe(SmokeConfirmationsRedesigned('Security Alert API - Signature'), () => { await setupMockPostRequest( mockServer, 'https://security-alerts.api.cx.metamask.io/validate/0xaa36a7', - typedSignRequestBody, + { ...typedSignRequestBody, origin: getTestDappLocalUrl() }, { block: 20733277, result_type: 'Malicious', @@ -162,7 +161,7 @@ describe(SmokeConfirmationsRedesigned('Security Alert API - Signature'), () => { await setupMockPostRequest( mockServer, 'https://security-alerts.api.cx.metamask.io/validate/0xaa36a7', - typedSignRequestBody, + { ...typedSignRequestBody, origin: getTestDappLocalUrl() }, { error: 'Internal Server Error', message: 'An unexpected error occurred on the server.', diff --git a/e2e/specs/identity/account-syncing/multi-srp.spec.ts b/e2e/specs/identity/account-syncing/multi-srp.spec.ts index af96ac126ada..75627b901f5c 100644 --- a/e2e/specs/identity/account-syncing/multi-srp.spec.ts +++ b/e2e/specs/identity/account-syncing/multi-srp.spec.ts @@ -162,7 +162,6 @@ describe(SmokeIdentity('Account syncing - Mutiple SRPs'), () => { await Assertions.expectElementToBeVisible(WalletView.container); await WalletView.tapIdenticon(); - await device.enableSynchronization(); const visibleAccounts = [ DEFAULT_ACCOUNT_NAME, SECOND_ACCOUNT_NAME, @@ -177,6 +176,7 @@ describe(SmokeIdentity('Account syncing - Mutiple SRPs'), () => { ), { description: `Account with name "${accountName}" should be visible`, + timeout: 20000, }, ); } diff --git a/e2e/specs/perps/perps-add-funds.spec.ts b/e2e/specs/perps/perps-add-funds.spec.ts index d8553572f4c8..e85f1225a2c7 100644 --- a/e2e/specs/perps/perps-add-funds.spec.ts +++ b/e2e/specs/perps/perps-add-funds.spec.ts @@ -26,7 +26,7 @@ describe(SmokePerps('Perps - Add funds (has funds, not first time)'), () => { jest.setTimeout(150000); }); - it.skip('deposits $80 from Add funds and verifies updated balance', async () => { + it('deposits $80 from Add funds and verifies updated balance', async () => { await withFixtures( { fixture: new FixtureBuilder() @@ -119,7 +119,7 @@ describe(SmokePerps('Perps - Add funds (has funds, not first time)'), () => { current === initialBalance + 80, ); }, - { interval: 500, timeout: 30000 }, + { interval: 1000, timeout: 60000 }, ); }, ); diff --git a/e2e/specs/send/send-native-token.spec.ts b/e2e/specs/quarantine/send-native-token.failing.ts similarity index 100% rename from e2e/specs/send/send-native-token.spec.ts rename to e2e/specs/quarantine/send-native-token.failing.ts diff --git a/e2e/specs/quarantine/send-to-contact.failing.ts b/e2e/specs/quarantine/send-to-contact.failing.ts index dc44d5c519ee..a18e0dae2280 100644 --- a/e2e/specs/quarantine/send-to-contact.failing.ts +++ b/e2e/specs/quarantine/send-to-contact.failing.ts @@ -7,11 +7,7 @@ import TabBarComponent from '../../pages/wallet/TabBarComponent'; import WalletView from '../../pages/wallet/WalletView'; import enContent from '../../../locales/languages/en.json'; import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; -import { - loadFixture, - startFixtureServer, - stopFixtureServer, -} from '../../framework/fixtures/FixtureHelper'; +import { loadFixture } from '../../framework/fixtures/FixtureHelper'; import { CustomNetworks } from '../../resources/networks.e2e'; import TestHelpers from '../../helpers'; import FixtureServer from '../../framework/fixtures/FixtureServer'; @@ -44,7 +40,7 @@ describe(RegressionConfirmations('Send ETH'), () => { // }, // }) .build(); - await startFixtureServer(fixtureServer); + await fixtureServer.start(); await loadFixture(fixtureServer, { fixture }); await device.launchApp({ permissions: { notifications: 'YES' }, @@ -54,7 +50,7 @@ describe(RegressionConfirmations('Send ETH'), () => { }); afterAll(async () => { - await stopFixtureServer(fixtureServer); + await fixtureServer.stop(); }); it('should send ETH to a contact from inside the wallet', async () => { diff --git a/e2e/specs/quarantine/swap-deeplink.failing.ts b/e2e/specs/quarantine/swap-deeplink.failing.ts index f84f37bb30a7..7582a6c6ae45 100644 --- a/e2e/specs/quarantine/swap-deeplink.failing.ts +++ b/e2e/specs/quarantine/swap-deeplink.failing.ts @@ -1,29 +1,24 @@ 'use strict'; /* eslint-disable no-console */ -import { Mockttp } from 'mockttp'; import { loginToApp } from '../../viewHelper'; import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; import Ganache from '../../../app/util/test/ganache'; import { loadFixture, - stopFixtureServer, - startFixtureServer, + createMockAPIServer, } from '../../framework/fixtures/FixtureHelper'; import TestHelpers from '../../helpers.js'; import FixtureServer from '../../framework/fixtures/FixtureServer'; -import { - getFixturesServerPort, - getMockServerPort, -} from '../../framework/fixtures/FixtureUtils'; +import { getFixturesServerPort } from '../../framework/fixtures/FixtureUtils'; import { SmokeTrade } from '../../tags.js'; import Assertions from '../../framework/Assertions'; -import { startMockServer, stopMockServer } from '../../api-mocking/mock-server'; import QuoteView from '../../pages/swaps/QuoteView'; import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; import { Assertions as FrameworkAssertions } from '../../framework'; import { testSpecificMock as swapTestSpecificMock } from '../swaps/helpers/swap-mocks'; import { localNodeOptions } from '../swaps/helpers/constants'; +import MockServerE2E from '../../api-mocking/MockServerE2E'; const fixtureServer: FixtureServer = new FixtureServer(); @@ -35,42 +30,37 @@ const SWAP_DEEPLINK_FULL = `${SWAP_DEEPLINK_BASE}?from=eip155:1/erc20:0xA0b86991 describe( SmokeTrade('Swap Deep Link Tests - Unified Bridge Experience'), (): void => { - let mockServer: Mockttp; let localNode: Ganache; + let mockServerInstance: MockServerE2E; beforeAll(async (): Promise => { localNode = new Ganache(); await localNode.start(localNodeOptions); - const mockServerPort = getMockServerPort(); - // Added to pass linting - this pattern is not recommended. Check other swaps test for new patter - mockServer = await startMockServer( - {}, - mockServerPort, - swapTestSpecificMock, - ); + mockServerInstance = (await createMockAPIServer(swapTestSpecificMock)) + .mockServerInstance; await TestHelpers.reverseServerPort(); const fixture = new FixtureBuilder() .withGanacheNetwork('0x1') .withMetaMetricsOptIn() .build(); - await startFixtureServer(fixtureServer); + await fixtureServer.start(); await loadFixture(fixtureServer, { fixture }); await TestHelpers.launchApp({ permissions: { notifications: 'YES' }, launchArgs: { fixtureServerPort: `${getFixturesServerPort()}`, - mockServerPort: `${mockServerPort}`, + mockServerPort: `${mockServerInstance.getServerPort()}`, }, }); await loginToApp(); }); afterAll(async (): Promise => { - await stopFixtureServer(fixtureServer); - if (mockServer) await stopMockServer(mockServer); - if (localNode) await localNode.quit(); + await fixtureServer.stop(); + if (mockServerInstance?.isStarted()) await mockServerInstance.stop(); + if (localNode) await localNode.stop(); }); beforeEach(async (): Promise => { diff --git a/e2e/specs/quarantine/swap-segment-smoke.failing.ts b/e2e/specs/quarantine/swap-segment-smoke.failing.ts index f84f37bb30a7..a0b7207da10d 100644 --- a/e2e/specs/quarantine/swap-segment-smoke.failing.ts +++ b/e2e/specs/quarantine/swap-segment-smoke.failing.ts @@ -1,29 +1,24 @@ 'use strict'; /* eslint-disable no-console */ -import { Mockttp } from 'mockttp'; import { loginToApp } from '../../viewHelper'; import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; import Ganache from '../../../app/util/test/ganache'; import { loadFixture, - stopFixtureServer, - startFixtureServer, + createMockAPIServer, } from '../../framework/fixtures/FixtureHelper'; import TestHelpers from '../../helpers.js'; import FixtureServer from '../../framework/fixtures/FixtureServer'; -import { - getFixturesServerPort, - getMockServerPort, -} from '../../framework/fixtures/FixtureUtils'; +import { getFixturesServerPort } from '../../framework/fixtures/FixtureUtils'; import { SmokeTrade } from '../../tags.js'; import Assertions from '../../framework/Assertions'; -import { startMockServer, stopMockServer } from '../../api-mocking/mock-server'; import QuoteView from '../../pages/swaps/QuoteView'; import Matchers from '../../framework/Matchers'; import Gestures from '../../framework/Gestures'; import { Assertions as FrameworkAssertions } from '../../framework'; import { testSpecificMock as swapTestSpecificMock } from '../swaps/helpers/swap-mocks'; import { localNodeOptions } from '../swaps/helpers/constants'; +import MockServerE2E from '../../api-mocking/MockServerE2E'; const fixtureServer: FixtureServer = new FixtureServer(); @@ -35,42 +30,37 @@ const SWAP_DEEPLINK_FULL = `${SWAP_DEEPLINK_BASE}?from=eip155:1/erc20:0xA0b86991 describe( SmokeTrade('Swap Deep Link Tests - Unified Bridge Experience'), (): void => { - let mockServer: Mockttp; + let mockServerInstance: MockServerE2E; let localNode: Ganache; beforeAll(async (): Promise => { localNode = new Ganache(); await localNode.start(localNodeOptions); - const mockServerPort = getMockServerPort(); + mockServerInstance = (await createMockAPIServer(swapTestSpecificMock)) + .mockServerInstance; // Added to pass linting - this pattern is not recommended. Check other swaps test for new patter - mockServer = await startMockServer( - {}, - mockServerPort, - swapTestSpecificMock, - ); - await TestHelpers.reverseServerPort(); const fixture = new FixtureBuilder() .withGanacheNetwork('0x1') .withMetaMetricsOptIn() .build(); - await startFixtureServer(fixtureServer); + await fixtureServer.start(); await loadFixture(fixtureServer, { fixture }); await TestHelpers.launchApp({ permissions: { notifications: 'YES' }, launchArgs: { fixtureServerPort: `${getFixturesServerPort()}`, - mockServerPort: `${mockServerPort}`, + mockServerPort: `${mockServerInstance.getServerPort()}`, }, }); await loginToApp(); }); afterAll(async (): Promise => { - await stopFixtureServer(fixtureServer); - if (mockServer) await stopMockServer(mockServer); - if (localNode) await localNode.quit(); + await fixtureServer.stop(); + if (mockServerInstance?.isStarted()) await mockServerInstance.stop(); + if (localNode) await localNode.stop(); }); beforeEach(async (): Promise => { diff --git a/e2e/specs/stake/stake-action-smoke.spec.ts b/e2e/specs/stake/stake-action-smoke.spec.ts index 0c4095a74c9f..76b17c1c9feb 100644 --- a/e2e/specs/stake/stake-action-smoke.spec.ts +++ b/e2e/specs/stake/stake-action-smoke.spec.ts @@ -6,17 +6,13 @@ import { ActivitiesViewSelectorsText } from '../../selectors/Transactions/Activi import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; import TokenOverview from '../../pages/wallet/TokenOverview'; import WalletView from '../../pages/wallet/WalletView'; -import { - loadFixture, - startFixtureServer, -} from '../../framework/fixtures/FixtureHelper'; +import { loadFixture } from '../../framework/fixtures/FixtureHelper'; import { CustomNetworks, PopularNetworksList, } from '../../resources/networks.e2e'; import TestHelpers from '../../helpers'; import FixtureServer from '../../framework/fixtures/FixtureServer'; -import { getFixturesServerPort } from '../../framework/fixtures/FixtureUtils'; import { SmokeTrade } from '../../tags'; import Assertions from '../../framework/Assertions'; import StakeView from '../../pages/Stake/StakeView'; @@ -66,11 +62,11 @@ describe.skip(SmokeTrade('Stake from Actions'), (): void => { .withNetworkController(PopularNetworksList.zkSync) .withNetworkController(CustomNetworks.Hoodi) .build(); - await startFixtureServer(fixtureServer); + await fixtureServer.start(); await loadFixture(fixtureServer, { fixture }); await TestHelpers.launchApp({ permissions: { notifications: 'YES' }, - launchArgs: { fixtureServerPort: `${getFixturesServerPort()}` }, + launchArgs: { fixtureServerPort: `${fixtureServer.getServerPort()}` }, }); await TestHelpers.delay(5000); await loginToApp(); diff --git a/locales/languages/en.json b/locales/languages/en.json index c321c50901e7..d8800a53648a 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1680,7 +1680,7 @@ "history_will_appear": "Your trading history will appear here" } }, - "risk_disclaimer": "Perpetual contracts are very risky, and you could suddenly and without notice lose your entire investment. You trade entirely at your own risk. Market data provided by Hyperliquid. Price chart powered by", + "risk_disclaimer": "Perpetual contracts are very risky, and you could suddenly and without notice lose your entire investment. You trade entirely at your own risk. Powered by {{source}}. Price chart powered by", "tutorial": { "continue": "Continue", "skip": "Skip", @@ -1786,6 +1786,8 @@ }, "outcomes_singular": "outcome", "outcomes_plural": "outcomes", + "outcome_winner": "Winner", + "outcome_loser": "Loser", "resolved_outcomes": "Resolved outcomes", "category": { "trending": "Trending", @@ -2956,6 +2958,8 @@ "deposit_description": "Low-fee bank or card transfer", "buy": "Buy", "buy_description": "Good for buying a specific token", + "buy_unified": "Buy", + "buy_unified_description": "Buy crypto with cash", "sell": "Withdraw", "sell_description": "Sell crypto for cash" }, @@ -3010,7 +3014,7 @@ "send_description": "Send crypto to any account", "receive_description": "Receive crypto", "earn_description": "Earn rewards on your tokens", - "perps_description": "Trade perp contracts", + "perps_description": "Trade perps contracts", "predict_description": "Trade on real-world events", "chart_time_period": { "1d": "Today", @@ -5780,13 +5784,13 @@ "apply": "Apply", "slippage": "Slippage", "slippage_info": "If the price changes between the time your order is placed and confirmed it’s called “slippage.” Your swap will automatically cancel if slippage exceeds the tolerance you set here.", - "network_fee": "Network Fee", + "network_fee": "Network fee", "included": "Included", "estimated_time": "Estimated Time", "quote": "Quote", "rate": "Rate", "quote_details": "Quote Details", - "price_impact": "Price Impact", + "price_impact": "Price impact", "time": "Time", "quote_info_content": "The best rate we found from providers, including provider fees and a 0.875% MetaMask fee.", "quote_info_title": "Rate", @@ -5830,7 +5834,7 @@ "approval_needed": "Approves token for swap.", "approval_tooltip_title": "Grant exact access", "approval_tooltip_content": "You are allowing access to the specified amount, {{amount}} {{symbol}}. The contract will not access any additional funds.", - "minimum_received": "Minimum Received", + "minimum_received": "Minimum received", "minimum_received_tooltip_title": "Minimum Received", "minimum_received_tooltip_content": "The minimum amount you'll receive if the price changes while your transaction is processing, based on your slippage tolerance. This is an estimate from our liquidity providers. Final amounts may differ.", "verified_token": "Verified token", diff --git a/package.json b/package.json index 6635be6b9e91..1c349bf7f29a 100644 --- a/package.json +++ b/package.json @@ -182,7 +182,7 @@ "dependencies": { "@config-plugins/detox": "^9.0.0", "@consensys/native-ramps-sdk": "2.1.6", - "@consensys/on-ramp-sdk": "2.1.11", + "@consensys/on-ramp-sdk": "2.1.12", "@craftzdog/react-native-buffer": "^6.1.0", "@ethersproject/abi": "^5.7.0", "@expo/fingerprint": "^0.15.0", @@ -198,7 +198,7 @@ "@metamask/address-book-controller": "^7.0.0", "@metamask/app-metadata-controller": "^2.0.0", "@metamask/approval-controller": "^8.0.0", - "@metamask/assets-controllers": "^84.0.0", + "@metamask/assets-controllers": "^87.0.0", "@metamask/base-controller": "^9.0.0", "@metamask/bitcoin-wallet-snap": "^1.4.3", "@metamask/bridge-controller": "^56.0.3", @@ -251,7 +251,7 @@ "@metamask/multichain-transactions-controller": "^6.0.0", "@metamask/native-utils": "^0.5.0", "@metamask/network-controller": "^25.0.0", - "@metamask/network-enablement-controller": "^3.0.0", + "@metamask/network-enablement-controller": "patch:@metamask/network-enablement-controller@npm%3A3.0.0#~/.yarn/patches/@metamask-network-enablement-controller-npm-3.0.0-cfba64ad39.patch", "@metamask/notification-services-controller": "^19.0.0", "@metamask/permission-controller": "^12.1.0", "@metamask/phishing-controller": "^15.0.0", @@ -544,6 +544,7 @@ "@types/react-native-vector-icons": "^6.4.13", "@types/react-native-video": "^5.0.14", "@types/redux-mock-store": "^1.0.3", + "@types/serve-handler": "^6.1.4", "@types/url-parse": "^1.4.8", "@types/valid-url": "^1.0.4", "@typescript-eslint/eslint-plugin": "^7.10.0", diff --git a/wdio/screen-objects/Onboarding/MetaMetricsScreen.js b/wdio/screen-objects/Onboarding/MetaMetricsScreen.js index 26ad619ad7b2..20dae1fb8ce4 100644 --- a/wdio/screen-objects/Onboarding/MetaMetricsScreen.js +++ b/wdio/screen-objects/Onboarding/MetaMetricsScreen.js @@ -39,6 +39,14 @@ class MetaMetricsScreen { } } + get continueButton() { + if (!this._device) { + return Selectors.getXpathElementByResourceId(MetaMetricsOptInSelectorsIDs.OPTIN_METRICS_CONTINUE_BUTTON_ID); + } else { + return AppwrightSelectors.getElementByID(this._device, MetaMetricsOptInSelectorsIDs.OPTIN_METRICS_CONTINUE_BUTTON_ID); + } + } + async isScreenTitleVisible() { if (!this._device) { await expect(this.screenTitle).toBeDisplayed(); @@ -48,6 +56,16 @@ class MetaMetricsScreen { } } + async tapContinueButton() { + if (!this._device) { + await Gestures.waitAndTap(this.continueButton); + } else { + const element = await this.continueButton; + await appwrightExpect(element).toBeVisible({ timeout: 30000 }); + await element.tap(); + } + } + async tapIAgreeButton() { if (!this._device) { const element = await this.iAgreeButton; diff --git a/wdio/screen-objects/Onboarding/OnboardingScreen.js b/wdio/screen-objects/Onboarding/OnboardingScreen.js index 12ccb08acf0d..12b213ade66e 100644 --- a/wdio/screen-objects/Onboarding/OnboardingScreen.js +++ b/wdio/screen-objects/Onboarding/OnboardingScreen.js @@ -50,6 +50,15 @@ class OnBoardingScreen { await AppwrightGestures.tap(this.createNewWalletButton); // Use static tapElement method with retry logic } } + + async isScreenTitleVisible() { + if (!this._device) { + await expect(this.createNewWalletButton).toBeDisplayed(); + } else { + const element = await this.createNewWalletButton; + await appwrightExpect(element).toBeVisible({ timeout: 30000 }); + } + } } export default new OnBoardingScreen(); diff --git a/yarn.lock b/yarn.lock index c9112e24a74a..5f47e750abb6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2061,9 +2061,9 @@ __metadata: languageName: node linkType: hard -"@consensys/on-ramp-sdk@npm:2.1.11": - version: 2.1.11 - resolution: "@consensys/on-ramp-sdk@npm:2.1.11" +"@consensys/on-ramp-sdk@npm:2.1.12": + version: 2.1.12 + resolution: "@consensys/on-ramp-sdk@npm:2.1.12" dependencies: async: "npm:^3.2.3" axios: "npm:^1.8.3" @@ -2071,7 +2071,7 @@ __metadata: crypto-js: "npm:^4.2.0" reflect-metadata: "npm:^0.1.13" uuid: "npm:^9.0.0" - checksum: 10/f14dd36b82c7f804a8d5fcb0f91bdb4cf234aecb5c8c5008d25ad5deece52029feeff1605977042c279ea684c7aebb1db735b52b6e060a60c013db3f34c160af + checksum: 10/59c70f0cbec62a55e3de01422026600913b07c075f592f212cbd8e5fc11b9555d1da385a7f12704403e5d1bc21d0f3b0730b6ca37f0440a51c05d18ac5b1e7a9 languageName: node linkType: hard @@ -6943,9 +6943,9 @@ __metadata: languageName: node linkType: hard -"@metamask/assets-controllers@npm:^84.0.0": - version: 84.0.0 - resolution: "@metamask/assets-controllers@npm:84.0.0" +"@metamask/assets-controllers@npm:^87.0.0": + version: 87.0.0 + resolution: "@metamask/assets-controllers@npm:87.0.0" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@ethersproject/abi": "npm:^5.7.0" @@ -6956,7 +6956,7 @@ __metadata: "@metamask/abi-utils": "npm:^2.0.3" "@metamask/base-controller": "npm:^9.0.0" "@metamask/contract-metadata": "npm:^2.4.0" - "@metamask/controller-utils": "npm:^11.14.1" + "@metamask/controller-utils": "npm:^11.15.0" "@metamask/eth-query": "npm:^4.0.0" "@metamask/keyring-api": "npm:^21.0.0" "@metamask/messenger": "npm:^0.3.0" @@ -6991,7 +6991,7 @@ __metadata: "@metamask/snaps-controllers": ^14.0.0 "@metamask/transaction-controller": ^61.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/ecb0147ca053a316a8f3f17aa35832ed8373a6c2e60028c091262e49e09a73fe11b510a5d678ebf9d8da6bfafbe4849741c45e7729493e85d20c3b273dc35cdc + checksum: 10/023c91a982e932978dcf33e057a82cb0a7531db18da0293721838cb14965a6adc38200cbcb1c129165c20411d3abd0dbd9dc6ac41ff0d1902c9648c536c07fde languageName: node linkType: hard @@ -7172,9 +7172,9 @@ __metadata: languageName: node linkType: hard -"@metamask/controller-utils@npm:^11.0.0, @metamask/controller-utils@npm:^11.11.0, @metamask/controller-utils@npm:^11.14.1, @metamask/controller-utils@npm:^11.3.0": - version: 11.14.1 - resolution: "@metamask/controller-utils@npm:11.14.1" +"@metamask/controller-utils@npm:^11.0.0, @metamask/controller-utils@npm:^11.11.0, @metamask/controller-utils@npm:^11.14.1, @metamask/controller-utils@npm:^11.15.0, @metamask/controller-utils@npm:^11.3.0": + version: 11.15.0 + resolution: "@metamask/controller-utils@npm:11.15.0" dependencies: "@metamask/eth-query": "npm:^4.0.0" "@metamask/ethjs-unit": "npm:^0.3.0" @@ -7189,7 +7189,7 @@ __metadata: lodash: "npm:^4.17.21" peerDependencies: "@babel/runtime": ^7.0.0 - checksum: 10/b00e2ba24a0903ec06c00de4506c789a717ecba3510244cc58435d26c990680e88d884ce417ba39e5cb3b8f7f16f3f42bdc77f284af248b7d1bd60abb80a836c + checksum: 10/30466473a73d02d32551c65820e307cd5231c35176521edce852efdf11a4b3dc2606afffd681e9105ddd686d1ba6bef85961b35f7ea3b77307141a92b66a6a12 languageName: node linkType: hard @@ -8061,7 +8061,7 @@ __metadata: languageName: node linkType: hard -"@metamask/network-enablement-controller@npm:^3.0.0": +"@metamask/network-enablement-controller@npm:3.0.0": version: 3.0.0 resolution: "@metamask/network-enablement-controller@npm:3.0.0" dependencies: @@ -8079,6 +8079,24 @@ __metadata: languageName: node linkType: hard +"@metamask/network-enablement-controller@patch:@metamask/network-enablement-controller@npm%3A3.0.0#~/.yarn/patches/@metamask-network-enablement-controller-npm-3.0.0-cfba64ad39.patch": + version: 3.0.0 + resolution: "@metamask/network-enablement-controller@patch:@metamask/network-enablement-controller@npm%3A3.0.0#~/.yarn/patches/@metamask-network-enablement-controller-npm-3.0.0-cfba64ad39.patch::version=3.0.0&hash=0805d0" + dependencies: + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/controller-utils": "npm:^11.14.1" + "@metamask/keyring-api": "npm:^21.0.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/utils": "npm:^11.8.1" + reselect: "npm:^5.1.1" + peerDependencies: + "@metamask/multichain-network-controller": ^2.0.0 + "@metamask/network-controller": ^25.0.0 + "@metamask/transaction-controller": ^61.0.0 + checksum: 10/3bbb6a13e2f1c08df6f79cbe5d619df20524e13e986307b2fb120e4950f3244b039a0f93710c1cfe9ce4b42b39f7a02d943bba7a93eaaa895e081af5324cfea4 + languageName: node + linkType: hard + "@metamask/nonce-tracker@npm:^6.0.0": version: 6.0.0 resolution: "@metamask/nonce-tracker@npm:6.0.0" @@ -17772,6 +17790,15 @@ __metadata: languageName: node linkType: hard +"@types/serve-handler@npm:^6.1.4": + version: 6.1.4 + resolution: "@types/serve-handler@npm:6.1.4" + dependencies: + "@types/node": "npm:*" + checksum: 10/c92ae204605659b37202af97cfcc7690be43b9290692c1d6c3c93805b399044fd67573af4eb2e7b1fd975451db6d0d5c6cd2f09b20997209fa3341f345f661e4 + languageName: node + linkType: hard + "@types/serve-static@npm:*": version: 1.15.1 resolution: "@types/serve-static@npm:1.15.1" @@ -34249,7 +34276,7 @@ __metadata: "@babel/runtime": "npm:^7.25.0" "@config-plugins/detox": "npm:^9.0.0" "@consensys/native-ramps-sdk": "npm:2.1.6" - "@consensys/on-ramp-sdk": "npm:2.1.11" + "@consensys/on-ramp-sdk": "npm:2.1.12" "@craftzdog/react-native-buffer": "npm:^6.1.0" "@cucumber/message-streams": "npm:^4.0.1" "@cucumber/messages": "npm:^22.0.0" @@ -34275,7 +34302,7 @@ __metadata: "@metamask/address-book-controller": "npm:^7.0.0" "@metamask/app-metadata-controller": "npm:^2.0.0" "@metamask/approval-controller": "npm:^8.0.0" - "@metamask/assets-controllers": "npm:^84.0.0" + "@metamask/assets-controllers": "npm:^87.0.0" "@metamask/auto-changelog": "npm:^5.1.0" "@metamask/base-controller": "npm:^9.0.0" "@metamask/bitcoin-wallet-snap": "npm:^1.4.3" @@ -34335,7 +34362,7 @@ __metadata: "@metamask/multichain-transactions-controller": "npm:^6.0.0" "@metamask/native-utils": "npm:^0.5.0" "@metamask/network-controller": "npm:^25.0.0" - "@metamask/network-enablement-controller": "npm:^3.0.0" + "@metamask/network-enablement-controller": "patch:@metamask/network-enablement-controller@npm%3A3.0.0#~/.yarn/patches/@metamask-network-enablement-controller-npm-3.0.0-cfba64ad39.patch" "@metamask/notification-services-controller": "npm:^19.0.0" "@metamask/object-multiplex": "npm:^1.1.0" "@metamask/permission-controller": "npm:^12.1.0" @@ -34454,6 +34481,7 @@ __metadata: "@types/react-native-video": "npm:^5.0.14" "@types/react-test-renderer": "npm:^18.0.0" "@types/redux-mock-store": "npm:^1.0.3" + "@types/serve-handler": "npm:^6.1.4" "@types/url-parse": "npm:^1.4.8" "@types/valid-url": "npm:^1.0.4" "@typescript-eslint/eslint-plugin": "npm:^7.10.0"