From da62c11d3f74cecf9f0d0a58e17eeddc3b68467b Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Mon, 1 Jun 2026 19:33:53 +0200 Subject: [PATCH 01/22] fix: fix market insights and security page buttons cp-7.80.0 (#30871) ## **Description** Pass the resolved ambient price color state (`isPricePositive` and `useAmbientColor`) from the Token Details page through to the Market Insights and Security Trust sub-screens so the Swap/Buy footer buttons consistently match the token details theme color (green when price is up, orange when price is down). Previously, navigating to Market Insights or the Security Trust page would always render the footer buttons in the default green, ignoring the ambient color A/B test treatment active on the parent screen. ## **Changelog** CHANGELOG entry: Fixed Market Insights and Security Trust page footer buttons not reflecting the ambient price color from the Token Details page ## **Related issues** Fixes: [ASSETS-3307](https://consensyssoftware.atlassian.net/browse/ASSETS-3307) ## **Manual testing steps** ```gherkin Feature: Ambient color consistency across Token Details sub-screens Scenario: Footer buttons match token details color on Market Insights Given the user is on the Token Details page for a token with a negative price change And the ambient price color A/B test is active And the footer buttons are orange When user taps the Market Insights entry card Then the Swap/Buy buttons on the Market Insights page should also be orange Scenario: Footer buttons match token details color on Security Trust Given the user is on the Token Details page for a token with a negative price change And the ambient price color A/B test is active And the footer buttons are orange When user taps the Security Trust entry card Then the Swap/Buy buttons on the Security Trust page should also be orange Scenario: Positive price direction shows green on sub-screens Given the user is on the Token Details page for a token with a positive price change And the ambient price color A/B test is active And the footer buttons are green When user navigates to Market Insights or Security Trust Then the Swap/Buy buttons should remain green Scenario: Control group unaffected Given the user is in the control group for the ambient price color A/B test When user navigates to Market Insights or Security Trust Then the Swap/Buy buttons should display the default green color ``` ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/3aff0900-edeb-4900-b2d0-4cc4f22f2610 ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > UI-only navigation prop plumbing for an existing A/B test; no auth, payments, or data-layer changes. > > **Overview** > **Token Details** now forwards chart price direction (`isPricePositive`) and the ambient price-color A/B flag (`useAmbientColor`) into **Market Insights** and **Security & Trust** navigation params and sticky footers. > > From `AssetOverviewContent`, opening Market Insights includes those fields on the route; the Security entry card passes them into `Routes.SECURITY_TRUST`. **MarketInsightsView** and **SecurityTrustScreen** read the params and pass them to `TokenDetailsStickyFooter`, so Swap/Buy styling stays aligned with Token Details (green vs orange) instead of defaulting to green on push. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 87ce1593a3d6665979e47e845d8df3b92faec7d0. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). [ASSETS-3307]: https://consensyssoftware.atlassian.net/browse/ASSETS-3307?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- .../Views/MarketInsightsView/MarketInsightsView.tsx | 8 ++++++++ .../UI/SecurityTrust/Views/SecurityTrustScreen.tsx | 7 ++++++- .../SecurityTrustEntryCard/SecurityTrustEntryCard.tsx | 6 ++++++ app/components/UI/TokenDetails/Views/TokenDetails.tsx | 1 + .../UI/TokenDetails/components/AssetOverviewContent.tsx | 9 +++++++++ 5 files changed, 30 insertions(+), 1 deletion(-) diff --git a/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx b/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx index 5731aa88325..ff8da6c20c7 100644 --- a/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx +++ b/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx @@ -162,6 +162,10 @@ interface MarketInsightsRouteParams { hasPerpsPosition?: boolean; /** Surface from which Market Insights was accessed */ source?: 'token_details' | 'perps' | 'unknown'; + /** Whether the price trend is positive on the parent Token Details screen. */ + isPricePositive?: boolean; + /** Whether the ambient price color A/B test treatment is active. */ + useAmbientColor?: boolean; } /** @@ -190,6 +194,8 @@ const MarketInsightsView: React.FC = () => { isPerps = false, hasPerpsPosition = false, source: routeSource = 'unknown', + isPricePositive, + useAmbientColor, } = route.params; const isMarketInsightsEnabled = isPerps @@ -823,6 +829,8 @@ const MarketInsightsView: React.FC = () => { onSwapPress={handleStickySwapPress} onBuyPress={handleStickyBuyPress} sourcePage="MarketInsightsView" + isPricePositive={isPricePositive} + useAmbientColor={useAmbientColor} /> { const hasTrackedView = useRef(false); const timeSpentStart = useRef(Date.now()); - const params = route.params as TokenDetailsRouteParams; + const params = route.params as TokenDetailsRouteParams & { + isPricePositive?: boolean; + useAmbientColor?: boolean; + }; const securityData = params?.securityData ?? null; const explorer = useBlockExplorer(params?.chainId); const evmNetworkConfigurations = useSelector( @@ -697,6 +700,8 @@ const SecurityTrustScreen: React.FC = () => { securityData={securityData} networkName={networkName} sourcePage="SecurityTrustView" + isPricePositive={params.isPricePositive} + useAmbientColor={params.useAmbientColor} /> ); diff --git a/app/components/UI/SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard.tsx b/app/components/UI/SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard.tsx index d029711cd68..8cb1220b455 100644 --- a/app/components/UI/SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard.tsx +++ b/app/components/UI/SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard.tsx @@ -29,12 +29,16 @@ interface SecurityTrustEntryCardProps { securityData: TokenSecurityData | null; isLoading: boolean; token: TokenDetailsRouteParams; + isPricePositive?: boolean; + useAmbientColor?: boolean; } const SecurityTrustEntryCard: React.FC = ({ securityData, isLoading, token, + isPricePositive, + useAmbientColor, }) => { const tw = useTailwind(); const navigation = useNavigation(); @@ -93,6 +97,8 @@ const SecurityTrustEntryCard: React.FC = ({ navigation.navigate(Routes.SECURITY_TRUST, { ...token, securityData, + isPricePositive, + useAmbientColor, }); }; diff --git a/app/components/UI/TokenDetails/Views/TokenDetails.tsx b/app/components/UI/TokenDetails/Views/TokenDetails.tsx index 34e5d9560b1..053f8bccbba 100644 --- a/app/components/UI/TokenDetails/Views/TokenDetails.tsx +++ b/app/components/UI/TokenDetails/Views/TokenDetails.tsx @@ -282,6 +282,7 @@ const TokenDetails: React.FC<{ hasSecurityDataError={Boolean(securityDataError)} onPriceDirectionChange={handlePriceDirectionChange} useAmbientColor={useAmbientColor} + isPricePositive={chartPricePositive} ///: BEGIN:ONLY_INCLUDE_IF(tron) stakedTrxAsset={stakedTrxAsset} inLockPeriodBalance={inLockPeriodBalance} diff --git a/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx b/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx index c3c0ec97272..9330f5ea381 100644 --- a/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx +++ b/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx @@ -202,6 +202,8 @@ export interface AssetOverviewContentProps { // Ambient price color A/B test onPriceDirectionChange?: (isPositive: boolean) => void; useAmbientColor?: boolean; + /** Resolved price direction from the chart; true = positive, false = negative, null = not yet resolved. */ + isPricePositive?: boolean | null; } /** @@ -243,6 +245,7 @@ const AssetOverviewContent: React.FC = ({ hasSecurityDataError = false, onPriceDirectionChange, useAmbientColor, + isPricePositive, }) => { const { styles } = useStyles(styleSheet, {}); const navigation = useNavigation(); @@ -531,6 +534,8 @@ const AssetOverviewContent: React.FC = ({ pricePercentChange: percentChange, token, source: 'token_details', + isPricePositive: isPricePositive ?? undefined, + useAmbientColor, }); }, [ navigation, @@ -541,6 +546,8 @@ const AssetOverviewContent: React.FC = ({ marketInsightsReport, priceDiff, comparePrice, + useAmbientColor, + isPricePositive, ]); const handlePerpsDiscoveryPress = useCallback(() => { @@ -835,6 +842,8 @@ const AssetOverviewContent: React.FC = ({ securityData={securityData ?? null} isLoading={isSecurityDataLoading} token={token as TokenDetailsRouteParams} + isPricePositive={isPricePositive ?? undefined} + useAmbientColor={useAmbientColor} /> )} From ee42c73a1d33e7e7103b221334619308d247c815 Mon Sep 17 00:00:00 2001 From: sophieqgu <37032128+sophieqgu@users.noreply.github.com> Date: Mon, 1 Jun 2026 13:52:38 -0400 Subject: [PATCH 02/22] fix(rewards): VIP splash screen title (#30882) ## **Description** ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** Simulator Screenshot - E2E Test -
2026-06-01 at 13 27 16 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Single Tailwind class on VIP splash title styling; cosmetic layout only with no logic or data impact. > > **Overview** > Adds **6px top padding** to the VIP splash screen gradient title styles in `VipSplashScreen` so the masked `MMPoly-Regular` headline aligns correctly with the layout (visual fix only; no behavior or copy changes). > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 208dcdc30ee4599ebfb6af5e22c5e9f459612b2d. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- app/components/UI/Rewards/components/Vip/VipSplashScreen.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/UI/Rewards/components/Vip/VipSplashScreen.tsx b/app/components/UI/Rewards/components/Vip/VipSplashScreen.tsx index 53f93d85c1b..0ae150e4df1 100644 --- a/app/components/UI/Rewards/components/Vip/VipSplashScreen.tsx +++ b/app/components/UI/Rewards/components/Vip/VipSplashScreen.tsx @@ -54,7 +54,7 @@ const VipGradientTitle = () => { const tw = useTailwind(); const title = strings('rewards.vip.splash_title'); const titleStyle = tw.style( - 'text-center text-[42px] leading-[42px]', + 'text-center text-[42px] leading-[42px] pt-[6px]', titleFontStyle, titleColorStyle, ); From e1fa7935a60e56dbe1781c22503a326f6f212dfa Mon Sep 17 00:00:00 2001 From: Wei Sun Date: Mon, 1 Jun 2026 11:02:14 -0700 Subject: [PATCH 03/22] refactor(NatigationUnitTest): migrate NavigationUnitTest to native stack (#30830) ## **Description** ## Summary - Migrate `NavigationUnitTest` from `@react-navigation/stack` to `@react-navigation/native-stack`. - This component is a navigation API regression harness (it verifies `useNavigationState` + `findRouteNameFromNavigatorState` still resolve the active route correctly). It is not imported anywhere in the app and has no active tests since the snapshot tests were removed in #29441. - No runtime behavior change today; this aligns the harness with the native stack navigator used elsewhere in the app. ## **Changelog** CHANGELOG entry:null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Two-line import/API swap in an unused test-only view with no production or test runner impact. > > **Overview** > Updates the **NavigationUnitTest** regression harness to use `@react-navigation/native-stack` instead of `@react-navigation/stack` (`createNativeStackNavigator` / `createStackNavigator`). > > The harness still checks that `useNavigationState` and `findRouteNameFromNavigatorState` resolve the active route name; it is not wired into production navigation and has no active snapshot tests. The change aligns this isolated test view with the native stack pattern used in the rest of the app. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 8c0c48507ca67a956199638834663c61109d6445. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- app/components/Views/NavigationUnitTest/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/Views/NavigationUnitTest/index.js b/app/components/Views/NavigationUnitTest/index.js index 0e314b9b3bd..ed112ca429e 100644 --- a/app/components/Views/NavigationUnitTest/index.js +++ b/app/components/Views/NavigationUnitTest/index.js @@ -6,7 +6,7 @@ /* eslint-disable react/prop-types */ /* eslint-disable react/no-unstable-nested-components */ import React from 'react'; -import { createStackNavigator } from '@react-navigation/stack'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { NavigationContainer, useNavigationState, @@ -14,7 +14,7 @@ import { import { findRouteNameFromNavigatorState } from '../../../util/general'; import { Text } from 'react-native'; -const Stack = createStackNavigator(); +const Stack = createNativeStackNavigator(); const TestScreen = ({ route }) => { const routes = useNavigationState((state) => state.routes); From e6c4b6c25f06dd6d94c23d31f9427b75bfd373ee Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Mon, 1 Jun 2026 20:38:37 +0200 Subject: [PATCH 04/22] revert: "ci: Add temporary fallback to Patroll token in `check-template-and-add-labels`" (#30881) Reverts MetaMask/metamask-mobile#30875. The fallback is no longer necessary, as the original issue has been resolved and the Patroll token will eventually be removed. --- > [!NOTE] > **Low Risk** > CI-only change that removes a secrets fallback; labeling jobs may fail if token exchange regresses, with no app or security surface impact. > > **Overview** > Reverts the temporary **Patroll** (`secrets.LABEL_TOKEN`) fallback in the **Check template and add labels** workflow. > > The **Get access token** step no longer uses `continue-on-error: true`, so a failed token exchange fails the job instead of continuing. **`LABEL_TOKEN`** is set only from `steps.get-token.outputs.token` (the `|| secrets.LABEL_TOKEN` fallback is removed). > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit a85d70cddfeb78807eddac5a16dbbea687a3e2ec. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .github/workflows/check-template-and-add-labels.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/check-template-and-add-labels.yml b/.github/workflows/check-template-and-add-labels.yml index bb8b3b5b767..b5c5451c24b 100644 --- a/.github/workflows/check-template-and-add-labels.yml +++ b/.github/workflows/check-template-and-add-labels.yml @@ -52,7 +52,6 @@ jobs: - name: Get access token id: get-token uses: MetaMask/github-tools/.github/actions/get-token@v1 - continue-on-error: true with: token-exchange-url: ${{ vars.TOKEN_EXCHANGE_URL }} permissions: | @@ -63,6 +62,6 @@ jobs: - name: Check template and add labels id: check-template-and-add-labels env: - LABEL_TOKEN: ${{ steps.get-token.outputs.token || secrets.LABEL_TOKEN }} + LABEL_TOKEN: ${{ steps.get-token.outputs.token }} run: npm run check-template-and-add-labels working-directory: '.github/scripts' From 1bdcec509a16c62185aa0cea5f61b2e863fbd1c0 Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Mon, 1 Jun 2026 19:52:52 +0100 Subject: [PATCH 05/22] feat: track Token Details secondary action buttons (ASSETS-3212) cp-7.80.0 (#30379) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adds `Token Details Action Clicked` analytics event instrumentation for the Token Details Page (TDP) secondary action buttons, enabling product to track how users interact with actions beyond the primary CTA. Segment Schema: https://github.com/Consensys/segment-schema/pull/577 **What changed:** 1. **New event**: `Token Details Action Clicked` registered in `MetaMetrics.events.ts` 2. **New enum**: `TokenDetailsAction` with values: `send`, `receive`, `more_opened`, `remove_token`, `view_on_explorer`, `copy_token_address` 3. **New tracking hook**: `useTokenDetailsActionTracking` — accepts token params, balance, and severity; returns a stable callback that fires the event 4. **Instrumented components**: - `TokenDetailsActions` — fires on Send, Receive, and More (menu open) button presses - `MoreTokenActionsMenu` — fires on Remove Token and View on Block Explorer - `TokenDetailsList` → Copy Token Address button **Event properties:** | Property | Type | Description | |---|---|---| | `action` | enum | `send`, `receive`, `more_opened`, `remove_token`, `view_on_explorer`, `copy_token_address` | | `token_symbol` | string | e.g. ETH | | `token_address` | string | Token contract address | | `chain_id` | string | Chain ID | | `has_balance` | boolean | Whether user holds the token | | `severity` | string | Security classification: Verified, Benign, Warning, Spam, Malicious | | `source` | string | Mirrors `Token Details Opened` source enum | ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-3212 ## **Manual testing steps** ```gherkin Feature: Token Details Action Clicked analytics Scenario: user taps Send on Token Details Page Given user is viewing a token with balance on Token Details Page When user taps the Send button Then "Token Details Action Clicked" event fires with action="send" Scenario: user taps Receive on Token Details Page Given user is viewing a token on Token Details Page When user taps the Receive button Then "Token Details Action Clicked" event fires with action="receive" Scenario: user taps More menu on Token Details Page Given user is viewing a token on Token Details Page When user taps the More (⋯) button Then "Token Details Action Clicked" event fires with action="more_opened" Scenario: user taps View on Block Explorer in More menu Given user has opened the More menu on Token Details Page When user taps View on Block Explorer Then "Token Details Action Clicked" event fires with action="view_on_explorer" Scenario: user taps Remove Token in More menu Given user has opened the More menu for a non-native token When user taps Remove Token Then "Token Details Action Clicked" event fires with action="remove_token" Scenario: user copies token contract address Given user is viewing token details section with contract address When user taps the copy address button Then "Token Details Action Clicked" event fires with action="copy_token_address" ``` ## **Screenshots/Recordings** https://www.loom.com/share/73dce87dc6bc47b48a0d40588213c4e1 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors.
Open in Web Open in Cursor 
--- .../TokenDetails/TokenDetails.tsx | 11 +- .../TokenDetailsList.test.tsx | 24 ++- .../TokenDetailsList/TokenDetailsList.tsx | 3 + .../components/AssetOverviewContent.tsx | 22 ++- .../components/MoreTokenActionsMenu.test.tsx | 41 ++++ .../components/MoreTokenActionsMenu.tsx | 7 + .../components/TokenDetailsActions.test.tsx | 51 +++++ .../components/TokenDetailsActions.tsx | 12 +- .../UI/TokenDetails/constants/constants.ts | 12 ++ .../useTokenDetailsActionTracking.test.ts | 181 ++++++++++++++++++ .../hooks/useTokenDetailsActionTracking.ts | 50 +++++ app/core/Analytics/MetaMetrics.events.ts | 4 + 12 files changed, 408 insertions(+), 10 deletions(-) create mode 100644 app/components/UI/TokenDetails/hooks/useTokenDetailsActionTracking.test.ts create mode 100644 app/components/UI/TokenDetails/hooks/useTokenDetailsActionTracking.ts diff --git a/app/components/UI/AssetOverview/TokenDetails/TokenDetails.tsx b/app/components/UI/AssetOverview/TokenDetails/TokenDetails.tsx index 3778565fb2e..0a0919c912f 100644 --- a/app/components/UI/AssetOverview/TokenDetails/TokenDetails.tsx +++ b/app/components/UI/AssetOverview/TokenDetails/TokenDetails.tsx @@ -57,9 +57,13 @@ export interface MarketDetails { interface TokenDetailsProps { asset: TokenI; + onCopyAddress?: () => void; } -const TokenDetails: React.FC = ({ asset }) => { +const TokenDetails: React.FC = ({ + asset, + onCopyAddress, +}) => { // For non evm assets, the resultChainId is equal to the asset.chainId; while for evm assets; the resultChainId === "eip155:1" !== asset.chainId const resultChainId = formatChainIdToCaip(asset.chainId as Hex); const isNonEvmAsset = resultChainId === asset.chainId; @@ -220,7 +224,10 @@ const TokenDetails: React.FC = ({ asset }) => { return ( {(asset.isETH || isNonEvmAsset || hasAddressAndDecimals) && ( - + )} {marketData && marketDetails && ( diff --git a/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.test.tsx b/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.test.tsx index 464f60f575e..15a6ded6b65 100644 --- a/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.test.tsx +++ b/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.test.tsx @@ -1,8 +1,13 @@ import React from 'react'; -import { render } from '@testing-library/react-native'; +import { render, fireEvent, waitFor } from '@testing-library/react-native'; import TokenDetailsList from './'; import { ToastContext } from '../../../../../component-library/components/Toast'; +const mockSetString = jest.fn(); +jest.mock('../../../../../core/ClipboardManager', () => ({ + setString: (...args: unknown[]) => mockSetString(...args), +})); + const mockShowToast = jest.fn(); const mockCloseToast = jest.fn(); const mockToastRef = { @@ -15,10 +20,10 @@ const mockTokenDetails = { tokenList: 'Metamask, Coinmarketcap', }; -const renderComponent = () => +const renderComponent = (props?: { onCopyAddress?: () => void }) => render( - + , ); @@ -38,4 +43,17 @@ describe('TokenDetails', () => { expect(getByText('Token list')).toBeOnTheScreen(); expect(getByText('Metamask, Coinmarketcap')).toBeOnTheScreen(); }); + + it('calls onCopyAddress when contract address is tapped', async () => { + mockSetString.mockResolvedValue(undefined); + const mockOnCopyAddress = jest.fn(); + const { getByText } = renderComponent({ + onCopyAddress: mockOnCopyAddress, + }); + + fireEvent.press(getByText('0x935E7...05477')); + await waitFor(() => { + expect(mockOnCopyAddress).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.tsx b/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.tsx index a8de91f2980..c6f31de54b4 100644 --- a/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.tsx +++ b/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.tsx @@ -25,10 +25,12 @@ import { useTheme } from '../../../../../util/theme'; interface TokenDetailsListProps { tokenDetails: TokenDetails; + onCopyAddress?: () => void; } const TokenDetailsList: React.FC = ({ tokenDetails, + onCopyAddress, }) => { const { styles } = useStyles(styleSheet, {}); const { toastRef } = useContext(ToastContext); @@ -37,6 +39,7 @@ const TokenDetailsList: React.FC = ({ const copyAccountToClipboard = async () => { await ClipboardManager.setString(tokenDetails.contractAddress); + onCopyAddress?.(); toastRef?.current?.showToast({ variant: ToastVariants.Icon, diff --git a/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx b/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx index 9330f5ea381..d222672ab94 100644 --- a/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx +++ b/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx @@ -67,7 +67,11 @@ import { isCaipAssetType, type Hex } from '@metamask/utils'; import { formatAddressToAssetId } from '@metamask/bridge-controller'; import type { TokenSecurityData } from '@metamask/assets-controllers'; import SecurityTrustEntryCard from '../../SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard'; -import type { TokenDetailsRouteParams } from '../constants/constants'; +import { + TokenDetailsAction, + type TokenDetailsRouteParams, +} from '../constants/constants'; +import { useTokenDetailsActionTracking } from '../hooks/useTokenDetailsActionTracking'; import { getResultTypeConfig } from '../../SecurityTrust/utils/securityUtils'; import { Box, @@ -253,6 +257,12 @@ const AssetOverviewContent: React.FC = ({ const { isTokenTradingOpen, isStockToken } = useRWAToken(); const { trackEvent, createEventBuilder } = useAnalytics(); + const hasBalanceValue = Boolean(balance) && balance !== '0'; + const trackActionTapped = useTokenDetailsActionTracking({ + token, + hasBalance: hasBalanceValue, + severity: securityData?.resultType, + }); const tronNativeToken = isTronNativeToken(token) ? token : null; const { @@ -750,7 +760,7 @@ const AssetOverviewContent: React.FC = ({ )} = ({ onReceive={onReceive} isLoading={isButtonsLoading} resetNavigationLockRef={resetNavigationLockRef} + onActionTapped={trackActionTapped} /> {shouldShowMarketInsights ? ( @@ -833,7 +844,12 @@ const AssetOverviewContent: React.FC = ({ /> )} - + + trackActionTapped(TokenDetailsAction.CopyTokenAddress) + } + /> {!hasSecurityDataError && (isSecurityDataLoading || securityData?.resultType) && ( diff --git a/app/components/UI/TokenDetails/components/MoreTokenActionsMenu.test.tsx b/app/components/UI/TokenDetails/components/MoreTokenActionsMenu.test.tsx index 7a5e3e0c7a3..9cfafa7c549 100644 --- a/app/components/UI/TokenDetails/components/MoreTokenActionsMenu.test.tsx +++ b/app/components/UI/TokenDetails/components/MoreTokenActionsMenu.test.tsx @@ -4,6 +4,7 @@ import MoreTokenActionsMenu, { MoreTokenActionsMenuParams, } from './MoreTokenActionsMenu'; import { TokenI } from '../../Tokens/types'; +import { TokenDetailsAction } from '../constants/constants'; import renderWithProvider from '../../../../util/test/renderWithProvider'; import { backgroundState } from '../../../../util/test/initial-root-state'; import { WalletActionsBottomSheetSelectorsIDs } from '../../../Views/WalletActions/WalletActionsBottomSheet.testIds'; @@ -584,6 +585,46 @@ describe('MoreTokenActionsMenu', () => { expect(mockTrackEvent).toHaveBeenCalled(); }); + it('fires onActionTapped with view_on_explorer when View on block explorer is pressed', async () => { + const mockOnActionTapped = jest.fn(); + updateRouteParams({ + hasPerpsMarket: false, + hasBalance: true, + isBuyable: false, + isNativeCurrency: false, + onActionTapped: mockOnActionTapped, + }); + + const { getByTestId } = renderWithProvider(, { + state: mockInitialState, + }); + + await userEvent.press(getByTestId('more-actions-view-explorer')); + expect(mockOnActionTapped).toHaveBeenCalledWith( + TokenDetailsAction.ViewOnExplorer, + ); + }); + + it('fires onActionTapped with remove_token when Remove token is pressed', async () => { + const mockOnActionTapped = jest.fn(); + updateRouteParams({ + hasPerpsMarket: false, + hasBalance: true, + isBuyable: false, + isNativeCurrency: false, + onActionTapped: mockOnActionTapped, + }); + + const { getByTestId } = renderWithProvider(, { + state: mockInitialState, + }); + + await userEvent.press(getByTestId('more-actions-remove-token')); + expect(mockOnActionTapped).toHaveBeenCalledWith( + TokenDetailsAction.RemoveToken, + ); + }); + it('logs error when hide token fails', async () => { ( Engine.context.TokensController.ignoreTokens as jest.Mock diff --git a/app/components/UI/TokenDetails/components/MoreTokenActionsMenu.tsx b/app/components/UI/TokenDetails/components/MoreTokenActionsMenu.tsx index 263dd45e19a..e1ac61cbba3 100644 --- a/app/components/UI/TokenDetails/components/MoreTokenActionsMenu.tsx +++ b/app/components/UI/TokenDetails/components/MoreTokenActionsMenu.tsx @@ -24,6 +24,7 @@ import { selectAsset } from '../../../../selectors/assets/assets-list'; import { isMusdToken } from '../../../UI/Earn/constants/musd'; import { selectIsAssetsUnifyStateEnabled } from '../../../../selectors/featureFlagController/assetsUnifyState'; import useAssetVisibility from './useAssetVisibility'; +import { TokenDetailsAction } from '../constants/constants'; import { isNonEvmChainId } from '../../../../core/Multichain/utils'; import { removeNonEvmToken } from '../../Tokens/util/removeNonEvmToken'; import { selectSelectedInternalAccountByScope } from '../../../../selectors/multichainAccounts/accounts'; @@ -36,6 +37,7 @@ export interface MoreTokenActionsMenuParams { asset: TokenI; onBuy: () => void; onReceive?: () => void; + onActionTapped?: (action: TokenDetailsAction) => void; } type MoreTokenActionsMenuRouteProp = RouteProp< @@ -67,6 +69,7 @@ const MoreTokenActionsMenu = () => { asset, onBuy, onReceive, + onActionTapped, } = route.params; const { trackEvent, createEventBuilder } = useAnalytics(); @@ -125,6 +128,7 @@ const MoreTokenActionsMenu = () => { } if (url) { + onActionTapped?.(TokenDetailsAction.ViewOnExplorer); goToBrowserUrl(url, explorer.getBlockExplorerName(asset.chainId)); } }, [ @@ -133,9 +137,11 @@ const MoreTokenActionsMenu = () => { asset.chainId, asset.address, goToBrowserUrl, + onActionTapped, ]); const handleRemoveToken = useCallback(() => { + onActionTapped?.(TokenDetailsAction.RemoveToken); closeBottomSheetAndNavigate(() => { navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { screen: 'AssetHideConfirmation', @@ -202,6 +208,7 @@ const MoreTokenActionsMenu = () => { selectInternalAccountByScope, trackEvent, createEventBuilder, + onActionTapped, ]); const tokenIsInAccount = !!useSelector((state: RootState) => diff --git a/app/components/UI/TokenDetails/components/TokenDetailsActions.test.tsx b/app/components/UI/TokenDetails/components/TokenDetailsActions.test.tsx index 2ee2b50b5a4..11136958ed9 100644 --- a/app/components/UI/TokenDetails/components/TokenDetailsActions.test.tsx +++ b/app/components/UI/TokenDetails/components/TokenDetailsActions.test.tsx @@ -1,10 +1,12 @@ import React from 'react'; +import { fireEvent } from '@testing-library/react-native'; import { TokenDetailsActions, TokenDetailsActionsProps, } from './TokenDetailsActions'; import { TokenOverviewSelectorsIDs } from '../../AssetOverview/TokenOverview.testIds'; import { TokenI } from '../../Tokens/types'; +import { TokenDetailsAction } from '../constants/constants'; import renderWithProvider from '../../../../util/test/renderWithProvider'; import { backgroundState } from '../../../../util/test/initial-root-state'; import { MOCK_ACCOUNTS_CONTROLLER_STATE } from '../../../../util/test/accountsControllerTestUtils'; @@ -272,6 +274,55 @@ describe('TokenDetailsActions', () => { }); }); }); + + describe('onActionTapped tracking', () => { + it('fires onActionTapped with send when Send is pressed', () => { + const mockOnActionTapped = jest.fn(); + const { getByTestId } = renderWithProvider( + , + { state: mockInitialState }, + ); + + fireEvent.press(getByTestId(TokenOverviewSelectorsIDs.SEND_BUTTON)); + expect(mockOnActionTapped).toHaveBeenCalledWith(TokenDetailsAction.Send); + }); + + it('fires onActionTapped with receive when Receive is pressed', () => { + const mockOnActionTapped = jest.fn(); + const { getByTestId } = renderWithProvider( + , + { state: mockInitialState }, + ); + + fireEvent.press(getByTestId(TokenOverviewSelectorsIDs.RECEIVE_BUTTON)); + expect(mockOnActionTapped).toHaveBeenCalledWith( + TokenDetailsAction.Receive, + ); + }); + + it('fires onActionTapped with more_opened when More is pressed', () => { + const mockOnActionTapped = jest.fn(); + const { getByTestId } = renderWithProvider( + , + { state: mockInitialState }, + ); + + fireEvent.press(getByTestId(TokenOverviewSelectorsIDs.MORE_BUTTON)); + expect(mockOnActionTapped).toHaveBeenCalledWith( + TokenDetailsAction.MoreOpened, + ); + }); + }); }); function assertButtonsVisibility({ diff --git a/app/components/UI/TokenDetails/components/TokenDetailsActions.tsx b/app/components/UI/TokenDetails/components/TokenDetailsActions.tsx index c4920ba88c8..1351c0b5e07 100644 --- a/app/components/UI/TokenDetails/components/TokenDetailsActions.tsx +++ b/app/components/UI/TokenDetails/components/TokenDetailsActions.tsx @@ -11,6 +11,7 @@ import { useSelector } from 'react-redux'; import { selectCanSignTransactions } from '../../../../selectors/accountsController'; import Routes from '../../../../constants/navigation/Routes'; import { TokenI } from '../../Tokens/types'; +import { TokenDetailsAction } from '../constants/constants'; // Height of MainActionButton: paddingVertical (16 * 2) + Icon (24px) + label marginTop (2) + label lineHeight (~16) const SKELETON_BUTTON_HEIGHT = 74; @@ -47,6 +48,7 @@ export interface TokenDetailsActionsProps { isLoading?: boolean; /** Optional ref to receive a callback that resets the navigation lock. Used when Long/Short show a modal instead of navigating (e.g. geo block). */ resetNavigationLockRef?: React.MutableRefObject<(() => void) | null>; + onActionTapped?: (action: TokenDetailsAction) => void; } /** @@ -83,6 +85,7 @@ export const TokenDetailsActions: React.FC = ({ onReceive, isLoading = false, resetNavigationLockRef, + onActionTapped, }) => { const { styles } = useStyles(styleSheet, {}); const canSignTransactions = useSelector(selectCanSignTransactions); @@ -145,11 +148,13 @@ export const TokenDetailsActions: React.FC = ({ const handleSendPress = useCallback(() => { withNavigationLock(onSend); - }, [withNavigationLock, onSend]); + onActionTapped?.(TokenDetailsAction.Send); + }, [withNavigationLock, onSend, onActionTapped]); const handleReceivePress = useCallback(() => { withNavigationLock(onReceive); - }, [withNavigationLock, onReceive]); + onActionTapped?.(TokenDetailsAction.Receive); + }, [withNavigationLock, onReceive, onActionTapped]); const handleMorePress = useCallback(() => { withNavigationLock(() => { @@ -163,9 +168,11 @@ export const TokenDetailsActions: React.FC = ({ asset: token, onBuy, onReceive, + onActionTapped, }, }); }); + onActionTapped?.(TokenDetailsAction.MoreOpened); }, [ withNavigationLock, navigate, @@ -176,6 +183,7 @@ export const TokenDetailsActions: React.FC = ({ token, onBuy, onReceive, + onActionTapped, ]); // Determine which buttons to display based on perps market and balance diff --git a/app/components/UI/TokenDetails/constants/constants.ts b/app/components/UI/TokenDetails/constants/constants.ts index a12b92be272..675b39bf65e 100644 --- a/app/components/UI/TokenDetails/constants/constants.ts +++ b/app/components/UI/TokenDetails/constants/constants.ts @@ -48,6 +48,18 @@ export const isExploreTokenDetailsSource = ( source?: TokenDetailsSource, ): boolean => Boolean(source && EXPLORE_TOKEN_DETAILS_SOURCES.has(source)); +/** + * Action types for "Token Details Action Tapped" event + */ +export enum TokenDetailsAction { + Send = 'send', + Receive = 'receive', + MoreOpened = 'more_opened', + RemoveToken = 'remove_token', + ViewOnExplorer = 'view_on_explorer', + CopyTokenAddress = 'copy_token_address', +} + /** * Extended route params for Token Details page * Includes source tracking for analytics diff --git a/app/components/UI/TokenDetails/hooks/useTokenDetailsActionTracking.test.ts b/app/components/UI/TokenDetails/hooks/useTokenDetailsActionTracking.test.ts new file mode 100644 index 00000000000..5038013f875 --- /dev/null +++ b/app/components/UI/TokenDetails/hooks/useTokenDetailsActionTracking.test.ts @@ -0,0 +1,181 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useTokenDetailsActionTracking } from './useTokenDetailsActionTracking'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import { TokenDetailsAction, TokenDetailsSource } from '../constants/constants'; + +const mockTrackEvent = jest.fn(); +const mockAddProperties = jest.fn().mockReturnThis(); +const mockBuild = jest.fn().mockReturnValue({ category: 'test' }); +const mockCreateEventBuilder = jest.fn(() => ({ + addProperties: mockAddProperties, + build: mockBuild, +})); + +jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + +const defaultToken = { + address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + chainId: '0x1', + symbol: 'DAI', + name: 'Dai Stablecoin', + decimals: 18, + balance: '100', + balanceFiat: '$100', + logo: '', + image: '', + isETH: false, + hasBalanceError: false, + aggregators: [], + source: TokenDetailsSource.MobileTokenList, +}; + +describe('useTokenDetailsActionTracking', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('tracks send action with correct properties', () => { + const { result } = renderHook(() => + useTokenDetailsActionTracking({ + token: defaultToken, + hasBalance: true, + severity: 'Verified', + }), + ); + + result.current(TokenDetailsAction.Send); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.TOKEN_DETAILS_ACTION_CLICKED, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + action: 'send', + token_symbol: 'DAI', + token_address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + chain_id: '0x1', + has_balance: true, + severity: 'Verified', + source: 'mobile-token-list', + }); + expect(mockTrackEvent).toHaveBeenCalled(); + }); + + it('tracks receive action', () => { + const { result } = renderHook(() => + useTokenDetailsActionTracking({ + token: defaultToken, + hasBalance: false, + severity: undefined, + }), + ); + + result.current(TokenDetailsAction.Receive); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'receive', + has_balance: false, + severity: undefined, + }), + ); + }); + + it('tracks more_opened action', () => { + const { result } = renderHook(() => + useTokenDetailsActionTracking({ + token: defaultToken, + hasBalance: true, + severity: 'Warning', + }), + ); + + result.current(TokenDetailsAction.MoreOpened); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'more_opened', + severity: 'Warning', + }), + ); + }); + + it('tracks remove_token action', () => { + const { result } = renderHook(() => + useTokenDetailsActionTracking({ + token: defaultToken, + hasBalance: true, + severity: 'Spam', + }), + ); + + result.current(TokenDetailsAction.RemoveToken); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'remove_token', + severity: 'Spam', + }), + ); + }); + + it('tracks view_on_explorer action', () => { + const { result } = renderHook(() => + useTokenDetailsActionTracking({ + token: defaultToken, + hasBalance: false, + severity: 'Benign', + }), + ); + + result.current(TokenDetailsAction.ViewOnExplorer); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'view_on_explorer', + severity: 'Benign', + }), + ); + }); + + it('tracks copy_token_address action', () => { + const { result } = renderHook(() => + useTokenDetailsActionTracking({ + token: defaultToken, + hasBalance: true, + severity: 'Verified', + }), + ); + + result.current(TokenDetailsAction.CopyTokenAddress); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'copy_token_address', + }), + ); + }); + + it('defaults source to Unknown when token has no source', () => { + const tokenWithoutSource = { ...defaultToken, source: undefined }; + const { result } = renderHook(() => + useTokenDetailsActionTracking({ + token: tokenWithoutSource, + hasBalance: false, + severity: undefined, + }), + ); + + result.current(TokenDetailsAction.Send); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'unknown', + }), + ); + }); +}); diff --git a/app/components/UI/TokenDetails/hooks/useTokenDetailsActionTracking.ts b/app/components/UI/TokenDetails/hooks/useTokenDetailsActionTracking.ts new file mode 100644 index 00000000000..346221a411d --- /dev/null +++ b/app/components/UI/TokenDetails/hooks/useTokenDetailsActionTracking.ts @@ -0,0 +1,50 @@ +import { useCallback } from 'react'; +import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import { + TokenDetailsAction, + TokenDetailsSource, + type TokenDetailsRouteParams, +} from '../constants/constants'; + +interface UseTokenDetailsActionTrackingParams { + token: TokenDetailsRouteParams; + hasBalance: boolean; + severity: string | undefined; +} + +export function useTokenDetailsActionTracking({ + token, + hasBalance, + severity, +}: UseTokenDetailsActionTrackingParams) { + const { trackEvent, createEventBuilder } = useAnalytics(); + + return useCallback( + (action: TokenDetailsAction) => { + trackEvent( + createEventBuilder(MetaMetricsEvents.TOKEN_DETAILS_ACTION_CLICKED) + .addProperties({ + action, + token_symbol: token.symbol, + token_address: token.address, + chain_id: token.chainId, + has_balance: hasBalance, + severity, + source: token.source ?? TokenDetailsSource.Unknown, + }) + .build(), + ); + }, + [ + createEventBuilder, + trackEvent, + token.symbol, + token.address, + token.chainId, + token.source, + hasBalance, + severity, + ], + ); +} diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index 2fbfc482ef9..d25f3ad6d8b 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -87,6 +87,7 @@ enum EVENT_NAME { TOKEN_LIST_ITEM_CLICKED = 'Token List Item Clicked', TOKEN_DETAILS_OPENED = 'Token Details Opened', TOKEN_DETAILS_CTA_CLICKED = 'Token Details CTA Clicked', + TOKEN_DETAILS_ACTION_CLICKED = 'Token Details Action Clicked', /** * Token overview advanced chart: zoom, pan, tooltip, timeframe change, chart type * toggle, or TradingView link (see `interaction_type` and optional properties). @@ -1645,6 +1646,9 @@ const events = { ), TOKEN_DETAILS_OPENED: generateOpt(EVENT_NAME.TOKEN_DETAILS_OPENED), TOKEN_DETAILS_CTA_CLICKED: generateOpt(EVENT_NAME.TOKEN_DETAILS_CTA_CLICKED), + TOKEN_DETAILS_ACTION_CLICKED: generateOpt( + EVENT_NAME.TOKEN_DETAILS_ACTION_CLICKED, + ), CHART_INTERACTED: generateOpt(EVENT_NAME.CHART_INTERACTED), CHART_EMPTY_DISPLAYED: generateOpt(EVENT_NAME.CHART_EMPTY_DISPLAYED), SECURITY_TRUST_BOTTOM_SHEET_OPENED: generateOpt( From 1d5bdb232d1b64b574e54fae3fa22a29eaca6648 Mon Sep 17 00:00:00 2001 From: Wei Sun Date: Mon, 1 Jun 2026 11:57:35 -0700 Subject: [PATCH 06/22] refactor(send): migrate send routes to native stack (#30822) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR migrates the Send flow's stack navigator (`app/components/Views/confirmations/components/send/send.tsx`) from the JS-based `createStackNavigator` to `@react-navigation/native-stack`. **Why** The JS stack adds overhead and produces less native-feeling transitions than the native stack used elsewhere in the migration effort. This migration has been done in other feature teams such as card, ramp, predict...etc **What changed:** 1. **Native stack migration** — `createStackNavigator` → `createNativeStackNavigator`, and `cardStyle` → `contentStyle` for the screen background. 2. **In-body headers** — With native-stack, a custom React header rendered by the navigator visibly lingers during the push/pop animation. To fix this, each Send screen (`Amount`, `Recipient`, `Asset`) now renders its own `HeaderCompactStandard` in-body (via `useSendNavbar`) with `headerShown: false`, so the header transitions natively with the screen content. 3. **Shared `Asset` component** — `Asset` is reused by `pay-with-modal` (which supplies its own header), so it gained a `hideHeader` prop. The `useSendNavbar` call is isolated in a child component (`AssetSendHeader`) that only mounts inside the Send flow, so `pay-with-modal` doesn't pull in `useSendNavbar`'s dependency chain. 4. **Back/Cancel fix** — Moving `useSendNavbar` from the parent `Send` route into the nested screens meant its navigation-state lookup no longer saw the main stack, which swapped the behavior (Back → home, Cancel → previous screen). `useSendNavbar` now reads the parent navigator's state, and `handleCancelPress` exits via the parent navigator, restoring correct Back (previous step) and Close (exit flow) behavior. Android build: https://github.com/MetaMask/metamask-mobile/actions/runs/26662374868 ## **Changelog** CHANGELOG entry:null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Send flow navigation (native stack) Scenario: Header transitions cleanly between Send screens Given the user is in the Send flow When the user navigates from Asset to Amount to Recipient Then the header transitions natively with the screen And no previous header lingers during the animation Scenario: Back button steps back within the Send flow Given the user is on a Send screen When the user taps the back button Then the user returns to the previous step (or exits Send correctly from the first screen) Scenario: Close button exits the Send flow Given the user is on a Send screen When the user taps the close button Then the entire Send flow is dismissed Scenario: Pay-with modal still works Given the user opens the "Other assets" picker from a Pay with confirmation Then the modal renders its own header (no duplicate Send header) And token selection works as before ``` ## **Screenshots/Recordings** happy path |before|after| |---|---| |send happy path before|send happy path after| cancel path |before|after| |---|---| |send cancel before|send cancel after| Token Selector |before|after| |---|---| |pay with token before|token selector after| Android https://github.com/user-attachments/assets/ba847f25-c94e-4d39-8cda-796f07757c20 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Touches core Send navigation and exit paths; incorrect parent-stack handling could mis-route users on back/close, though scope is limited to the Send stack and shared Asset picker. > > **Overview** > The Send flow’s nested navigator is switched from **JS stack** to **native stack**, with `headerShown: false` and **in-body** `HeaderCompactStandard` on Amount, Recipient, and Asset (via `useSendNavbar`) so headers animate with screen content instead of lingering during transitions. > > **Asset** gains `hideHeader` and an `AssetSendHeader` child so **pay-with-modal** can keep its own header without duplicating Send chrome or mounting the full navbar hook chain. **Back/close** behavior is corrected: `useSendNavbar` reads the **parent** stack for back navigation, and **close** calls `getParent().goBack()` to leave the whole Send flow. Tests are updated with navbar mocks and parent-navigation expectations. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 4f3d78044b2a1bf22080c1e39e3850c14f75eb6b. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../modals/pay-with-modal/pay-with-modal.tsx | 1 + .../components/send/amount/amount.test.tsx | 8 ++ .../components/send/amount/amount.tsx | 3 + .../components/send/asset/asset.test.tsx | 8 ++ .../components/send/asset/asset.tsx | 13 +++ .../send/recipient/recipient.test.tsx | 8 ++ .../components/send/recipient/recipient.tsx | 3 + .../confirmations/components/send/send.tsx | 20 ++-- .../hooks/send/useSendActions.test.ts | 9 +- .../hooks/send/useSendActions.ts | 6 + .../hooks/send/useSendNavbar.test.tsx | 106 +++++++++--------- .../hooks/send/useSendNavbar.tsx | 26 ++++- 12 files changed, 143 insertions(+), 68 deletions(-) diff --git a/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.tsx b/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.tsx index 1bc7254cb1d..bec264febb8 100644 --- a/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.tsx +++ b/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.tsx @@ -260,6 +260,7 @@ export function PayWithModal() { ({ jest.mock('../../../hooks/send/useCurrencyConversions'); +jest.mock('../../../hooks/send/useSendNavbar', () => ({ + useSendNavbar: () => ({ + Amount: { header: () => null }, + Asset: { header: () => null }, + Recipient: { header: () => null }, + }), +})); + jest.mock('../../../hooks/send/useRouteParams', () => ({ useRouteParams: jest.fn().mockReturnValue({ isLoading: false }), })); diff --git a/app/components/Views/confirmations/components/send/amount/amount.tsx b/app/components/Views/confirmations/components/send/amount/amount.tsx index d15caff4545..bbdf1673428 100644 --- a/app/components/Views/confirmations/components/send/amount/amount.tsx +++ b/app/components/Views/confirmations/components/send/amount/amount.tsx @@ -30,6 +30,7 @@ import { useBalance } from '../../../hooks/send/useBalance'; import { useCurrencyConversions } from '../../../hooks/send/useCurrencyConversions'; import { useRouteParams } from '../../../hooks/send/useRouteParams'; import { useSendContext } from '../../../context/send-context'; +import { useSendNavbar } from '../../../hooks/send/useSendNavbar'; import { useParams } from '../../../../../../util/navigation/navUtils'; import { AmountKeyboard } from './amount-keyboard'; import { AnimatedCursor } from './animated-cursor'; @@ -38,6 +39,7 @@ import { InitSendLocation } from '../../../constants/send'; export const Amount = () => { const navigation = useNavigation(); + const { header: renderAmountHeader } = useSendNavbar().Amount; const { location } = useParams<{ location?: string }>(); const primaryCurrency = useSelector(selectPrimaryCurrency); const { asset, value } = useSendContext(); @@ -135,6 +137,7 @@ export const Amount = () => { edges={isIos ? ['left', 'right'] : ['left', 'right', 'bottom']} style={styles.container} > + {renderAmountHeader()} {isNFT && ( diff --git a/app/components/Views/confirmations/components/send/asset/asset.test.tsx b/app/components/Views/confirmations/components/send/asset/asset.test.tsx index d8750c2f315..a138b0906ad 100644 --- a/app/components/Views/confirmations/components/send/asset/asset.test.tsx +++ b/app/components/Views/confirmations/components/send/asset/asset.test.tsx @@ -81,6 +81,14 @@ const mockHighlightedAsset: HighlightedItem = { action: jest.fn(), }; +jest.mock('../../../hooks/send/useSendNavbar', () => ({ + useSendNavbar: () => ({ + Amount: { header: () => null }, + Asset: { header: () => null }, + Recipient: { header: () => null }, + }), +})); + jest.mock('../../../hooks/send/useSendTokens', () => ({ useSendTokens: jest.fn(), })); diff --git a/app/components/Views/confirmations/components/send/asset/asset.tsx b/app/components/Views/confirmations/components/send/asset/asset.tsx index cf7b13fb58f..67ba248df0b 100644 --- a/app/components/Views/confirmations/components/send/asset/asset.tsx +++ b/app/components/Views/confirmations/components/send/asset/asset.tsx @@ -12,6 +12,7 @@ import { import { strings } from '../../../../../../../locales/i18n'; import TextFieldSearch from '../../../../../../component-library/components/Form/TextFieldSearch'; import { useAssetSelectionMetrics } from '../../../hooks/send/metrics/useAssetSelectionMetrics'; +import { useSendNavbar } from '../../../hooks/send/useSendNavbar'; import { useTokenSearch } from '../../../hooks/send/useTokenSearch'; import { TokenList } from '../../token-list'; import { NftList } from '../../nft-list'; @@ -37,8 +38,18 @@ export interface AssetProps { onTokenSelect?: (token: AssetType) => void; tokenFilter?: (assets: AssetType[]) => TokenListItem[]; hideNetworkFilter?: boolean; + // Hides the in-body send navbar. Set by consumers that render their own + // header (e.g. pay-with-modal) while reusing this asset picker. + hideHeader?: boolean; } +// Rendered only inside the send flow so that consumers reusing this picker +// (e.g. pay-with-modal) don't pull in useSendNavbar's dependency chain. +const AssetSendHeader = () => { + const { header: renderHeader } = useSendNavbar().Asset; + return renderHeader(); +}; + export const Asset: React.FC = (props = {}) => { const { hideNfts = false, @@ -46,6 +57,7 @@ export const Asset: React.FC = (props = {}) => { onTokenSelect, tokenFilter, hideNetworkFilter = false, + hideHeader = false, } = props; const originalTokens = useSendTokens({ includeNoBalance }); @@ -195,6 +207,7 @@ export const Asset: React.FC = (props = {}) => { return ( + {!hideHeader && } {highlightedItemsOutsideAssetList.length > 0 && ( {highlightedItemsOutsideAssetList.map((item, index) => ( diff --git a/app/components/Views/confirmations/components/send/recipient/recipient.test.tsx b/app/components/Views/confirmations/components/send/recipient/recipient.test.tsx index edcdfaae1b9..cde916b3af1 100644 --- a/app/components/Views/confirmations/components/send/recipient/recipient.test.tsx +++ b/app/components/Views/confirmations/components/send/recipient/recipient.test.tsx @@ -18,6 +18,14 @@ import { Recipient } from './recipient'; jest.mock('../../../../../../component-library/components-temp/Skeleton'); +jest.mock('../../../hooks/send/useSendNavbar', () => ({ + useSendNavbar: () => ({ + Amount: { header: () => null }, + Asset: { header: () => null }, + Recipient: { header: () => null }, + }), +})); + jest.mock('@react-navigation/native', () => { const actual = jest.requireActual('@react-navigation/native'); const ReactActual = jest.requireActual('react'); diff --git a/app/components/Views/confirmations/components/send/recipient/recipient.tsx b/app/components/Views/confirmations/components/send/recipient/recipient.tsx index ca89de8d8ad..e29efc62b85 100644 --- a/app/components/Views/confirmations/components/send/recipient/recipient.tsx +++ b/app/components/Views/confirmations/components/send/recipient/recipient.tsx @@ -22,6 +22,7 @@ import { useContacts } from '../../../hooks/send/useContacts'; import { useRecipientPageReset } from '../../../hooks/send/useRecipientPageReset'; import { useRouteParams } from '../../../hooks/send/useRouteParams'; import { useSendActions } from '../../../hooks/send/useSendActions'; +import { useSendNavbar } from '../../../hooks/send/useSendNavbar'; import { useAddressPoisoningDetection } from '../../../hooks/send/useAddressPoisoningDetection'; import { useToAddressValidation } from '../../../hooks/send/useToAddressValidation'; import { RecipientInput } from '../../recipient-input'; @@ -37,6 +38,7 @@ export const Recipient = () => { const [pastedRecipient, setPastedRecipient] = useState(); const [isAlertModalOpen, setIsAlertModalOpen] = useState(false); const { to, updateTo, asset, chainId } = useSendContext(); + const { header: renderRecipientHeader } = useSendNavbar().Recipient; const { handleSubmitPress } = useSendActions(); const accounts = useAccounts(); const contacts = useContacts(); @@ -209,6 +211,7 @@ export const Recipient = () => { return ( + {renderRecipientHeader()} { const { colors } = useTheme(); - const sendNavigationOptions = useSendNavbar(); const emptyNavHeaderOptions = useEmptyNavHeaderForConfirmations(); return ( @@ -25,23 +29,23 @@ export const Send = () => { ; const mockGoBack = jest.fn(); +const mockParentGoBack = jest.fn(); const mockNavigate = jest.fn(); jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), useNavigation: () => ({ goBack: mockGoBack, navigate: mockNavigate, + getParent: () => ({ + goBack: mockParentGoBack, + }), }), useRoute: jest.fn().mockReturnValue({ params: { @@ -127,13 +131,14 @@ describe('useSendActions', () => { expect(mockGoBack).toHaveBeenCalled(); }); - it('calls navigation.goBack when handleCancelPress is invoked', () => { + it('calls parent navigation.goBack when handleCancelPress is invoked', () => { const { result } = renderHookWithProvider( () => useSendActions(), mockState, ); result.current.handleCancelPress(); - expect(mockGoBack).toHaveBeenCalled(); + expect(mockParentGoBack).toHaveBeenCalled(); + expect(mockGoBack).not.toHaveBeenCalled(); }); it('capture metrics when handleCancelPress is invoked', () => { diff --git a/app/components/Views/confirmations/hooks/send/useSendActions.ts b/app/components/Views/confirmations/hooks/send/useSendActions.ts index 7304e28704c..30c4cd060ce 100644 --- a/app/components/Views/confirmations/hooks/send/useSendActions.ts +++ b/app/components/Views/confirmations/hooks/send/useSendActions.ts @@ -114,6 +114,12 @@ export const useSendActions = () => { const handleCancelPress = useCallback(() => { captureSendExit(); + // Exit the whole Send flow (main stack), not just the nested send screen. + const parentNavigation = navigation.getParent(); + if (parentNavigation) { + parentNavigation.goBack(); + return; + } navigation.goBack(); }, [captureSendExit, navigation]); diff --git a/app/components/Views/confirmations/hooks/send/useSendNavbar.test.tsx b/app/components/Views/confirmations/hooks/send/useSendNavbar.test.tsx index 967dae7748f..677f626e7ea 100644 --- a/app/components/Views/confirmations/hooks/send/useSendNavbar.test.tsx +++ b/app/components/Views/confirmations/hooks/send/useSendNavbar.test.tsx @@ -60,8 +60,10 @@ jest.mock('@metamask/design-system-react-native', () => { }); describe('useSendNavbar', () => { + const mockParentNavigate = jest.fn(); const mockNavigation = { navigate: mockNavigate, + getParent: jest.fn(), }; const createMockNavigationState = ( @@ -70,16 +72,34 @@ describe('useSendNavbar', () => { params?: Record; state?: unknown; }[], + index = routes.length - 1, ) => ({ - index: 0, + index, routes, }); + const mockParentStackState = ( + routes: { + name: string; + params?: Record; + state?: unknown; + }[], + index = routes.length - 1, + ) => { + const state = createMockNavigationState(routes, index); + mockNavigation.getParent.mockReturnValue({ + navigate: mockParentNavigate, + getState: () => state, + }); + return state; + }; + beforeEach(() => { jest.clearAllMocks(); + mockParentStackState([{ name: 'Send' }]); (useNavigation as jest.Mock).mockReturnValue(mockNavigation); (useNavigationState as jest.Mock).mockReturnValue( - createMockNavigationState([{ name: 'Send' }]), + createMockNavigationState([{ name: Routes.SEND.ASSET }]), ); }); @@ -122,9 +142,7 @@ describe('useSendNavbar', () => { }); it('navigates to wallet view when back button is pressed with no previous routes', () => { - (useNavigationState as jest.Mock).mockReturnValue( - createMockNavigationState([{ name: 'Send' }]), - ); + mockParentStackState([{ name: 'Send' }]); const { result } = renderHookWithProvider(() => useSendNavbar()); const { Amount } = result.current; @@ -135,16 +153,14 @@ describe('useSendNavbar', () => { fireEvent.press(backButton); - expect(mockNavigate).toHaveBeenCalledWith(Routes.WALLET_VIEW); + expect(mockParentNavigate).toHaveBeenCalledWith(Routes.WALLET_VIEW); }); it('navigates to previous main route when back button is pressed', () => { - (useNavigationState as jest.Mock).mockReturnValue( - createMockNavigationState([ - { name: 'SomeOtherRoute', params: { test: 'data' } }, - { name: 'Send' }, - ]), - ); + mockParentStackState([ + { name: 'SomeOtherRoute', params: { test: 'data' } }, + { name: 'Send' }, + ]); const { result } = renderHookWithProvider(() => useSendNavbar()); const { Amount } = result.current; @@ -155,15 +171,13 @@ describe('useSendNavbar', () => { fireEvent.press(backButton); - expect(mockNavigate).toHaveBeenCalledWith('SomeOtherRoute', { + expect(mockParentNavigate).toHaveBeenCalledWith('SomeOtherRoute', { test: 'data', }); }); it('navigates to wallet view when previous route is Home', () => { - (useNavigationState as jest.Mock).mockReturnValue( - createMockNavigationState([{ name: 'Home' }, { name: 'Send' }]), - ); + mockParentStackState([{ name: 'Home' }, { name: 'Send' }]); const { result } = renderHookWithProvider(() => useSendNavbar()); const { Amount } = result.current; @@ -174,24 +188,19 @@ describe('useSendNavbar', () => { fireEvent.press(backButton); - expect(mockNavigate).toHaveBeenCalledWith(Routes.WALLET_VIEW); + expect(mockParentNavigate).toHaveBeenCalledWith(Routes.WALLET_VIEW); }); it('navigates within Send sub-routes when nested routes exist', () => { - (useNavigationState as jest.Mock).mockReturnValue( - createMockNavigationState([ - { - name: 'Send', - state: { - index: 1, - routes: [ - { name: Routes.SEND.ASSET }, - { name: Routes.SEND.AMOUNT }, - ], - }, + mockParentStackState([ + { + name: 'Send', + state: { + index: 1, + routes: [{ name: Routes.SEND.ASSET }, { name: Routes.SEND.AMOUNT }], }, - ]), - ); + }, + ]); const { result } = renderHookWithProvider(() => useSendNavbar()); const { Amount } = result.current; @@ -202,26 +211,21 @@ describe('useSendNavbar', () => { fireEvent.press(backButton); - expect(mockNavigate).toHaveBeenCalledWith(Routes.SEND.DEFAULT, { + expect(mockParentNavigate).toHaveBeenCalledWith(Routes.SEND.DEFAULT, { screen: Routes.SEND.ASSET, }); }); it('navigates to Asset screen when at first route in nested Send stack', () => { - (useNavigationState as jest.Mock).mockReturnValue( - createMockNavigationState([ - { - name: 'Send', - state: { - index: 0, - routes: [ - { name: Routes.SEND.ASSET }, - { name: Routes.SEND.AMOUNT }, - ], - }, + mockParentStackState([ + { + name: 'Send', + state: { + index: 0, + routes: [{ name: Routes.SEND.ASSET }, { name: Routes.SEND.AMOUNT }], }, - ]), - ); + }, + ]); const { result } = renderHookWithProvider(() => useSendNavbar()); const { Amount } = result.current; @@ -232,7 +236,7 @@ describe('useSendNavbar', () => { fireEvent.press(backButton); - expect(mockNavigate).toHaveBeenCalledWith(Routes.SEND.DEFAULT, { + expect(mockParentNavigate).toHaveBeenCalledWith(Routes.SEND.DEFAULT, { screen: Routes.SEND.ASSET, }); }); @@ -305,12 +309,10 @@ describe('useSendNavbar', () => { }); it('uses same back navigation logic as Amount route', () => { - (useNavigationState as jest.Mock).mockReturnValue( - createMockNavigationState([ - { name: 'SomeOtherRoute', params: { test: 'data' } }, - { name: 'Send' }, - ]), - ); + mockParentStackState([ + { name: 'SomeOtherRoute', params: { test: 'data' } }, + { name: 'Send' }, + ]); const { result } = renderHookWithProvider(() => useSendNavbar()); const { Recipient } = result.current; @@ -321,7 +323,7 @@ describe('useSendNavbar', () => { fireEvent.press(backButton); - expect(mockNavigate).toHaveBeenCalledWith('SomeOtherRoute', { + expect(mockParentNavigate).toHaveBeenCalledWith('SomeOtherRoute', { test: 'data', }); }); diff --git a/app/components/Views/confirmations/hooks/send/useSendNavbar.tsx b/app/components/Views/confirmations/hooks/send/useSendNavbar.tsx index 61b2555887f..1646459ac0d 100644 --- a/app/components/Views/confirmations/hooks/send/useSendNavbar.tsx +++ b/app/components/Views/confirmations/hooks/send/useSendNavbar.tsx @@ -9,9 +9,20 @@ import { useSendActions } from './useSendActions'; export function useSendNavbar() { const { handleCancelPress } = useSendActions(); const navigation = useNavigation(); - const sendStackState = useNavigationState((state) => state); + const parentNavigation = navigation.getParent(); + // Back/cancel logic must read the main stack (which owns the `Send` route). + // When the header is rendered inside Amount/Asset/Recipient, `useNavigationState` + // only sees the nested Send stack — not the parent — so we read parent state here. + const navigationForStack = parentNavigation ?? navigation; + const nestedStackState = useNavigationState((state) => state); const handleBackPress = useCallback(() => { + const parentState = parentNavigation?.getState(); + const sendStackState = + parentState?.routes.some((route) => route.name === 'Send') === true + ? parentState + : nestedStackState; + const sendRoute = sendStackState.routes.find( (route) => route.name === 'Send', ); @@ -24,7 +35,7 @@ export function useSendNavbar() { : null; const screenName = previousRoute?.name || Routes.SEND.ASSET; - navigation.navigate(Routes.SEND.DEFAULT, { screen: screenName }); + navigationForStack.navigate(Routes.SEND.DEFAULT, { screen: screenName }); return; } @@ -34,7 +45,7 @@ export function useSendNavbar() { ); if (sendRouteIndex <= 0) { - navigation.navigate(Routes.WALLET_VIEW); + navigationForStack.navigate(Routes.WALLET_VIEW); return; } @@ -42,11 +53,14 @@ export function useSendNavbar() { // Navigate to previous route with special handling for specific routes if (previousMainRoute.name === 'Home') { - navigation.navigate(Routes.WALLET_VIEW); + navigationForStack.navigate(Routes.WALLET_VIEW); } else { - navigation.navigate(previousMainRoute.name, previousMainRoute.params); + navigationForStack.navigate( + previousMainRoute.name, + previousMainRoute.params, + ); } - }, [navigation, sendStackState]); + }, [navigationForStack, nestedStackState, parentNavigation]); return { Amount: getHeaderCompactStandardNavbarOptions({ From a6d345986f65ea3919983b30a95e6d57f3c8485b Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:10:40 -0600 Subject: [PATCH 07/22] refactor(onboarding): replace Pressable with TouchableOpacity in interest questionnaire (#30876) ## **Description** This PR updates the onboarding interest questionnaire option cards to use `TouchableOpacity` instead of `Pressable`. Reason for change: - The option cards no longer need a pressed-state style callback for their interaction feedback. Improvement/solution: - Replaced the option card touch target with `TouchableOpacity` in `OnboardingInterestQuestionnaire.tsx`. - Simplified the `style` prop from a callback form to a static `tw.style(...)` expression. - Removed the `pressed` opacity condition while preserving selection-based border and background styling. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: N/A ## **Manual testing steps** ```gherkin Feature: onboarding interest selection card interaction Scenario: user selects and deselects an interest option Given the onboarding interest questionnaire is visible When user taps an unselected interest option card Then the card appears selected When user taps the same selected interest option card Then the card appears unselected ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/7eac6d7a-4953-4505-99e4-6fb6bb9a7181 ### **After** https://github.com/user-attachments/assets/3de229bf-4c5b-47cc-b15e-d0e4eb9c819a ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Single-screen UI touch component swap with no changes to selection logic, analytics, or navigation. > > **Overview** > Onboarding interest questionnaire **option cards** now use **`TouchableOpacity`** instead of **`Pressable`**, with a static `tw.style(...)` instead of a pressed-state style callback. > > **Removed** the custom **pressed opacity** (`opacity-70`); **selected vs unselected** border and background styling is unchanged. Toggle behavior, test IDs, and checkbox accessibility are the same. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 214fe40f7a702b112cfd3f36787c5398bb9509e3. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .../OnboardingInterestQuestionnaire.tsx | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/app/components/Views/OnboardingInterestQuestionnaire/OnboardingInterestQuestionnaire.tsx b/app/components/Views/OnboardingInterestQuestionnaire/OnboardingInterestQuestionnaire.tsx index 519cfaa55c0..d551ecaeaa1 100644 --- a/app/components/Views/OnboardingInterestQuestionnaire/OnboardingInterestQuestionnaire.tsx +++ b/app/components/Views/OnboardingInterestQuestionnaire/OnboardingInterestQuestionnaire.tsx @@ -4,9 +4,9 @@ import { Image, type ImageSourcePropType, Platform, - Pressable, ScrollView, StatusBar, + TouchableOpacity, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useRoute, type RouteProp } from '@react-navigation/native'; @@ -215,17 +215,14 @@ const OnboardingInterestQuestionnaire = () => { marginBottom: GRID_GUTTER_PX * 2, })} > - toggleOption(option.id)} - style={({ pressed }) => - tw.style( - 'relative h-[120px] w-full rounded-xl bg-background-muted', - isSelected - ? 'border border-border-default bg-background-muted-hover' - : 'border border-muted', - pressed && 'opacity-70', - ) - } + style={tw.style( + 'relative h-[120px] w-full rounded-xl bg-background-muted', + isSelected + ? 'border border-border-default bg-background-muted-hover' + : 'border border-muted', + )} testID={`${OnboardingInterestQuestionnaireTestIds.OPTION_PREFIX}${option.id}`} accessibilityRole="checkbox" accessibilityState={{ checked: isSelected }} @@ -248,7 +245,7 @@ const OnboardingInterestQuestionnaire = () => { > {strings(option.labelKey)}
- +
); })} From 23581ce6ac1ca2901ce60300dd3f24a6755a2397 Mon Sep 17 00:00:00 2001 From: sophieqgu <37032128+sophieqgu@users.noreply.github.com> Date: Mon, 1 Jun 2026 16:11:09 -0400 Subject: [PATCH 08/22] fix(rewards): hypertracker logo color (#30891) ## **Description** ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** Simulator Screenshot - E2E Test -
2026-06-01 at 14 30 25 Simulator Screenshot - E2E Test -
2026-06-01 at 14 31 03 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Small UI/theming change on the rewards leaderboard with a focused test; no auth, data, or payment logic. > > **Overview** > The **HyperTracker** wordmark on the perps trading campaign leaderboard no longer stays hard-coded white. The SVG wordmark path now uses **`currentColor`**, and **`PerpsTradingCampaignLeaderboard`** passes **`colors.text.default`** from **`useTheme`** into **`HyperTrackerLogo`** so the logo matches the active theme (e.g. readable in light mode). > > A unit test wraps the leaderboard in **`ThemeContext`** and asserts the logo receives the theme text color. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 909d29e6761058fdc5fe272d973b307bccec034a. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../PerpsTradingCampaignLeaderboard.test.tsx | 25 +++++++++++++++++++ .../PerpsTradingCampaignLeaderboard.tsx | 9 ++++++- app/images/rewards/hypertracker.svg | 2 +- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.test.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.test.tsx index 8d334d3dd70..175ab6ef471 100644 --- a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.test.tsx @@ -5,6 +5,7 @@ import PerpsTradingCampaignLeaderboard, { } from './PerpsTradingCampaignLeaderboard'; import type { PerpsTradingCampaignLeaderboardEntry } from '../../../../../core/Engine/controllers/rewards-controller/types'; import { PERPS_TRADING_MAX_WINNERS } from '../../utils/perpsCampaignConstants'; +import { mockTheme, ThemeContext } from '../../../../../util/theme'; jest.mock('@metamask/design-system-react-native', () => { const actual = jest.requireActual('@metamask/design-system-react-native'); @@ -108,6 +109,30 @@ describe('PerpsTradingCampaignLeaderboard', () => { ); }); + it('uses the theme text color for the HyperTracker wordmark', () => { + const logoTextColor = mockTheme.colors.text.alternative; + const theme = { + ...mockTheme, + colors: { + ...mockTheme.colors, + text: { + ...mockTheme.colors.text, + default: logoTextColor, + }, + }, + }; + + const { UNSAFE_getByProps } = render( + + + , + ); + + expect(UNSAFE_getByProps({ name: 'HyperTrackerLogo' }).props.color).toBe( + logoTextColor, + ); + }); + it('shows crown in full view for perps winner ranks only', () => { const entries = [ createPerpsEntry({ diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.tsx index fe9b7fcda74..5a4d6ae14f6 100644 --- a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.tsx +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.tsx @@ -23,6 +23,7 @@ import { PERPS_TRADING_MAX_WINNERS, } from '../../utils/perpsCampaignConstants'; import HyperTrackerLogo from '../../../../../images/rewards/hypertracker.svg'; +import { useTheme } from '../../../../../util/theme'; export const PERPS_CAMPAIGN_LEADERBOARD_TEST_IDS = { CONTAINER: 'perps-campaign-leaderboard-container', @@ -76,6 +77,7 @@ const PerpsTradingCampaignLeaderboard: React.FC< isCampaignComplete = false, }) => { const navigation = useNavigation(); + const { colors } = useTheme(); const handleHyperTrackerPress = useCallback(() => { navigation.navigate(Routes.BROWSER.HOME, { @@ -239,7 +241,12 @@ const PerpsTradingCampaignLeaderboard: React.FC< onPress={handleHyperTrackerPress} testID={PERPS_CAMPAIGN_LEADERBOARD_TEST_IDS.HYPERTRACKER_LOGO} > - + diff --git a/app/images/rewards/hypertracker.svg b/app/images/rewards/hypertracker.svg index 56cc433f269..4b6739df6af 100644 --- a/app/images/rewards/hypertracker.svg +++ b/app/images/rewards/hypertracker.svg @@ -1,4 +1,4 @@ - + From ad68ca5d8aa0e9caeb967faf5d343e0dd7d2156c Mon Sep 17 00:00:00 2001 From: tommasini <46944231+tommasini@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:38:24 +0100 Subject: [PATCH 09/22] chore: nightly build external distributed (#30887) ## **Description** ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > CI-only flag change for nightly iOS TestFlight; no app runtime or auth logic affected. > > **Overview** > Nightly **iOS** `exp` and `rc` jobs now pass **`distribute_external: true`** into `build-and-upload-to-testflight.yml`, so scheduled builds are distributed to external TestFlight testers (still using the **MetaMask BETA & Release Candidates** group) instead of relying on the workflow default of internal-only upload. > > Android nightly jobs are unchanged. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit c2648c58b83c293549ff5660f175546ee19e04f5. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .github/workflows/nightly-build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index d5ebd5490eb..b7cb78ee703 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -24,6 +24,7 @@ jobs: source_branch: main environment: exp testflight_group: 'MetaMask BETA & Release Candidates' + distribute_external: true secrets: inherit ios-rc: @@ -34,6 +35,7 @@ jobs: source_branch: main environment: rc testflight_group: 'MetaMask BETA & Release Candidates' + distribute_external: true secrets: inherit android-exp-generate: From 48aaee7afe3c07df8541337b496d35c764982cef Mon Sep 17 00:00:00 2001 From: Vince Howard Date: Mon, 1 Jun 2026 14:59:41 -0600 Subject: [PATCH 10/22] fix(pure black): elevate ListItemSelect and Input surfaces under MM_PURE_BLACK_PREVIEW (#30661) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Part of the pure-black dark mode migration (TMCU-798). When `MM_PURE_BLACK_PREVIEW=true`, `background.default` resolves to `#000000`, causing elevated surfaces bound to that token to collapse into the screen background (black-on-black). This PR addresses two design-system components plus their downstream consumers: - `ListItemSelect` selected-row background → uses `getElevatedSurfaceColor` shim (`background.alternative` under pure-black dark, unchanged otherwise). - Deprecated `Input` (and its SRP-input fork) → same shim. Bridge `TokenInputArea` and `InputStepper` previously relied on `Input`'s `background.default` to match the screen background and render "invisible"; both now explicitly opt out via `backgroundColor: importedColors.transparent` so the swap amount inputs stay flat. - `EditMultichainAccountName` was using a raw RN `TextInput` with no `backgroundColor`, so the field rendered as an outline-only border against pure black. Migrated to `TextField` from `@metamask/design-system-react-native`, which paints `background.muted` (translucent overlay → elevated under pure black). Net effect: elevated surfaces render correctly under pure-black, no regressions on the Bridge/Swap amount entry, and `EditMultichainAccountName` is now off a deprecated RN-native input. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TMCU-798 ## **Manual testing steps** ```gherkin Feature: Elevated surfaces remain visible under MM_PURE_BLACK_PREVIEW Scenario: ListItemSelect selected row is visibly elevated Given MM_PURE_BLACK_PREVIEW=true and the app is in dark mode When the user opens the Tokens sort bottom sheet Then the currently-selected sort option renders an elevated row (background.section) Scenario: Deprecated Input renders elevated where no parent provides elevation Given the same flag and theme When the user navigates to a screen using the deprecated Input directly (e.g. SRP input) Then the input fill is visibly elevated, not pure black Scenario: Bridge/Swap amount inputs stay flat Given the same flag and theme When the user opens the Swap screen Then the source and destination amount fields render flat against the screen background (no elevated rectangle around the "0") Scenario: EditMultichainAccountName field is visible Given the same flag and theme When the user opens Account details → Edit account name Then the "Account name" field renders with a visible elevated fill (DS TextField muted background) ``` ## **Screenshots/Recordings** ### ListItemSelect (Shows sort but the change is the same for all consumers | Before | After | |--------|-------| | ![Before](https://github.com/user-attachments/assets/c4321a74-a64f-4271-9fd4-15b3c079d544) | ![After](https://github.com/user-attachments/assets/166d8c53-59af-42b9-8d1f-1d7fa8924628) | ### Input (Nothing Changes) https://github.com/user-attachments/assets/6a2a4eb9-dddd-4476-bb84-a00009337e2a ### Migrate input from react native to design system component for edit wallet name | Before | After | |--------|-------| | ![Before](https://github.com/user-attachments/assets/3fbcbfec-30a2-4985-9152-14c34c3ae669) | ![After](https://github.com/user-attachments/assets/8fc6b08e-86a4-44a5-895c-3894ec14813b) | ### **Before** `~` ### **After** `~` ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Theme and presentation-only changes behind a preview flag; no auth, persistence, or payment logic. > > **Overview** > Under **`MM_PURE_BLACK_PREVIEW`**, elevated UI no longer disappears on a pure-black screen: deprecated **`Input`** and **`ListItemSelect`** now use **`getElevatedSurfaceColor`** instead of **`background.default`**, so fills stay visible in dark mode while other themes stay the same. > > **Bridge/Swap** amount fields still look flat: **`TokenInputArea`** and **`InputStepper`** pass **`backgroundColor: transparent`** on the nested **`Input`** styles so the new elevated default does not draw a box around the amount. > > **Edit multichain account name** drops the raw RN **`TextInput`** (border-only on black) for design-system **`TextField`**, with error state and keyboard props moved into **`inputProps`**. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit e4c092d31447cf24b35f200d95fb27ff1327a56a. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../foundation/Input/Input.styles.ts | 3 +- .../ListItemSelect/ListItemSelect.styles.ts | 2 ++ .../Bridge/components/InputStepper/styles.tsx | 2 ++ .../components/TokenInputArea/index.tsx | 2 ++ .../EditMultichainAccountName.tsx | 32 +++++++------------ 5 files changed, 19 insertions(+), 22 deletions(-) diff --git a/app/component-library/components/Form/TextField/foundation/Input/Input.styles.ts b/app/component-library/components/Form/TextField/foundation/Input/Input.styles.ts index 7dd0e003216..a05e540e97d 100644 --- a/app/component-library/components/Form/TextField/foundation/Input/Input.styles.ts +++ b/app/component-library/components/Form/TextField/foundation/Input/Input.styles.ts @@ -4,6 +4,7 @@ import { Platform, StyleSheet, TextStyle } from 'react-native'; // External dependencies. import { Theme } from '../../../../../../util/theme/models'; import { colors } from '../../../../../../styles/common'; +import { getElevatedSurfaceColor } from '../../../../../../util/theme/themeUtils'; import { getFontFamily } from '../../../../Texts/Text/'; // Internal dependencies @@ -50,7 +51,7 @@ const styleSheet = (params: { theme: Theme; vars: InputStyleSheetVars }) => { color: theme.colors.text.default, borderWidth: 1, borderColor: colors.transparent, - backgroundColor: theme.colors.background.default, + backgroundColor: getElevatedSurfaceColor(theme), ...stateObj, fontFamily: getFontFamily(textVariant), fontWeight: theme.typography[textVariant].fontWeight, diff --git a/app/component-library/components/List/ListItemSelect/ListItemSelect.styles.ts b/app/component-library/components/List/ListItemSelect/ListItemSelect.styles.ts index fd08dcd55ec..3a1d83fd85a 100644 --- a/app/component-library/components/List/ListItemSelect/ListItemSelect.styles.ts +++ b/app/component-library/components/List/ListItemSelect/ListItemSelect.styles.ts @@ -3,6 +3,7 @@ import { StyleSheet, ViewStyle } from 'react-native'; // External dependencies. import { Theme } from '../../../../util/theme/models'; +import { getElevatedSurfaceColor } from '../../../../util/theme/themeUtils'; // Internal dependencies. import { ListItemSelectStyleSheetVars } from './ListItemSelect.types'; @@ -28,6 +29,7 @@ const styleSheet = (params: { position: 'relative', opacity: isDisabled ? 0.5 : 1, borderRadius: 4, + backgroundColor: getElevatedSurfaceColor(theme), } as ViewStyle, style, ) as ViewStyle, diff --git a/app/components/UI/Bridge/components/InputStepper/styles.tsx b/app/components/UI/Bridge/components/InputStepper/styles.tsx index 96fb97aa332..c4360170e30 100644 --- a/app/components/UI/Bridge/components/InputStepper/styles.tsx +++ b/app/components/UI/Bridge/components/InputStepper/styles.tsx @@ -1,5 +1,6 @@ import { Theme } from '@metamask/design-tokens'; import { Platform, StyleSheet } from 'react-native'; +import { colors as importedColors } from '../../../../../styles/common'; export const inputStepperStyles = ({ vars, @@ -22,6 +23,7 @@ export const inputStepperStyles = ({ justifyContent: 'center', }, input: { + backgroundColor: importedColors.transparent, borderWidth: 0, lineHeight: vars.fontSize * 1.25, height: vars.fontSize * 1.25, diff --git a/app/components/UI/Bridge/components/TokenInputArea/index.tsx b/app/components/UI/Bridge/components/TokenInputArea/index.tsx index dbe5e897e38..44a0c3e44a4 100644 --- a/app/components/UI/Bridge/components/TokenInputArea/index.tsx +++ b/app/components/UI/Bridge/components/TokenInputArea/index.tsx @@ -12,6 +12,7 @@ import { } from 'react-native'; import { useSelector } from 'react-redux'; import { useStyles } from '../../../../../component-library/hooks'; +import { colors as importedColors } from '../../../../../styles/common'; import { Box } from '../../../Box/Box'; import Text, { TextColor, @@ -86,6 +87,7 @@ const createStyles = ({ height: vars.fontSize * 1.25, fontSize: vars.fontSize, paddingVertical: Platform.OS === 'ios' ? 2 : 1, + backgroundColor: importedColors.transparent, flex: 1, flexShrink: 1, }, diff --git a/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.tsx b/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.tsx index a178d619dd1..0bde55e3c1f 100644 --- a/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.tsx +++ b/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.tsx @@ -1,11 +1,6 @@ import React, { useCallback, useState } from 'react'; import { useSelector } from 'react-redux'; -import { - KeyboardAvoidingView, - Platform, - StatusBar, - TextInput, -} from 'react-native'; +import { KeyboardAvoidingView, Platform, StatusBar } from 'react-native'; import { strings } from '../../../../../../locales/i18n'; import Engine from '../../../../../core/Engine'; import { @@ -26,6 +21,7 @@ import { IconName, Text, TextColor, + TextField, TextVariant, FontWeight, } from '@metamask/design-system-react-native'; @@ -50,7 +46,7 @@ type EditMultichainAccountNameRouteProp = RouteProp< export const EditMultichainAccountName = () => { const tw = useTailwind(); - const { colors, themeAppearance } = useTheme(); + const { themeAppearance } = useTheme(); const route = useRoute(); const { accountGroup: initialAccountGroup } = route.params; const navigation = useNavigation(); @@ -75,11 +71,6 @@ export const EditMultichainAccountName = () => { : undefined, ); - const inputStyle = tw.style( - 'h-10 w-full rounded-lg border-2 border-default p-2.5', - { color: colors.text.default }, - ); - const handleAccountNameChange = useCallback(() => { // Validate that account name is not empty if (!accountName || accountName.trim() === '') { @@ -134,24 +125,23 @@ export const EditMultichainAccountName = () => { {strings('multichain_accounts.edit_account_name.account_name')} - { setAccountName(newName); - // Clear error when user starts typing if (error) { setError(null); } }} placeholder={initialName} - placeholderTextColor={colors.text.muted} - spellCheck={false} - keyboardAppearance={themeAppearance} - autoCapitalize="none" + isError={Boolean(error)} autoFocus - editable + inputProps={{ + testID: EditAccountNameIds.ACCOUNT_NAME_INPUT, + spellCheck: false, + keyboardAppearance: themeAppearance, + autoCapitalize: 'none', + }} /> {error ? {error} : null} From 0a88c0e5b294fc6d936c20106949ad0273b9fac2 Mon Sep 17 00:00:00 2001 From: infiniteflower <139582705+infiniteflower@users.noreply.github.com> Date: Tue, 2 Jun 2026 06:12:14 +0900 Subject: [PATCH 11/22] feat: batch sell submit tx (#30710) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Wires Batch Sell final review submission using the preview Bridge Controller and Bridge Status Controller builds. This PR adds the required preview package configuration, allows `BridgeStatusController` to call `BridgeController:getState`, and introduces a Batch Sell-specific submit hook that calls `BridgeStatusController.submitBatchSell`. The submit path passes the list of executable recommended quote responses, matching the controller contract for Batch Sell. It also updates the final review modal to: - submit via the new `useSubmitBatchSellTx` hook - block `Sell all` until `isBatchSellTradeAvailable` is true - use `isBatchSellTradeAvailable` directly instead of returning a duplicate `networkFeeIsLoading` field - toggle the existing submitting state and navigate to the Transactions view after submit ## **Changelog** CHANGELOG entry: Added Batch Sell submit support ## **Related issues** Fixes: [SWAPS-4441](https://consensyssoftware.atlassian.net/browse/SWAPS-4441) ## **Manual testing steps** ```gherkin Feature: Batch Sell submit Scenario: user submits a Batch Sell transaction Given Batch Sell quotes have loaded on the final review screen And Batch Sell trades are available And the user has sufficient funds for gas When the user taps Sell all Then the app submits the list of recommended Batch Sell quote responses And the button enters the submitting state And the app navigates to the Transactions view after submission ``` ```gherkin Feature: Batch Sell unavailable trade state Scenario: Batch Sell trades are not ready Given the final review screen is open And isBatchSellTradeAvailable is false Then the network fee row shows its loading skeleton And the Sell all button is disabled ``` ```gherkin Feature: Batch Sell expired quote refresh Scenario: user refreshes an expired Batch Sell quote Given the final review screen is open And the quote has expired after max refresh When the user taps Get new quote Then BridgeController quote state is reset And fresh Batch Sell quote requests are made ``` ## **Screenshots/Recordings** ### **Before** n/a ### **After** https://github.com/user-attachments/assets/c6d9fddc-7c51-4abc-890f-6de3b98d6105 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. ## **Automated testing performed** - `yarn jest app/components/UI/Bridge/hooks/useSubmitBatchSellTx/useSubmitBatchSellTx.test.tsx app/components/UI/Bridge/components/BatchSellFinalReviewModal/BatchSellFinalReviewModal.test.tsx` - `yarn jest app/util/bridge/hooks/useSubmitBridgeTx.test.tsx app/components/UI/Bridge/hooks/useSubmitBatchSellTx/useSubmitBatchSellTx.test.tsx app/components/UI/Bridge/hooks/useBatchSellQuoteData/useBatchSellQuoteData.test.ts app/components/UI/Bridge/components/BatchSellFinalReviewModal/BatchSellFinalReviewModal.test.tsx app/components/UI/Bridge/Views/BatchSellReview/BatchSellReview.test.tsx` - `yarn eslint` on touched Batch Sell submit/final review files - `yarn lint:tsc` - `git diff --check` [SWAPS-4441]: https://consensyssoftware.atlassian.net/browse/SWAPS-4441?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- > [!NOTE] > **High Risk** > Introduces real on-chain batch submission through Bridge Status Controller and changes when users can confirm trades; incorrect gating or submit payloads could block or mis-route user transactions. > > **Overview** > This PR **wires end-to-end Batch Sell submission** and tightens quote/trade readiness in the UI. > > **Final review:** **Sell all** now calls a new `useSubmitBatchSellTx` hook that forwards **recommended quote responses** to `BridgeStatusController.submitBatchSell` (wallet, STX, destination security). The modal toggles **submitting** state, blocks the CTA while trades are loading or unavailable, treats **gasless** fee coverage failures as insufficient funds, and **always navigates to Transactions** after submit (success or error). Quote data drops `networkFeeIsLoading` in favor of **`isBatchSellTradeAvailable` / `isBatchSellTradesLoading`** and exposes **`recommendedQuotes`** for submit. > > **Review screen:** The primary button stays **disabled while quotes are still fetching** (even with partial row data), shows **“Searching for best quotes”** during fetch, and keeps **Get new quote** when expired. > > **Platform:** Bumps **`@metamask/bridge-controller`** and **`@metamask/bridge-status-controller`**, delegates **`BridgeController:getState`** to `BridgeStatusController`, adds **BSC USDT** and **Linea MUSD** stablecoin metadata, plus tests and copy. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 4e4859d38dfd8fe567da0bf4f3d57ef7a4d9b6f3. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../BatchSellReview/BatchSellReview.test.tsx | 30 +++- .../Views/BatchSellReview/BatchSellReview.tsx | 30 ++-- .../BatchSellFinalReviewModal.test.tsx | 116 ++++++++++++- .../BatchSellFinalReviewModal/index.tsx | 55 +++++- app/components/UI/Bridge/constants/tokens.ts | 18 ++ .../hooks/useBatchSellQuoteData/index.ts | 8 +- .../useBatchSellQuoteData.test.ts | 25 ++- .../hooks/useSubmitBatchSellTx/index.ts | 51 ++++++ .../useSubmitBatchSellTx.test.tsx | 156 ++++++++++++++++++ .../index.ts | 2 +- locales/languages/en.json | 1 + package.json | 7 +- yarn.lock | 50 ++++-- 13 files changed, 494 insertions(+), 55 deletions(-) create mode 100644 app/components/UI/Bridge/hooks/useSubmitBatchSellTx/index.ts create mode 100644 app/components/UI/Bridge/hooks/useSubmitBatchSellTx/useSubmitBatchSellTx.test.tsx diff --git a/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReview.test.tsx b/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReview.test.tsx index 40dc051553e..c5d8c117840 100644 --- a/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReview.test.tsx +++ b/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReview.test.tsx @@ -44,7 +44,6 @@ interface MockBatchSellQuoteData { hasPendingQuoteRows: boolean; needsNewQuote: boolean; networkFee: { formatted: string; formattedFiat: string }; - networkFeeIsLoading: boolean; } const defaultQuoteData: MockBatchSellQuoteData = { @@ -78,7 +77,6 @@ const defaultQuoteData: MockBatchSellQuoteData = { formatted: '1.20 USDC', formattedFiat: '$1.20', }, - networkFeeIsLoading: false, }; let mockBatchSellQuoteData = defaultQuoteData; const defaultSelectedTokens: BridgeToken[] = [ @@ -258,9 +256,10 @@ describe('BatchSellReview', () => { isSummaryLoading: true, hasPendingQuoteRows: true, }; - const { getByTestId } = render(); + const { getByTestId, getByText } = render(); const reviewButton = getByTestId(BatchSellReviewSelectorsIDs.REVIEW_BUTTON); + expect(getByText('Searching for best quotes')).toBeOnTheScreen(); expect( getByTestId(BatchSellReviewSelectorsIDs.TOTAL_RECEIVED_SKELETON), ).toBeOnTheScreen(); @@ -277,6 +276,22 @@ describe('BatchSellReview', () => { expect(reviewButton.props.accessibilityState.disabled).toBe(true); }); + it('keeps the review CTA disabled while quotes are fetching even when rows have streamed in', () => { + mockBatchSellQuoteData = { + ...defaultQuoteData, + isLoading: true, + isSummaryLoading: false, + hasAnyQuote: true, + hasPendingQuoteRows: false, + }; + + const { getByTestId, getByText } = render(); + const reviewButton = getByTestId(BatchSellReviewSelectorsIDs.REVIEW_BUTTON); + + expect(getByText('Searching for best quotes')).toBeOnTheScreen(); + expect(reviewButton.props.accessibilityState.disabled).toBe(true); + }); + it('shows available row quotes and progressive total while other rows are still loading', () => { mockBatchSellQuoteData = { ...defaultQuoteData, @@ -300,11 +315,12 @@ describe('BatchSellReview', () => { }, }; - const { getAllByText, getByTestId, queryByTestId } = render( + const { getAllByText, getByTestId, getByText, queryByTestId } = render( , ); const reviewButton = getByTestId(BatchSellReviewSelectorsIDs.REVIEW_BUTTON); + expect(getByText('Searching for best quotes')).toBeOnTheScreen(); expect(getAllByText('$3,456.78').length).toBeGreaterThan(0); expect( queryByTestId(BatchSellReviewSelectorsIDs.TOTAL_RECEIVED_SKELETON), @@ -416,10 +432,13 @@ describe('BatchSellReview', () => { hasAnyQuote: false, hasPendingQuoteRows: false, }; - const { getAllByText, getByTestId } = render(); + const { getAllByText, getByTestId, getByText } = render( + , + ); const reviewButton = getByTestId(BatchSellReviewSelectorsIDs.REVIEW_BUTTON); expect(getAllByText('No quote available')).toHaveLength(2); + expect(getByText('Review')).toBeOnTheScreen(); expect(reviewButton.props.accessibilityState.disabled).toBe(true); }); @@ -536,7 +555,6 @@ describe('BatchSellReview', () => { mockBatchSellQuoteData = { ...defaultQuoteData, needsNewQuote: true, - networkFeeIsLoading: true, hasPendingQuoteRows: true, }; const { getByTestId, getByText } = render(); diff --git a/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReview.tsx b/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReview.tsx index 0fb0ad9f344..c12a8cf80a1 100644 --- a/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReview.tsx +++ b/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReview.tsx @@ -324,6 +324,20 @@ export function BatchSellReview() { [dispatch, isRemoveTokenDisabled, selectedTokens], ); + const shouldGetNewQuote = batchSellQuoteData.needsNewQuote; + const isFetchingQuotes = batchSellQuoteData.isLoading && !shouldGetNewQuote; + const hasReviewableQuote = + batchSellQuoteData.hasAnyQuote && !batchSellQuoteData.hasPendingQuoteRows; + const isReviewButtonDisabled = + !shouldGetNewQuote && (isFetchingQuotes || !hasReviewableQuote); + let reviewButtonLabel = strings('bridge.batch_sell_review'); + + if (shouldGetNewQuote) { + reviewButtonLabel = strings('quote_expired_modal.get_new_quote'); + } else if (isFetchingQuotes) { + reviewButtonLabel = strings('bridge.batch_sell_searching_best_quotes'); + } + return ( - {batchSellQuoteData.needsNewQuote - ? strings('quote_expired_modal.get_new_quote') - : strings('bridge.batch_sell_review')} + {reviewButtonLabel} diff --git a/app/components/UI/Bridge/components/BatchSellFinalReviewModal/BatchSellFinalReviewModal.test.tsx b/app/components/UI/Bridge/components/BatchSellFinalReviewModal/BatchSellFinalReviewModal.test.tsx index 9b0d0c575db..9638b3b8039 100644 --- a/app/components/UI/Bridge/components/BatchSellFinalReviewModal/BatchSellFinalReviewModal.test.tsx +++ b/app/components/UI/Bridge/components/BatchSellFinalReviewModal/BatchSellFinalReviewModal.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { StyleSheet } from 'react-native'; -import { fireEvent, render } from '@testing-library/react-native'; +import { fireEvent, render, waitFor } from '@testing-library/react-native'; import { lightTheme } from '@metamask/design-tokens'; import Routes from '../../../../../constants/navigation/Routes'; @@ -9,9 +9,12 @@ import { BatchSellFinalReviewModal } from './index'; import { BatchSellFinalReviewModalSelectorsIDs } from './BatchSellFinalReviewModal.testIds'; const mockGoBack = jest.fn(); +const mockNavigate = jest.fn(); const mockReplace = jest.fn(); +const mockDispatch = jest.fn(); const mockUpdateBatchSellQuoteParams = jest.fn(); const mockGetNewQuote = jest.fn(); +const mockSubmitBatchSellTx = jest.fn(); const mockUseBatchSellHasSufficientGas = jest.fn((_params: unknown) => true); const errorTextColor = lightTheme.colors.error.default; const ethAssetId = 'eip155:1/erc20:0x1111111111111111111111111111111111111111'; @@ -38,6 +41,11 @@ const linkToken = { decimals: 18, symbol: 'LINK', }; +const defaultRecommendedQuotes = [ + { quoteId: 'eth-quote-id' }, + { quoteId: 'uni-quote-id' }, +]; +let mockIsSubmittingTx = false; interface MockQuoteTokenData { key: string; @@ -57,10 +65,13 @@ interface MockBatchSellQuoteData { isLoading: boolean; isSummaryLoading: boolean; isGasless: boolean; + isBatchSellTradeAvailable: boolean; + isBatchSellTradesLoading: boolean; hasAnyQuote: boolean; hasPendingQuoteRows: boolean; needsNewQuote: boolean; quotePercentFee?: string; + recommendedQuotes: unknown[]; networkFee: { amount?: string; valueInCurrency?: string | null; @@ -75,7 +86,6 @@ interface MockBatchSellQuoteData { formatted: string; formattedFiat: string; }; - networkFeeIsLoading: boolean; } const defaultQuoteData: MockBatchSellQuoteData = { @@ -106,10 +116,13 @@ const defaultQuoteData: MockBatchSellQuoteData = { isLoading: false, isSummaryLoading: false, isGasless: false, + isBatchSellTradeAvailable: true, + isBatchSellTradesLoading: false, hasAnyQuote: true, hasPendingQuoteRows: false, needsNewQuote: false, quotePercentFee: '1.25', + recommendedQuotes: defaultRecommendedQuotes, networkFee: { amount: '1.2', valueInCurrency: '1.2', @@ -124,7 +137,6 @@ const defaultQuoteData: MockBatchSellQuoteData = { formatted: '1.20 USDC', formattedFiat: '$1.20', }, - networkFeeIsLoading: false, }; let mockSelectedTokens = defaultSelectedTokens; let mockBatchSellQuoteData = defaultQuoteData; @@ -132,16 +144,23 @@ let mockBatchSellQuoteData = defaultQuoteData; jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({ goBack: mockGoBack, + navigate: mockNavigate, replace: mockReplace, }), })); jest.mock('react-redux', () => ({ useSelector: (selector: (state: unknown) => unknown) => selector({}), + useDispatch: () => mockDispatch, })); jest.mock('../../../../../core/redux/slices/bridge', () => ({ selectBatchSellSourceTokens: jest.fn(() => mockSelectedTokens), + selectIsSubmittingTx: jest.fn(() => mockIsSubmittingTx), + setIsSubmittingTx: jest.fn((isSubmittingTx: boolean) => ({ + type: 'bridge/setIsSubmittingTx', + payload: isSubmittingTx, + })), })); jest.mock('../../hooks/useBatchSellQuoteData', () => ({ @@ -160,6 +179,12 @@ jest.mock('../../hooks/useBatchSellHasSufficientGas', () => ({ mockUseBatchSellHasSufficientGas(params), })); +jest.mock('../../hooks/useSubmitBatchSellTx', () => ({ + useSubmitBatchSellTx: () => ({ + submitBatchSellTx: mockSubmitBatchSellTx, + }), +})); + function renderModal(overrides: Partial = {}) { mockBatchSellQuoteData = { ...defaultQuoteData, @@ -173,9 +198,11 @@ describe('BatchSellFinalReviewModal', () => { beforeEach(() => { jest.clearAllMocks(); mockSelectedTokens = defaultSelectedTokens; + mockIsSubmittingTx = false; mockBatchSellQuoteData = defaultQuoteData; mockUpdateBatchSellQuoteParams.mockClear(); mockGetNewQuote.mockClear(); + mockSubmitBatchSellTx.mockResolvedValue(undefined); mockUseBatchSellHasSufficientGas.mockReturnValue(true); }); @@ -217,6 +244,45 @@ describe('BatchSellFinalReviewModal', () => { ).toBeNull(); }); + it('submits Batch Sell with the recommended quotes', async () => { + const { getByTestId } = renderModal(); + + fireEvent.press( + getByTestId(BatchSellFinalReviewModalSelectorsIDs.SELL_ALL_BUTTON), + ); + + await waitFor(() => { + expect(mockSubmitBatchSellTx).toHaveBeenCalledWith({ + quoteResponses: defaultRecommendedQuotes, + }); + }); + expect(mockDispatch).toHaveBeenNthCalledWith(1, { + type: 'bridge/setIsSubmittingTx', + payload: true, + }); + expect(mockDispatch).toHaveBeenLastCalledWith({ + type: 'bridge/setIsSubmittingTx', + payload: false, + }); + expect(mockNavigate).toHaveBeenCalledWith(Routes.TRANSACTIONS_VIEW); + }); + + it('blocks Sell all while submitting', () => { + mockIsSubmittingTx = true; + + const { getByTestId, getByText } = renderModal(); + + expect(getByText('Submitting')).toBeOnTheScreen(); + expect( + getByTestId(BatchSellFinalReviewModalSelectorsIDs.SELL_ALL_BUTTON).props + .accessibilityState.disabled, + ).toBe(true); + expect( + getByTestId(BatchSellFinalReviewModalSelectorsIDs.SELL_ALL_BUTTON).props + .accessibilityState.busy, + ).toBe(true); + }); + it('closes with navigation when the close button is pressed', () => { const { getByTestId } = renderModal(); @@ -377,7 +443,8 @@ describe('BatchSellFinalReviewModal', () => { it('renders a network fee values skeleton while the network fee is loading', () => { const { getByTestId, getByText, queryByText } = renderModal({ - networkFeeIsLoading: true, + isBatchSellTradeAvailable: false, + isBatchSellTradesLoading: true, }); expect( @@ -398,6 +465,45 @@ describe('BatchSellFinalReviewModal', () => { ).toBe(true); }); + it('blocks Sell all while the Batch Sell trade is unavailable', () => { + const { getByTestId } = renderModal({ + isBatchSellTradeAvailable: false, + }); + + expect( + getByTestId(BatchSellFinalReviewModalSelectorsIDs.SELL_ALL_BUTTON).props + .accessibilityState.disabled, + ).toBe(true); + }); + + it('shows insufficient funds when gasless destination-token fee cannot be covered', () => { + const { getByTestId, getByText, queryByTestId } = renderModal({ + isGasless: true, + isBatchSellTradeAvailable: false, + isBatchSellTradesLoading: false, + }); + const getTextColor = (text: string) => + StyleSheet.flatten(getByText(text).props.style).color; + + expect( + queryByTestId( + BatchSellFinalReviewModalSelectorsIDs.NETWORK_FEE_VALUES_SKELETON, + ), + ).toBeNull(); + expect(getByText('Insufficient funds')).toBeOnTheScreen(); + expect(getTextColor('Network fee')).toBe(errorTextColor); + expect(getTextColor('1.20 USDC')).toBe(errorTextColor); + expect(getTextColor('$1.20')).toBe(errorTextColor); + expect( + getByTestId(BatchSellFinalReviewModalSelectorsIDs.SELL_ALL_BUTTON).props + .accessibilityState.disabled, + ).toBe(true); + expect( + getByTestId(BatchSellFinalReviewModalSelectorsIDs.SELL_ALL_BUTTON).props + .accessibilityState.busy, + ).not.toBe(true); + }); + it('blocks Sell all and highlights the network fee when gas is insufficient', () => { mockUseBatchSellHasSufficientGas.mockReturnValue(false); @@ -424,7 +530,7 @@ describe('BatchSellFinalReviewModal', () => { const { getByTestId, getByText } = renderModal({ needsNewQuote: true, - networkFeeIsLoading: true, + isBatchSellTradeAvailable: false, hasPendingQuoteRows: true, }); const button = getByTestId( diff --git a/app/components/UI/Bridge/components/BatchSellFinalReviewModal/index.tsx b/app/components/UI/Bridge/components/BatchSellFinalReviewModal/index.tsx index 192d05da263..e5a9282208f 100644 --- a/app/components/UI/Bridge/components/BatchSellFinalReviewModal/index.tsx +++ b/app/components/UI/Bridge/components/BatchSellFinalReviewModal/index.tsx @@ -2,7 +2,7 @@ import { useNavigation } from '@react-navigation/native'; import { StackNavigationProp } from '@react-navigation/stack'; import React, { useCallback, useMemo, useState } from 'react'; import { Pressable } from 'react-native'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { formatAddressToAssetId } from '@metamask/bridge-controller'; import { @@ -31,13 +31,18 @@ import { import { strings } from '../../../../../../locales/i18n'; import Routes from '../../../../../constants/navigation/Routes'; import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; -import { selectBatchSellSourceTokens } from '../../../../../core/redux/slices/bridge'; +import { + selectBatchSellSourceTokens, + selectIsSubmittingTx, + setIsSubmittingTx, +} from '../../../../../core/redux/slices/bridge'; import { type BatchSellQuoteTokenData, useBatchSellQuoteData, } from '../../hooks/useBatchSellQuoteData'; import { useBatchSellQuoteRequest } from '../../hooks/useBatchSellQuoteRequest'; import { useBatchSellHasSufficientGas } from '../../hooks/useBatchSellHasSufficientGas'; +import { useSubmitBatchSellTx } from '../../hooks/useSubmitBatchSellTx'; import type { BridgeToken } from '../../types'; import { BatchSellQuoteDetails } from '../BatchSellQuoteDetailsModal'; import { BatchSellFinalReviewModalSelectorsIDs } from './BatchSellFinalReviewModal.testIds'; @@ -279,13 +284,16 @@ function NetworkFeeRow({ } export function BatchSellFinalReviewModal() { + const dispatch = useDispatch(); const navigation = useNavigation>>(); const selectedTokens = useSelector(selectBatchSellSourceTokens); + const isSubmittingTx = useSelector(selectIsSubmittingTx); const batchSellQuoteData = useBatchSellQuoteData({ shouldUpdateBatchSellTrades: false, }); const { getNewQuote } = useBatchSellQuoteRequest(); + const { submitBatchSellTx } = useSubmitBatchSellTx(); const hasSufficientGas = useBatchSellHasSufficientGas({ isGasless: batchSellQuoteData.isGasless, networkFee: batchSellQuoteData.networkFee, @@ -305,12 +313,20 @@ export function BatchSellFinalReviewModal() { selectedTokens, ], ); - const hasInsufficientGas = hasSufficientGas === false; + const isBatchSellTradesLoading = batchSellQuoteData.isBatchSellTradesLoading; + const hasInsufficientGaslessDestinationToken = + batchSellQuoteData.isGasless && + !isBatchSellTradesLoading && + !batchSellQuoteData.isBatchSellTradeAvailable; + const hasInsufficientGas = + hasSufficientGas === false || hasInsufficientGaslessDestinationToken; const isSellAllDisabled = batchSellQuoteData.isLoading || - batchSellQuoteData.networkFeeIsLoading || + isBatchSellTradesLoading || + !batchSellQuoteData.isBatchSellTradeAvailable || !batchSellQuoteData.hasAnyQuote || batchSellQuoteData.hasPendingQuoteRows || + isSubmittingTx || hasInsufficientGas; const isButtonDisabled = batchSellQuoteData.needsNewQuote ? false @@ -319,7 +335,8 @@ export function BatchSellFinalReviewModal() { !batchSellQuoteData.needsNewQuote && isSellAllDisabled && (batchSellQuoteData.isLoading || - batchSellQuoteData.networkFeeIsLoading || + isBatchSellTradesLoading || + isSubmittingTx || batchSellQuoteData.hasPendingQuoteRows); const actionButtonLabel = (() => { if (batchSellQuoteData.needsNewQuote) { @@ -330,6 +347,10 @@ export function BatchSellFinalReviewModal() { return strings('bridge.insufficient_funds'); } + if (isSubmittingTx) { + return strings('bridge.submitting_transaction'); + } + return strings('bridge.batch_sell_sell_all'); })(); @@ -356,9 +377,25 @@ export function BatchSellFinalReviewModal() { }); }; - const handleSellAll = useCallback(() => { - // TODO: submit the executable Batch Sell trades. - }, []); + const handleSellAll = useCallback(async () => { + try { + dispatch(setIsSubmittingTx(true)); + + await submitBatchSellTx({ + quoteResponses: batchSellQuoteData.recommendedQuotes, + }); + } catch (error) { + console.error('Error submitting Batch Sell tx', error); + } finally { + dispatch(setIsSubmittingTx(false)); + navigation.navigate(Routes.TRANSACTIONS_VIEW); + } + }, [ + batchSellQuoteData.recommendedQuotes, + dispatch, + navigation, + submitBatchSellTx, + ]); return ( diff --git a/app/components/UI/Bridge/constants/tokens.ts b/app/components/UI/Bridge/constants/tokens.ts index 5d38439e668..445f5309181 100644 --- a/app/components/UI/Bridge/constants/tokens.ts +++ b/app/components/UI/Bridge/constants/tokens.ts @@ -47,4 +47,22 @@ export const BridgeTokenMetadata: Record = { 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/137/erc20/0x3c499c542cef5e3811e1192ce70d8cc03d5c3359.png', chainId: '0x89', }, + 'eip155:56/erc20:0x55d398326f99059ff775485246999027b3197955': { + symbol: 'USDT', + name: 'Tether USD', + address: '0x55d398326f99059ff775485246999027b3197955', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/56/erc20/0x55d398326f99059ff775485246999027b3197955.png', + chainId: '0x38', + }, + 'eip155:59144/erc20:0xaca92e438df0b2401ff60da7e4337b687a2435da': { + decimals: 6, + name: 'MetaMask USD', + symbol: 'MUSD', + address: '0xaca92e438df0b2401ff60da7e4337b687a2435da', + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/59144/erc20/0xaca92e438df0b2401ff60da7e4337b687a2435da.png', + chainId: '0xe708', + }, }; diff --git a/app/components/UI/Bridge/hooks/useBatchSellQuoteData/index.ts b/app/components/UI/Bridge/hooks/useBatchSellQuoteData/index.ts index c1a9e8a71f3..f99e249920a 100644 --- a/app/components/UI/Bridge/hooks/useBatchSellQuoteData/index.ts +++ b/app/components/UI/Bridge/hooks/useBatchSellQuoteData/index.ts @@ -299,13 +299,14 @@ export function useBatchSellQuoteData({ ); const hasAnyQuote = availableRecommendedQuotes.length > 0; const totalNetworkFee = batchSellTrades.totalNetworkFee; + const isBatchSellTradesLoading = Boolean(batchSellTrades.isLoading); + // Quote-level gasless params are not reliable for Batch Sell because gasless // behavior is only simulated when the controller calls obtainGaslessBatch. // Clients do not consume that API response directly; selectBatchSellTrades // exposes the controller-interpreted result, so derive gasless state from it. const isGasless = hasAnyQuote && - batchSellTrades.isBatchSellTradeAvailable && Boolean( totalNetworkFee?.asset && !isNativeAddress(totalNetworkFee.asset.address), ); @@ -349,7 +350,6 @@ export function useBatchSellQuoteData({ () => getBatchSellTradesRequestKey(availableRecommendedQuotes), [availableRecommendedQuotes], ); - const networkFeeIsLoading = !batchSellTrades.isBatchSellTradeAvailable; const totalReceivedAmount = canDisplayAggregatedQuoteData ? totalReceived.amount : undefined; @@ -496,11 +496,13 @@ export function useBatchSellQuoteData({ isLoading, isSummaryLoading, isGasless, + isBatchSellTradeAvailable: batchSellTrades.isBatchSellTradeAvailable, + isBatchSellTradesLoading, hasAnyQuote, hasPendingQuoteRows, needsNewQuote, - networkFeeIsLoading, networkFee: networkFeeData, quotePercentFee, + recommendedQuotes: availableRecommendedQuotes, }; } diff --git a/app/components/UI/Bridge/hooks/useBatchSellQuoteData/useBatchSellQuoteData.test.ts b/app/components/UI/Bridge/hooks/useBatchSellQuoteData/useBatchSellQuoteData.test.ts index 22d88793101..42f06aa4825 100644 --- a/app/components/UI/Bridge/hooks/useBatchSellQuoteData/useBatchSellQuoteData.test.ts +++ b/app/components/UI/Bridge/hooks/useBatchSellQuoteData/useBatchSellQuoteData.test.ts @@ -154,6 +154,7 @@ let mockBatchSellTrades: { } | undefined; isBatchSellTradeAvailable: boolean; + isLoading: boolean; } = { totalNetworkFee: { amount: '1.2', @@ -161,6 +162,7 @@ let mockBatchSellTrades: { asset: ethNetworkFeeAsset, }, isBatchSellTradeAvailable: true, + isLoading: false, }; let mockBridgeFeatureFlags: { chains: Record; @@ -226,6 +228,7 @@ describe('useBatchSellQuoteData', () => { asset: ethNetworkFeeAsset, }, isBatchSellTradeAvailable: true, + isLoading: false, }; mockBridgeFeatureFlags = { chains: {}, @@ -239,6 +242,8 @@ describe('useBatchSellQuoteData', () => { expect(result.current.hasAnyQuote).toBe(true); expect(result.current.isGasless).toBe(false); + expect(result.current.isBatchSellTradeAvailable).toBe(true); + expect(result.current.isBatchSellTradesLoading).toBe(false); expect(result.current.isLoading).toBe(false); expect(result.current.isSummaryLoading).toBe(false); expect(result.current.hasPendingQuoteRows).toBe(false); @@ -252,9 +257,11 @@ describe('useBatchSellQuoteData', () => { expect(result.current.totalReceived.formatted).toBe('200 USDC'); expect(result.current.totalReceived.formattedFiat).toBe('$201.34'); expect(result.current.minimumReceived.formatted).toBe('200 USDC'); - expect(result.current.networkFeeIsLoading).toBe(false); expect(result.current.networkFee.formatted).toBe('1.2 ETH'); expect(result.current.networkFee.formattedFiat).toBe('$1.25'); + expect(result.current.recommendedQuotes).toEqual( + mockBatchSellQuotes.recommendedQuotes, + ); expect( Engine.context.BridgeController.updateBatchSellTrades, ).toHaveBeenCalledWith(mockBatchSellQuotes.recommendedQuotes); @@ -325,6 +332,18 @@ describe('useBatchSellQuoteData', () => { expect(result.current.isGasless).toBe(true); }); + it('returns the Batch Sell trades loading state', () => { + mockBatchSellTrades = { + ...mockBatchSellTrades, + isBatchSellTradeAvailable: false, + isLoading: true, + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.isBatchSellTradesLoading).toBe(true); + }); + it('does not need a new quote when the quote is expired but going to refresh', () => { const now = 60000; const dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(now); @@ -500,12 +519,14 @@ describe('useBatchSellQuoteData', () => { mockBatchSellTrades = { totalNetworkFee: undefined, isBatchSellTradeAvailable: false, + isLoading: false, }; const { result } = renderHook(() => useBatchSellQuoteData()); expect(result.current.networkFee.formatted).toBe('--'); - expect(result.current.networkFeeIsLoading).toBe(true); + expect(result.current.isBatchSellTradeAvailable).toBe(false); + expect(result.current.isBatchSellTradesLoading).toBe(false); expect(result.current.networkFee.formattedFiat).toBe('-'); }); diff --git a/app/components/UI/Bridge/hooks/useSubmitBatchSellTx/index.ts b/app/components/UI/Bridge/hooks/useSubmitBatchSellTx/index.ts new file mode 100644 index 00000000000..c93eca484de --- /dev/null +++ b/app/components/UI/Bridge/hooks/useSubmitBatchSellTx/index.ts @@ -0,0 +1,51 @@ +import { useSelector } from 'react-redux'; +import type { + MetaMetricsSwapsEventSource, + QuoteMetadata, + QuoteResponse, +} from '@metamask/bridge-controller'; + +import Engine from '../../../../../core/Engine'; +import { selectBatchSellDestToken } from '../../../../../core/redux/slices/bridge'; +import { selectBatchSellSourceWalletAddress } from '../../../../../selectors/bridge'; +import { selectShouldUseSmartTransaction } from '../../../../../selectors/smartTransactionsController'; + +export function useSubmitBatchSellTx() { + const stxEnabled = useSelector(selectShouldUseSmartTransaction); + const walletAddress = useSelector(selectBatchSellSourceWalletAddress); + const destToken = useSelector(selectBatchSellDestToken); + + const submitBatchSellTx = async ({ + quoteResponses, + location, + }: { + quoteResponses: ((QuoteResponse & QuoteMetadata) | null)[]; + /** The entry point from which the user initiated the swap or bridge */ + location?: MetaMetricsSwapsEventSource; + }) => { + if (!walletAddress) { + throw new Error('Batch Sell wallet address is not set'); + } + + const tokenSecurityTypeDestination = destToken?.securityData?.type ?? null; + const normalizedQuoteResponses = quoteResponses.map((quoteResponse) => + quoteResponse + ? { + ...quoteResponse, + approval: quoteResponse.approval ?? undefined, + } + : quoteResponse, + ); + + return await Engine.context.BridgeStatusController.submitBatchSell({ + quoteResponses: normalizedQuoteResponses, + accountAddress: walletAddress, + location, + isStxEnabled: stxEnabled, + quotesReceivedContext: undefined, + tokenSecurityTypeDestination, + }); + }; + + return { submitBatchSellTx }; +} diff --git a/app/components/UI/Bridge/hooks/useSubmitBatchSellTx/useSubmitBatchSellTx.test.tsx b/app/components/UI/Bridge/hooks/useSubmitBatchSellTx/useSubmitBatchSellTx.test.tsx new file mode 100644 index 00000000000..6e65fc47400 --- /dev/null +++ b/app/components/UI/Bridge/hooks/useSubmitBatchSellTx/useSubmitBatchSellTx.test.tsx @@ -0,0 +1,156 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { Provider } from 'react-redux'; +import configureMockStore from 'redux-mock-store'; +import type { QuoteMetadata, QuoteResponse } from '@metamask/bridge-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; + +import { + DummyQuoteMetadata, + DummyQuotesNoApproval, + DummyQuotesWithApproval, +} from '../../../../../../tests/api-mocking/mock-responses/bridge-api-quotes'; +import { selectBatchSellSourceWalletAddress } from '../../../../../selectors/bridge'; +import { useSubmitBatchSellTx } from '.'; + +type BridgeQuoteResponse = QuoteResponse & QuoteMetadata; + +let mockSubmitBatchSell: jest.Mock< + Promise, + [ + { + quoteResponses: (BridgeQuoteResponse | null)[]; + accountAddress: string; + }, + ] +>; + +jest.mock('../../../../../core/Engine', () => { + mockSubmitBatchSell = jest.fn< + Promise, + [ + { + quoteResponses: (BridgeQuoteResponse | null)[]; + accountAddress: string; + }, + ] + >(); + + return { + controllerMessenger: {}, + context: { + BridgeStatusController: { + submitBatchSell: mockSubmitBatchSell, + }, + }, + }; +}); + +jest.mock('../../../../../selectors/smartTransactionsController', () => ({ + ...jest.requireActual('../../../../../selectors/smartTransactionsController'), + selectShouldUseSmartTransaction: jest.fn(() => true), +})); + +jest.mock('../../../../../selectors/bridge', () => ({ + ...jest.requireActual('../../../../../selectors/bridge'), + selectBatchSellSourceWalletAddress: jest.fn( + () => '0x1234567890123456789012345678901234567890', + ), +})); + +const mockStore = configureMockStore(); + +describe('useSubmitBatchSellTx', () => { + const createWrapper = (mockState = {}) => { + const store = mockStore({ + bridge: { + batchSellDestToken: undefined, + }, + ...mockState, + }); + + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + jest + .mocked(selectBatchSellSourceWalletAddress) + .mockReturnValue('0x1234567890123456789012345678901234567890'); + }); + + it('submits Batch Sell with the recommended quote responses', async () => { + const { result } = renderHook(() => useSubmitBatchSellTx(), { + wrapper: createWrapper({ + bridge: { + batchSellDestToken: { + symbol: 'SCAM', + securityData: { + type: 'Malicious', + }, + }, + }, + }), + }); + + const firstQuoteResponse = { + ...DummyQuotesWithApproval.ETH_11_USDC_TO_ARB[0], + ...DummyQuoteMetadata, + } as BridgeQuoteResponse; + const secondQuoteResponse = { + ...DummyQuotesNoApproval.OP_0_005_ETH_TO_ARB[0], + ...DummyQuoteMetadata, + } as BridgeQuoteResponse; + const mockBatchSellResult = { + chainId: '0x1', + id: 'batch-sell-1', + networkClientId: '1', + status: 'submitted', + time: Date.now(), + txParams: { + from: '0x1234567890123456789012345678901234567890', + }, + } as TransactionMeta; + + mockSubmitBatchSell.mockResolvedValueOnce(mockBatchSellResult); + + const txResult = await result.current.submitBatchSellTx({ + quoteResponses: [firstQuoteResponse, secondQuoteResponse], + }); + + expect(mockSubmitBatchSell).toHaveBeenCalledWith({ + quoteResponses: [ + { + ...firstQuoteResponse, + approval: firstQuoteResponse.approval ?? undefined, + }, + { + ...secondQuoteResponse, + approval: secondQuoteResponse.approval ?? undefined, + }, + ], + accountAddress: '0x1234567890123456789012345678901234567890', + location: undefined, + isStxEnabled: true, + quotesReceivedContext: undefined, + tokenSecurityTypeDestination: 'Malicious', + }); + expect(txResult).toEqual(mockBatchSellResult); + }); + + it('throws when Batch Sell wallet address is not set', async () => { + jest.mocked(selectBatchSellSourceWalletAddress).mockReturnValue(undefined); + + const { result } = renderHook(() => useSubmitBatchSellTx(), { + wrapper: createWrapper(), + }); + + await expect( + result.current.submitBatchSellTx({ + quoteResponses: [], + }), + ).rejects.toThrow('Batch Sell wallet address is not set'); + }); +}); diff --git a/app/core/Engine/messengers/bridge-status-controller-messenger/index.ts b/app/core/Engine/messengers/bridge-status-controller-messenger/index.ts index 3824622c1d2..a13f618d3bc 100644 --- a/app/core/Engine/messengers/bridge-status-controller-messenger/index.ts +++ b/app/core/Engine/messengers/bridge-status-controller-messenger/index.ts @@ -31,9 +31,9 @@ export function getBridgeStatusControllerMessenger( 'NetworkController:findNetworkClientIdByChainId', 'NetworkController:getState', 'KeyringController:signTypedMessage', + 'BridgeController:getState', 'BridgeController:stopPollingForQuotes', 'BridgeController:trackUnifiedSwapBridgeEvent', - 'GasFeeController:getState', 'SnapController:handleRequest', 'TransactionController:getState', 'AuthenticationController:getBearerToken', diff --git a/locales/languages/en.json b/locales/languages/en.json index 80dd67c4349..722da70f64d 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -7536,6 +7536,7 @@ "batch_sell_high_price_impact": "High price impact", "batch_sell_high_price_impact_description": "This trade has an estimated {{priceImpact}} price impact, which reflects how much your trade changes the market price. The quote already reflects this.", "batch_sell_review": "Review", + "batch_sell_searching_best_quotes": "Searching for best quotes", "batch_sell_you_sell": "You sell", "batch_sell_token_count": "{{tokenCount}} tokens", "batch_sell_toggle_you_sell": "Toggle token details", diff --git a/package.json b/package.json index 2e938b775ef..af12734d0b1 100644 --- a/package.json +++ b/package.json @@ -252,8 +252,8 @@ "@metamask/authenticated-user-storage": "^2.0.0", "@metamask/base-controller": "^9.0.1", "@metamask/bitcoin-wallet-snap": "^1.11.0", - "@metamask/bridge-controller": "^73.0.1", - "@metamask/bridge-status-controller": "^71.1.1", + "@metamask/bridge-controller": "^73.2.0", + "@metamask/bridge-status-controller": "^72.0.0", "@metamask/chain-agnostic-permission": "^1.5.0", "@metamask/chomp-api-service": "^3.1.0", "@metamask/client-controller": "^1.0.1", @@ -808,7 +808,8 @@ "ws>utf-8-validate": false, "eslint-plugin-import-x>unrs-resolver": false, "eslint-import-resolver-typescript>unrs-resolver": false, - "tsx>esbuild": false + "tsx>esbuild": false, + "@metamask/transaction-pay-controller>@metamask/bridge-status-controller>@metamask/keyring-controller>ethereumjs-wallet>ethereum-cryptography>keccak": false } }, "packageManager": "yarn@4.14.1", diff --git a/yarn.lock b/yarn.lock index e7cfaf68352..d0f2007d0c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7916,7 +7916,7 @@ __metadata: languageName: node linkType: hard -"@metamask/assets-controller@npm:^8.0.0, @metamask/assets-controller@npm:^8.0.1, @metamask/assets-controller@npm:^8.2.0": +"@metamask/assets-controller@npm:^8.0.1, @metamask/assets-controller@npm:^8.1.0, @metamask/assets-controller@npm:^8.2.0": version: 8.2.0 resolution: "@metamask/assets-controller@npm:8.2.0" dependencies: @@ -7953,7 +7953,7 @@ __metadata: languageName: node linkType: hard -"@metamask/assets-controllers@npm:^108.0.0, @metamask/assets-controllers@npm:^108.1.0, @metamask/assets-controllers@npm:^108.3.0": +"@metamask/assets-controllers@npm:^108.0.0, @metamask/assets-controllers@npm:^108.1.0, @metamask/assets-controllers@npm:^108.2.0, @metamask/assets-controllers@npm:^108.3.0": version: 108.3.0 resolution: "@metamask/assets-controllers@npm:108.3.0" dependencies: @@ -8151,18 +8151,18 @@ __metadata: languageName: node linkType: hard -"@metamask/bridge-controller@npm:^73.0.1": - version: 73.0.1 - resolution: "@metamask/bridge-controller@npm:73.0.1" +"@metamask/bridge-controller@npm:^73.0.1, @metamask/bridge-controller@npm:^73.2.0": + version: 73.2.0 + resolution: "@metamask/bridge-controller@npm:73.2.0" dependencies: "@ethersproject/address": "npm:^5.7.0" "@ethersproject/bignumber": "npm:^5.7.0" "@ethersproject/constants": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/accounts-controller": "npm:^38.1.1" - "@metamask/assets-controller": "npm:^8.0.0" - "@metamask/assets-controllers": "npm:^108.1.0" + "@metamask/accounts-controller": "npm:^38.1.2" + "@metamask/assets-controller": "npm:^8.1.0" + "@metamask/assets-controllers": "npm:^108.2.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.1.0" "@metamask/gas-fee-controller": "npm:^26.2.2" @@ -8172,7 +8172,7 @@ __metadata: "@metamask/multichain-network-controller": "npm:^3.1.2" "@metamask/network-controller": "npm:^32.0.0" "@metamask/polling-controller": "npm:^16.0.6" - "@metamask/profile-sync-controller": "npm:^28.1.0" + "@metamask/profile-sync-controller": "npm:^28.1.1" "@metamask/remote-feature-flag-controller": "npm:^4.2.1" "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/transaction-controller": "npm:^66.0.0" @@ -8180,11 +8180,11 @@ __metadata: bignumber.js: "npm:^9.1.2" reselect: "npm:^5.1.1" uuid: "npm:^8.3.2" - checksum: 10/cdf4a2b0eb23e36b7f0e3b3e10beb2aacff01fe6fc939e2a1a4251cbea91fef8dfa2b3fb181770ed358574bd55d647ba931a039083398f9e85e7f65cf0b8dbb1 + checksum: 10/7d093196ce31e6644d31d3c1f047245b1142ef6c7248c058b9906c07505b716ca131c73b67659b684e0f1e9b371e6e2beb2ecb348ee3e1f7e63214f6c0aa03d8 languageName: node linkType: hard -"@metamask/bridge-status-controller@npm:^71.1.1, @metamask/bridge-status-controller@npm:^71.2.0": +"@metamask/bridge-status-controller@npm:^71.2.0": version: 71.2.0 resolution: "@metamask/bridge-status-controller@npm:71.2.0" dependencies: @@ -8208,6 +8208,30 @@ __metadata: languageName: node linkType: hard +"@metamask/bridge-status-controller@npm:^72.0.0": + version: 72.0.0 + resolution: "@metamask/bridge-status-controller@npm:72.0.0" + dependencies: + "@metamask/accounts-controller": "npm:^38.1.2" + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/bridge-controller": "npm:^73.2.0" + "@metamask/controller-utils": "npm:^12.1.0" + "@metamask/gas-fee-controller": "npm:^26.2.2" + "@metamask/keyring-controller": "npm:^26.0.0" + "@metamask/messenger": "npm:^1.2.0" + "@metamask/network-controller": "npm:^32.0.0" + "@metamask/polling-controller": "npm:^16.0.6" + "@metamask/profile-sync-controller": "npm:^28.1.1" + "@metamask/snaps-controllers": "npm:^19.0.0" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/transaction-controller": "npm:^66.0.0" + "@metamask/utils": "npm:^11.9.0" + bignumber.js: "npm:^9.1.2" + uuid: "npm:^8.3.2" + checksum: 10/123fec19759bd56988e937c7edb7e72cdfa1acd26d8b3870338d320dfc64b1f52d8d540b5e41cdabe06f147a035f3f71f0d46ad8063045fc825f417a61b43cbf + languageName: node + linkType: hard + "@metamask/browser-passworder@npm:^5.0.0": version: 5.0.0 resolution: "@metamask/browser-passworder@npm:5.0.0" @@ -35448,8 +35472,8 @@ __metadata: "@metamask/auto-changelog": "npm:^5.3.0" "@metamask/base-controller": "npm:^9.0.1" "@metamask/bitcoin-wallet-snap": "npm:^1.11.0" - "@metamask/bridge-controller": "npm:^73.0.1" - "@metamask/bridge-status-controller": "npm:^71.1.1" + "@metamask/bridge-controller": "npm:^73.2.0" + "@metamask/bridge-status-controller": "npm:^72.0.0" "@metamask/browser-passworder": "npm:^5.0.0" "@metamask/browser-playground": "npm:0.3.0" "@metamask/build-utils": "npm:^3.0.0" From 6d67f21f5f3f7299b5938638e9d88c20df16685e Mon Sep 17 00:00:00 2001 From: Matthew Grainger <46547583+Matt561@users.noreply.github.com> Date: Mon, 1 Jun 2026 17:16:40 -0400 Subject: [PATCH 12/22] feat: MUSD-830 expand earn on your crypto to include all mm pay supported tokens (#30824) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Expands "Earn on your crypto" section of Money Home to include all supported MM Pay tokens. #### Changes - Added `useMoneyDepositTokens` as single source of truth for Money deposit payment tokens - Remotely configurable blocklist, minimum balance, "no fee" tokens, and sorting modes - Wired up "Add" buttons to the Money account deposit screen ## **Changelog** CHANGELOG entry: Expanded "Earn on your crypto" in Money to show all MM Pay-supported deposit tokens and start deposit with the selected token. ## **Related issues** - Fixes: [MUSD-830](https://consensyssoftware.atlassian.net/browse/MUSD-830) ## **Manual testing steps** ```gherkin Feature: Money potential earnings uses MM Pay deposit tokens Scenario: user sees expanded eligible token list in potential earnings Given user opens Money Home with multiple MM Pay-supported tokens that pass filters When the potential earnings section renders Then the token rows are shown from the Money deposit token set Scenario: user starts deposit from token row Given user is on Money Home or Potential Earnings view When user taps a token row Then Money deposit flow opens with that token pre-selected Scenario: user starts deposit from primary CTA Given user is on Potential Earnings view with at least one eligible token When user taps "Convert" Then Money deposit flow opens with the first eligible token pre-selected ``` ## **Screenshots/Recordings** ### **Before** - Token list displayed mUSD conversion eligible tokens only - "Add" button redirected to the mUSD conversion screen ### **After** https://github.com/user-attachments/assets/be14f704-7829-4367-8b76-cd4b396e6a4b ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. [MUSD-830]: https://consensyssoftware.atlassian.net/browse/MUSD-830?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- > [!NOTE] > **Medium Risk** > Changes which tokens users can fund Money from and switches the primary action from mUSD conversion to deposit initiation; mitigated by feature flags, blocklists, and extensive unit tests but still user-facing payment flow logic. > > **Overview** > **Earn on your crypto** on Money Home and Potential Earnings now uses **MM Pay–eligible deposit tokens** instead of the narrower mUSD conversion token list. > > A new **`useMoneyDepositTokens`** hook centralizes which wallet tokens appear: MM Pay blocklist, Money-specific blocklist, minimum fiat balance (remote/env), optional **no-fee** token lists, and sort modes (`fiatBalanceDesc` vs **no-fee first**). **Add/Convert** actions call **`initiateDeposit`** with a **preferred payment token** rather than **`initiateCustomConversion`**. > > **No fee** badges on token rows come from configurable **`isNoFeeToken`** (replacing hardcoded stablecoin symbols). New remote flags and `.js.env` fallbacks back the blocklist, no-fee map, min balance, and sort mode. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 9b092ab01885e002dcb8044568ae0b5d8b4a109a. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .js.env.example | 3 + .../MoneyHomeView/MoneyHomeView.test.tsx | 64 ++- .../Views/MoneyHomeView/MoneyHomeView.tsx | 22 +- .../MoneyPotentialEarningsView.test.tsx | 64 +-- .../MoneyPotentialEarningsView.tsx | 32 +- .../MoneyOnboardingCard.tsx | 4 +- .../MoneyPotentialEarnings.test.tsx | 55 +++ .../MoneyPotentialEarnings.tsx | 35 +- .../Money/hooks/useMoneyDepositTokens.test.ts | 431 ++++++++++++++++++ .../UI/Money/hooks/useMoneyDepositTokens.ts | 147 ++++++ .../UI/Money/hooks/useProjectedEarnings.ts | 8 - .../UI/Money/selectors/featureFlags.test.ts | 248 ++++++++++ .../UI/Money/selectors/featureFlags.ts | 120 +++++ tests/feature-flags/feature-flag-registry.ts | 37 ++ 14 files changed, 1127 insertions(+), 143 deletions(-) create mode 100644 app/components/UI/Money/hooks/useMoneyDepositTokens.test.ts create mode 100644 app/components/UI/Money/hooks/useMoneyDepositTokens.ts diff --git a/.js.env.example b/.js.env.example index 2bf4eca766c..2a44fe9c778 100644 --- a/.js.env.example +++ b/.js.env.example @@ -135,6 +135,9 @@ export MM_MUSD_CONVERSION_MIN_ASSET_BALANCE_REQUIRED="0.01" # Money Hub export MM_MONEY_HUB_ENABLED="false" +export MM_MONEY_PAYMENT_TOKENS_BLOCKLIST="" +export MM_MONEY_DEPOSIT_NO_FEE_TOKENS="" +export MM_MONEY_DEPOSIT_MIN_ASSET_BALANCE="0.01" # Activates remote feature flag override mode. # Remote feature flag values won't be updated, diff --git a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx index 66a07ad0799..f03382d457f 100644 --- a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx +++ b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx @@ -29,12 +29,11 @@ import { useMoneyAccountCardLinkage } from '../../../Card/hooks/useMoneyAccountC import { MONEY_HOME_CARD_ORIGIN } from '../../../Card/hooks/useCardPostAuthRedirect'; import { getDetectedGeolocation } from '../../../../../reducers/fiatOrders'; import { moneyFormatFiat } from '../../utils/moneyFormatFiat'; -import { useMusdConversion } from '../../../Earn/hooks/useMusdConversion'; import { useMusdBalance } from '../../../Earn/hooks/useMusdBalance'; const mockGoBack = jest.fn(); const mockNavigate = jest.fn(); -const mockInitiateCustomConversion = jest.fn(); +const mockInitiateDeposit = jest.fn(); const mockRefetchBalance = jest.fn(); const mockMoneyFormatFiat = moneyFormatFiat as jest.MockedFunction< typeof moneyFormatFiat @@ -51,7 +50,7 @@ jest.mock('@react-navigation/native', () => { }; }); -const mockConversionTokens = [ +const mockDepositTokens = [ { name: 'USD Coin', symbol: 'USDC', @@ -63,15 +62,15 @@ const mockConversionTokens = [ }, ]; -const mockUseMusdConversionTokens = jest.fn(() => ({ - tokens: mockConversionTokens as ReturnType, +const mockUseMoneyDepositTokens = jest.fn(() => ({ + tokens: mockDepositTokens as ReturnType, + isNoFeeToken: jest.fn(() => false), + isEligibleToken: jest.fn(() => false), + filterAllowedTokens: jest.fn((t) => t), })); -jest.mock('../../../Earn/hooks/useMusdConversionTokens', () => ({ - useMusdConversionTokens: () => mockUseMusdConversionTokens(), - STABLECOIN_SYMBOLS: new Set(['USDC', 'USDT', 'DAI']), - tokenFiatValue: (token: { fiat?: { balance?: number } }) => - token?.fiat?.balance ?? 0, +jest.mock('../../hooks/useMoneyDepositTokens', () => ({ + useMoneyDepositTokens: () => mockUseMoneyDepositTokens(), })); jest.mock('../../hooks/useMoneyAccountTransactions', () => ({ @@ -88,14 +87,6 @@ jest.mock('../../hooks/useMoneyAccountInfo', () => ({ default: jest.fn(), })); -jest.mock('../../../Earn/hooks/useMusdConversion', () => ({ - useMusdConversion: jest.fn(), -})); - -jest.mock('../../../Earn/hooks/useMusdBalance', () => ({ - useMusdBalance: jest.fn(), -})); - jest.mock('../../../../../core/NavigationService', () => ({ __esModule: true, default: { @@ -140,7 +131,7 @@ jest.mock('../../../Earn/hooks/useMusdBalance', () => ({ jest.mock('../../hooks/useMoneyAccount', () => ({ useMoneyAccountDeposit: jest.fn(() => ({ - initiateDeposit: jest.fn(() => Promise.resolve()), + initiateDeposit: mockInitiateDeposit, })), useMoneyAccountWithdrawal: jest.fn(() => ({ initiateWithdrawal: jest.fn(() => Promise.resolve()), @@ -171,13 +162,11 @@ const mockUseMoneyAccountTransactions = jest.mocked( useMoneyAccountTransactions, ); -const mockUseMusdConversion = jest.mocked(useMusdConversion); +const mockUseMusdBalance = jest.mocked(useMusdBalance); const mockUseMoneyAccountBalance = jest.mocked(useMoneyAccountBalance); const mockUseMoneyAccountInfo = jest.mocked(useMoneyAccountInfo); -const mockUseMusdBalance = jest.mocked(useMusdBalance); - jest.mock( '../../../../UI/Assets/components/AssetLogo/AssetLogo', () => 'AssetLogo', @@ -231,10 +220,7 @@ describe('MoneyHomeView', () => { jest.clearAllMocks(); global.alert = jest.fn(); - mockInitiateCustomConversion.mockResolvedValue(undefined); - mockUseMusdConversion.mockReturnValue({ - initiateCustomConversion: mockInitiateCustomConversion, - } as unknown as ReturnType); + mockInitiateDeposit.mockResolvedValue(undefined); mockSelectIsCardholder.mockReturnValue(false); mockGetDetectedGeolocation.mockReturnValue('US'); @@ -680,13 +666,16 @@ describe('MoneyHomeView', () => { }); it('navigates to potential earnings screen when View potential earnings is pressed', () => { - mockUseMusdConversionTokens.mockReturnValueOnce({ + mockUseMoneyDepositTokens.mockReturnValueOnce({ tokens: Array.from({ length: 6 }, (_, i) => ({ - ...mockConversionTokens[0], + ...mockDepositTokens[0], address: `0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB${i.toString(16).padStart(2, '0')}` as `0x${string}`, fiat: { balance: 5000 }, })), + isNoFeeToken: jest.fn(() => false), + isEligibleToken: jest.fn(() => false), + filterAllowedTokens: jest.fn((t) => t), }); const { getByTestId } = renderWithProvider(); @@ -1074,13 +1063,16 @@ describe('MoneyHomeView', () => { describe('filled state navigation handlers', () => { it('navigates to Potential Earnings when View all is pressed on potential earnings section', () => { - mockUseMusdConversionTokens.mockReturnValueOnce({ + mockUseMoneyDepositTokens.mockReturnValueOnce({ tokens: Array.from({ length: 6 }, (_, i) => ({ - ...mockConversionTokens[0], + ...mockDepositTokens[0], address: `0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB${i.toString(16).padStart(2, '0')}` as `0x${string}`, fiat: { balance: 5000 }, })), + isNoFeeToken: jest.fn(() => false), + isEligibleToken: jest.fn(() => false), + filterAllowedTokens: jest.fn((t) => t), }); const { getByTestId } = renderWithProvider(); @@ -1093,7 +1085,7 @@ describe('MoneyHomeView', () => { ); }); - it('initiates a custom conversion when a token Convert button is pressed', async () => { + it('initiates a deposit when a token Convert button is pressed', async () => { const { getByTestId } = renderWithProvider(); const potentialEarnings = getByTestId( @@ -1105,19 +1097,17 @@ describe('MoneyHomeView', () => { ), ); - expect(mockInitiateCustomConversion).toHaveBeenCalledWith( + expect(mockInitiateDeposit).toHaveBeenCalledWith( expect.objectContaining({ preferredPaymentToken: expect.objectContaining({ - address: mockConversionTokens[0].address, + address: mockDepositTokens[0].address, }), }), ); }); - it('logs an error when initiateCustomConversion rejects', async () => { - mockInitiateCustomConversion.mockRejectedValueOnce( - new Error('network failure'), - ); + it('logs an error when initiateDeposit rejects', async () => { + mockInitiateDeposit.mockRejectedValueOnce(new Error('network failure')); const Logger = jest.requireMock('../../../../../util/Logger'); const { getByTestId } = renderWithProvider(); diff --git a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx index b47ec8068f9..08c565c7690 100644 --- a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx +++ b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx @@ -15,7 +15,6 @@ import MoneyOnboardingCard from '../../components/MoneyOnboardingCard'; import MoneyCondensedInfoCards from '../../components/MoneyCondensedInfoCards'; import MoneyHowItWorks from '../../components/MoneyHowItWorks'; import MoneyPotentialEarnings from '../../components/MoneyPotentialEarnings'; -import { hasConvertibleTokensWithBalance } from '../../components/MoneyPotentialEarnings/MoneyPotentialEarnings'; import MoneyMetaMaskCard from '../../components/MoneyMetaMaskCard'; import MoneyWhatYouGet from '../../components/MoneyWhatYouGet'; import MoneyActivityList from '../../components/MoneyActivityList'; @@ -23,8 +22,7 @@ import MoneyFooter from '../../components/MoneyFooter'; import Routes from '../../../../../constants/navigation/Routes'; import { MoneyHomeViewTestIds } from './MoneyHomeView.testIds'; import styleSheet from './MoneyHomeView.styles'; -import { useMusdConversionTokens } from '../../../Earn/hooks/useMusdConversionTokens'; -import { useMusdConversion } from '../../../Earn/hooks/useMusdConversion'; +import { useMoneyDepositTokens } from '../../hooks/useMoneyDepositTokens'; import { useMusdBalance } from '../../../Earn/hooks/useMusdBalance'; import { useMoneyAccountTransactions } from '../../hooks/useMoneyAccountTransactions'; import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance'; @@ -47,6 +45,7 @@ import { MoneyBalanceDisplayState } from '../../types'; import { Hex } from '@metamask/utils'; import { AssetType } from '../../../../Views/confirmations/types/token'; import { MONEY_ONBOARDING_TOTAL_STEPS } from '../../components/MoneyOnboardingCard/MoneyOnboardingCard'; +import { useMoneyAccountDeposit } from '../../hooks/useMoneyAccount'; const Divider = () => ; type MoneyHomeState = 'empty' | 'milestone' | 'filled'; @@ -93,8 +92,8 @@ const MoneyHomeView = () => { const { fiatBalanceAggregatedFormatted: musdFiatFormatted } = useMusdBalance(); - const { tokens: conversionTokens } = useMusdConversionTokens(); - const { initiateCustomConversion } = useMusdConversion(); + const { tokens: depositTokens, isNoFeeToken } = useMoneyDepositTokens(); + const { initiateDeposit } = useMoneyAccountDeposit(); const { allTransactions, moneyAddress, mockDataEnabled } = useMoneyAccountTransactions(); @@ -218,10 +217,10 @@ const MoneyHomeView = () => { }); }, []); - const handleTokenConvertPress = useCallback( + const handleTokenDepositPress = useCallback( async (token: AssetType) => { try { - await initiateCustomConversion({ + await initiateDeposit({ preferredPaymentToken: { address: token.address as Hex, chainId: token.chainId as Hex, @@ -234,7 +233,7 @@ const MoneyHomeView = () => { }); } }, - [initiateCustomConversion], + [initiateDeposit], ); const handleEarnCryptoPress = useCallback(() => { @@ -355,12 +354,13 @@ const MoneyHomeView = () => { )} - {hasConvertibleTokensWithBalance(conversionTokens) && ( + {depositTokens.length > 0 && ( <> { @@ -23,7 +23,7 @@ jest.mock('@react-navigation/native', () => { }; }); -const mockConversionTokens = [ +const mockDepositTokens = [ { name: 'USD Coin', symbol: 'USDC', @@ -80,11 +80,13 @@ const mockConversionTokens = [ }, ]; -jest.mock('../../../Earn/hooks/useMusdConversionTokens', () => ({ - useMusdConversionTokens: () => ({ tokens: mockTokens }), - STABLECOIN_SYMBOLS: new Set(['USDC', 'USDT', 'DAI']), - tokenFiatValue: (token: { fiat?: { balance?: number } }) => - token?.fiat?.balance ?? 0, +jest.mock('../../hooks/useMoneyDepositTokens', () => ({ + useMoneyDepositTokens: () => ({ + tokens: mockTokens, + isNoFeeToken: jest.fn(() => false), + isEligibleToken: jest.fn(() => false), + filterAllowedTokens: jest.fn((t) => t), + }), })); jest.mock('../../hooks/useMoneyAccountBalance', () => ({ @@ -92,9 +94,9 @@ jest.mock('../../hooks/useMoneyAccountBalance', () => ({ default: jest.fn(), })); -jest.mock('../../../Earn/hooks/useMusdConversion', () => ({ - useMusdConversion: () => ({ - initiateCustomConversion: mockInitiateCustomConversion, +jest.mock('../../hooks/useMoneyAccount', () => ({ + useMoneyAccountDeposit: () => ({ + initiateDeposit: mockInitiateDeposit, }), })); @@ -129,8 +131,8 @@ const mockUseMoneyAccountBalance = jest.mocked(useMoneyAccountBalance); describe('MoneyPotentialEarningsView', () => { beforeEach(() => { jest.clearAllMocks(); - mockTokens = mockConversionTokens; - mockInitiateCustomConversion.mockResolvedValue(undefined); + mockTokens = mockDepositTokens; + mockInitiateDeposit.mockResolvedValue(undefined); mockUseMoneyAccountBalance.mockReturnValue({ apyPercent: 4, apyDecimal: 0.04, @@ -260,14 +262,12 @@ describe('MoneyPotentialEarningsView', () => { ); }); - it('triggers conversion when the bottom Convert CTA is pressed', async () => { + it('triggers deposit when the bottom Convert CTA is pressed', async () => { const { getByTestId } = renderWithProvider(); fireEvent.press(getByTestId(MoneyPotentialEarningsViewTestIds.CTA_BUTTON)); - await waitFor(() => - expect(mockInitiateCustomConversion).toHaveBeenCalled(), - ); + await waitFor(() => expect(mockInitiateDeposit).toHaveBeenCalled()); }); it('disables the Convert CTA when there are no eligible tokens', () => { @@ -294,46 +294,24 @@ describe('MoneyPotentialEarningsView', () => { fireEvent.press(getByTestId(MoneyPotentialEarningsViewTestIds.CTA_BUTTON)); - await waitFor(() => - expect(mockInitiateCustomConversion).not.toHaveBeenCalled(), - ); + await waitFor(() => expect(mockInitiateDeposit).not.toHaveBeenCalled()); }); - it('logs but swallows conversion errors from the Convert CTA', async () => { - const conversionError = new Error('conversion failed'); - mockInitiateCustomConversion.mockRejectedValueOnce(conversionError); + it('calls initiateDeposit from the Convert CTA', async () => { const { getByTestId } = renderWithProvider(); fireEvent.press(getByTestId(MoneyPotentialEarningsViewTestIds.CTA_BUTTON)); - await waitFor(() => - expect(mockInitiateCustomConversion).toHaveBeenCalled(), - ); - }); - - it('triggers conversion when a token row is pressed', async () => { - const { getByTestId } = renderWithProvider(); - - fireEvent.press( - getByTestId(MoneyPotentialEarningsViewTestIds.TOKEN_ROW(0)), - ); - - await waitFor(() => - expect(mockInitiateCustomConversion).toHaveBeenCalled(), - ); + await waitFor(() => expect(mockInitiateDeposit).toHaveBeenCalled()); }); - it('logs but swallows conversion errors when a token row press throws', async () => { - const conversionError = new Error('token conversion failed'); - mockInitiateCustomConversion.mockRejectedValueOnce(conversionError); + it('triggers deposit when a token row is pressed', async () => { const { getByTestId } = renderWithProvider(); fireEvent.press( getByTestId(MoneyPotentialEarningsViewTestIds.TOKEN_ROW(0)), ); - await waitFor(() => - expect(mockInitiateCustomConversion).toHaveBeenCalled(), - ); + await waitFor(() => expect(mockInitiateDeposit).toHaveBeenCalled()); }); }); diff --git a/app/components/UI/Money/Views/MoneyPotentialEarningsView/MoneyPotentialEarningsView.tsx b/app/components/UI/Money/Views/MoneyPotentialEarningsView/MoneyPotentialEarningsView.tsx index 1a9d13307ea..595b3f63ef7 100644 --- a/app/components/UI/Money/Views/MoneyPotentialEarningsView/MoneyPotentialEarningsView.tsx +++ b/app/components/UI/Money/Views/MoneyPotentialEarningsView/MoneyPotentialEarningsView.tsx @@ -22,11 +22,7 @@ import { } from '@metamask/design-system-react-native'; import { strings } from '../../../../../../locales/i18n'; import { useStyles } from '../../../../../component-library/hooks'; -import { - useMusdConversionTokens, - STABLECOIN_SYMBOLS, -} from '../../../Earn/hooks/useMusdConversionTokens'; -import { useMusdConversion } from '../../../Earn/hooks/useMusdConversion'; +import { useMoneyDepositTokens } from '../../hooks/useMoneyDepositTokens'; import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance'; import { useProjectedEarnings } from '../../hooks/useProjectedEarnings'; import { selectCurrentCurrency } from '../../../../../selectors/currencyRateController'; @@ -39,6 +35,7 @@ import PotentialEarningsTokenRow from '../../components/MoneyPotentialEarnings/P import { isPositiveNumber } from '../../utils/number'; import styleSheet from './MoneyPotentialEarningsView.styles'; import { MoneyPotentialEarningsViewTestIds } from './MoneyPotentialEarningsView.testIds'; +import { useMoneyAccountDeposit } from '../../hooks/useMoneyAccount'; const MoneyPotentialEarningsView = () => { const navigation = useNavigation(); @@ -46,13 +43,13 @@ const MoneyPotentialEarningsView = () => { const { styles } = useStyles(styleSheet, {}); const currentCurrency = useSelector(selectCurrentCurrency); - const { tokens } = useMusdConversionTokens(); - const { initiateCustomConversion } = useMusdConversion(); + const { tokens: depositTokens, isNoFeeToken } = useMoneyDepositTokens(); + const { initiateDeposit } = useMoneyAccountDeposit(); const { apyPercent } = useMoneyAccountBalance(); const apyPercentForProjection = apyPercent ?? 0; const { eligibleTokens, totalAssetsFiat, projectedAmount } = - useProjectedEarnings(tokens, apyPercent); + useProjectedEarnings(depositTokens, apyPercent); const handleBackPress = useCallback(() => { navigation.goBack(); @@ -65,16 +62,13 @@ const MoneyPotentialEarningsView = () => { }, [navigation]); const handleConvertPress = useCallback(async () => { - // The conversion flow picks the actual source by inspecting balances; the - // first eligible token (sorted by useMusdConversionTokens) seeds the - // confirmation screen so it can resolve a default if the user does not - // change it. const defaultToken = eligibleTokens[0]; + if (!defaultToken) { return; } try { - await initiateCustomConversion({ + await initiateDeposit({ preferredPaymentToken: { address: defaultToken.address as Hex, chainId: defaultToken.chainId as Hex, @@ -83,15 +77,15 @@ const MoneyPotentialEarningsView = () => { } catch (error) { Logger.error(error as Error, { message: - '[MoneyPotentialEarningsView] Failed to initiate conversion from CTA', + '[MoneyPotentialEarningsView] Failed to initiate deposit from CTA', }); } - }, [eligibleTokens, initiateCustomConversion]); + }, [eligibleTokens, initiateDeposit]); const handleTokenPress = useCallback( (token: AssetType) => async () => { try { - await initiateCustomConversion({ + await initiateDeposit({ preferredPaymentToken: { address: token.address as Hex, chainId: token.chainId as Hex, @@ -99,11 +93,11 @@ const MoneyPotentialEarningsView = () => { }); } catch (error) { Logger.error(error as Error, { - message: '[MoneyPotentialEarningsView] Failed to initiate conversion', + message: '[MoneyPotentialEarningsView] Failed to initiate deposit', }); } }, - [initiateCustomConversion], + [initiateDeposit], ); return ( @@ -184,7 +178,7 @@ const MoneyPotentialEarningsView = () => { { const { startLinkFlow, isCardAuthenticated, isCardLinkedToMoneyAccount } = useMoneyAccountCardLinkage(); - const handleRedirectToCryptoDeposit = useCallback(() => { - initiateDeposit().catch(() => undefined); + const handleRedirectToCryptoDeposit = useCallback(async () => { + await initiateDeposit().catch(() => undefined); }, [initiateDeposit]); const handleCardCtaPress = useCallback(() => { diff --git a/app/components/UI/Money/components/MoneyPotentialEarnings/MoneyPotentialEarnings.test.tsx b/app/components/UI/Money/components/MoneyPotentialEarnings/MoneyPotentialEarnings.test.tsx index c649ddccde2..e208476738e 100644 --- a/app/components/UI/Money/components/MoneyPotentialEarnings/MoneyPotentialEarnings.test.tsx +++ b/app/components/UI/Money/components/MoneyPotentialEarnings/MoneyPotentialEarnings.test.tsx @@ -326,4 +326,59 @@ describe('MoneyPotentialEarnings', () => { // which fails isPositiveNumber and hides the "+$..." text in each token row. expect(queryByText(/^\+\$/)).not.toBeOnTheScreen(); }); + + describe('isNoFeeToken prop — "No fee" badge', () => { + it('renders the No fee badge on a token row when isNoFeeToken returns true', () => { + const { getByText } = render( + true} + />, + ); + + expect( + getByText(strings('money.potential_earnings.no_fee')), + ).toBeOnTheScreen(); + }); + + it('does not render the No fee badge when isNoFeeToken returns false', () => { + const { queryByText } = render( + false} + />, + ); + + expect( + queryByText(strings('money.potential_earnings.no_fee')), + ).not.toBeOnTheScreen(); + }); + + it('does not render any No fee badge when isNoFeeToken is omitted', () => { + const { queryByText } = render( + , + ); + + expect( + queryByText(strings('money.potential_earnings.no_fee')), + ).not.toBeOnTheScreen(); + }); + + it('renders No fee badge only on eligible token rows', () => { + const { getAllByText, queryByText } = render( + token.symbol === 'USDC'} + />, + ); + + expect( + getAllByText(strings('money.potential_earnings.no_fee')), + ).toHaveLength(1); + expect(queryByText('USDT')).toBeOnTheScreen(); + }); + }); }); diff --git a/app/components/UI/Money/components/MoneyPotentialEarnings/MoneyPotentialEarnings.tsx b/app/components/UI/Money/components/MoneyPotentialEarnings/MoneyPotentialEarnings.tsx index 86013007673..bba09978d6f 100644 --- a/app/components/UI/Money/components/MoneyPotentialEarnings/MoneyPotentialEarnings.tsx +++ b/app/components/UI/Money/components/MoneyPotentialEarnings/MoneyPotentialEarnings.tsx @@ -20,26 +20,12 @@ import MoneySectionHeader from '../MoneySectionHeader'; import { MoneyPotentialEarningsTestIds } from './MoneyPotentialEarnings.testIds'; import { selectCurrentCurrency } from '../../../../../selectors/currencyRateController'; import { moneyFormatFiat } from '../../utils/moneyFormatFiat'; -import { - STABLECOIN_SYMBOLS, - tokenFiatValue, -} from '../../../Earn/hooks/useMusdConversionTokens'; import { AssetType } from '../../../../Views/confirmations/types/token'; import { isPositiveNumber } from '../../utils/number'; import PotentialEarningsTokenRow from './PotentialEarningsTokenRow'; import { useProjectedEarnings } from '../../hooks/useProjectedEarnings'; -/** Number of years the projected earnings are simulated over. */ -const MAX_TOKENS = 5; - -/** - * True when the token list contains at least one token with a positive fiat - * balance — the same criterion MoneyPotentialEarnings uses before rendering. - * Exported so parents can gate surrounding chrome (e.g. Dividers) without - * drifting from the component's internal filter. - */ -export const hasConvertibleTokensWithBalance = (tokens: AssetType[]) => - tokens.some((token) => tokenFiatValue(token) > 0); +const VISIBLE_TOKENS_COUNT = 5; interface MoneyPotentialEarningsProps { tokens: AssetType[]; @@ -49,6 +35,13 @@ interface MoneyPotentialEarningsProps { * alongside each token and in the description. */ apy: number | undefined; + /** + * Returns true when the given token qualifies for a subsidised (no-fee) + * deposit. Used to render the "No fee" badge on each token row. + * Sourced from the `earnMoneyDepositNoFeeTokens` remote feature flag via + * useMoneyDepositTokens. + */ + isNoFeeToken?: (token: AssetType) => boolean; onTokenPress?: (token: AssetType) => void; onViewAllPress?: () => void; onHeaderPress?: () => void; @@ -62,6 +55,7 @@ interface MoneyPotentialEarningsProps { const MoneyPotentialEarnings = ({ tokens, apy, + isNoFeeToken = () => false, onTokenPress, onViewAllPress, onHeaderPress, @@ -70,11 +64,6 @@ const MoneyPotentialEarnings = ({ const currentCurrency = useSelector(selectCurrentCurrency); const apyPercent = apy ?? 0; - // Tokens arrive pre-sorted (stablecoins first, then fiat desc) from - // useMusdConversionTokens; the hook strips zero-balance entries - // defensively, since the feature flag threshold may be set to 0 in some - // environments. - // // Sum across every eligible token (not just the five we render). The "View // all" affordance tells users there are more rows than shown, so the // headline is intentionally the full projection — clipping the headline to @@ -82,7 +71,7 @@ const MoneyPotentialEarnings = ({ const { eligibleTokens, totalAssetsFiat, projectedAmount } = useProjectedEarnings(tokens, apyPercent); const visibleTokens = useMemo( - () => eligibleTokens.slice(0, MAX_TOKENS), + () => eligibleTokens.slice(0, VISIBLE_TOKENS_COUNT), [eligibleTokens], ); @@ -173,13 +162,13 @@ const MoneyPotentialEarnings = ({ ))} - {eligibleTokens.length > MAX_TOKENS && ( + {eligibleTokens.length > VISIBLE_TOKENS_COUNT && (