From d5c916ff57161a183bc3b765cf98e148614d48e8 Mon Sep 17 00:00:00 2001 From: Wei Sun Date: Wed, 10 Jun 2026 22:39:45 -0700 Subject: [PATCH 01/17] refactor(ramp): migrate aggregator BuildQuote to HeaderStandard (#31519) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** 1. Migrates Ramp Aggregator Build Quote from stack navigation.setOptions + getDepositNavbarOptions to in-screen **HeaderStandard**, with headerShown: false on Build Quote stack screens. Aligns Aggregator buy/sell (Routes.RAMP.BUY / Routes.RAMP.SELL) with the native-stack header pattern. 2. Removes dead setOptions navbar code from Quotes and Checkout (they already use overlay options with headerShown: false and headers inside BottomSheet). Updates Quotes cancel tests to use the in-sheet close button. ## **Changelog** CHANGELOG entry: Updated Ramp Aggregator buy and sell amount screen headers to use the standard in-app header component. ## **Related issues** Fixes: Ramp screens nav header ## **Manual testing steps** ```gherkin Feature: Ramp Aggregator Build Quote HeaderStandard Background: Given unified buy flags are disabled OR I navigate directly to Routes.RAMP.BUY And I open the Aggregator buy or sell Build Quote screen Scenario: Buy flow header shows back and settings Given I am on the Aggregator buy Build Quote screen Then I see the "Amount to buy" title And I see a back button And I see the settings button When I tap the back button Then I leave the Build Quote screen Scenario: Sell flow header shows back without settings Given I am on the Aggregator sell Build Quote screen Then I see the "Amount to sell" title And I see a back button And I do not see the buy settings button Scenario: showBack false hides back button Given Build Quote is opened with showBack false Then the back button is not shown Scenario: Quotes close tracks cancel Given I proceed from Build Quote to Quotes When quotes load and I tap the close button on the recommended quote sheet Then cancel analytics fire for the Quotes screen Scenario: Activity header does not shift on load Given I open Activity with the back-button header variant Then the header does not jump vertically after render ``` ## **Screenshots/Recordings** |BEFORE|AFTER| |---|---| |BEFORE|AFTER| Android After: Android Android Build: https://github.com/MetaMask/metamask-mobile/actions/runs/27314970757 ## **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** > UI/navigation header refactor in Ramp flows with test updates; no auth, payments, or data-layer changes beyond existing cancel/back behavior. > > **Overview** > Moves Ramp Aggregator **Build Quote** off the native stack header (`navigation.setOptions` + `getDepositNavbarOptions`) to an in-screen **`HeaderStandard`**, with **`headerShown: false`** on the Build Quote stack routes so buy/sell amount screens match the standard in-app header pattern. > > **Build Quote** now renders title, optional back (default on unless `showBack: false`), and buy-only settings in the header; back runs existing cancel analytics then **`navigation.pop()`**. **Quotes** and **Checkout** drop unused deposit navbar `setOptions` code (those flows already use overlay headers / bottom-sheet `HeaderStandard`). **Quotes** tests target the sheet **close** button (`quotes-close-button`) for cancel analytics instead of the old deposit back navbar control. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 4ab4ffbd9d32f5e278ea37cda404462534d43db1. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../Views/BuildQuote/BuildQuote.test.tsx | 20 +++---- .../Views/BuildQuote/BuildQuote.tsx | 59 +++++++++++-------- .../Aggregator/Views/Checkout/Checkout.tsx | 14 ----- .../Aggregator/Views/Quotes/Quotes.test.tsx | 33 ++++------- .../Aggregator/Views/Quotes/Quotes.testIds.ts | 1 + .../Ramp/Aggregator/Views/Quotes/Quotes.tsx | 15 +---- .../UI/Ramp/Aggregator/routes/index.tsx | 8 ++- 7 files changed, 64 insertions(+), 86 deletions(-) diff --git a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.test.tsx b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.test.tsx index 39347a37e86..1869a41c715 100644 --- a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.test.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.test.tsx @@ -431,18 +431,6 @@ describe('BuildQuote View', () => { expect(mockPop).toBeCalledTimes(1); }); - it('calls setOptions when rendering', () => { - render(BuildQuote); - expect(mockSetOptions).toHaveBeenCalled(); - - mockSetOptions.mockReset(); - - mockUseRampSDKValues.isBuy = false; - mockUseRampSDKValues.isSell = true; - render(BuildQuote); - expect(mockSetOptions).toHaveBeenCalled(); - }); - it('calls setIntent when params have intent', () => { mockUseParamsValues = { assetId: 'eip155:1/er20:0x6b175474e89094c44da98b954eedeac495271d0f', @@ -477,6 +465,14 @@ describe('BuildQuote View', () => { }); }); + it('hides back button when showBack is false', () => { + mockUseParamsValues = { showBack: false }; + render(BuildQuote); + expect( + screen.queryByTestId('deposit-back-navbar-button'), + ).not.toBeOnTheScreen(); + }); + it('calls endTrace when the conditions are met', () => { render(BuildQuote); expect(endTrace).toHaveBeenCalledWith({ diff --git a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx index 9388928515f..461ace708f6 100644 --- a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx @@ -17,6 +17,7 @@ import BN4 from 'bnjs4'; import { AvatarToken, AvatarTokenSize, + HeaderStandard, } from '@metamask/design-system-react-native'; import { useRampSDK } from '../../sdk'; @@ -51,7 +52,7 @@ import BadgeWrapper, { import BadgeNetwork from '../../../../../../component-library/components/Badges/Badge/variants/BadgeNetwork'; import { NATIVE_ADDRESS } from '../../../../../../constants/on-ramp'; -import { getDepositNavbarOptions } from '../../../../Navbar'; +import { NavbarSelectorsIDs } from '../../../../Navbar/Navbar.testIds'; import { strings } from '../../../../../../../locales/i18n'; import { createNavigationDetails, @@ -96,6 +97,7 @@ import Button, { ButtonVariants, ButtonWidthTypes, } from '../../../../../../component-library/components/Buttons/Button'; +import { IconName } from '../../../../../../component-library/components/Icons/Icon'; import { BuildQuoteSelectors } from './BuildQuote.testIds'; import { isNonEvmAddress } from '../../../../../../core/Multichain/utils'; @@ -121,6 +123,7 @@ const BuildQuote = () => { const navigation = useNavigation(); const params = useParams(); const { showBack } = params; + const shouldShowBack = showBack !== false; // Memoize the intent object to prevent unnecessary re-renders const intent = useMemo(() => { @@ -483,30 +486,11 @@ const BuildQuote = () => { navigation.navigate(...createBuySettingsModalNavigationDetails()); }, [navigation]); - useEffect(() => { - navigation.setOptions( - getDepositNavbarOptions( - navigation, - { - title: isBuy - ? strings('fiat_on_ramp_aggregator.amount_to_buy') - : strings('fiat_on_ramp_aggregator.amount_to_sell'), - showBack: showBack ?? false, - showConfiguration: isBuy, - onConfigurationPress: handleConfigurationPress, - }, - theme, - handleCancelPress, - ), - ); - }, [ - navigation, - theme, - handleCancelPress, - showBack, - isBuy, - handleConfigurationPress, - ]); + const handleBackPress = useCallback(() => { + handleCancelPress(); + // @ts-expect-error navigation prop mismatch + navigation.pop(); + }, [handleCancelPress, navigation]); /** * * Keypad style, handlers and effects @@ -924,6 +908,31 @@ const BuildQuote = () => { return ( + { const [key, setKey] = useState(0); const navigation = useNavigation(); const params = useParams(); - const theme = useTheme(); const handleSuccessfulOrder = useHandleSuccessfulOrder(); const { styles } = useStyles(styleSheet, {}); @@ -95,17 +92,6 @@ const CheckoutWebView = () => { sheetRef.current?.onCloseBottomSheet(); }, [handleCancelPress]); - useEffect(() => { - navigation.setOptions( - getDepositNavbarOptions( - navigation, - { title: provider.name }, - theme, - handleCancelPress, - ), - ); - }, [navigation, theme, handleCancelPress, provider.name]); - useEffect(() => { if ( !customOrderId || diff --git a/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.test.tsx b/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.test.tsx index afb107d261e..ae17f23d961 100644 --- a/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.test.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.test.tsx @@ -19,6 +19,7 @@ import { import Quotes, { QuotesParams } from './Quotes'; import { mockQuotesData } from './Quotes.constants'; +import { QuoteSelectors } from './Quotes.testIds'; import Timer from './Timer'; import LoadingQuotes from './LoadingQuotes'; @@ -242,25 +243,13 @@ describe('Quotes', () => { }; }); - it('calls setOptions when rendering', async () => { - mockUseQuotesAndCustomActionsValues = { - ...mockUseQuotesAndCustomActionsInitialValues, - isFetching: true, - quotes: undefined, - quotesWithoutError: [], - quotesWithError: [], - quotesByPriceWithoutError: [], - quotesByReliabilityWithoutError: [], - recommendedQuote: undefined, - }; - render(Quotes); - expect(mockSetOptions).toHaveBeenCalled(); - }); - - it('navigates and tracks event on back button press', async () => { + it('tracks event on close button press', async () => { render(Quotes); - fireEvent.press(screen.getByTestId('deposit-back-navbar-button')); - expect(mockPop).toHaveBeenCalled(); + act(() => { + jest.advanceTimersByTime(3000); + jest.clearAllTimers(); + }); + fireEvent.press(screen.getByTestId(QuoteSelectors.CLOSE_BUTTON)); expect(mockTrackEvent).toHaveBeenCalledWith('ONRAMP_CANCELED', { chain_id_destination: '1', location: 'Quotes Screen', @@ -273,12 +262,16 @@ describe('Quotes', () => { }); }); - it('navigates and tracks event on SELL back button press', async () => { + it('tracks event on SELL close button press', async () => { mockUseRampSDKValues.rampType = RampType.SELL; mockUseRampSDKValues.isSell = true; mockUseRampSDKValues.isBuy = false; render(Quotes); - fireEvent.press(screen.getByTestId('deposit-back-navbar-button')); + act(() => { + jest.advanceTimersByTime(3000); + jest.clearAllTimers(); + }); + fireEvent.press(screen.getByTestId(QuoteSelectors.CLOSE_BUTTON)); expect(mockTrackEvent).toHaveBeenCalledWith('OFFRAMP_CANCELED', { chain_id_source: '1', location: 'Quotes Screen', diff --git a/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.testIds.ts b/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.testIds.ts index ced479ddf73..384b7a97ac6 100644 --- a/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.testIds.ts +++ b/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.testIds.ts @@ -7,4 +7,5 @@ export const QuoteSelectors = { EXPLORE_MORE_OPTIONS: enContent.fiat_on_ramp_aggregator.explore_more_options, EXPANDED_QUOTES_SECTION: 'expanded-quotes-section', CONTINUE_WITH_PROVIDER: enContent.fiat_on_ramp_aggregator.continue_with, + CLOSE_BUTTON: 'quotes-close-button', }; diff --git a/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.tsx b/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.tsx index 5cc0c4724a9..f9ab0eef5f9 100644 --- a/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.tsx @@ -30,7 +30,6 @@ import Row from '../../components/Row'; import Quote from '../../components/Quote'; import CustomAction from '../../components/CustomAction'; import InfoAlert from '../../components/InfoAlert'; -import { getDepositNavbarOptions } from '../../../../Navbar'; import { ButtonSize, ButtonVariants, @@ -117,7 +116,7 @@ function Quotes() { const [remainingTime, setRemainingTime] = useState( appConfig.POLLING_INTERVAL, ); - const { styles, theme } = useStyles(styleSheet, {}); + const { styles } = useStyles(styleSheet, {}); const scrollOffsetY = useSharedValue(0); const scrollHandler = useAnimatedScrollHandler((event) => { @@ -581,17 +580,6 @@ function Quotes() { } }, [ErrorFetchingQuotes, pollingCyclesLeft]); - useEffect(() => { - navigation.setOptions( - getDepositNavbarOptions( - navigation, - { title: strings('fiat_on_ramp_aggregator.select_a_quote') }, - theme, - handleCancelPress, - ), - ); - }, [navigation, theme, handleCancelPress]); - useEffect(() => { if (isFetchingQuotes) return; setShouldFinishAnimation(true); @@ -967,6 +955,7 @@ function Quotes() { handleClosePress(bottomSheetRef)} + closeButtonProps={{ testID: QuoteSelectors.CLOSE_BUTTON }} /> {isInPolling && ( diff --git a/app/components/UI/Ramp/Aggregator/routes/index.tsx b/app/components/UI/Ramp/Aggregator/routes/index.tsx index d190cfcd414..e985cf05c4b 100644 --- a/app/components/UI/Ramp/Aggregator/routes/index.tsx +++ b/app/components/UI/Ramp/Aggregator/routes/index.tsx @@ -30,11 +30,15 @@ const overlayScreenOptions = { const MainRoutes = () => ( - + Date: Thu, 11 Jun 2026 11:36:29 +0530 Subject: [PATCH 02/17] fix: change 'Money balance' label to 'Money account' on perps and predict pages (#31529) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Change "Money balance" label to "Money account" on perps / predict pages. ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/CONF-1528 ## **Manual testing steps** NA ## **Screenshots/Recordings** NA ## **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** - [ ] 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** > Copy and i18n key rename only; no payment or transaction logic changes. > > **Overview** > Renames the pay-with **Money** option from **"Money balance"** to **"Money account"** for confirmation flows (including perps/predict money-account payment). > > The English string key `confirm.pay_with_bottom_sheet.money_balance` is replaced with `money_account` in `en.json`, and UI/hooks (`pay-with-row`, `usePayWithMoneyAccountSection`) plus related tests now use the new copy. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 1bcd3a502279477f6a76e662c282249d24bd1afd. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../components/rows/pay-with-row/pay-with-row.test.tsx | 6 +++--- .../components/rows/pay-with-row/pay-with-row.tsx | 2 +- .../pay/sections/usePayWithMoneyAccountSection.test.tsx | 4 ++-- .../hooks/pay/sections/usePayWithMoneyAccountSection.tsx | 2 +- .../confirmations/hooks/pay/usePayWithSections.test.ts | 4 ++-- locales/languages/en.json | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/components/Views/confirmations/components/rows/pay-with-row/pay-with-row.test.tsx b/app/components/Views/confirmations/components/rows/pay-with-row/pay-with-row.test.tsx index 65e3f569f33..88e9fdf7a53 100644 --- a/app/components/Views/confirmations/components/rows/pay-with-row/pay-with-row.test.tsx +++ b/app/components/Views/confirmations/components/rows/pay-with-row/pay-with-row.test.tsx @@ -329,7 +329,7 @@ describe('PayWithRow', () => { it('renders money balance label without inline balance', () => { const { getByTestId, queryByTestId } = renderMoneyAccount(); - expect(getByTestId('pay-with-symbol')).toHaveTextContent('Money balance'); + expect(getByTestId('pay-with-symbol')).toHaveTextContent('Money account'); expect(queryByTestId('pay-with-balance')).toBeNull(); }); @@ -357,7 +357,7 @@ describe('PayWithRow', () => { it('renders money account row regardless of isResultReady when paymentOverride is MoneyAccount', () => { const { getByTestId } = renderMoneyAccount({ isResultReady: true }); - expect(getByTestId('pay-with-symbol')).toHaveTextContent('Money balance'); + expect(getByTestId('pay-with-symbol')).toHaveTextContent('Money account'); }); it('renders money account row for perps deposit', () => { @@ -369,7 +369,7 @@ describe('PayWithRow', () => { const { getByTestId } = renderMoneyAccount(); expect(getByTestId('pay-with')).toBeDefined(); - expect(getByTestId('pay-with-symbol')).toHaveTextContent('Money balance'); + expect(getByTestId('pay-with-symbol')).toHaveTextContent('Money account'); }); }); diff --git a/app/components/Views/confirmations/components/rows/pay-with-row/pay-with-row.tsx b/app/components/Views/confirmations/components/rows/pay-with-row/pay-with-row.tsx index e8774a591a9..588de944422 100644 --- a/app/components/Views/confirmations/components/rows/pay-with-row/pay-with-row.tsx +++ b/app/components/Views/confirmations/components/rows/pay-with-row/pay-with-row.tsx @@ -327,7 +327,7 @@ function PayWithRowMoneyAccount() { color={TextColor.TextDefault} testID={TransactionPayComponentIDs.PAY_WITH_SYMBOL} > - {strings('confirm.pay_with_bottom_sheet.money_balance')} + {strings('confirm.pay_with_bottom_sheet.money_account')} ({ 'confirm.pay_with_bottom_sheet.available_balance': `${ params?.balance ?? '' } available`, - 'confirm.pay_with_bottom_sheet.money_balance': 'Money balance', + 'confirm.pay_with_bottom_sheet.money_account': 'Money account', }; return translations[key] ?? key; }, @@ -222,7 +222,7 @@ describe('usePayWithMoneyAccountSection', () => { expect(result.current?.rows[0]).toEqual( expect.objectContaining({ id: 'money-account-musd', - title: 'Money balance', + title: 'Money account', subtitle: '$100.00 available', isSelected: false, isLastUsed: false, diff --git a/app/components/Views/confirmations/hooks/pay/sections/usePayWithMoneyAccountSection.tsx b/app/components/Views/confirmations/hooks/pay/sections/usePayWithMoneyAccountSection.tsx index 82dd5db3f40..1c9319e4854 100644 --- a/app/components/Views/confirmations/hooks/pay/sections/usePayWithMoneyAccountSection.tsx +++ b/app/components/Views/confirmations/hooks/pay/sections/usePayWithMoneyAccountSection.tsx @@ -101,7 +101,7 @@ export function usePayWithMoneyAccountSection(): PayWithSectionConfig | null { source: MoneyIcon, style: styles.moneyIcon, }), - title: strings('confirm.pay_with_bottom_sheet.money_balance'), + title: strings('confirm.pay_with_bottom_sheet.money_account'), subtitle, isSelected: isMoneyAccountSelected, isLastUsed: false, diff --git a/app/components/Views/confirmations/hooks/pay/usePayWithSections.test.ts b/app/components/Views/confirmations/hooks/pay/usePayWithSections.test.ts index 08a871f484c..2792e24fe2b 100644 --- a/app/components/Views/confirmations/hooks/pay/usePayWithSections.test.ts +++ b/app/components/Views/confirmations/hooks/pay/usePayWithSections.test.ts @@ -55,8 +55,8 @@ const MONEY_ACCOUNT_SECTION_MOCK: PayWithSectionConfig = { rows: [ { id: 'money-account-musd', - icon: 'Money balance', - title: 'Money balance', + icon: 'Money account', + title: 'Money account', }, ], }; diff --git a/locales/languages/en.json b/locales/languages/en.json index 7f57b5a5911..ba0d21ba136 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -7397,7 +7397,7 @@ "other_assets": "Other assets", "other_assets_description": "Select from your tokens", "other_assets_receive_description": "Select the token you want to receive", - "money_balance": "Money balance" + "money_account": "Money account" }, "staking_footer": { "part1": "By continuing, you agree to our ", From d7023ae873b4d1b7ef7adaac1709c07aa5b63fa3 Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Thu, 11 Jun 2026 01:17:01 -0500 Subject: [PATCH 03/17] fix(ramp): fiat order details navigation from activity and BuildQuote legacy headless param cleanup (#31480) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Two fixes for the MM Pay / headless fiat deposit flow: 1. **`fiat-order-summary-line`**: Tapping the order-details button on a fiat deposit in the activity view did nothing, because `RampsOrderDetails` is registered inside the `TransactionsHome` (`TransactionsView`) navigator while the summary line renders in the MoneyModalStack transparent-modal context. The navigate call now uses the nested-navigator format (`navigate(TRANSACTIONS_VIEW, { screen: RAMPS_ORDER_DETAILS, ... })`) so the screen is actually reachable. 2. **`BuildQuote`**: Removes the legacy Phase-3 `headlessSessionId` param and its `failSession` bridging. The headless flow now navigates straight to `Routes.RAMP.HEADLESS_HOST`, which owns session error bridging (`nativeFlowError` → `failSession`), so no caller passes this param to BuildQuote anymore — dead code from the pre-HeadlessHost architecture. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: [TRAM-3636](https://consensyssoftware.atlassian.net/browse/TRAM-3636) ## **Manual testing steps** ```gherkin Feature: Fiat deposit order details from activity Scenario: user opens fiat order details from the activity view Given the user has completed a fiat deposit via MM Pay When user taps the order details button on the deposit row in the activity view Then the RampsOrderDetails screen opens with the order's provider and fee details Feature: BuildQuote regression check (dead-code removal) Scenario: user buys via the standard (non-headless) flow Given the user opens the Buy flow and lands on BuildQuote When user selects an amount and continues with a quote Then the flow proceeds normally and provider errors surface on BuildQuote as before ``` ## **Screenshots/Recordings** ### **Before** N/A — order details button did not navigate; BuildQuote change is dead-code removal with no UI impact. ### **After** https://github.com/user-attachments/assets/6ad09f97-52fd-4fa3-9541-eb3ef78e2b02 ## **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). - [ ] 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. [TRAM-3636]: https://consensyssoftware.atlassian.net/browse/TRAM-3636?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- > [!NOTE] > **Low Risk** > Navigation fix is scoped to the fiat activity summary line; BuildQuote removal is dead code with tests updated and no change to active headless paths (Checkout/HeadlessHost). > > **Overview** > Fixes **fiat order details** from activity: the summary line lives under **MoneyModalStack**, so direct navigation to `RAMPS_ORDER_DETAILS` never worked. Pressing the button now uses the **root** navigator (`getParent()`) and nested routing via `TRANSACTIONS_VIEW` → `RAMPS_ORDER_DETAILS`, with a test for that path. > > **BuildQuote** drops the unused Phase-3 **`headlessSessionId`** param and all **`failSession`** handling there (native errors and continue failures). Headless buy goes through **`HEADLESS_HOST`** instead; standard buy error behavior on BuildQuote is unchanged. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 4dee4fe6df6fe6bded841d2d11dcb6541eb4c3f6. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Claude Sonnet 4.6 --- .../Ramp/Views/BuildQuote/BuildQuote.test.tsx | 13 -------- .../UI/Ramp/Views/BuildQuote/BuildQuote.tsx | 31 +------------------ .../fiat-order-summary-line.test.tsx | 26 ++++++++++++++++ .../fiat-order-summary-line.tsx | 11 +++++-- 4 files changed, 35 insertions(+), 46 deletions(-) diff --git a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx index d76e0d0f5cf..edfa793d76a 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx +++ b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx @@ -321,19 +321,6 @@ describe('createBuildQuoteNavDetails', () => { nativeFlowError: 'error', }); }); - - it('forwards headlessSessionId for headless buy attempts', () => { - const result = createBuildQuoteNavDetails({ - assetId: 'eip155:1/slip44:60', - amount: 25, - headlessSessionId: 'headless-abc', - }); - expect(result[1].params.params).toEqual({ - assetId: 'eip155:1/slip44:60', - amount: 25, - headlessSessionId: 'headless-abc', - }); - }); }); const mockSetSelectedProvider = jest.fn(); diff --git a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx index 069204aafdf..59d78bceac6 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx +++ b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx @@ -69,7 +69,6 @@ import { import TruncatedError from '../../components/TruncatedError'; import { PROVIDER_LINKS } from '../../Aggregator/types'; -import { failSession } from '../../headless/sessionRegistry'; const BAILED_ORDER_STATUSES = new Set([ RampsOrderStatus.Precreated, RampsOrderStatus.IdExpired, @@ -97,16 +96,6 @@ export interface BuildQuoteParams { buyFlowOrigin?: BuyFlowOrigin; /** Pre-fill the amount input (e.g. when restoring state after a navigation reset). */ amount?: number; - /** - * Legacy param from Phase 3. The headless flow now navigates straight - * to `Routes.RAMP.HEADLESS_HOST` and never lands on BuildQuote, so the - * field is unused. Kept as `optional` for backward compatibility with - * any in-flight deeplinks; safe to remove once we're sure no callers - * pass it. - * - * @deprecated Use `Routes.RAMP.HEADLESS_HOST` instead. - */ - headlessSessionId?: string; } /** @@ -162,24 +151,10 @@ function BuildQuote() { useEffect(() => { if (params?.nativeFlowError) { - if ( - params.headlessSessionId && - failSession( - params.headlessSessionId, - { - code: 'AUTH_FAILED', - message: params.nativeFlowError, - }, - 'AUTH_FAILED', - ) - ) { - navigation.setParams({ nativeFlowError: undefined }); - return; - } setRampsError(params.nativeFlowError); navigation.setParams({ nativeFlowError: undefined }); } - }, [params?.headlessSessionId, params?.nativeFlowError, navigation]); + }, [params?.nativeFlowError, navigation]); const { userRegion, @@ -646,9 +621,6 @@ function BuildQuote() { assetId: selectedToken?.assetId ?? '', }); } catch (err) { - if (failSession(params?.headlessSessionId, err)) { - return; - } setRampsError((err as Error).message); } finally { setIsContinueLoading(false); @@ -664,7 +636,6 @@ function BuildQuote() { selectedPaymentMethod?.id, rampRoutingDecision, userRegion?.regionCode, - params?.headlessSessionId, trackEvent, createEventBuilder, continueWithQuote, diff --git a/app/components/Views/confirmations/components/activity/transaction-details-summary/fiat-order-summary-line.test.tsx b/app/components/Views/confirmations/components/activity/transaction-details-summary/fiat-order-summary-line.test.tsx index 584cacd89a6..549dae65a19 100644 --- a/app/components/Views/confirmations/components/activity/transaction-details-summary/fiat-order-summary-line.test.tsx +++ b/app/components/Views/confirmations/components/activity/transaction-details-summary/fiat-order-summary-line.test.tsx @@ -3,6 +3,7 @@ import { TransactionMeta, TransactionStatus, } from '@metamask/transaction-controller'; +import { fireEvent } from '@testing-library/react-native'; import renderWithProvider from '../../../../../../util/test/renderWithProvider'; import { strings } from '../../../../../../../locales/i18n'; import { selectBridgeHistoryForAccount } from '../../../../../../selectors/bridgeStatusController'; @@ -10,8 +11,16 @@ import { useBridgeTxHistoryData } from '../../../../../../util/bridge/hooks/useB import { useTokenAmount } from '../../../hooks/useTokenAmount'; import { useTransactionDetails } from '../../../hooks/activity/useTransactionDetails'; import { useFiatOrderStatus } from '../../../hooks/activity/useFiatOrderStatus'; +import Routes from '../../../../../../constants/navigation/Routes'; import { FiatOrderSummaryLine } from './fiat-order-summary-line'; +const mockNavigate = jest.fn(); +const mockGetParent = jest.fn(() => ({ navigate: mockNavigate })); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ navigate: jest.fn(), getParent: mockGetParent }), +})); jest.mock('../../../../../../selectors/bridgeStatusController'); jest.mock('../../../../../../util/bridge/hooks/useBridgeTxHistoryData'); jest.mock('../../../hooks/useTokenAmount'); @@ -52,6 +61,8 @@ describe('FiatOrderSummaryLine', () => { beforeEach(() => { jest.resetAllMocks(); + mockNavigate.mockClear(); + mockGetParent.mockReturnValue({ navigate: mockNavigate }); useFiatOrderStatusMock.mockReturnValue({ severity: 'success', @@ -147,4 +158,19 @@ describe('FiatOrderSummaryLine', () => { expect(queryByTestId('block-explorer-button')).toBeNull(); }); + + it('navigates to RampsOrderDetails inside TransactionsView when order details button is pressed', () => { + const { getByTestId } = render(); + + fireEvent.press(getByTestId('block-explorer-button')); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(Routes.TRANSACTIONS_VIEW, { + screen: Routes.RAMP.RAMPS_ORDER_DETAILS, + params: { + orderId: '/providers/transak/orders/order-123', + showCloseButton: true, + }, + }); + }); }); diff --git a/app/components/Views/confirmations/components/activity/transaction-details-summary/fiat-order-summary-line.tsx b/app/components/Views/confirmations/components/activity/transaction-details-summary/fiat-order-summary-line.tsx index aac3caa4bb8..1ae32d6b640 100644 --- a/app/components/Views/confirmations/components/activity/transaction-details-summary/fiat-order-summary-line.tsx +++ b/app/components/Views/confirmations/components/activity/transaction-details-summary/fiat-order-summary-line.tsx @@ -42,9 +42,14 @@ export function FiatOrderSummaryLine({ const subtitle = formatSubtitle(parentTransaction.time, statusText); const handleOrderDetailsPress = useCallback(() => { - navigation.navigate(Routes.RAMP.RAMPS_ORDER_DETAILS, { - orderId: fiatOrderId, - showCloseButton: true, + // FiatOrderSummaryLine renders inside MoneyModalStack (a transparent modal + // nested navigator). useNavigation() returns the ModalStack navigator, which + // doesn't know about RAMPS_ORDER_DETAILS. We must escape to the root Stack + // navigator via getParent() to reach it. + const rootNav = navigation.getParent() ?? navigation; + rootNav.navigate(Routes.TRANSACTIONS_VIEW, { + screen: Routes.RAMP.RAMPS_ORDER_DETAILS, + params: { orderId: fiatOrderId, showCloseButton: true }, }); }, [navigation, fiatOrderId]); From b414875e1e4d00309b0be0cfa5adfc982e898b1f Mon Sep 17 00:00:00 2001 From: sophieqgu <37032128+sophieqgu@users.noreply.github.com> Date: Thu, 11 Jun 2026 02:28:47 -0400 Subject: [PATCH 04/17] chore(rewards): UI audit (#31503) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** UI audit pass for Rewards campaign leaderboard and post-campaign stats screens (7.82.0). **Leaderboard screens** - Remove the total-participants line above the Predict the Pitch leaderboard list. - Show the Ondo leaderboard divider under the user position block only when that section is visible. - Default loading skeleton row counts to `maxEntries ?? 20` for Ondo, Perps, and Predict leaderboards. - Add vertical padding to the Ondo leaderboard empty error state. - Show a crown icon on Predict leaderboard rows in the top 20 ranks (main list and neighbor rows). **Ended campaign stats** - Refactor Perps post-campaign stats to delegate to the shared `CampaignEndedStats` component. - Use shared `rewards.campaign_ended_stats.*` locale keys (including new `top_pnl`) and remove per-campaign completed-label strings. - Use `PERPS_TRADING_MAX_WINNERS` from shared constants. **Misc** - Group `CampaignLeaderboardStatsHeader` loading skeletons in a spaced `Box`. - Update unit tests to match the refactored components. ## **Changelog** CHANGELOG entry: null ## **Related issues** Refs: UI audit 7.82.0 ## **Manual testing steps** ```gherkin Feature: Rewards campaign UI audit Scenario: Predict the Pitch leaderboard no longer shows total participants header Given an active Predict the Pitch campaign with leaderboard data When user opens the campaign leaderboard screen Then the total participants count line above the list is not shown And the leaderboard entries render normally Scenario: Perps ended campaign stats use shared layout Given a completed Perps Trading campaign When user views the campaign details / ended stats section Then total participants, volume, top PnL, and winners use the shared ended-stats layout And labels match other campaign types Scenario: Predict top-20 rows show crown icon Given a Predict the Pitch leaderboard with ranked entries When user views the leaderboard Then rows in the top 20 ranks display a crown icon (when not in preview mode) Scenario: Ondo leaderboard divider only when user position is shown Given user is opted in with a visible position on the Ondo leaderboard When user opens the Ondo leaderboard screen Then a divider appears below the user position block When user has no visible position Then the divider is not shown ``` ## **Screenshots/Recordings** N/A — layout and copy alignment changes; no new user-facing flows. ### **Before** N/A ### **After** N/A ## **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** > Presentation-layer changes and test refactors in Rewards campaign screens; no auth, payments, or core wallet logic. > > **Overview** > Rewards campaign leaderboard and post-campaign stats get a **UI audit** pass: layout polish, shared ended-stats UI, and clearer winner treatment on leaderboards. > > **Leaderboard screens:** The Predict the Pitch leaderboard no longer shows a **total participants** line above the list (and related tests were dropped). On Ondo, the divider under the user position block only appears when that position section is shown. Loading skeletons for Ondo, Perps, and Predict leaderboards now default to **`maxEntries ?? 20`** rows instead of a fixed small count; Ondo’s empty error state gains vertical padding. Predict leaderboard rows in the top **`PREDICT_THE_PITCH_CAMPAIGN_MAX_WINNERS` (20)** ranks show a **crown** when not in preview (main list and neighbor rows). > > **Ended campaign stats:** Perps post-campaign stats are **delegated to `CampaignEndedStats`** instead of bespoke layout and copy; labels use shared **`rewards.campaign_ended_stats.*`** keys (including new **`top_pnl`**), and per-campaign “completed label” strings were removed from locales. Tests now assert props passed into the generic component. > > **Misc:** `CampaignLeaderboardStatsHeader` loading skeletons are grouped in a spaced `Box`. `PerpsTradingCampaignEndedStats` uses **`PERPS_TRADING_MAX_WINNERS`** from shared constants. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit e0254831553a7d62022d17c57bd03d5e3b899727. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../UI/Rewards/Views/OndoLeaderboardView.tsx | 39 +-- ...ctThePitchCampaignLeaderboardView.test.tsx | 19 +- ...PredictThePitchCampaignLeaderboardView.tsx | 17 -- .../CampaignLeaderboardStatsHeader.tsx | 6 +- .../components/Campaigns/OndoLeaderboard.tsx | 4 +- .../PerpsTradingCampaignEndedStats.test.tsx | 243 +++++++++--------- .../PerpsTradingCampaignEndedStats.tsx | 146 ++++------- .../PerpsTradingCampaignLeaderboard.tsx | 2 +- .../Campaigns/PredictThePitchLeaderboard.tsx | 11 +- .../Rewards/utils/predictCampaignConstants.ts | 2 + locales/languages/de.json | 6 +- locales/languages/el.json | 6 +- locales/languages/en.json | 7 +- locales/languages/es.json | 6 +- locales/languages/fr.json | 6 +- locales/languages/hi.json | 6 +- locales/languages/id.json | 6 +- locales/languages/ja.json | 6 +- locales/languages/ko.json | 6 +- locales/languages/pt.json | 6 +- locales/languages/ru.json | 6 +- locales/languages/tl.json | 6 +- locales/languages/tr.json | 6 +- locales/languages/vi.json | 6 +- locales/languages/zh.json | 6 +- 25 files changed, 216 insertions(+), 364 deletions(-) create mode 100644 app/components/UI/Rewards/utils/predictCampaignConstants.ts diff --git a/app/components/UI/Rewards/Views/OndoLeaderboardView.tsx b/app/components/UI/Rewards/Views/OndoLeaderboardView.tsx index fd965e88005..8b6bc35c8f2 100644 --- a/app/components/UI/Rewards/Views/OndoLeaderboardView.tsx +++ b/app/components/UI/Rewards/Views/OndoLeaderboardView.tsx @@ -190,27 +190,28 @@ const OndoLeaderboardView: React.FC = () => { > {/* User position */} {position && ( - - - + <> + + + + {/* Divider */} + + )} - {/* Divider */} - - {/* Tier selector + last updated row */} {selectedTier && ( { const { View } = jest.requireActual('react-native'); return { __esModule: true, - PREDICT_THE_PITCH_LEADERBOARD_TEST_IDS: { - TOTAL_PARTICIPANTS: 'predict-the-pitch-leaderboard-total-participants', - }, + PREDICT_THE_PITCH_LEADERBOARD_TEST_IDS: {}, default: (props: Record) => { mockPredictLeaderboard(props); return ReactActual.createElement(View, { @@ -125,15 +123,7 @@ jest.mock('../hooks/useGetCampaignParticipantStatus'); jest.mock('../hooks/useTrackRewardsPageView', () => jest.fn()); jest.mock('../../../../../locales/i18n', () => ({ - strings: (key: string, params?: { count?: string }) => { - if ( - key === - 'rewards.predict_the_pitch_campaign.leaderboard_total_participants' - ) { - return `${params?.count ?? '0'} participants`; - } - return key; - }, + strings: (key: string) => key, })); const mockUseSelector = useSelector as jest.MockedFunction; @@ -395,9 +385,4 @@ describe('PredictThePitchCampaignLeaderboardView', () => { }), ); }); - - it('shows total participants when count is greater than zero', () => { - const { getByText } = render(); - expect(getByText('50 participants')).toBeDefined(); - }); }); diff --git a/app/components/UI/Rewards/Views/PredictThePitchCampaignLeaderboardView.tsx b/app/components/UI/Rewards/Views/PredictThePitchCampaignLeaderboardView.tsx index 6e8d008c061..1e93ca36404 100644 --- a/app/components/UI/Rewards/Views/PredictThePitchCampaignLeaderboardView.tsx +++ b/app/components/UI/Rewards/Views/PredictThePitchCampaignLeaderboardView.tsx @@ -91,7 +91,6 @@ const PredictThePitchCampaignLeaderboardView: React.FC = () => { const isCampaignComplete = campaign != null && getCampaignStatus(campaign) === 'complete'; - const totalParticipants = leaderboard?.totalParticipants ?? 0; return ( { )} - - {totalParticipants > 0 && ( - - {strings( - 'rewards.predict_the_pitch_campaign.leaderboard_total_participants', - { count: totalParticipants.toLocaleString() }, - )} - - )} - {isLoading ? ( - <> + {showSubtextRow && ( - + )} - + ) : ( <> = ({ ); if (isLoading && entries.length === 0) { - return ; + return ; } if (hasError && entries.length === 0) { return ( - + { - const ReactActual = jest.requireActual('react'); - const RN = jest.requireActual('react-native'); - return { - __esModule: true, - default: (props: { title: string; onConfirm: () => void }) => - ReactActual.createElement( - RN.View, - { testID: 'rewards-error-banner' }, - ReactActual.createElement(RN.Text, null, props.title), - ReactActual.createElement(RN.TouchableOpacity, { - testID: 'rewards-error-banner-retry', - onPress: props.onConfirm, - }), - ), +interface CapturedEndedStatsProps { + totalParticipants: { label: string; value: string; isLoading?: boolean }; + totalVolume: { label: string; value: string; isLoading?: boolean }; + topMetric: { + label: string; + value: string; + isLoading?: boolean; + valueColor?: unknown; }; -}); + totalWinners: { label: string; value: string; isLoading?: boolean }; + hasError?: boolean; + onRetry?: () => void; +} + +let latestProps: CapturedEndedStatsProps | null = null; -jest.mock('@metamask/design-system-react-native', () => { - const actual = jest.requireActual('@metamask/design-system-react-native'); +jest.mock('./CampaignEndedStats', () => { const ReactActual = jest.requireActual('react'); - const RN = jest.requireActual('react-native'); + const { View } = jest.requireActual('react-native'); return { - ...actual, - Text: (props: Record) => - ReactActual.createElement(RN.Text, props, props.children), - Skeleton: (props: Record) => - ReactActual.createElement(RN.View, { testID: 'skeleton', ...props }), + __esModule: true, + default: (props: CapturedEndedStatsProps) => { + latestProps = props; + return ReactActual.createElement(View, { + testID: 'campaign-ended-stats', + }); + }, }; }); -jest.mock('@metamask/design-system-twrnc-preset', () => { - const tw = (..._args: unknown[]) => ({}); - tw.style = jest.fn(() => ({})); - return { useTailwind: () => tw }; -}); - jest.mock('../../../../../../locales/i18n', () => ({ strings: (key: string) => key, })); @@ -87,38 +79,50 @@ const makeLeaderboard = ( }; describe('PerpsTradingCampaignEndedStats', () => { - it('renders all four stat cells with correct values when leaderboard has 20+ entries', () => { - const { getByTestId } = render( + beforeEach(() => { + latestProps = null; + jest.clearAllMocks(); + }); + + it('maps perps leaderboard and volume into the generic ended stats props', () => { + render( , ); - expect( - getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.CONTAINER), - ).toBeTruthy(); - expect( - getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_PARTICIPANTS).props - .children, - ).toBe((200).toLocaleString()); - expect( - getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_VOLUME).props - .children, - ).toBe('$27.5M'); - expect( - getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOP_PNL).props.children, - ).toBe('+$80,000'); - // Leaderboard has 25 entries (>= 20) → fixed 20 winners - expect( - getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.WINNERS).props.children, - ).toBe('20'); + expect(latestProps).toMatchObject({ + totalParticipants: { + label: 'rewards.campaign_ended_stats.total_participants', + value: '200', + isLoading: false, + }, + totalVolume: { + label: 'rewards.campaign_ended_stats.total_volume', + value: '$27.5M', + isLoading: false, + }, + topMetric: { + label: 'rewards.campaign_ended_stats.top_pnl', + value: '+$80,000', + isLoading: false, + }, + totalWinners: { + label: 'rewards.campaign_ended_stats.total_winners', + value: '20', + isLoading: false, + }, + hasError: false, + }); }); it('shows dash for winners when leaderboard has fewer than 20 entries', () => { - const { getByTestId } = render( + render( { />, ); - expect( - getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.WINNERS).props.children, - ).toBe('-'); + expect(latestProps?.totalWinners.value).toBe('-'); }); it('shows dashes when leaderboard and volume are null', () => { - const { getByTestId } = render( + render( { />, ); - expect( - getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_PARTICIPANTS).props - .children, - ).toBe('-'); - expect( - getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_VOLUME).props - .children, - ).toBe('-'); - expect( - getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOP_PNL).props.children, - ).toBe('-'); - expect( - getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.WINNERS).props.children, - ).toBe('-'); + expect(latestProps).toMatchObject({ + totalParticipants: { value: '-', isLoading: false }, + totalVolume: { value: '-', isLoading: false }, + topMetric: { value: '-', isLoading: false }, + totalWinners: { value: '-', isLoading: false }, + }); }); - it('renders skeletons while data is loading', () => { - const { getAllByTestId } = render( + it('shows loading state while uncached data is loading', () => { + render( { />, ); - const skeletons = getAllByTestId('skeleton'); - expect(skeletons.length).toBeGreaterThanOrEqual(3); + expect(latestProps).toMatchObject({ + totalParticipants: { value: '-', isLoading: true }, + totalVolume: { value: '-', isLoading: true }, + topMetric: { value: '-', isLoading: true }, + totalWinners: { value: '-', isLoading: true }, + }); }); - it('handles a leaderboard with no entries (no top PnL)', () => { + it('handles a leaderboard with no entries', () => { const empty: PerpsTradingCampaignLeaderboardDto = { campaignId: 'perps-1', computedAt: '2026-01-01T00:00:00Z', @@ -181,7 +179,7 @@ describe('PerpsTradingCampaignEndedStats', () => { entries: [], }; - const { getByTestId } = render( + render( { />, ); - expect( - getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_PARTICIPANTS).props - .children, - ).toBe('0'); - expect( - getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOP_PNL).props.children, - ).toBe('-'); - expect( - getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.WINNERS).props.children, - ).toBe('-'); + expect(latestProps).toMatchObject({ + totalParticipants: { value: '0' }, + topMetric: { value: '-' }, + totalWinners: { value: '-' }, + }); + }); + + it('uses error color for negative top PnL', () => { + const negativeTop: PerpsTradingCampaignLeaderboardDto = { + campaignId: 'perps-1', + computedAt: '2026-01-01T00:00:00Z', + totalParticipants: 1, + minVolumeForEligibility: 25_000, + entries: [makeEntry(1, -5_000)], + }; + + render( + , + ); + + expect(latestProps?.topMetric.value).toBe('-$5,000'); + expect(latestProps?.topMetric.valueColor).toBe(TextColor.ErrorDefault); }); - it('shows error banner when both sources fail and triggers both retries', () => { + it('shows error and retries both data sources when uncached data fails', () => { const onRetryLeaderboard = jest.fn(); const onRetryVolume = jest.fn(); - const { getByTestId } = render( + render( { />, ); - expect(getByTestId('rewards-error-banner')).toBeTruthy(); - fireEvent.press(getByTestId('rewards-error-banner-retry')); + expect(latestProps?.hasError).toBe(true); + latestProps?.onRetry?.(); expect(onRetryLeaderboard).toHaveBeenCalledTimes(1); expect(onRetryVolume).toHaveBeenCalledTimes(1); }); - it('shows error banner when only leaderboard fails; volume still renders', () => { - const { getByTestId } = render( + it('shows error when only leaderboard fails while volume still renders', () => { + render( { />, ); - expect(getByTestId('rewards-error-banner')).toBeTruthy(); - expect( - getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_VOLUME).props - .children, - ).toBe('$27.5M'); + expect(latestProps?.hasError).toBe(true); + expect(latestProps?.totalVolume.value).toBe('$27.5M'); }); - it('does not render error banner when there are no errors', () => { - const { queryByTestId } = render( + it('does not show error when there are no errors', () => { + render( , ); - expect(queryByTestId('rewards-error-banner')).toBeNull(); - }); - - it('renders negative top PnL with error color and a minus sign', () => { - const negativeTop: PerpsTradingCampaignLeaderboardDto = { - campaignId: 'perps-1', - computedAt: '2026-01-01T00:00:00Z', - totalParticipants: 1, - minVolumeForEligibility: 25_000, - entries: [makeEntry(1, -5_000)], - }; - - const { getByTestId } = render( - , - ); - - expect( - getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOP_PNL).props.children, - ).toBe('-$5,000'); + expect(latestProps?.hasError).toBe(false); }); }); diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.tsx index af3a1616288..e569a2a371d 100644 --- a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.tsx +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.tsx @@ -1,27 +1,10 @@ -import React, { useMemo } from 'react'; -import { - Box, - BoxFlexDirection, - FontWeight, - Text, - TextColor, - TextVariant, -} from '@metamask/design-system-react-native'; +import React, { useCallback, useMemo } from 'react'; +import { TextColor } from '@metamask/design-system-react-native'; import type { PerpsTradingCampaignLeaderboardDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; -import { StatCell } from './OndoCampaignStatsSummary'; -import RewardsErrorBanner from '../RewardsErrorBanner'; import { strings } from '../../../../../../locales/i18n'; import { formatCompactUsd, formatSignedUsd } from '../../utils/formatUtils'; - -const PERPS_WINNERS_CAP = 20; - -export const PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS = { - CONTAINER: 'perps-campaign-ended-stats-container', - TOTAL_PARTICIPANTS: 'perps-campaign-ended-stats-total-participants', - TOTAL_VOLUME: 'perps-campaign-ended-stats-total-volume', - TOP_PNL: 'perps-campaign-ended-stats-top-pnl', - WINNERS: 'perps-campaign-ended-stats-winners', -} as const; +import { PERPS_TRADING_MAX_WINNERS } from '../../utils/perpsCampaignConstants'; +import CampaignEndedStats from './CampaignEndedStats'; interface PerpsTradingCampaignEndedStatsProps { leaderboard: PerpsTradingCampaignLeaderboardDto | null; @@ -53,100 +36,57 @@ const PerpsTradingCampaignEndedStats: React.FC< const totalParticipants = leaderboard.totalParticipants; const topPnl = entries.length > 0 ? Math.max(...entries.map((e) => e.pnl)) : null; - const hasFullLeaderboard = entries.length >= PERPS_WINNERS_CAP; + const hasFullLeaderboard = entries.length >= PERPS_TRADING_MAX_WINNERS; return { totalParticipants, topPnl, hasFullLeaderboard }; }, [leaderboard]); - const isLeaderboardSkeletonVisible = isLeaderboardLoading && !leaderboard; - const isVolumeSkeletonVisible = isVolumeLoading && !totalNotionalVolume; - + const hasStats = stats != null; + const hasTotalVolume = totalNotionalVolume != null; + const isStatsLoading = isLeaderboardLoading && !hasStats; + const isTotalVolumeLoading = isVolumeLoading && !hasTotalVolume; const hasError = - (hasLeaderboardError && !leaderboard) || - (hasVolumeError && !totalNotionalVolume); + (hasLeaderboardError && !hasStats) || (hasVolumeError && !hasTotalVolume); - const totalParticipantsValue = stats - ? stats.totalParticipants.toLocaleString() - : '-'; - - const totalVolumeValue = totalNotionalVolume - ? formatCompactUsd(parseFloat(totalNotionalVolume)) - : '-'; - - const topPnlValue = - stats?.topPnl != null ? formatSignedUsd(stats.topPnl) : '-'; + const retry = useCallback(() => { + onRetryLeaderboard?.(); + onRetryVolume?.(); + }, [onRetryLeaderboard, onRetryVolume]); const topPnlColor = stats?.topPnl != null && stats.topPnl >= 0 ? TextColor.SuccessDefault : TextColor.ErrorDefault; - const winnersValue = stats?.hasFullLeaderboard - ? String(PERPS_WINNERS_CAP) - : '-'; - return ( - - - {strings('rewards.perps_trading_campaign.stats_title')} - - {hasError && ( - { - onRetryLeaderboard?.(); - onRetryVolume?.(); - }} - confirmButtonLabel={strings( - 'rewards.perps_trading_campaign.stats_retry', - )} - /> - )} - - - - - - - - - + ); }; diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.tsx index e91f4a222c6..c83e305a76a 100644 --- a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.tsx +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.tsx @@ -100,7 +100,7 @@ const PerpsTradingCampaignLeaderboard: React.FC< ); if (isLoading && entries.length === 0) { - return ; + return ; } if (hasError && entries.length === 0) { diff --git a/app/components/UI/Rewards/components/Campaigns/PredictThePitchLeaderboard.tsx b/app/components/UI/Rewards/components/Campaigns/PredictThePitchLeaderboard.tsx index 17d815d9702..73302178fb8 100644 --- a/app/components/UI/Rewards/components/Campaigns/PredictThePitchLeaderboard.tsx +++ b/app/components/UI/Rewards/components/Campaigns/PredictThePitchLeaderboard.tsx @@ -16,6 +16,7 @@ import { CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS, } from './CampaignLeaderboard'; import { useCampaignLeaderboardEntries } from '../../hooks/useCampaignLeaderboardEntries'; +import { PREDICT_THE_PITCH_CAMPAIGN_MAX_WINNERS } from '../../utils/predictCampaignConstants'; export const PREDICT_THE_PITCH_LEADERBOARD_TEST_IDS = { CONTAINER: 'predict-the-pitch-leaderboard-container', @@ -75,7 +76,7 @@ const PredictThePitchLeaderboard: React.FC = ({ ); if (isLoading && entries.length === 0) { - return ; + return ; } if (hasError && entries.length === 0) { @@ -145,6 +146,10 @@ const PredictThePitchLeaderboard: React.FC = ({ qualified: currentUser ? (isCurrentUserEligible ?? true) : true, }} isCurrentUser={currentUser} + showCrown={ + !isPreview && + entry.rank <= PREDICT_THE_PITCH_CAMPAIGN_MAX_WINNERS + } isCampaignComplete={isCampaignComplete} formatPrimaryMetric={(e) => formatPercentChange(e.roi)} isPositivePrimaryMetric={(e) => e.roi >= 0} @@ -166,6 +171,10 @@ const PredictThePitchLeaderboard: React.FC = ({ : true, }} isCurrentUser={currentUser} + showCrown={ + !isPreview && + entry.rank <= PREDICT_THE_PITCH_CAMPAIGN_MAX_WINNERS + } isCampaignComplete={isCampaignComplete} formatPrimaryMetric={(e) => formatPercentChange(e.roi)} isPositivePrimaryMetric={(e) => e.roi >= 0} diff --git a/app/components/UI/Rewards/utils/predictCampaignConstants.ts b/app/components/UI/Rewards/utils/predictCampaignConstants.ts new file mode 100644 index 00000000000..c3dc5cba8c7 --- /dev/null +++ b/app/components/UI/Rewards/utils/predictCampaignConstants.ts @@ -0,0 +1,2 @@ +/** Maximum winners for the predict the pitch campaign. */ +export const PREDICT_THE_PITCH_CAMPAIGN_MAX_WINNERS = 20; diff --git a/locales/languages/de.json b/locales/languages/de.json index 3c3fd64b52d..24cae42bbd2 100644 --- a/locales/languages/de.json +++ b/locales/languages/de.json @@ -8902,11 +8902,7 @@ "stats_qualify_for_rank_description": "Handeln Sie {{notionalRemaining}} mehr an Nominalvolumen, um berechtigt zu sein.", "stats_error_title": "Statistiken konnten nicht geladen werden", "stats_error_description": "Wir hatten ein Problem beim Laden Ihrer Statistik. Bitte versuchen Sie es erneut.", - "stats_retry": "Erneut versuchen", - "completed_label_total_participants": "Gesamteilnehmer", - "completed_label_total_volume": "Gesamtvolumen", - "completed_label_top_pnl": "Top-GuV", - "completed_label_winners": "Gewinner" + "stats_retry": "Erneut versuchen" }, "campaigns_preview": { "title": "Kampagnen", diff --git a/locales/languages/el.json b/locales/languages/el.json index 2b7fd5fad70..44e2a4dab8b 100644 --- a/locales/languages/el.json +++ b/locales/languages/el.json @@ -8902,11 +8902,7 @@ "stats_qualify_for_rank_description": "Κάντε συναλλαγές επιπλέον {{notionalRemaining}} σε ονομαστικό όγκο για να πληροίτε τα κριτήρια.", "stats_error_title": "Δεν ήταν δυνατή η φόρτωση των στατιστικών", "stats_error_description": "Παρουσιάστηκε πρόβλημα κατά τη φόρτωση των στατιστικών σας. Προσπαθήστε ξανά.", - "stats_retry": "Επανάληψη", - "completed_label_total_participants": "Συνολικοί συμμετέχοντες", - "completed_label_total_volume": "Συνολικός όγκος συναλλαγών", - "completed_label_top_pnl": "Κορυφαία Κ&Ζ", - "completed_label_winners": "Νικητές" + "stats_retry": "Επανάληψη" }, "campaigns_preview": { "title": "Καμπάνιες", diff --git a/locales/languages/en.json b/locales/languages/en.json index ba0d21ba136..6099202da41 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -9086,6 +9086,7 @@ "total_participants": "Total participants", "total_volume": "Total volume", "top_return": "Top return", + "top_pnl": "Top PnL", "total_winners": "Total winners" }, "campaign_prize_pool": { @@ -9215,11 +9216,7 @@ "stats_qualify_for_rank_description": "Trade {{notionalRemaining}} more in notional volume to become eligible.", "stats_error_title": "Unable to load stats", "stats_error_description": "We had a problem loading your stats. Please try again.", - "stats_retry": "Retry", - "completed_label_total_participants": "Total participants", - "completed_label_total_volume": "Total volume", - "completed_label_top_pnl": "Top PnL", - "completed_label_winners": "Winners" + "stats_retry": "Retry" }, "predict_the_pitch_campaign": { "title": "Predict The Pitch", diff --git a/locales/languages/es.json b/locales/languages/es.json index ef590333800..cf3d0c9f622 100644 --- a/locales/languages/es.json +++ b/locales/languages/es.json @@ -8902,11 +8902,7 @@ "stats_qualify_for_rank_description": "Opera {{notionalRemaining}} más en volumen nocional para poder participar.", "stats_error_title": "No se pueden cargar las estadísticas", "stats_error_description": "Hubo un problema al cargar tus estadísticas. Inténtalo de nuevo.", - "stats_retry": "Reintentar", - "completed_label_total_participants": "Participantes totales", - "completed_label_total_volume": "Volumen total", - "completed_label_top_pnl": "Mejor P&L", - "completed_label_winners": "Ganadores" + "stats_retry": "Reintentar" }, "campaigns_preview": { "title": "Campañas", diff --git a/locales/languages/fr.json b/locales/languages/fr.json index c13d5ce2f29..d35c7b31959 100644 --- a/locales/languages/fr.json +++ b/locales/languages/fr.json @@ -8901,11 +8901,7 @@ "stats_qualify_for_rank_description": "Tradez {{notionalRemaining}} de plus en volume notionnel pour devenir admissible.", "stats_error_title": "Impossible de charger les statistiques", "stats_error_description": "Nous avons rencontré un problème lors du chargement de vos statistiques. Veuillez réessayer.", - "stats_retry": "Réessayer", - "completed_label_total_participants": "Nombre total de participants", - "completed_label_total_volume": "Volume total", - "completed_label_top_pnl": "Meilleur résultat net", - "completed_label_winners": "Gagnants" + "stats_retry": "Réessayer" }, "campaigns_preview": { "title": "Campagnes", diff --git a/locales/languages/hi.json b/locales/languages/hi.json index 9954d05ef4c..1d6d19f44dd 100644 --- a/locales/languages/hi.json +++ b/locales/languages/hi.json @@ -8902,11 +8902,7 @@ "stats_qualify_for_rank_description": "योग्य बनने के लिए नोशनल वॉल्यूम में {{notionalRemaining}} और ट्रेड करें।", "stats_error_title": "स्टैट्स लोड नहीं हो पा रहे हैं", "stats_error_description": "हमें आपके स्टैट्स लोड करने में दिक्कत हुई। कृपया फिर से कोशिश करें।", - "stats_retry": "फिर से प्रयास करें", - "completed_label_total_participants": "कुल प्रतिभागी", - "completed_label_total_volume": "कुल वॉल्यूम", - "completed_label_top_pnl": "शीर्ष PnL", - "completed_label_winners": "विनर" + "stats_retry": "फिर से प्रयास करें" }, "campaigns_preview": { "title": "कैंपेन", diff --git a/locales/languages/id.json b/locales/languages/id.json index c765e891c29..745a8ae7bb3 100644 --- a/locales/languages/id.json +++ b/locales/languages/id.json @@ -8902,11 +8902,7 @@ "stats_qualify_for_rank_description": "Lakukan perdagangan {{notionalRemaining}} lagi dalam volume notional untuk memenuhi syarat.", "stats_error_title": "Tidak dapat memuat statistik", "stats_error_description": "Terjadi masalah saat memuat statistik. Coba lagi.", - "stats_retry": "Coba lagi", - "completed_label_total_participants": "Total peserta", - "completed_label_total_volume": "Volume total", - "completed_label_top_pnl": "PnL Tertinggi", - "completed_label_winners": "Pemenang" + "stats_retry": "Coba lagi" }, "campaigns_preview": { "title": "Kampanye", diff --git a/locales/languages/ja.json b/locales/languages/ja.json index 296fe1c8c9b..cce8e2f4604 100644 --- a/locales/languages/ja.json +++ b/locales/languages/ja.json @@ -8902,11 +8902,7 @@ "stats_qualify_for_rank_description": "名目取引量があと{{notionalRemaining}}で条件が満たされます。", "stats_error_title": "統計を読み込めません", "stats_error_description": "統計の読み込み中に問題が発生しました。もう一度お試しください。", - "stats_retry": "再試行", - "completed_label_total_participants": "参加総数", - "completed_label_total_volume": "総取引量", - "completed_label_top_pnl": "最高損益", - "completed_label_winners": "入賞者" + "stats_retry": "再試行" }, "campaigns_preview": { "title": "キャンペーン", diff --git a/locales/languages/ko.json b/locales/languages/ko.json index d2d3b34ec92..c250f397cfb 100644 --- a/locales/languages/ko.json +++ b/locales/languages/ko.json @@ -8902,11 +8902,7 @@ "stats_qualify_for_rank_description": "참가 자격을 획득하려면 명목 거래량 기준으로 {{notionalRemaining}}만큼 더 거래해야 합니다.", "stats_error_title": "통계를 불러올 수 없음", "stats_error_description": "통계를 불러오는 중에 문제가 발생했습니다. 다시 시도하세요.", - "stats_retry": "다시 시도", - "completed_label_total_participants": "총 참가자 수", - "completed_label_total_volume": "총 거래량", - "completed_label_top_pnl": "손익 상위", - "completed_label_winners": "수상자" + "stats_retry": "다시 시도" }, "campaigns_preview": { "title": "캠페인", diff --git a/locales/languages/pt.json b/locales/languages/pt.json index 19978e38123..4295260b661 100644 --- a/locales/languages/pt.json +++ b/locales/languages/pt.json @@ -8902,11 +8902,7 @@ "stats_qualify_for_rank_description": "Negocie mais {{notionalRemaining}} em volume nocional para se qualificar.", "stats_error_title": "Não foi possível carregar estatísticas", "stats_error_description": "Tivemos um problema ao carregar suas estatísticas. Tente novamente.", - "stats_retry": "Tentar novamente", - "completed_label_total_participants": "Total de participantes", - "completed_label_total_volume": "Valor total", - "completed_label_top_pnl": "Melhor PnL", - "completed_label_winners": "Ganhadores" + "stats_retry": "Tentar novamente" }, "campaigns_preview": { "title": "Campanhas", diff --git a/locales/languages/ru.json b/locales/languages/ru.json index 59fde5147a2..3af27aafe87 100644 --- a/locales/languages/ru.json +++ b/locales/languages/ru.json @@ -8902,11 +8902,7 @@ "stats_qualify_for_rank_description": "Наторгуйте еще {{notionalRemaining}} номинального объема, чтобы получить право на участие.", "stats_error_title": "Не удалось загрузить статистику", "stats_error_description": "У нас возникла проблема с загрузкой вашей статистики. Пожалуйста, попробуйте еще раз.", - "stats_retry": "Повтор", - "completed_label_total_participants": "Всего участников", - "completed_label_total_volume": "Общий объем", - "completed_label_top_pnl": "Лучший показатель П/У", - "completed_label_winners": "Победители" + "stats_retry": "Повтор" }, "campaigns_preview": { "title": "Кампании", diff --git a/locales/languages/tl.json b/locales/languages/tl.json index a21330226c7..46df25de030 100644 --- a/locales/languages/tl.json +++ b/locales/languages/tl.json @@ -8902,11 +8902,7 @@ "stats_qualify_for_rank_description": "Mag-trade ng {{notionalRemaining}} pang notional na dami para maging kwalipikado.", "stats_error_title": "Hindi nai-load ang mga stat", "stats_error_description": "Nagkaproblema kami sa pag-load ng mga stat mo. Subukan ulit.", - "stats_retry": "Subukang muli", - "completed_label_total_participants": "Kabuuang bilang ng mga kalahok", - "completed_label_total_volume": "Kabuuang dami", - "completed_label_top_pnl": "Nangungunang PnL", - "completed_label_winners": "Mga nanalo" + "stats_retry": "Subukang muli" }, "campaigns_preview": { "title": "Mga campaign", diff --git a/locales/languages/tr.json b/locales/languages/tr.json index d59ed2c3b67..6469bbff26b 100644 --- a/locales/languages/tr.json +++ b/locales/languages/tr.json @@ -8902,11 +8902,7 @@ "stats_qualify_for_rank_description": "Uygunluk kazanmak için {{notionalRemaining}} daha nominal hacimli işlem yapın.", "stats_error_title": "İstatistikler yüklenemiyor", "stats_error_description": "İstatistikleriniz yüklenirken bir problem yaşadık. Lütfen tekrar deneyin.", - "stats_retry": "Tekrar Dene", - "completed_label_total_participants": "Toplam katılımcı", - "completed_label_total_volume": "Toplam hacim", - "completed_label_top_pnl": "Toplam Kazanç/Zarar", - "completed_label_winners": "Kazananlar" + "stats_retry": "Tekrar Dene" }, "campaigns_preview": { "title": "Kampanyalar", diff --git a/locales/languages/vi.json b/locales/languages/vi.json index 81295bc2f9c..c487a147fb8 100644 --- a/locales/languages/vi.json +++ b/locales/languages/vi.json @@ -8902,11 +8902,7 @@ "stats_qualify_for_rank_description": "Giao dịch thêm {{notionalRemaining}} khối lượng danh nghĩa để đủ điều kiện tham gia.", "stats_error_title": "Không thể tải số liệu thống kê", "stats_error_description": "Chúng tôi đã gặp sự cố khi tải số liệu thống kê của bạn. Vui lòng thử lại.", - "stats_retry": "Thử lại", - "completed_label_total_participants": "Tổng số người tham gia", - "completed_label_total_volume": "Tổng khối lượng", - "completed_label_top_pnl": "Lãi/Lỗ cao nhất", - "completed_label_winners": "Người chiến thắng" + "stats_retry": "Thử lại" }, "campaigns_preview": { "title": "Chiến dịch", diff --git a/locales/languages/zh.json b/locales/languages/zh.json index c205df459d9..192f91f86a1 100644 --- a/locales/languages/zh.json +++ b/locales/languages/zh.json @@ -8902,11 +8902,7 @@ "stats_qualify_for_rank_description": "还需完成{{notionalRemaining}}名义交易量即可获得资格。", "stats_error_title": "无法加载统计数据", "stats_error_description": "加载您的统计数据时遇到问题。请重试。", - "stats_retry": "重试", - "completed_label_total_participants": "总参与人数", - "completed_label_total_volume": "总交易量", - "completed_label_top_pnl": "最高盈亏(PnL)", - "completed_label_winners": "获胜者" + "stats_retry": "重试" }, "campaigns_preview": { "title": "活动", From beaffba9a080b58605688300db2b92de1fdd7790 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Thu, 11 Jun 2026 12:59:02 +0530 Subject: [PATCH 05/17] fix: remove max options from money deposit pages (#31532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Remove "Max" deposit option from Money deposit page. ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/CONF-1496 ## **Manual testing steps** NA ## **Screenshots/Recordings** NA ## **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** - [ ] 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** > Small confirmation UI change scoped to Money deposits; no auth, payment routing, or transaction logic is modified in this diff. > > **Overview** > Removes the **Max** quick-amount shortcut from the Money account deposit confirmation flow by no longer passing `hasMax` into `CustomAmountInfo` from `MoneyAccountDepositInfo`. > > Users on that screen get the default percentage shortcuts (including **90%** instead of **Max**). The unit test that asserted `hasMax={true}` is removed to match. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 2304a13a929e4b3b62c5ff9ca6466b2837548e16. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../money-account-deposit-info.test.tsx | 10 ---------- .../money-account-deposit-info.tsx | 1 - 2 files changed, 11 deletions(-) diff --git a/app/components/Views/confirmations/components/info/money-account-deposit-info/money-account-deposit-info.test.tsx b/app/components/Views/confirmations/components/info/money-account-deposit-info/money-account-deposit-info.test.tsx index d6dd5d79358..621de65c719 100644 --- a/app/components/Views/confirmations/components/info/money-account-deposit-info/money-account-deposit-info.test.tsx +++ b/app/components/Views/confirmations/components/info/money-account-deposit-info/money-account-deposit-info.test.tsx @@ -71,16 +71,6 @@ describe('MoneyAccountDepositInfo', () => { expect(lastCall.supportAccountSelection).toBe(true); }); - it('passes hasMax=true to CustomAmountInfo', () => { - render(); - - const lastCall = - mockCustomAmountInfo.mock.calls[ - mockCustomAmountInfo.mock.calls.length - 1 - ][0]; - expect(lastCall.hasMax).toBe(true); - }); - it('passes autoSelectFiatPayment and hideAccountSelector from route params', () => { mockUseParams.mockReturnValue({ autoSelectFiatPayment: true }); diff --git a/app/components/Views/confirmations/components/info/money-account-deposit-info/money-account-deposit-info.tsx b/app/components/Views/confirmations/components/info/money-account-deposit-info/money-account-deposit-info.tsx index 566064fa18d..f0a05898b44 100644 --- a/app/components/Views/confirmations/components/info/money-account-deposit-info/money-account-deposit-info.tsx +++ b/app/components/Views/confirmations/components/info/money-account-deposit-info/money-account-deposit-info.tsx @@ -17,7 +17,6 @@ export function MoneyAccountDepositInfo() { Date: Thu, 11 Jun 2026 09:31:18 +0200 Subject: [PATCH 06/17] feat(ci): add per-directive blocking flag to PR template checks (MCWP-649) (#31486) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Restores the blocking behavior that existed before [#30541](https://github.com/MetaMask/metamask-mobile/pull/30541): **only the `CHANGELOG entry:` check fails the CI workflow**. All other semantic checks (description, issue link, manual testing, screenshots, checklist) become informational warnings shown in the sticky comment without blocking the PR. Adds a `blocking=true|false` key to the `mms-check` directive grammar (default `false`). Today only `type=changelog` is tagged `blocking=true`, matching pre-#30541 behavior. Draft and ready-for-review PRs follow identical rules (the `isDraft` preview behavior introduced by #30541 is removed). **Behavior matrix after change:** | Scenario | Exit | `INVALID-PR-TEMPLATE` label | Sticky comment | |---|---|---|---| | Non-`main` base | 0 | cleared | cleared | | All headings present, no failures | 0 | removed | deleted | | All headings present, warnings only | 0 | removed | Warnings section | | All headings present, blocking failure | 1 | removed | Blocking + Warnings sections | | Section heading missing | 0* | **added** | listed under Warnings | | Section heading missing + blocking failure | 1 | **added** | Blocking + Warnings sections | | Draft PR | same rules as ready-for-review | | | \* Unless a blocking semantic failure also exists. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/MCWP-649 ## **Manual testing steps** ```gherkin Feature: PR template CI check blocking behavior Scenario: missing CHANGELOG entry blocks the workflow Given a PR targeting main with no CHANGELOG entry line and no no-changelog label When the PR template check runs Then CI exits 1 And the sticky comment shows a "Blocking" section with the changelog reason And the INVALID-PR-TEMPLATE label is not added Scenario: missing description is a warning only Given a PR targeting main with a valid CHANGELOG entry but an empty description When the PR template check runs Then CI exits 0 And the sticky comment shows a "Warnings" section with the description reason And no INVALID-PR-TEMPLATE label is added Scenario: draft PR follows the same rules as ready-for-review Given a draft PR targeting main with a valid CHANGELOG entry but an empty description When the PR template check runs Then CI exits 0 And the sticky comment shows a "Warnings" section (no special draft treatment) Scenario: PR targeting a non-main branch skips all checks Given a PR targeting a release branch When the PR template check runs Then CI exits 0 with no validation performed ``` ## **Screenshots/Recordings** ### **Before** N/A ### **After** N/A ## **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 - [x] I've tested with a power user scenario - [x] I've instrumented key operations with Sentry traces for production performance metrics ## **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] > **Medium Risk** > Changes merge gating and author-facing CI behavior for all PRs targeting main; misconfigured `blocking` directives could accidentally block or fail to block merges. > > **Overview** > Introduces a **`blocking`** flag on `mms-check` directives (default **`false`**) so each template section can either fail the workflow or only show up as a warning. **Changelog** is the only section tagged **`blocking=true`**, matching behavior before #30541; other semantic checks (description, issue link, manual testing, screenshots, checklist) are warnings that still appear in the sticky comment but do not fail CI. > > The PR template check orchestrator **drops draft-only “informational” mode**—draft and ready-for-review PRs use the same rules. **`INVALID-PR-TEMPLATE`** is applied only when required **section headings** are missing; semantic issues no longer add that label. Exit status is driven by whether any **blocking** failure exists (`setFailed` / exit 1 vs `core.warning` / exit 0). Sticky comments are split into **Blocking** and **Warnings** sections instead of a single draft-dependent message. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit fa07a089f3be2c2342a8df78e016c42e5ce5fdc3. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .github/pull-request-template.md | 7 +- .../check-template-and-add-labels.test.ts | 132 +++++++++++++ .../scripts/check-template-and-add-labels.ts | 59 +++--- .../scripts/shared/pr-template-checks.test.ts | 178 +++++++++++++++++- .github/scripts/shared/pr-template-checks.ts | 26 +-- .../shared/pr-template-comment.test.ts | 71 ++++--- .github/scripts/shared/pr-template-comment.ts | 41 ++-- 7 files changed, 435 insertions(+), 79 deletions(-) diff --git a/.github/pull-request-template.md b/.github/pull-request-template.md index 47795e4b657..b01d65cbdfb 100644 --- a/.github/pull-request-template.md +++ b/.github/pull-request-template.md @@ -19,7 +19,10 @@ markdown and must NOT be removed or edited without updating the validator regist type=manual-testing Section must have real testing steps or an explicit N/A. type=screenshot Section must have evidence (image/URL) or an explicit N/A. type=checklist Section must have all checkboxes consciously checked. - required=true|false Whether a missing/invalid section blocks the PR check. + required=true|false Whether a missing/invalid section runs the validator at all. + blocking=true|false Whether a failure of this check fails the CI workflow. + Default: false — failures are shown as warnings in the sticky + comment but do not block the PR. Sections without a directive are checked for structural presence only. --> @@ -36,7 +39,7 @@ Write a short description of the changes included in this pull request, also inc ## **Changelog** - + '; + const plan = buildValidationPlan(tokenize(md)); + expect(plan[0].blocking).toBe(true); + }); + + it('reads blocking=false from the directive', () => { + const md = '## **Section**\n'; + const plan = buildValidationPlan(tokenize(md)); + expect(plan[0].blocking).toBe(false); + }); + + it('defaults blocking to false when the key is absent', () => { + const md = '## **Section**\n'; + const plan = buildValidationPlan(tokenize(md)); + expect(plan[0].blocking).toBe(false); + }); }); // ─── validatePlanTypes ──────────────────────────────────────────────────────── @@ -622,12 +640,12 @@ describe('validatePlanTypes', () => { const registry = { text: () => ({ ok: true as const }) }; it('does not throw when all types are present in the registry', () => { - const plan = [{ title: '## Foo', type: 'text', required: true }]; + const plan = [{ title: '## Foo', type: 'text', required: true, blocking: false }]; expect(() => validatePlanTypes(plan, registry)).not.toThrow(); }); it('throws for an unknown type', () => { - const plan = [{ title: '## Bar', type: 'unknown-type', required: true }]; + const plan = [{ title: '## Bar', type: 'unknown-type', required: true, blocking: false }]; expect(() => validatePlanTypes(plan, registry)).toThrow( /Unknown mms-check type "unknown-type" in section "## Bar"/, ); @@ -635,9 +653,157 @@ describe('validatePlanTypes', () => { it('throws for the first unknown type in a mixed plan', () => { const plan = [ - { title: '## Good', type: 'text', required: true }, - { title: '## Bad', type: 'typo-type', required: true }, + { title: '## Good', type: 'text', required: true, blocking: false }, + { title: '## Bad', type: 'typo-type', required: true, blocking: false }, ]; expect(() => validatePlanTypes(plan, registry)).toThrow(/typo-type/); }); }); + +// ─── parseDirective — blocking key ──────────────────────────────────────────── + +describe('parseDirective — blocking key', () => { + it('parses blocking=true', () => { + expect(parseDirective('mms-check: type=changelog required=true blocking=true')).toEqual({ + type: 'changelog', + required: 'true', + blocking: 'true', + }); + }); + + it('parses blocking=false', () => { + expect(parseDirective('mms-check: type=text required=true blocking=false')).toEqual({ + type: 'text', + required: 'true', + blocking: 'false', + }); + }); + + it('does not include a blocking key when the field is absent', () => { + const result = parseDirective('mms-check: type=text required=true'); + expect(result).not.toHaveProperty('blocking'); + }); +}); + +// ─── runAllChecks — blocking flag stamping ──────────────────────────────────── + +describe('runAllChecks — blocking flag on failures', () => { + it('stamps blocking:true on the changelog failure when template has blocking=true', () => { + // The real pull-request-template.md tags changelog with blocking=true. + const body = ` +## **Description** + +Real description. + +## **Changelog** + +CHANGELOG entry: + +## **Related issues** + +Fixes: #1 + +## **Manual testing steps** + +\`\`\`gherkin +Feature: ready for review + + Scenario: user opens the PR + Given the author fills the PR + When user submits + Then the validator passes +\`\`\` + +## **Screenshots/Recordings** + +### **Before** + +N/A + +### **After** + +N/A + +## **Pre-merge author checklist** + +- [x] Followed contributor docs +- [x] Completed PR template +- [x] Included tests if applicable +- [x] Documented code with JSDoc if applicable +- [x] Applied right labels + +#### Performance checks (if applicable) + +- [x] Tested on Android +- [x] Tested with power user scenario +- [x] Instrumented with Sentry traces if applicable + +## **Pre-merge reviewer checklist** + +- [ ] Reviewer item +`.trim(); + + const failures = runAllChecks(body, false); + const changelogFailure = failures.find((f) => f.reason.includes('Changelog')); + expect(changelogFailure).toBeDefined(); + expect(changelogFailure?.blocking).toBe(true); + }); + + it('stamps blocking:false on non-blocking failures', () => { + const body = ` +## **Description** + +## **Changelog** + +CHANGELOG entry: Real entry. + +## **Related issues** + +Fixes: #1 + +## **Manual testing steps** + +\`\`\`gherkin +Feature: ready for review + + Scenario: user opens the PR + Given the author fills the PR + When user submits + Then the validator passes +\`\`\` + +## **Screenshots/Recordings** + +### **Before** + +N/A + +### **After** + +N/A + +## **Pre-merge author checklist** + +- [x] Followed contributor docs +- [x] Completed PR template +- [x] Included tests if applicable +- [x] Documented code with JSDoc if applicable +- [x] Applied right labels + +#### Performance checks (if applicable) + +- [x] Tested on Android +- [x] Tested with power user scenario +- [x] Instrumented with Sentry traces if applicable + +## **Pre-merge reviewer checklist** + +- [ ] Reviewer item +`.trim(); + + const failures = runAllChecks(body, false); + const descriptionFailure = failures.find((f) => f.reason.includes('Description')); + expect(descriptionFailure).toBeDefined(); + expect(descriptionFailure?.blocking).toBe(false); + }); +}); diff --git a/.github/scripts/shared/pr-template-checks.ts b/.github/scripts/shared/pr-template-checks.ts index 733f49e8a94..c47c1a9adbf 100644 --- a/.github/scripts/shared/pr-template-checks.ts +++ b/.github/scripts/shared/pr-template-checks.ts @@ -14,7 +14,7 @@ import { tokenize, sectionTokens, Token } from './markdown-tokenizer'; export type PrTemplateCheckResult = | { ok: true } - | { ok: false; reason: string }; + | { ok: false; reason: string; blocking: boolean }; // ─── Validation-plan types ─────────────────────────────────────────────────── @@ -24,6 +24,8 @@ interface ValidationPlanEntry { // Validator type key, must exist in VALIDATORS or module load throws. type: string; required: boolean; + // Whether a failure of this check fails the workflow. Default: false. + blocking: boolean; } type ValidatorContext = { hasNoChangelogLabel: boolean }; @@ -87,13 +89,15 @@ export function buildValidationPlan(tokens: Token[]): ValidationPlanEntry[] { !directiveFoundForCurrent ) { const directive = parseDirective(token.content); - if (directive?.type) { - plan.push({ - title: currentHeading, - type: directive.type, - // Any value other than the explicit string "false" means required. - required: directive.required !== 'false', - }); + if (directive?.type) { + plan.push({ + title: currentHeading, + type: directive.type, + // Any value other than the explicit string "false" means required. + required: directive.required !== 'false', + // Only the explicit string "true" makes a check blocking; default is false. + blocking: directive.blocking === 'true', + }); directiveFoundForCurrent = true; } } @@ -442,14 +446,14 @@ export function hasChangelogEntry( export function runAllChecks( body: string, hasNoChangelogLabel: boolean, -): { ok: false; reason: string }[] { +): { ok: false; reason: string; blocking: boolean }[] { const ctx = { hasNoChangelogLabel }; - const failures: { ok: false; reason: string }[] = []; + const failures: { ok: false; reason: string; blocking: boolean }[] = []; for (const entry of VALIDATION_PLAN) { if (!entry.required) continue; const section = extractSection(body, entry.title) ?? ''; const result = VALIDATORS[entry.type](section, ctx); - if (!result.ok) failures.push(result); + if (!result.ok) failures.push({ ...result, blocking: entry.blocking }); } return failures; } diff --git a/.github/scripts/shared/pr-template-comment.test.ts b/.github/scripts/shared/pr-template-comment.test.ts index 47ce0b40f41..1b7bdcc6c71 100644 --- a/.github/scripts/shared/pr-template-comment.test.ts +++ b/.github/scripts/shared/pr-template-comment.test.ts @@ -52,38 +52,67 @@ function makeOctokit( describe('renderFailureComment', () => { it('contains the static heading', () => { - const result = renderFailureComment(['Reason A'], false); + const result = renderFailureComment({ blocking: ['Reason A'], warning: [] }); expect(result).toContain('PR template — items to address before'); }); - it('lists each reason as a bullet', () => { - const result = renderFailureComment(['First reason', 'Second reason'], false); - expect(result).toContain('- First reason'); - expect(result).toContain('- Second reason'); - }); - it('contains the ready-for-review doc link', () => { - const result = renderFailureComment(['x'], false); + const result = renderFailureComment({ blocking: [], warning: ['x'] }); expect(result).toContain('ready-for-review.md'); }); - it('shows the blocking message when isDraft is false', () => { - const result = renderFailureComment(['x'], false); - expect(result).toContain('blocking'); - expect(result).not.toContain('informational'); + it('does not prepend the sticky marker (that is done by upsertStickyComment)', () => { + const result = renderFailureComment({ blocking: ['x'], warning: [] }); + expect(result).not.toContain(''); }); - it('shows the informational message when isDraft is true', () => { - const result = renderFailureComment(['x'], true); - expect(result).toContain('informational'); - // The draft note mentions "blocking" only in the future-tense clause - // ("will start blocking once…"), not as the primary description. - expect(result).not.toMatch(/^_This check is blocking\./m); + describe('blocking-only failures', () => { + it('shows the Blocking section with the reason', () => { + const result = renderFailureComment({ blocking: ['Missing changelog'], warning: [] }); + expect(result).toContain('**Blocking**'); + expect(result).toContain('- Missing changelog'); + }); + + it('does not show the Warnings section when there are no warnings', () => { + const result = renderFailureComment({ blocking: ['x'], warning: [] }); + expect(result).not.toContain('**Warnings**'); + }); }); - it('does not prepend the sticky marker (that is done by upsertStickyComment)', () => { - const result = renderFailureComment(['x'], false); - expect(result).not.toContain(''); + describe('warning-only failures', () => { + it('shows the Warnings section with the reason', () => { + const result = renderFailureComment({ blocking: [], warning: ['Missing description'] }); + expect(result).toContain('**Warnings**'); + expect(result).toContain('- Missing description'); + }); + + it('does not show the Blocking section when there are no blocking failures', () => { + const result = renderFailureComment({ blocking: [], warning: ['x'] }); + expect(result).not.toContain('**Blocking**'); + }); + }); + + describe('mixed failures', () => { + it('shows both Blocking and Warnings sections', () => { + const result = renderFailureComment({ + blocking: ['Missing changelog'], + warning: ['Missing description'], + }); + expect(result).toContain('**Blocking**'); + expect(result).toContain('- Missing changelog'); + expect(result).toContain('**Warnings**'); + expect(result).toContain('- Missing description'); + }); + + it('lists each reason as a bullet in the correct group', () => { + const result = renderFailureComment({ + blocking: ['B1', 'B2'], + warning: ['W1'], + }); + expect(result).toContain('- B1'); + expect(result).toContain('- B2'); + expect(result).toContain('- W1'); + }); }); }); diff --git a/.github/scripts/shared/pr-template-comment.ts b/.github/scripts/shared/pr-template-comment.ts index b3ce25c1ff3..339cb9709e7 100644 --- a/.github/scripts/shared/pr-template-comment.ts +++ b/.github/scripts/shared/pr-template-comment.ts @@ -10,22 +10,39 @@ const READY_FOR_REVIEW_DOC_URL = 'https://github.com/MetaMask/metamask-mobile/blob/main/docs/readme/ready-for-review.md'; /** - * Build the markdown body of the sticky comment, given the aggregated failure - * reasons and the draft flag. The marker is prepended by `upsertStickyComment` - * so callers never need to think about it. + * Build the markdown body of the sticky comment from grouped failure reasons. + * Blocking failures fail the workflow; warning failures are informational. + * Groups with no entries are omitted so authors never see an empty section. + * The marker is prepended by `upsertStickyComment` so callers never need to + * think about it. */ -export function renderFailureComment( - reasons: string[], - isDraft: boolean, -): string { +export function renderFailureComment({ + blocking, + warning, +}: { + blocking: string[]; + warning: string[]; +}): string { const heading = '### PR template — items to address before "Ready for review"'; - const draftNote = isDraft - ? '_This check is informational while the PR is in draft. It will start blocking once you mark the PR as Ready for review._' - : '_This check is blocking. Address every item below, then push to re-run._'; - const bullets = reasons.map((r) => `- ${r}`).join('\n'); const footer = `See [docs/readme/ready-for-review.md](${READY_FOR_REVIEW_DOC_URL}) for the full Definition of Ready for Review.`; - return [heading, '', draftNote, '', bullets, '', footer].join('\n'); + + const sections: string[] = [heading, '']; + + if (blocking.length > 0) { + sections.push('**Blocking** — these items fail the workflow until fixed:'); + sections.push(blocking.map((r) => `- ${r}`).join('\n')); + sections.push(''); + } + + if (warning.length > 0) { + sections.push('**Warnings** — informational, address before merging:'); + sections.push(warning.map((r) => `- ${r}`).join('\n')); + sections.push(''); + } + + sections.push(footer); + return sections.join('\n'); } /** From f92a4b97878bd89730f74eab9394aa08b38009ed Mon Sep 17 00:00:00 2001 From: Devin Stewart <49423028+Bigshmow@users.noreply.github.com> Date: Thu, 11 Jun 2026 01:43:46 -0600 Subject: [PATCH 07/17] feat: mobile show all trades in the trade list (#31515) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The trade list under a trader position was filtered by the active chart time period (default: 1M), which hid trades that fell outside the window eventhough the API returns them all. Positions opened more than a month ago looked like they had zero trades. `useTraderPositionData` now returns two derivations of the same trade array: - **`allTrades`** — pass-through of `positionParam?.trades`. Fed to `TraderTradesSection` so the list always shows every trade for the position. - **`chartTrades`** — filtered to the active period via `PERIOD_DURATION_MS`. Fed to `TraderPositionChartSection` so the buy/sell dot markers on the price chart still match the visible price window. `TraderPositionView` wires each derivation to its respective consumer. No backend changes — `SocialService.fetchPositionById` already returns all trades. Empty-state copy updated from "No trades for this interval" to "No trades yet" to match the new "always all" behavior. > Note: when TSA-146 (AdvancedChart integration) lands, `chartTrades` will feed> the new `SET_TRADE_MARKERS` bridge message instead of the SVG markers — the derivation stays, only the transport changes. ## **Changelog** CHANGELOG entry: Fixed a bug where the trade list on a trader position only showed trades inside the active chart time period, hiding older trades that still belonged to the position. ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TSA-638 ## **Manual testing steps** ```gherkin Feature: Trader position view — trade list shows all trades Scenario: Trade list is unaffected by chart time period Given I am viewing a trader position whose trades span more than a month And the chart time period is the default (1M) When I switch the chart time period to 1H, 1D, 1W, or All Then the trade list continues to show every trade for the position And only the chart buy/sell dot markers narrow to the selected period Scenario: Position with no trades Given I am viewing a trader position that has no trades When the view renders Then the trade list shows the "No trades yet" empty state ``` ## **Screenshots/Recordings** ### **Before** Screenshot 2026-06-10 at 3 43 43 PM Screenshot 2026-06-10 at 3 43 54 PM ### **After** Screenshot 2026-06-10 at 3 41 50 PM ## **Pre-merge author checklist** - [x] I've followed MetaMask Contributor Docs and MetaMask Mobile Coding Standards. - [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 format if applicable - [x] I've applied the right labels on the PR (see labeling guidelines). 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 to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See trace() for usage and addToken for an example For performance guidelines and tooling, see the Performance Guide. ## **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 data wiring and copy in Social Leaderboard trader position view; no API or auth changes, with tests updated for the new behavior. > > **Overview** > Fixes trader positions where the **trade list** only showed trades inside the active chart window (e.g. default **1M**), so older trades looked missing even though the API returns the full set. > > `useTraderPositionData` now exposes **`allTrades`** (full `position.trades`) and **`chartTrades`** (still filtered by `PERIOD_DURATION_MS` for the selected period). **`TraderPositionView`** passes **`allTrades`** to **`TraderTradesSection`** and **`chartTrades`** to **`TraderPositionChartSection`**, so changing **1H / 1D / 1W / 1M / All** only narrows chart buy/sell markers, not the list. > > Empty-state copy changes from **"No trades for this interval"** to **"No trades yet"** in `en.json`, with matching test updates. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit c7aa599fe3f1ae63cd65bae97fbd7c347d3bcdbb. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../TraderPositionView.test.tsx | 22 +++++++++++-------- .../TraderPositionView/TraderPositionView.tsx | 7 +++--- .../useTraderPositionData.ts | 20 +++++++++++------ locales/languages/en.json | 2 +- 4 files changed, 31 insertions(+), 20 deletions(-) diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx index 5f7b5950a30..18a8a5568eb 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx @@ -246,7 +246,7 @@ describe('TraderPositionView', () => { renderWithProvider(, { state: mockState }); - expect(screen.getByText('No trades for this interval')).toBeOnTheScreen(); + expect(screen.getByText('No trades yet')).toBeOnTheScreen(); }); it('calls goBack when the back button is pressed', () => { @@ -506,9 +506,8 @@ describe('TraderPositionView', () => { }); }); - it('filters trades when switching time periods', async () => { - const fixedNow = new Date('2026-04-21T12:00:00.000Z').getTime(); - const dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(fixedNow); + it('shows all trades regardless of the active time period, but filters chart markers', async () => { + const now = Date.now(); mockRouteParams.position = { ...makeDefaultPosition(), @@ -518,7 +517,7 @@ describe('TraderPositionView', () => { direction: 'buy', tokenAmount: 1000, usdCost: 2200, - timestamp: fixedNow - 30 * 60 * 1000, + timestamp: now - 30 * 60 * 1000, transactionHash: '0xrecent', }, { @@ -526,7 +525,7 @@ describe('TraderPositionView', () => { direction: 'sell', tokenAmount: 500, usdCost: 1100, - timestamp: fixedNow - 2 * 24 * 60 * 60 * 1000, + timestamp: now - 2 * 24 * 60 * 60 * 1000, transactionHash: '0xolder', }, ], @@ -540,10 +539,15 @@ describe('TraderPositionView', () => { fireEvent.press(screen.getByText('1H')); await waitFor(() => { - expect(screen.queryByTestId('trade-row-0xolder')).not.toBeOnTheScreen(); + const chartTrades = + mockTraderPriceChart.mock.calls[ + mockTraderPriceChart.mock.calls.length - 1 + ]?.[0]?.trades; + expect(chartTrades).toHaveLength(1); + expect(chartTrades[0].transactionHash).toBe('0xrecent'); }); - - dateNowSpy.mockRestore(); + expect(screen.getByTestId('trade-row-0xrecent')).toBeOnTheScreen(); + expect(screen.getByTestId('trade-row-0xolder')).toBeOnTheScreen(); }); it('refetches position and profile on pull-to-refresh', async () => { diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx index e2f96bfcdb7..eae222ac5c6 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx @@ -117,7 +117,8 @@ const TraderPositionView = () => { pnlValue, pnlPercent, isPnlPositive, - trades, + allTrades, + chartTrades, activeTimePeriod, setActiveTimePeriod, timePeriods, @@ -339,7 +340,7 @@ const TraderPositionView = () => { priceDiff={priceDiff} isPricesLoading={isPricesLoading} onChartIndexChange={handleChartIndexChange} - trades={trades} + trades={chartTrades} /> { /> diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/useTraderPositionData.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/useTraderPositionData.ts index f3331f718d9..2f4541be09f 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/useTraderPositionData.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/useTraderPositionData.ts @@ -92,8 +92,8 @@ export interface TraderPositionData { pnlPercent: number | null; isPnlPositive: boolean; - // Trades (filtered by active period) - trades: Position['trades']; + allTrades: Position['trades']; + chartTrades: Position['trades']; // Time period activeTimePeriod: TimePeriod; @@ -285,18 +285,23 @@ export function useTraderPositionData( : (positionParam?.pnlPercent ?? null); const isPnlPositive = (pnlValue ?? 0) >= 0; - // ── Trades (filtered by period) ──────────────────────────────────────── + // ── Trades ───────────────────────────────────────────────────────────── - const trades = useMemo(() => { + const allTrades = useMemo( + () => positionParam?.trades ?? [], + [positionParam?.trades], + ); + + const chartTrades = useMemo(() => { const now = Date.now(); - return (positionParam?.trades ?? []).filter((t) => { + return allTrades.filter((t) => { const tsMs = t.timestamp > 0 && t.timestamp < 1e12 ? t.timestamp * 1000 : t.timestamp; return tsMs >= now - PERIOD_DURATION_MS[activeTimePeriod]; }); - }, [positionParam?.trades, activeTimePeriod]); + }, [allTrades, activeTimePeriod]); // ── Return ───────────────────────────────────────────────────────────── @@ -313,7 +318,8 @@ export function useTraderPositionData( pnlValue, pnlPercent, isPnlPositive, - trades, + allTrades, + chartTrades, activeTimePeriod, setActiveTimePeriod: setActiveTimePeriod as (period: TimePeriod) => void, timePeriods: TIME_PERIODS, diff --git a/locales/languages/en.json b/locales/languages/en.json index 6099202da41..702e45b1f98 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1094,7 +1094,7 @@ "buy": "Buy", "bought": "{{name}} bought", "sold": "{{name}} sold", - "no_trades": "No trades for this interval", + "no_trades": "No trades yet", "closed_position": "Closed position", "fallback_title": "Position not available", "fallback_subtitle": "We couldn't load this position right now.", From 2ef79d777b195f2fd651faa5ef28437482ab7a58 Mon Sep 17 00:00:00 2001 From: Nico MASSART Date: Thu, 11 Jun 2026 11:16:15 +0200 Subject: [PATCH 08/17] fix(ci): narrow validator return type to exclude blocking field (#31542) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** A recent change added a `blocking: boolean` field to the `PrTemplateCheckResult` failure variant so `runAllChecks` could signal whether a check is CI-blocking. However, individual validators (e.g. `hasNonEmptyDescription`, `hasChangelogEntry`) were still typed as returning `PrTemplateCheckResult`, which forced them to carry the `blocking` field — a concern they have no business knowing about. This broke TypeScript compilation on every unrelated PR whose CI ran `yarn lint:tsc`, producing: ``` Type '{ ok: false; reason: string; }' is not assignable to type 'PrTemplateCheckResult'. ``` The fix introduces a narrower internal `ValidatorResult` type (without `blocking`) for the 6 individual validators and the `Validator` function alias. `runAllChecks` already stamps `blocking` from the plan entry via `{ ...result, blocking: entry.blocking }`, so `PrTemplateCheckResult` (with `blocking`) remains the correct output type for that public function only. > [!NOTE] > Why neither yarn lint:tsc nor Jest caught the type error > > yarn lint:tsc runs tsc --project tsconfig.json, whose include only covers app/**/*, tests/**/*, and scripts/**/*. The .github/scripts/ directory is entirely outside that project boundary, so tsc never sees those files. The commit hook runs the same command, so it has the same blind spot. > >Jest uses Babel (babel-jest) to transpile TypeScript before running tests. Babel is a transpiler, not a type-checker — it strips all type annotations and runs the plain JavaScript. A type mismatch like returning { ok: false, reason: string } where { ok: false, reason: string, blocking: boolean } is expected is invisible to Babel and therefore invisible to Jest. > >The result: both gates passed, yet the production tsc invoked by the GitHub Actions CI workflow (which does point at .github/scripts/) caught the error when another PR ran against main. > >Fix applied: introduced a narrower ValidatorResult type for the return of individual validator functions (without blocking), keeping blocking only on the PrTemplateCheckResult produced by the orchestrator — eliminating the mismatch. > >Longer-term guard: add a dedicated tsconfig.json inside .github/scripts/ and a CI step that runs tsc --noEmit against it, so these scripts are type-checked on every PR. ## **Changelog** CHANGELOG entry: null ## **Related issues** Refs: #31486 ## **Manual testing steps** ```gherkin Feature: PR template CI check compilation Scenario: TypeScript compilation succeeds Given a developer opens a PR When the CI job runs yarn lint:tsc Then TypeScript compilation completes without errors And the PR is not incorrectly blocked by a type mismatch ``` ## **Screenshots/Recordings** ### **Before** N/A ### **After** N/A ## **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** - [ ] 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. --- .github/scripts/shared/pr-template-checks.ts | 23 +++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/.github/scripts/shared/pr-template-checks.ts b/.github/scripts/shared/pr-template-checks.ts index c47c1a9adbf..d253a9f459c 100644 --- a/.github/scripts/shared/pr-template-checks.ts +++ b/.github/scripts/shared/pr-template-checks.ts @@ -10,8 +10,15 @@ import * as fs from 'fs'; import * as path from 'path'; import { tokenize, sectionTokens, Token } from './markdown-tokenizer'; -// ─── Public result type ────────────────────────────────────────────────────── +// ─── Result types ───────────────────────────────────────────────────────────── +// Returned by individual validators — no knowledge of the blocking flag, which +// is a plan-level concern stamped by runAllChecks. +type ValidatorResult = + | { ok: true } + | { ok: false; reason: string }; + +// Returned by runAllChecks — adds the blocking flag from the plan entry. export type PrTemplateCheckResult = | { ok: true } | { ok: false; reason: string; blocking: boolean }; @@ -29,7 +36,7 @@ interface ValidationPlanEntry { } type ValidatorContext = { hasNoChangelogLabel: boolean }; -type Validator = (section: string, ctx: ValidatorContext) => PrTemplateCheckResult; +type Validator = (section: string, ctx: ValidatorContext) => ValidatorResult; // ─── Template loading (single read at module load) ─────────────────────────── @@ -284,7 +291,7 @@ export function extractSection(body: string, title: string): string | null { export function hasNonEmptyDescription( descriptionSection: string, -): PrTemplateCheckResult { +): ValidatorResult { if (stripHtmlComments(descriptionSection).trim().length === 0) { return { ok: false, @@ -296,7 +303,7 @@ export function hasNonEmptyDescription( export function hasValidIssueLink( relatedIssuesSection: string, -): PrTemplateCheckResult { +): ValidatorResult { // Search only in plain text tokens — a Fixes/Closes/Refs line inside a // fenced code block (e.g. a doc example) must not count as the real link. const textOutsideFences = tokenize(relatedIssuesSection) @@ -321,7 +328,7 @@ export function hasValidIssueLink( export function hasRealManualTesting( manualTestingSection: string, -): PrTemplateCheckResult { +): ValidatorResult { const sanitized = stripHtmlComments(manualTestingSection).trim(); if (sanitized.length === 0) { return { @@ -342,7 +349,7 @@ export function hasRealManualTesting( export function hasScreenshotsOrNa( screenshotsSection: string, -): PrTemplateCheckResult { +): ValidatorResult { // Subheadings exist in every PR (they ship with the template); they don't // count as content. We trust the author for whether the content is // meaningful — CI only enforces "section was filled". @@ -361,7 +368,7 @@ export function hasScreenshotsOrNa( export function hasAllAuthorChecklistChecked( checklistSection: string, -): PrTemplateCheckResult { +): ValidatorResult { // Use only checkbox tokens — checkboxes that appear inside fenced code // blocks are examples or docs, not real checklist items. const checkboxes = tokenize(checklistSection).filter( @@ -404,7 +411,7 @@ export function hasAllAuthorChecklistChecked( export function hasChangelogEntry( changelogSection: string, hasNoChangelogLabel: boolean, -): PrTemplateCheckResult { +): ValidatorResult { if (hasNoChangelogLabel) { return { ok: true }; } From 95bad08784fbccda7ed96c4df756ac3e04f685dc Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Thu, 11 Jun 2026 10:26:18 +0200 Subject: [PATCH 09/17] docs(perps): update MetaMetrics reference with missing events and properties (#31471) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The perps MetaMetrics documentation (`docs/perps/perps-metametrics-reference.md`) had fallen behind the codebase. This PR brings it up to date by: - Adding the undocumented **9th event** `PERPS_ACCOUNT_SETUP` (`'Perp Account Setup'`) with its full property schema (`status`, `abstraction_mode`, `previous_abstraction_mode`, `error_message`). This event is fired from the controller's `HyperLiquidProvider` during unified account (HIP-3) migration. - Adding 4 missing `interaction_type` values to `PERPS_UI_INTERACTION`: `cancel_trade_with_token`, `slippage_config_opened`, `slippage_config_changed`, `slippage_limit_blocked_order`. - Adding the missing `cancel_trade_with_token_toast` screen type to `PERPS_SCREEN_VIEWED`. - Documenting new properties: `max_slippage_pct`, `max_slippage_source`, `estimated_slippage_pct`, `section_viewed`, `location`, `market_insights_displayed`. - Adding missing source values (`home_section`, `market_insights`, `cancel_all_orders_button`), action value (`connection_go_back`), and setting type (`slippage`). - Annotating `competition_banner_engage`/`competition_banner_close` as local constants pending upstream addition to `PERPS_EVENT_VALUE`. - Fixing "Related Files" references to point to the correct `@metamask/perps-controller` npm package paths instead of non-existent local controller files. - Updating event count from 8 to 9 across overview, section heading, and comparison table. ## **Changelog** CHANGELOG entry: null ## **Related issues** Refs: N/A — documentation-only update to match existing code. ## **Manual testing steps** ```gherkin Feature: Perps MetaMetrics documentation accuracy Scenario: Developer reviews perps MetaMetrics reference Given the developer opens docs/perps/perps-metametrics-reference.md When the developer reads the event reference Then 9 consolidated events are documented (including PERPS_ACCOUNT_SETUP) And all interaction_type values match PERPS_EVENT_VALUE.INTERACTION_TYPE in @metamask/perps-controller And all screen_type values match PERPS_EVENT_VALUE.SCREEN_TYPE in @metamask/perps-controller And the Related Files section references the correct npm package paths ``` ## **Screenshots/Recordings** N/A — documentation-only change, no UI impact. ### **Before** N/A ### **After** N/A ## **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** - [ ] 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. --- docs/perps/perps-metametrics-reference.md | 96 +++++++++++++++-------- 1 file changed, 65 insertions(+), 31 deletions(-) diff --git a/docs/perps/perps-metametrics-reference.md b/docs/perps/perps-metametrics-reference.md index 9c778b5bf0e..2c2268d1d23 100644 --- a/docs/perps/perps-metametrics-reference.md +++ b/docs/perps/perps-metametrics-reference.md @@ -2,7 +2,7 @@ ## Overview -MetaMetrics uses **8 consolidated events** with discriminating properties (vs 38+ Sentry traces). Optimizes Segment costs by grouping similar actions into generic events with type properties. +MetaMetrics uses **9 consolidated events** with discriminating properties (vs 38+ Sentry traces). Optimizes Segment costs by grouping similar actions into generic events with type properties. **Example:** `PERPS_SCREEN_VIEWED` with `screen_type: 'trading' | 'withdrawal' | ...` instead of 9 separate screen events. @@ -61,7 +61,7 @@ this.#getMetrics().trackPerpsEvent(PerpsAnalyticsEvent.TradeTransaction, { }); ``` -## 8 Events +## 9 Events ### 1. PERPS_SCREEN_VIEWED @@ -74,7 +74,7 @@ this.#getMetrics().trackPerpsEvent(PerpsAnalyticsEvent.TradeTransaction, { - **Deposit screens:** `'deposit_input'` | `'deposit_review'` - **Market list screens:** `'market_list'` | `'market_list_all'` | `'market_list_crypto'` | `'market_list_stocks'` - **Position management:** `'close_all_positions'` | `'cancel_all_orders'` | `'increase_exposure'` | `'add_margin'` | `'remove_margin'` - - **Other screens:** `'pnl_hero_card'` | `'order_book'` | `'full_screen_chart'` | `'activity'` | `'geo_block_notif'` | `'compliance_block_notif'` + - **Other screens:** `'pnl_hero_card'` | `'order_book'` | `'full_screen_chart'` | `'activity'` | `'geo_block_notif'` | `'compliance_block_notif'` | `'cancel_trade_with_token_toast'` - `asset` (optional): Asset symbol (e.g., `'BTC'`, `'ETH'`) - `direction` (optional): `'long' | 'short'` - `source` (optional): Where user came from @@ -84,8 +84,8 @@ this.#getMetrics().trackPerpsEvent(PerpsAnalyticsEvent.TradeTransaction, { - **Market list sources:** `'perps_market_list_all'` | `'perps_market_list_crypto'` | `'perps_market_list_stocks'` - **Trade/Position sources:** `'trade_screen'` | `'position_screen'` | `'tp_sl_view'` | `'trade_menu_action'` | `'open_position'` | `'trade_details'` - **Explore source:** `'explore'` (from Explore/Trending page) - - **Other sources:** `'tutorial'` | `'perps_tutorial'` | `'close_toast'` | `'position_close_toast'` | `'tooltip'` | `'magnifying_glass'` | `'crypto_button'` | `'stocks_button'` | `'order_book'` | `'full_screen_chart'` | `'stop_loss_prompt_banner'` | `'wallet_home'` | `'wallet_main_action_menu'` | `'homescreen_tab'` | `'perps_asset_screen_no_funds'` - - **Geo-block sources:** `'deposit_button'` | `'withdraw_button'` | `'trade_action'` | `'add_funds_action'` | `'cancel_order'` | `'asset_detail_screen'` | `'close_position_action'` | `'modify_position_action'` | `'order_book_long_button'` | `'order_book_short_button'` | `'order_book_close_button'` | `'order_book_modify_button'` | `'auto_close_action'` | `'adjust_margin_action'` | `'stop_loss_prompt_add_margin'` | `'stop_loss_prompt_set_sl'` | `'close_all_positions_button'` + - **Other sources:** `'tutorial'` | `'perps_tutorial'` | `'close_toast'` | `'position_close_toast'` | `'tooltip'` | `'magnifying_glass'` | `'crypto_button'` | `'stocks_button'` | `'order_book'` | `'full_screen_chart'` | `'stop_loss_prompt_banner'` | `'wallet_home'` | `'wallet_main_action_menu'` | `'homescreen_tab'` | `'perps_asset_screen_no_funds'` | `'home_section'` | `'market_insights'` + - **Geo-block sources:** `'deposit_button'` | `'withdraw_button'` | `'trade_action'` | `'add_funds_action'` | `'cancel_order'` | `'asset_detail_screen'` | `'close_position_action'` | `'modify_position_action'` | `'order_book_long_button'` | `'order_book_short_button'` | `'order_book_close_button'` | `'order_book_modify_button'` | `'auto_close_action'` | `'adjust_margin_action'` | `'stop_loss_prompt_add_margin'` | `'stop_loss_prompt_set_sl'` | `'close_all_positions_button'` | `'cancel_all_orders_button'` - `open_position` (optional): Number of open positions (used for homepage_perps_tab, perps_home, asset_details, order_book, trading, close_all_positions screens; number) - `open_order` (optional): Number of open orders (used for wallet_home_perps_tab, perps_home, asset_details screens; number) - `market_category` (optional): Currently active market filter tab (e.g., `'All'`, `'Crypto'`, `'Stocks'`, `'Commodities'`, `'Forex'`; used for market_list screen) @@ -98,6 +98,7 @@ this.#getMetrics().trackPerpsEvent(PerpsAnalyticsEvent.TradeTransaction, { - `button_clicked` (optional): Button that led to this screen (entry point tracking, see [Entry Point Tracking](#entry-point-tracking)) - `button_location` (optional): Location of the button clicked (entry point tracking, see [Entry Point Tracking](#entry-point-tracking)) - `outage_banner_shown` (optional): Whether the service interruption banner is displayed (boolean, used for perps_home, asset_details, trading screens) +- `market_insights_displayed` (optional): Whether market insights content is displayed on the screen (boolean, used for asset_details screen) - `ab_test_button_color` (optional): Button color test variant (`'control' | 'monochrome'`), only included when test is enabled (for baseline exposure tracking) - Future AB tests: `ab_test_{test_name}` (see [Multiple Concurrent Tests](#multiple-concurrent-tests)) @@ -114,8 +115,9 @@ this.#getMetrics().trackPerpsEvent(PerpsAnalyticsEvent.TradeTransaction, { - **Watchlist interactions:** `'favorite_toggled'` (add/remove from watchlist) - **Position management:** `'add_margin'` | `'remove_margin'` | `'increase_exposure'` | `'reduce_exposure'` | `'flip_position'` | `'contact_support'` | `'stop_loss_one_click_prompt'` - **Hero card interactions:** `'display_hero_card'` | `'share_pnl_hero_card'` - - **Pay-with interactions:** `'payment_token_selector'` | `'payment_method_changed'` -- `action` (optional): Specific action performed: `'connection_retry'` | `'share'` | `'add_margin'` | `'remove_margin'` | `'edit_tp_sl'` | `'create_tp_sl'` | `'create_position'` | `'increase_exposure'` | `'flip_long_to_short'` | `'flip_short_to_long'` + - **Pay-with interactions:** `'payment_token_selector'` | `'payment_method_changed'` | `'cancel_trade_with_token'` + - **Slippage interactions:** `'slippage_config_opened'` | `'slippage_config_changed'` | `'slippage_limit_blocked_order'` +- `action` (optional): Specific action performed: `'connection_retry'` | `'connection_go_back'` | `'share'` | `'add_margin'` | `'remove_margin'` | `'edit_tp_sl'` | `'create_tp_sl'` | `'create_position'` | `'increase_exposure'` | `'flip_long_to_short'` | `'flip_short_to_long'` - `attempt_number` (optional): Retry attempt number when action is 'connection_retry' (number) - `action_type` (optional): `'start_trading'` | `'skip'` | `'stop_loss_set'` | `'take_profit_set'` | `'adl_learn_more'` | `'learn_more'` | `'favorite_market'` | `'unfavorite_market'` (Note: `favorite_market` = add to watchlist, `unfavorite_market` = remove from watchlist) - `asset` (optional): Asset symbol (e.g., `'BTC'`, `'ETH'`) @@ -123,7 +125,7 @@ this.#getMetrics().trackPerpsEvent(PerpsAnalyticsEvent.TradeTransaction, { - `order_size` (optional): Size of the order in tokens (number) - `leverage_used` (optional): Leverage value being used (number) - `order_type` (optional): `'market' | 'limit'` -- `setting_type` (optional): Type of setting changed (e.g., `'leverage'`) +- `setting_type` (optional): Type of setting changed: `'leverage'` | `'slippage'` - `input_method` (optional): How value was entered: `'slider' | 'keyboard' | 'preset' | 'manual' | 'percentage_button'` - `candle_period` (optional): Selected candle period - `favorites_count` (optional): Total number of markets in watchlist after toggle (number, used with `favorite_toggled`) @@ -131,6 +133,11 @@ this.#getMetrics().trackPerpsEvent(PerpsAnalyticsEvent.TradeTransaction, { - `button_location` (optional): Location of the button for entry point tracking (see [Entry Point Tracking](#entry-point-tracking)): `'perps_home'` | `'perps_tutorial'` | `'perps_home_empty_state'` | `'perps_asset_screen'` | `'perps_tab'` | `'trade_menu_action'` | `'wallet_home'` | `'market_list'` | `'screen'` | `'tooltip'` | `'perp_market_details'` | `'order_book'` | `'full_screen_chart'` - `initial_payment_method` (optional): Payment method before change (e.g. `'perps_balance'` or token symbol; used with `payment_method_changed`) - `new_payment_method` (optional): Payment method after change (e.g. `'perps_balance'` or token symbol; used with `payment_method_changed`) +- `max_slippage_pct` (optional): Current max slippage percentage (number, used with slippage interactions) +- `max_slippage_source` (optional): How the slippage value was set: `'default' | 'user_configured'` (used with slippage interactions) +- `estimated_slippage_pct` (optional): Estimated slippage percentage (number, used with `slippage_limit_blocked_order`) +- `section_viewed` (optional): Home section scrolled into view (e.g., `'perps_home_explore_crypto'`, `'perps_home_explore_stocks'`, `'perps_home_activity'`; used with `slide` interaction) +- `location` (optional): Location context for scroll tracking (e.g., `'perps_home'`) - `source` (optional): Source context for favorites (e.g., `'perp_asset_screen'`) - `tab_name` (optional): Tab being viewed (e.g., `'trades'` | `'orders'` | `'funding'` | `'deposits'`) - `screen_name` (optional): Screen name context (e.g., `'connection_error'` | `'perps_hero_card'` | `'perps_activity_history'`) @@ -252,9 +259,36 @@ this.#getMetrics().trackPerpsEvent(PerpsAnalyticsEvent.TradeTransaction, { **Note:** This event is used for both errors (with `error_type` + `error_message`) and warnings (with `warning_message`). Use `PERPS_EVENT_VALUE.ERROR_MESSAGE_KEY` for standardized error message keys (e.g., `insufficient_balance`, `order_failed`, `geo_restriction`). +### 9. PERPS_ACCOUNT_SETUP + +Tracks unified account (HIP-3) migration lifecycle on HyperLiquid. Fired from the controller's `HyperLiquidProvider` during account abstraction mode transitions. Not used directly from UI components. + +**Properties:** + +- `status` (required): Migration outcome + - `'not_applicable'` - Wallet has no HyperLiquid account yet (nothing to migrate) + - `'already_enabled'` - Account is already in a compatible mode (`unifiedAccount` or `portfolioMargin`) + - `'migration_required'` - Account needs migration from a legacy mode + - `'success'` - Migration completed successfully + - `'failed'` - Migration failed (user rejected signature or network error) +- `abstraction_mode` (required): Current or target account abstraction mode (string, e.g., `'unifiedAccount'`, `'dexAbstraction'`, `'default'`, `'disabled'`) +- `previous_abstraction_mode` (optional): Account mode before migration attempt (string, included on success/failure) +- `error_message` (optional): Error description when status is `'failed'` or `'not_applicable'` (e.g., `'no_hl_account'`) + +**Usage (controller-side only):** + +```typescript +// Inside HyperLiquidProvider (via trackPerpsEvent) +this.#deps.metrics.trackPerpsEvent(PerpsAnalyticsEvent.AccountSetup, { + [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.SUCCESS, + [PERPS_EVENT_PROPERTY.PREVIOUS_ABSTRACTION_MODE]: currentMode, + [PERPS_EVENT_PROPERTY.ABSTRACTION_MODE]: 'unifiedAccount', +}); +``` + ## Quick Reference -> **Note:** In code, property names and values are accessed via `PERPS_EVENT_PROPERTY` and `PERPS_EVENT_VALUE` from `@metamask/perps-controller` (defined in `app/controllers/perps/constants/eventNames.ts`). The string values shown in the event sections above are what actually gets sent to Segment. +> **Note:** In code, property names and values are accessed via `PERPS_EVENT_PROPERTY` and `PERPS_EVENT_VALUE` from `@metamask/perps-controller` (source: `packages/perps-controller/src/constants/eventNames.ts` in the [MetaMask/core](https://github.com/MetaMask/core) monorepo). The string values shown in the event sections above are what actually gets sent to Segment. ## Adding Events @@ -446,24 +480,24 @@ Entry point tracking captures how users navigate to screens, enabling analysis o ### Button Clicked Values -| Value | Description | -| ----------------------------- | --------------------------------------------------------- | -| `'deposit'` | Add funds / deposit button | -| `'withdraw'` | Withdraw funds button | -| `'perps_home'` | Navigate to perps home button | -| `'tutorial'` | Learn more / tutorial button | -| `'tooltip'` | Got it button in tooltip bottom sheets | -| `'market_list'` | Market list navigation button | -| `'open_position'` | Tap on a position card | -| `'magnifying_glass'` | Search icon button | -| `'crypto'` | Crypto tab in market list | -| `'stocks'` | Stocks & Commodities tab in market list | -| `'commodities'` | Commodities tab | -| `'forex'` | Forex tab | -| `'new'` | New markets | -| `'give_feedback'` | Give feedback button | -| `'competition_banner_engage'` | User tapped the competition banner to navigate to rewards | -| `'competition_banner_close'` | User dismissed the competition banner | +| Value | Description | +| ----------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| `'deposit'` | Add funds / deposit button | +| `'withdraw'` | Withdraw funds button | +| `'perps_home'` | Navigate to perps home button | +| `'tutorial'` | Learn more / tutorial button | +| `'tooltip'` | Got it button in tooltip bottom sheets | +| `'market_list'` | Market list navigation button | +| `'open_position'` | Tap on a position card | +| `'magnifying_glass'` | Search icon button | +| `'crypto'` | Crypto tab in market list | +| `'stocks'` | Stocks & Commodities tab in market list | +| `'commodities'` | Commodities tab | +| `'forex'` | Forex tab | +| `'new'` | New markets | +| `'give_feedback'` | Give feedback button | +| `'competition_banner_engage'` | User tapped the competition banner to navigate to rewards _(local constant, pending addition to `PERPS_EVENT_VALUE`)_ | +| `'competition_banner_close'` | User dismissed the competition banner _(local constant, pending addition to `PERPS_EVENT_VALUE`)_ | ### Button Location Values @@ -622,7 +656,7 @@ const showAccessRestrictedModal = useCallback(() => { | Sentry | MetaMetrics | | --------------------- | ----------------------- | -| 38+ traces | 8 events | +| 38+ traces | 9 events | | Performance | Behavior | | Technical metrics | Business metrics | | `usePerpsMeasurement` | `usePerpsEventTracking` | @@ -631,7 +665,7 @@ const showAccessRestrictedModal = useCallback(() => { - **Event Tracking Hook**: `app/components/UI/Perps/hooks/usePerpsEventTracking.ts` - **Events**: `app/core/Analytics/MetaMetrics.events.ts` -- **Properties & Values**: `app/controllers/perps/constants/eventNames.ts` (exported via `@metamask/perps-controller` as `PERPS_EVENT_PROPERTY`, `PERPS_EVENT_VALUE`) +- **Properties & Values**: Exported from `@metamask/perps-controller` as `PERPS_EVENT_PROPERTY`, `PERPS_EVENT_VALUE` (source: `packages/perps-controller/src/constants/eventNames.ts` in the [MetaMask/core](https://github.com/MetaMask/core) monorepo) - **Metrics Adapter**: `app/components/UI/Perps/adapters/mobileInfrastructure.ts` (maps `trackPerpsEvent` to MetaMetrics) -- **Controller**: `app/controllers/perps/PerpsController.ts` -- **Trading Service**: `app/controllers/perps/services/TradingService.ts` +- **Controller**: `@metamask/perps-controller` (`PerpsController`, `HyperLiquidProvider`, `TradingService`, `AccountService`) +- **Asset Viewed Funnel**: `app/core/Analytics/trade-transaction-funnel/assetViewedAnalytics.ts` (parallel `ASSET_VIEWED` emission for Perps) From 60874865d505d042c14072be642bc14a0aaa928d Mon Sep 17 00:00:00 2001 From: VGR Date: Thu, 11 Jun 2026 11:53:27 +0200 Subject: [PATCH 10/17] feat(rewards): gate VIP features behind feature flag (#31531) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Gates all VIP rewards surfaces behind the existing app-wide `vipProgramEnabled` remote feature flag (`selectVipProgramEnabled`, `app/selectors/featureFlagController/vipProgram`). This is the broader app flag already consumed by the Perps fee hooks and `useVipTier` — this PR extends the same gate to the rewards controller and the rewards VIP UI surfaces. When the flag is OFF: - The homepage VIP icon does not render, which implicitly prevents navigation into the VIP screens. - The VIP splash page is unreachable (redirects to the dashboard). - `RewardsController` VIP methods short-circuit: `getVIPDashboard` and `getVipTierForAccount` return `null`, and `getPerpsDiscountForAccount` returns `null` (no discount applied). ### Implementation - Threaded an `isVipDisabled` callback into the `RewardsController` constructor, mirroring the existing `isDisabled` rewards-feature gate. Added `isVipFeatureEnabled()` which ANDs the rewards gate with the VIP gate (VIP is a sub-feature of rewards). Exposed it via the messenger (`RewardsController:isVipFeatureEnabled`). - Wired `selectVipProgramEnabled` into the controller init (`rewards-controller/index.ts`) following the exact pattern used for `selectBasicFunctionalityEnabled`. - Gated `RewardsDashboard` VIP icon render and `RewardsVipSplashView` reachability with `selectVipProgramEnabled`. ## **Changelog** CHANGELOG entry: VIP rewards surfaces (home VIP icon, VIP splash page) and VIP controller endpoints (`getVIPDashboard`, `getVipTierForAccount`, `getPerpsDiscountForAccount`) are now gated behind the `vipProgramEnabled` feature flag. ## **Related issues** Refs: N/A ## **Manual testing steps** 1. With `vipProgramEnabled` OFF: open Rewards home as a VIP-eligible subscription — the crown/VIP icon should not appear; deep-linking to the VIP splash route should bounce back to the dashboard; perps fee estimates should show no VIP discount. 2. With `vipProgramEnabled` ON: VIP icon renders for VIP subscriptions, splash is reachable, and the perps VIP discount applies as before. ## **Screenshots/Recordings** N/A — gating only; no new UI. ### **Before** N/A ### **After** N/A ## **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** - [ ] 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. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- > [!NOTE] > **Medium Risk** > Changes when perps VIP discounts and VIP dashboards apply (fee/pricing UX) and add a second gate on rewards VIP navigation; behavior is flag-driven with broad test coverage but touches money-adjacent controller paths. > > **Overview** > VIP rewards behavior is now tied to the remote **`vipProgramEnabled`** flag via **`selectVipProgramEnabled`**, in addition to existing subscription VIP checks. > > **Rewards UI:** The dashboard VIP icon only shows when both the program flag and subscription VIP are on. Splash, main VIP, and tiers views treat **`canViewVip`** as requiring the flag and redirect to the dashboard when it is off (including deep links). > > **RewardsController:** An **`isVipDisabled`** constructor callback (wired from **`selectVipProgramEnabled`** at init, like basic functionality for rewards) drives new **`isVipFeatureEnabled()`** (rewards enabled AND VIP not disabled). VIP APIs **`getVIPDashboard`**, **`getVipTierForAccount`**, and **`getPerpsDiscountForAccount`** short-circuit when VIP is disabled instead of using only **`isRewardsFeatureEnabled`**. > > Tests were updated across dashboard/VIP views, controller init, and controller unit tests for flag-off behavior. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 57ac167f510db20257ceaec7f84b9da160af1123. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Claude Sonnet 4.6 --- .../Rewards/Views/RewardsDashboard.test.tsx | 43 +++++++++ .../UI/Rewards/Views/RewardsDashboard.tsx | 4 +- .../Views/RewardsVipSplashView.test.tsx | 21 ++++ .../UI/Rewards/Views/RewardsVipSplashView.tsx | 6 +- .../Views/RewardsVipTiersView.test.tsx | 25 +++++ .../UI/Rewards/Views/RewardsVipTiersView.tsx | 6 +- .../UI/Rewards/Views/RewardsVipView.test.tsx | 37 ++++++++ .../UI/Rewards/Views/RewardsVipView.tsx | 6 +- .../RewardsController-method-action-types.ts | 13 +++ .../RewardsController.test.ts | 95 +++++++++++++++++++ .../rewards-controller/RewardsController.ts | 25 ++++- .../rewards-controller/index.test.ts | 29 ++++++ .../controllers/rewards-controller/index.ts | 6 ++ 13 files changed, 308 insertions(+), 8 deletions(-) diff --git a/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx b/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx index 5f582f45b20..65f87efd473 100644 --- a/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx +++ b/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx @@ -48,6 +48,10 @@ jest.mock('../../../../selectors/rewards', () => ({ selectRewardsSubscriptionId: jest.fn(), })); +jest.mock('../../../../selectors/featureFlagController/vipProgram', () => ({ + selectVipProgramEnabled: jest.fn(), +})); + jest.mock( '../../../../selectors/multichainAccounts/accountTreeController', () => ({ @@ -65,6 +69,7 @@ import { selectIsCurrentSubscriptionVipEnabled, selectRewardsSubscriptionId, } from '../../../../selectors/rewards'; +import { selectVipProgramEnabled } from '../../../../selectors/featureFlagController/vipProgram'; import { selectSelectedAccountGroup } from '../../../../selectors/multichainAccounts/accountTreeController'; const mockSelectActiveTab = selectActiveTab as jest.MockedFunction< @@ -83,6 +88,10 @@ const mockSelectIsCurrentSubscriptionVipEnabled = selectIsCurrentSubscriptionVipEnabled as jest.MockedFunction< typeof selectIsCurrentSubscriptionVipEnabled >; +const mockSelectVipProgramEnabled = + selectVipProgramEnabled as jest.MockedFunction< + typeof selectVipProgramEnabled + >; const mockSelectHideUnlinkedAccountsBanner = selectHideUnlinkedAccountsBanner as jest.MockedFunction< typeof selectHideUnlinkedAccountsBanner @@ -265,6 +274,7 @@ describe('RewardsDashboard', () => { activeTab: 'campaigns' as const, subscriptionId: 'test-subscription-id', isVipEnabled: false, + isVipProgramEnabled: true, hideUnlinkedAccountsBanner: false, hideCurrentAccountNotOptedInBannerArray: [], selectedAccountGroup: mockSelectedAccountGroup, @@ -334,6 +344,9 @@ describe('RewardsDashboard', () => { mockSelectIsCurrentSubscriptionVipEnabled.mockReturnValue( defaultSelectorValues.isVipEnabled, ); + mockSelectVipProgramEnabled.mockReturnValue( + defaultSelectorValues.isVipProgramEnabled, + ); mockSelectHideUnlinkedAccountsBanner.mockReturnValue( defaultSelectorValues.hideUnlinkedAccountsBanner, ); @@ -366,6 +379,8 @@ describe('RewardsDashboard', () => { return defaultSelectorValues.subscriptionId; if (selector === selectIsCurrentSubscriptionVipEnabled) return defaultSelectorValues.isVipEnabled; + if (selector === selectVipProgramEnabled) + return defaultSelectorValues.isVipProgramEnabled; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) @@ -483,6 +498,7 @@ describe('RewardsDashboard', () => { if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectIsCurrentSubscriptionVipEnabled) return true; + if (selector === selectVipProgramEnabled) return true; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) @@ -498,6 +514,30 @@ describe('RewardsDashboard', () => { expect(getByTestId(REWARDS_VIEW_SELECTORS.VIP_BUTTON)).toBeOnTheScreen(); }); + it('does not render the VIP button when the VIP program flag is off, even if the subscription is VIP', () => { + mockSelectIsCurrentSubscriptionVipEnabled.mockReturnValue(true); + mockUseSelector.mockImplementation((selector) => { + if (selector === selectActiveTab) + return defaultSelectorValues.activeTab; + if (selector === selectRewardsSubscriptionId) + return defaultSelectorValues.subscriptionId; + if (selector === selectIsCurrentSubscriptionVipEnabled) return true; + if (selector === selectVipProgramEnabled) return false; + if (selector === selectHideUnlinkedAccountsBanner) + return defaultSelectorValues.hideUnlinkedAccountsBanner; + if (selector === selectHideCurrentAccountNotOptedInBannerArray) + return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; + if (selector === selectSelectedAccountGroup) + return defaultSelectorValues.selectedAccountGroup; + if (selector === mockHasAcceptedVipInviteSelector) return false; + return undefined; + }); + + const { queryByTestId } = render(); + + expect(queryByTestId(REWARDS_VIEW_SELECTORS.VIP_BUTTON)).toBeNull(); + }); + it('navigates to VIP splash when the invite has not been accepted', () => { mockSelectIsCurrentSubscriptionVipEnabled.mockReturnValue(true); mockUseSelector.mockImplementation((selector) => { @@ -506,6 +546,7 @@ describe('RewardsDashboard', () => { if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectIsCurrentSubscriptionVipEnabled) return true; + if (selector === selectVipProgramEnabled) return true; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) @@ -531,6 +572,7 @@ describe('RewardsDashboard', () => { if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectIsCurrentSubscriptionVipEnabled) return true; + if (selector === selectVipProgramEnabled) return true; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) @@ -1295,6 +1337,7 @@ describe('RewardsDashboard', () => { if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectIsCurrentSubscriptionVipEnabled) return true; + if (selector === selectVipProgramEnabled) return true; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) diff --git a/app/components/UI/Rewards/Views/RewardsDashboard.tsx b/app/components/UI/Rewards/Views/RewardsDashboard.tsx index 0b3a1766f39..c9e7cade7f8 100644 --- a/app/components/UI/Rewards/Views/RewardsDashboard.tsx +++ b/app/components/UI/Rewards/Views/RewardsDashboard.tsx @@ -26,6 +26,7 @@ import { selectIsCurrentSubscriptionVipEnabled, selectRewardsSubscriptionId, } from '../../../../selectors/rewards'; +import { selectVipProgramEnabled } from '../../../../selectors/featureFlagController/vipProgram'; import { useRewardOptinSummary } from '../hooks/useRewardOptinSummary'; import { useRewardDashboardModals, @@ -53,6 +54,7 @@ const RewardsDashboard: React.FC = () => { const tw = useTailwind(); const navigation = useNavigation(); const subscriptionId = useSelector(selectRewardsSubscriptionId); + const isVipProgramEnabled = useSelector(selectVipProgramEnabled); const isVipEnabled = useSelector(selectIsCurrentSubscriptionVipEnabled); const hasAcceptedVipInvite = useSelector( selectHasAcceptedVipInvite(subscriptionId), @@ -288,7 +290,7 @@ const RewardsDashboard: React.FC = () => { - {isVipEnabled && ( + {isVipProgramEnabled && isVipEnabled && ( = {}; @@ -117,6 +119,10 @@ jest.mock('../../../../selectors/rewards', () => ({ selectRewardsSubscriptionId: jest.fn(), })); +jest.mock('../../../../selectors/featureFlagController/vipProgram', () => ({ + selectVipProgramEnabled: jest.fn(), +})); + jest.mock('../../../Views/ErrorBoundary', () => ({ __esModule: true, default: ({ children }: { children: React.ReactNode }) => children, @@ -141,6 +147,7 @@ describe('RewardsVipSplashView', () => { beforeEach(() => { jest.clearAllMocks(); mockIsVipEnabled = true; + mockIsVipProgramEnabled = true; mockCanGoBack = true; mockVipSplashAccepted = {}; mockUseVipDashboard.mockReturnValue({ @@ -155,6 +162,9 @@ describe('RewardsVipSplashView', () => { if (selector === selectIsCurrentSubscriptionVipEnabled) { return mockIsVipEnabled; } + if (selector === selectVipProgramEnabled) { + return mockIsVipProgramEnabled; + } return ( selector as ( @@ -234,4 +244,15 @@ describe('RewardsVipSplashView', () => { StackActions.replace(Routes.REWARDS_DASHBOARD), ); }); + + it('replaces with dashboard when the VIP program flag is off, even if the subscription is VIP', () => { + mockIsVipProgramEnabled = false; + + const { queryByTestId } = render(); + + expect(queryByTestId(VIP_SPLASH_SCREEN_TEST_IDS.CONTAINER)).toBeNull(); + expect(mockNavigateDispatch).toHaveBeenCalledWith( + StackActions.replace(Routes.REWARDS_DASHBOARD), + ); + }); }); diff --git a/app/components/UI/Rewards/Views/RewardsVipSplashView.tsx b/app/components/UI/Rewards/Views/RewardsVipSplashView.tsx index 6e7896ecdae..8c4f109fdf3 100644 --- a/app/components/UI/Rewards/Views/RewardsVipSplashView.tsx +++ b/app/components/UI/Rewards/Views/RewardsVipSplashView.tsx @@ -7,6 +7,7 @@ import { selectIsCurrentSubscriptionVipEnabled, selectRewardsSubscriptionId, } from '../../../../selectors/rewards'; +import { selectVipProgramEnabled } from '../../../../selectors/featureFlagController/vipProgram'; import ErrorBoundary from '../../../Views/ErrorBoundary'; import VipSplashScreen from '../components/Vip/VipSplashScreen'; import { useVipDashboard } from '../hooks/useVipDashboard'; @@ -14,11 +15,14 @@ import { useVipDashboard } from '../hooks/useVipDashboard'; const RewardsVipSplashViewContent: React.FC = () => { const navigation = useNavigation(); const subscriptionId = useSelector(selectRewardsSubscriptionId); + const isVipProgramEnabled = useSelector(selectVipProgramEnabled); const isVipEnabled = useSelector(selectIsCurrentSubscriptionVipEnabled); const hasAcceptedVipInvite = useSelector( selectHasAcceptedVipInvite(subscriptionId), ); - const canViewVip = Boolean(subscriptionId && isVipEnabled); + const canViewVip = Boolean( + isVipProgramEnabled && subscriptionId && isVipEnabled, + ); useVipDashboard(); diff --git a/app/components/UI/Rewards/Views/RewardsVipTiersView.test.tsx b/app/components/UI/Rewards/Views/RewardsVipTiersView.test.tsx index 94256300c69..05b22f09e2a 100644 --- a/app/components/UI/Rewards/Views/RewardsVipTiersView.test.tsx +++ b/app/components/UI/Rewards/Views/RewardsVipTiersView.test.tsx @@ -7,6 +7,7 @@ import { selectIsCurrentSubscriptionVipEnabled, selectRewardsSubscriptionId, } from '../../../../selectors/rewards'; +import { selectVipProgramEnabled } from '../../../../selectors/featureFlagController/vipProgram'; import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView'; import { useVipDashboard } from '../hooks/useVipDashboard'; import type { VipDashboardState } from '../../../../core/Engine/controllers/rewards-controller/types'; @@ -171,6 +172,10 @@ jest.mock('../../../../selectors/rewards', () => ({ selectRewardsSubscriptionId: jest.fn(), })); +jest.mock('../../../../selectors/featureFlagController/vipProgram', () => ({ + selectVipProgramEnabled: jest.fn(), +})); + jest.mock('../../../Views/ErrorBoundary', () => ({ __esModule: true, default: function MockErrorBoundary({ @@ -288,6 +293,7 @@ const mockSubscribed = () => { mockUseSelector.mockImplementation((selector) => { if (selector === selectRewardsSubscriptionId) return 'test-subscription-id'; if (selector === selectIsCurrentSubscriptionVipEnabled) return true; + if (selector === selectVipProgramEnabled) return true; return undefined; }); }; @@ -354,6 +360,25 @@ describe('RewardsVipTiersView', () => { mockUseSelector.mockImplementation((selector) => { if (selector === selectRewardsSubscriptionId) return 'sub'; if (selector === selectIsCurrentSubscriptionVipEnabled) return false; + if (selector === selectVipProgramEnabled) return true; + return undefined; + }); + + const { queryByTestId } = render(); + expect(queryByTestId(REWARDS_VIP_TIERS_VIEW_TEST_IDS.ROOT)).toBeNull(); + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledWith( + StackActions.replace(Routes.REWARDS_DASHBOARD), + ); + }); + }); + + it('redirects to the rewards dashboard when the VIP program flag is off, even for a VIP subscription', async () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectRewardsSubscriptionId) + return 'test-subscription-id'; + if (selector === selectIsCurrentSubscriptionVipEnabled) return true; + if (selector === selectVipProgramEnabled) return false; return undefined; }); diff --git a/app/components/UI/Rewards/Views/RewardsVipTiersView.tsx b/app/components/UI/Rewards/Views/RewardsVipTiersView.tsx index 76dea62772f..38d6705ee21 100644 --- a/app/components/UI/Rewards/Views/RewardsVipTiersView.tsx +++ b/app/components/UI/Rewards/Views/RewardsVipTiersView.tsx @@ -15,6 +15,7 @@ import { selectIsCurrentSubscriptionVipEnabled, selectRewardsSubscriptionId, } from '../../../../selectors/rewards'; +import { selectVipProgramEnabled } from '../../../../selectors/featureFlagController/vipProgram'; import ErrorBoundary from '../../../Views/ErrorBoundary'; import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView'; import { useVipDashboard } from '../hooks/useVipDashboard'; @@ -33,8 +34,11 @@ const RewardsVipTiersViewContent: React.FC = () => { const tw = useTailwind(); const navigation = useNavigation(); const subscriptionId = useSelector(selectRewardsSubscriptionId); + const isVipProgramEnabled = useSelector(selectVipProgramEnabled); const isVipEnabled = useSelector(selectIsCurrentSubscriptionVipEnabled); - const canViewVip = Boolean(subscriptionId && isVipEnabled); + const canViewVip = Boolean( + isVipProgramEnabled && subscriptionId && isVipEnabled, + ); const { dashboard, diff --git a/app/components/UI/Rewards/Views/RewardsVipView.test.tsx b/app/components/UI/Rewards/Views/RewardsVipView.test.tsx index cea1386c26d..20d18d19fac 100644 --- a/app/components/UI/Rewards/Views/RewardsVipView.test.tsx +++ b/app/components/UI/Rewards/Views/RewardsVipView.test.tsx @@ -8,6 +8,7 @@ import { selectIsCurrentSubscriptionVipEnabled, selectRewardsSubscriptionId, } from '../../../../selectors/rewards'; +import { selectVipProgramEnabled } from '../../../../selectors/featureFlagController/vipProgram'; import { REWARDS_VIEW_SELECTORS } from './RewardsView.constants'; import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView'; import { useVipDashboard } from '../hooks/useVipDashboard'; @@ -186,6 +187,10 @@ jest.mock('../../../../selectors/rewards', () => ({ selectRewardsSubscriptionId: jest.fn(), })); +jest.mock('../../../../selectors/featureFlagController/vipProgram', () => ({ + selectVipProgramEnabled: jest.fn(), +})); + jest.mock('../../../Views/ErrorBoundary', () => ({ __esModule: true, default: function MockErrorBoundary({ @@ -346,6 +351,7 @@ const mockSubscribed = () => { mockUseSelector.mockImplementation((selector) => { if (selector === selectRewardsSubscriptionId) return 'test-subscription-id'; if (selector === selectIsCurrentSubscriptionVipEnabled) return true; + if (selector === selectVipProgramEnabled) return true; return ( selector as (state: ReturnType) => unknown )(getRewardsSelectorState()); @@ -667,6 +673,37 @@ describe('RewardsVipView', () => { if (selector === selectIsCurrentSubscriptionVipEnabled) { return false; } + if (selector === selectVipProgramEnabled) { + return true; + } + return undefined; + }); + + const { queryByTestId } = render(); + + expect(queryByTestId(REWARDS_VIEW_SELECTORS.VIP_VIEW)).toBeNull(); + expect(mockUseTrackRewardsPageView).toHaveBeenCalledWith({ + page_type: 'vip', + enabled: false, + }); + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledWith( + StackActions.replace(Routes.REWARDS_DASHBOARD), + ); + }); + }); + + it('redirects to the rewards dashboard when the VIP program flag is off, even for a VIP subscription', async () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectRewardsSubscriptionId) { + return 'test-subscription-id'; + } + if (selector === selectIsCurrentSubscriptionVipEnabled) { + return true; + } + if (selector === selectVipProgramEnabled) { + return false; + } return undefined; }); diff --git a/app/components/UI/Rewards/Views/RewardsVipView.tsx b/app/components/UI/Rewards/Views/RewardsVipView.tsx index cae0a6e57ad..a2805ef72a1 100644 --- a/app/components/UI/Rewards/Views/RewardsVipView.tsx +++ b/app/components/UI/Rewards/Views/RewardsVipView.tsx @@ -24,6 +24,7 @@ import { selectIsCurrentSubscriptionVipEnabled, selectRewardsSubscriptionId, } from '../../../../selectors/rewards'; +import { selectVipProgramEnabled } from '../../../../selectors/featureFlagController/vipProgram'; import ErrorBoundary from '../../../Views/ErrorBoundary'; import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView'; import { useVipDashboard } from '../hooks/useVipDashboard'; @@ -66,8 +67,11 @@ const RewardsVipViewContent: React.FC = () => { const dispatch = useDispatch(); const navigation = useNavigation(); const subscriptionId = useSelector(selectRewardsSubscriptionId); + const isVipProgramEnabled = useSelector(selectVipProgramEnabled); const isVipEnabled = useSelector(selectIsCurrentSubscriptionVipEnabled); - const canViewVip = Boolean(subscriptionId && isVipEnabled); + const canViewVip = Boolean( + isVipProgramEnabled && subscriptionId && isVipEnabled, + ); const referralCode = useSelector(selectReferralCode); const hasAcceptedVipInvite = useSelector( selectHasAcceptedVipInvite(subscriptionId), diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts b/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts index 6e52d5677de..82339272ff1 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts @@ -276,6 +276,18 @@ export type RewardsControllerIsRewardsFeatureEnabledAction = { handler: RewardsController['isRewardsFeatureEnabled']; }; +/** + * Check if the VIP feature is enabled. + * VIP is a sub-feature of rewards, so it requires both the rewards feature + * and the dedicated VIP feature flag to be enabled. + * + * @returns boolean - True if the VIP feature is enabled, false otherwise + */ +export type RewardsControllerIsVipFeatureEnabledAction = { + type: `RewardsController:isVipFeatureEnabled`; + handler: RewardsController['isVipFeatureEnabled']; +}; + /** * Check if there is an active season. * Temporarily hardcoded to false while no season is configured. Callers @@ -864,6 +876,7 @@ export type RewardsControllerMethodActions = | RewardsControllerEstimatePointsAction | RewardsControllerAddPointsEstimateToHistoryAction | RewardsControllerIsRewardsFeatureEnabledAction + | RewardsControllerIsVipFeatureEnabledAction | RewardsControllerHasActiveSeasonAction | RewardsControllerGetSeasonMetadataAction | RewardsControllerGetSeasonStatusAction diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts index c4a05c7bbb8..4e18fe724c3 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts @@ -3318,6 +3318,23 @@ describe('RewardsController', () => { expect(result).toBeNull(); }); + it('returns null when VIP is disabled via isVipDisabled callback', async () => { + const vipDisabledController = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + isDisabled: () => false, + isVipDisabled: () => true, + }); + + const result = await vipDisabledController.getPerpsDiscountForAccount( + CAIP_ACCOUNT_1, + 10, + ); + + expect(result).toBeNull(); + expect(mockMessenger.call).not.toHaveBeenCalled(); + }); + it('returns null for accounts the controller has never seen (unhydrated)', async () => { const result = await controller.getPerpsDiscountForAccount( CAIP_ACCOUNT_2, @@ -3871,6 +3888,21 @@ describe('RewardsController', () => { expect(result).toBeNull(); }); + it('returns null when VIP is disabled via isVipDisabled callback', async () => { + const vipDisabledController = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + isDisabled: () => false, + isVipDisabled: () => true, + }); + + const result = + await vipDisabledController.getVipTierForAccount(CAIP_ACCOUNT_1); + + expect(result).toBeNull(); + expect(mockMessenger.call).not.toHaveBeenCalled(); + }); + it('returns null for accounts the controller has never seen (unhydrated)', async () => { const result = await controller.getVipTierForAccount(CAIP_ACCOUNT_2); expect(result).toBeNull(); @@ -3923,6 +3955,51 @@ describe('RewardsController', () => { }); }); + describe('isVipFeatureEnabled', () => { + it('returns true when neither rewards nor VIP is disabled', () => { + const enabledController = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + isDisabled: () => false, + isVipDisabled: () => false, + }); + + expect(enabledController.isVipFeatureEnabled()).toBe(true); + }); + + it('returns false when VIP is disabled via isVipDisabled callback', () => { + const vipDisabledController = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + isDisabled: () => false, + isVipDisabled: () => true, + }); + + expect(vipDisabledController.isVipFeatureEnabled()).toBe(false); + }); + + it('returns false when rewards is disabled even if VIP is enabled', () => { + const rewardsDisabledController = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + isDisabled: () => true, + isVipDisabled: () => false, + }); + + expect(rewardsDisabledController.isVipFeatureEnabled()).toBe(false); + }); + + it('defaults to enabled when isVipDisabled is not provided', () => { + const defaultController = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + isDisabled: () => false, + }); + + expect(defaultController.isVipFeatureEnabled()).toBe(true); + }); + }); + describe('performSilentAuth', () => { let subscribeCallback: any; const mockInternalAccount: InternalAccount = { @@ -7040,6 +7117,24 @@ describe('RewardsController', () => { }); }); + it('returns null without fetching when VIP is disabled via isVipDisabled callback', async () => { + const vipDisabledController = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + isDisabled: () => false, + isVipDisabled: () => true, + }); + + const result = + await vipDisabledController.getVIPDashboard(mockSubscriptionId); + + expect(result).toBeNull(); + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'RewardsDataService:getVIPDashboard', + mockSubscriptionId, + ); + }); + it('persists fetched VIP dashboard to vipDashboard state', async () => { const apiDashboard = createMockVIPDashboard(); diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.ts index 6bd3155c8f6..a45133a23f3 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.ts @@ -552,6 +552,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'invalidateSubscriptionCache', 'isOptInSupported', 'isRewardsFeatureEnabled', + 'isVipFeatureEnabled', 'linkAccountsToSubscriptionCandidate', 'linkAccountToSubscriptionCandidate', 'logout', @@ -585,6 +586,7 @@ export class RewardsController extends BaseController< { payload: OndoGmCampaignParticipantOutcomeDto; lastFetched: number } > = new Map(); #isDisabled: () => boolean; + #isVipDisabled: () => boolean; #reauthPromises: Map> = new Map(); // Deduplicates concurrent /vip/fees fetches for the same subscriptionId. @@ -752,10 +754,12 @@ export class RewardsController extends BaseController< messenger, state, isDisabled, + isVipDisabled, }: { messenger: RewardsControllerMessenger; state?: Partial; isDisabled?: () => boolean; + isVipDisabled?: () => boolean; }) { super({ name: controllerName, @@ -768,6 +772,7 @@ export class RewardsController extends BaseController< }); this.#isDisabled = isDisabled ?? (() => false); + this.#isVipDisabled = isVipDisabled ?? (() => false); this.messenger.registerMethodActionHandlers( this, @@ -1798,8 +1803,7 @@ export class RewardsController extends BaseController< } async getVipTierForAccount(account: CaipAccountId): Promise { - const rewardsEnabled = this.isRewardsFeatureEnabled(); - if (!rewardsEnabled) return null; + if (!this.isVipFeatureEnabled()) return null; const subscriptionId = this.getActualSubscriptionId(account); if (!subscriptionId) return null; @@ -1841,8 +1845,7 @@ export class RewardsController extends BaseController< account: CaipAccountId, baseFeeBips: number, ): Promise { - const rewardsEnabled = this.isRewardsFeatureEnabled(); - if (!rewardsEnabled) return null; + if (!this.isVipFeatureEnabled()) return null; const vipDiscountBips = await this.#getVipPerpsDiscountBips( account, @@ -2319,6 +2322,18 @@ export class RewardsController extends BaseController< return true; } + /** + * Check if the VIP feature is enabled. + * VIP is a sub-feature of rewards, so it requires both the rewards feature + * and the dedicated VIP feature flag to be enabled. + * @returns boolean - True if the VIP feature is enabled, false otherwise + */ + isVipFeatureEnabled(): boolean { + if (!this.isRewardsFeatureEnabled()) return false; + if (this.#isVipDisabled()) return false; + return true; + } + /** * Check if there is an active season. * Temporarily hardcoded to false while no season is configured. Callers @@ -4490,6 +4505,8 @@ export class RewardsController extends BaseController< async getVIPDashboard( subscriptionId: string, ): Promise { + if (!this.isVipFeatureEnabled()) return null; + return await wrapWithCache({ key: subscriptionId, ttl: VIP_DASHBOARD_CACHE_THRESHOLD_MS, diff --git a/app/core/Engine/controllers/rewards-controller/index.test.ts b/app/core/Engine/controllers/rewards-controller/index.test.ts index 0166c1c552e..f54273a83a1 100644 --- a/app/core/Engine/controllers/rewards-controller/index.test.ts +++ b/app/core/Engine/controllers/rewards-controller/index.test.ts @@ -9,11 +9,13 @@ import { import { rewardsControllerInit } from '.'; import { MOCK_ANY_NAMESPACE, MockAnyNamespace } from '@metamask/messenger'; import { selectBasicFunctionalityEnabled } from '../../../../selectors/settings'; +import { selectVipProgramEnabled } from '../../../../selectors/featureFlagController/vipProgram'; import { isVersionGatedFeatureFlag } from '../../../../util/remoteFeatureFlag'; import type { RemoteFeatureFlagControllerState } from '@metamask/remote-feature-flag-controller'; jest.mock('./RewardsController'); jest.mock('../../../../selectors/settings'); +jest.mock('../../../../selectors/featureFlagController/vipProgram'); jest.mock('../../../../util/remoteFeatureFlag'); describe('rewardsControllerInit', () => { @@ -21,6 +23,7 @@ describe('rewardsControllerInit', () => { const selectBasicFunctionalityEnabledMock = jest.mocked( selectBasicFunctionalityEnabled, ); + const selectVipProgramEnabledMock = jest.mocked(selectVipProgramEnabled); const isVersionGatedFeatureFlagMock = jest.mocked(isVersionGatedFeatureFlag); let initRequestMock: jest.Mocked< @@ -72,6 +75,7 @@ describe('rewardsControllerInit', () => { // Default mock return values selectBasicFunctionalityEnabledMock.mockReturnValue(true); + selectVipProgramEnabledMock.mockReturnValue(true); isVersionGatedFeatureFlagMock.mockReturnValue(false); }); @@ -135,4 +139,29 @@ describe('rewardsControllerInit', () => { expect(isDisabledFn()).toBe(true); }); }); + + describe('isVipDisabled function', () => { + it('returns false when the VIP program is enabled', () => { + selectVipProgramEnabledMock.mockReturnValue(true); + + rewardsControllerInit(initRequestMock); + + const constructorArgs = rewardsControllerClassMock.mock.calls[0][0]; + const isVipDisabledFn = constructorArgs.isVipDisabled as () => boolean; + expect(isVipDisabledFn()).toBe(false); + expect(selectVipProgramEnabledMock).toHaveBeenCalledWith( + initRequestMock.getState(), + ); + }); + + it('returns true when the VIP program is disabled', () => { + selectVipProgramEnabledMock.mockReturnValue(false); + + rewardsControllerInit(initRequestMock); + + const constructorArgs = rewardsControllerClassMock.mock.calls[0][0]; + const isVipDisabledFn = constructorArgs.isVipDisabled as () => boolean; + expect(isVipDisabledFn()).toBe(true); + }); + }); }); diff --git a/app/core/Engine/controllers/rewards-controller/index.ts b/app/core/Engine/controllers/rewards-controller/index.ts index e33ebaf0f3b..171dacfc4cb 100644 --- a/app/core/Engine/controllers/rewards-controller/index.ts +++ b/app/core/Engine/controllers/rewards-controller/index.ts @@ -1,4 +1,5 @@ import { selectBasicFunctionalityEnabled } from '../../../../selectors/settings'; +import { selectVipProgramEnabled } from '../../../../selectors/featureFlagController/vipProgram'; import type { MessengerClientInitFunction } from '../../types'; import { RewardsController, @@ -27,6 +28,10 @@ export const rewardsControllerInit: MessengerClientInitFunction< const isEnabled = selectBasicFunctionalityEnabled(getState()); return !isEnabled; }, + isVipDisabled: () => { + const isVipEnabled = selectVipProgramEnabled(getState()); + return !isVipEnabled; + }, }); return { controller }; @@ -80,6 +85,7 @@ export type { RewardsControllerInvalidateSubscriptionCacheAction, RewardsControllerIsOptInSupportedAction, RewardsControllerIsRewardsFeatureEnabledAction, + RewardsControllerIsVipFeatureEnabledAction, RewardsControllerLinkAccountsToSubscriptionCandidateAction, RewardsControllerLinkAccountToSubscriptionCandidateAction, RewardsControllerLogoutAction, From a08feded07847b044920c80f44d8f6507517c2fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Santos?= Date: Thu, 11 Jun 2026 12:16:11 +0200 Subject: [PATCH 11/17] feat(quickbuy): red/green trade toggle, slider grip haptics, and Figma polish (#31474) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Polishes the QuickBuy (Trader Position) flow and adds slider haptics, addressing the UI nits and haptic-feedback request in [TSA-589](https://consensyssoftware.atlassian.net/browse/TSA-589). **What changed and why:** 1. **Trade-mode toggle — lively red/green sliding fill.** The Buy/Sell toggle's sliding pill now animates its background from vivid green (Buy) to vivid red (Sell), interpolating the color as it slides so the transition tracks the thumb. The active label switches to `PrimaryInverse` for contrast on the saturated fill. 2. **Trade-mode toggle — fixed vertical gap.** The pill previously sat ~1px low (the button's measured `y` double-counted the container border, which an absolute child's `top` already accounts for), leaving a larger dark gap at the top than the bottom. The slider now lives in a borderless/padding-less inner row and fills it with `top: 0` / `bottom: 0`, so the only inset is the uniform outer padding — symmetric top and bottom. 3. **Trade-mode toggle — sized to match Figma.** Buttons now use `BodyMd` text, `px-4`, and `rounded-lg` (8px), bringing the container to the design's 40px height (was ~36px). 4. **Amount slider — grip haptics.** Added `ImpactMoment.SliderGrip` on pick-up (pan start) and drop (pan end), per the ticket, matching the Perps implementation and distinct from the existing per-tick `SliderTick` crossings. 5. **Conversion-rate tag — radius fix.** `rounded-full` → `rounded-md` (6px) to match the design (no longer a full pill). 6. **Pay-with / Receive selector — removed pill background.** The inline selector dropped its `bg-muted` rounded-full pill to match the design, which has no background. Design reference: [Figma — Swap Next, QuickBuy sheet](https://www.figma.com/design/GO9uJiYlhdWRuXJe8dzZfR/Swap-Next?node-id=2236-26517). ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: [TSA-589 — UI nits + Implement haptic feedback on the amount slider](https://consensyssoftware.atlassian.net/browse/TSA-589) ## **Manual testing steps** ```gherkin Feature: QuickBuy toggle, slider haptics, and UI polish Scenario: Trade-mode toggle shows transitioning red/green fill Given the QuickBuy sheet is open in Buy mode Then the sliding pill is green behind the "Buy" label When the user taps "Sell" Then the pill slides across and its background transitions from green to red And the dark gap above and below the pill is equal Scenario: Amount slider grip haptics Given the QuickBuy sheet is open with a sellable balance When the user presses and starts dragging the amount slider Then the device plays a grip haptic on pick-up When the user lifts their finger Then the device plays a grip haptic on drop Scenario: Conversion-rate tag and pay-with selector styling Given the QuickBuy sheet is open Then the conversion-rate tag uses a 6px corner radius (not a full pill) And the "Pay with" / "Receive" selector has no pill background ``` ## **Screenshots/Recordings** image ### **Before** N/A ### **After** N/A ## **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** - [ ] 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. 🤖 Generated with [Claude Code](https://claude.com/claude-code) [TSA-589]: https://consensyssoftware.atlassian.net/browse/TSA-589?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- > [!NOTE] > **Low Risk** > Scoped UI and haptic feedback in the in-development QuickBuy flow; no changes to swap execution, auth, or payment logic. > > **Overview** > Polishes the **QuickBuy** sheet UI to match Figma and adds **amount-slider grip haptics** (TSA-589). > > The **Buy/Sell toggle** now uses a sliding pill that interpolates **green → red** as it moves to Sell, with **PrimaryInverse** labels on the active side. Layout is fixed by moving the pill into a borderless inner row (`top`/`bottom` fill) so vertical gaps are symmetric; buttons are taller (`BodyMd`, `px-4`, `rounded-lg`). Animation runs with **`useNativeDriver: false`** so `backgroundColor` can animate. > > **QuickBuyPercentageSlider** fires **`ImpactMoment.SliderGrip`** on pan start and pan end (separate from existing **SliderTick** at 25/50/75%), with new unit tests. > > Minor styling: **conversion-rate tag** uses **`rounded-md`** instead of a full pill; **Pay with / Receive** row drops the muted pill background on the token selector. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit d9a2a3d90e696d83034e5fd2053899f1129c7a17. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/QuickBuyActionFooter.tsx | 1 - .../QuickBuyPercentageSlider.test.tsx | 46 +++++- .../components/QuickBuyPercentageSlider.tsx | 14 ++ .../QuickBuy/components/QuickBuyRateTag.tsx | 4 +- .../components/QuickBuyTradeModeToggle.tsx | 148 ++++++++++-------- 5 files changed, 148 insertions(+), 65 deletions(-) diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyActionFooter.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyActionFooter.tsx index 07f9706a627..6321ef269d1 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyActionFooter.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyActionFooter.tsx @@ -104,7 +104,6 @@ const QuickBuyActionFooter: React.FC = () => { flexDirection={BoxFlexDirection.Row} alignItems={BoxAlignItems.Center} gap={2} - twClassName="rounded-full bg-muted px-3 py-1" > {pickerToken ? ( networkImage ? ( diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyPercentageSlider.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyPercentageSlider.test.tsx index f41d23d29e9..90638bb5e63 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyPercentageSlider.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyPercentageSlider.test.tsx @@ -6,6 +6,7 @@ import { playImpact, ImpactMoment } from '../../../../../../../util/haptics'; // Capture gesture handlers so tests can invoke them directly. We re-assign // these in beforeEach via the mock factory below. let tapOnEnd: ((event: { x: number }) => void) | undefined; +let panOnStart: (() => void) | undefined; let panOnUpdate: ((event: { x: number }) => void) | undefined; let panOnEnd: ((event: { x: number }) => void) | undefined; @@ -23,6 +24,10 @@ jest.mock('react-native-gesture-handler', () => { }), Pan: () => { const panBuilder = { + onStart: (cb: () => void) => { + panOnStart = cb; + return panBuilder; + }, onUpdate: (cb: (event: { x: number }) => void) => { panOnUpdate = cb; return panBuilder; @@ -59,7 +64,7 @@ jest.mock('react-native-reanimated', () => { jest.mock('../../../../../../../util/haptics', () => ({ playImpact: jest.fn(), - ImpactMoment: { SliderTick: 'sliderTick' }, + ImpactMoment: { SliderTick: 'sliderTick', SliderGrip: 'sliderGrip' }, })); const SLIDER_WIDTH = 200; @@ -89,6 +94,7 @@ describe('QuickBuyPercentageSlider', () => { beforeEach(() => { jest.clearAllMocks(); tapOnEnd = undefined; + panOnStart = undefined; panOnUpdate = undefined; panOnEnd = undefined; }); @@ -278,6 +284,44 @@ describe('QuickBuyPercentageSlider', () => { }); }); + describe('grip haptics', () => { + it('fires a grip haptic when the drag starts and again when it ends', () => { + render( + , + ); + act(() => triggerLayout()); + + act(() => panOnStart?.()); + expect(playImpact).toHaveBeenCalledTimes(1); + expect(playImpact).toHaveBeenLastCalledWith(ImpactMoment.SliderGrip); + + act(() => panOnEnd?.({ x: 100 })); + expect(playImpact).toHaveBeenLastCalledWith(ImpactMoment.SliderGrip); + expect(playImpact).toHaveBeenCalledTimes(2); + }); + + it('does not fire grip haptics when disabled', () => { + render( + , + ); + act(() => triggerLayout()); + + act(() => panOnStart?.()); + act(() => panOnEnd?.({ x: 100 })); + + expect(playImpact).not.toHaveBeenCalled(); + }); + }); + describe('accessibility', () => { it('exposes the current value via accessibilityValue', () => { render( diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyPercentageSlider.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyPercentageSlider.tsx index 09f71b20815..d22efd18270 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyPercentageSlider.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyPercentageSlider.tsx @@ -59,6 +59,14 @@ export function QuickBuyPercentageSlider({ [translateX], ); + // Fired when the user grabs the handle (pan start) and again when they let + // go (pan end) — a tactile "pick up / drop" pair distinct from the per-tick + // SliderTick crossings. + const handleGrip = useCallback(() => { + if (disabled) return; + playImpact(ImpactMoment.SliderGrip); + }, [disabled]); + const checkThresholdCrossing = useCallback((nextValue: number) => { const prevValue = previousValueRef.current; for (const threshold of HAPTIC_THRESHOLDS) { @@ -150,12 +158,18 @@ export function QuickBuyPercentageSlider({ runOnJS(commitFromPosition)(event.x, sliderWidth.value); }), Gesture.Pan() + .onStart(() => { + // Pick up — fire the grip haptic the moment the drag is recognized. + runOnJS(handleGrip)(); + }) .onUpdate((event) => { runOnJS(updateValueFromPosition)(event.x, sliderWidth.value); }) .onEnd((event) => { // Commit the final position when the user lifts their finger. runOnJS(commitFromPosition)(event.x, sliderWidth.value); + // Drop — fire the grip haptic again on release. + runOnJS(handleGrip)(); }), ); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyRateTag.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyRateTag.tsx index 3553ee12b72..92affc832ff 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyRateTag.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyRateTag.tsx @@ -56,8 +56,8 @@ const QuickBuyRateTag: React.FC = ({ diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyTradeModeToggle.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyTradeModeToggle.tsx index 088f1d2f922..1c468fff651 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyTradeModeToggle.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyTradeModeToggle.tsx @@ -20,12 +20,19 @@ import { useTheme } from '../../../../../../../util/theme'; import { playSelection } from '../../../../../../../util/haptics'; const styles = StyleSheet.create({ - container: { + // Inner row owns the relative positioning context. It carries no border or + // padding, so the absolute slider's insets line up 1:1 with the buttons' + // measured frames (the outer border would otherwise offset the slider by its + // width, leaving a larger gap at the top than the bottom). + row: { position: 'relative', }, slider: { position: 'absolute', - borderRadius: 10, + top: 0, + bottom: 0, + // Matches the buttons' rounded-lg (8px) so the fill tucks neatly behind them. + borderRadius: 8, }, }); @@ -53,7 +60,9 @@ const QuickBuyTradeModeToggle: React.FC = ({ if (!buyLayout) return; Animated.spring(slideAnim, { toValue: tradeMode === 'buy' ? 0 : buyLayout.width, - useNativeDriver: true, + // Color interpolation (backgroundColor below) is not supported by the + // native driver, so the slide must run on the JS driver too. + useNativeDriver: false, tension: 180, friction: 20, }).start(); @@ -61,74 +70,91 @@ const QuickBuyTradeModeToggle: React.FC = ({ const sliderWidth = tradeMode === 'buy' ? (buyLayout?.width ?? 0) : sellWidth; + // Transition the slider background from green (buy, translateX 0) to red + // (sell, translateX = buy button width) as it slides across. + const sliderBackgroundColor = + buyLayout && buyLayout.width > 0 + ? slideAnim.interpolate({ + inputRange: [0, buyLayout.width], + outputRange: [colors.success.default, colors.error.default], + }) + : colors.success.default; + return ( - {buyLayout && sliderWidth > 0 && ( - - )} + + {buyLayout && sliderWidth > 0 && ( + + )} - handlePress('buy')} - onLayout={(e) => setBuyLayout(e.nativeEvent.layout)} - accessibilityRole="button" - accessibilityState={{ selected: tradeMode === 'buy' }} - testID="quick-buy-trade-mode-buy" - > - - - {strings('social_leaderboard.quick_buy.buy_label')} - - - + handlePress('buy')} + onLayout={(e) => setBuyLayout(e.nativeEvent.layout)} + accessibilityRole="button" + accessibilityState={{ selected: tradeMode === 'buy' }} + testID="quick-buy-trade-mode-buy" + > + + + {strings('social_leaderboard.quick_buy.buy_label')} + + + - handlePress('sell')} - disabled={!hasSellableBalance} - onLayout={(e) => setSellWidth(e.nativeEvent.layout.width)} - accessibilityRole="button" - accessibilityState={{ - selected: tradeMode === 'sell', - disabled: !hasSellableBalance, - }} - testID="quick-buy-trade-mode-sell" - > - handlePress('sell')} + disabled={!hasSellableBalance} + onLayout={(e) => setSellWidth(e.nativeEvent.layout.width)} + accessibilityRole="button" + accessibilityState={{ + selected: tradeMode === 'sell', + disabled: !hasSellableBalance, + }} + testID="quick-buy-trade-mode-sell" > - - {strings('social_leaderboard.quick_buy.sell_label')} - - - + + {strings('social_leaderboard.quick_buy.sell_label')} + + + + ); }; From 0a549065f424f7b812c290b2316ca9890e34b7ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B3nio=20Regadas?= Date: Thu, 11 Jun 2026 11:30:10 +0100 Subject: [PATCH 12/17] fix: make Select Quote bottom sheet scrollable when 4+ quotes are shown (#31544) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** When the Select Quote bottom sheet displayed 4 or more quotes, the list overflowed the visible area and overlapped the device's rounded corners and home indicator with no way to scroll to the last quote. This fix wraps the quote list in a `ScrollView` (from `react-native-gesture-handler`) in `QuickBuySelectQuoteScreen`, keeping the header sticky above the scrollable content so all quotes are fully accessible regardless of how many are returned. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/31464 ## **Manual testing steps** ```gherkin Feature: Select Quote bottom sheet scrollability Scenario: User can scroll when 4 or more quotes are available Given the user is on a token detail screen (e.g. SOL) with the Quick Buy sheet open And the token has 4 or more bridge/swap quotes available When the user taps the rate row to open the Select Quote screen Then all quotes are visible and the list is scrollable When the user scrolls down Then the last quote is fully visible and does not overlap the device corners or home indicator When the user taps a quote Then the sheet navigates back to the Quote Details screen with the selected quote applied ``` ## **Screenshots/Recordings** ### **Before** N/A ### **After** https://github.com/user-attachments/assets/1b3f0716-c70a-4eca-a194-226a7c161ec6 ## **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 ## **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** > Localized layout change to one Quick Buy screen; no swap, quote selection, or transaction logic is modified. > > **Overview** > Fixes overflow on the Quick Buy **Select Quote** bottom sheet when many bridge quotes are returned by making the quote list scrollable while keeping `QuickBuySubScreenHeader` fixed above it. > > The info text and `QuoteRow` list are wrapped in a `react-native-gesture-handler` `ScrollView` with `flex: 1`, `keyboardShouldPersistTaps="handled"`, and hidden vertical scroll indicators—matching the scroll pattern used on other Quick Buy sub-screens. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 55af21225a96aab07fb9d50ca693639a3925038e. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../QuickBuy/QuickBuySelectQuoteScreen.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuySelectQuoteScreen.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuySelectQuoteScreen.tsx index 3a2810c4dd5..b06039aa3ad 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuySelectQuoteScreen.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuySelectQuoteScreen.tsx @@ -8,6 +8,8 @@ import { TextColor, TextVariant, } from '@metamask/design-system-react-native'; +import { StyleSheet } from 'react-native'; +import { ScrollView } from 'react-native-gesture-handler'; import { fromTokenMinimalUnit } from '../../../../../../util/number/bigint'; import formatFiat from '../../../../../../util/formatFiat'; import { isGaslessQuote } from '../../../../../UI/Bridge/utils/isGaslessQuote'; @@ -16,6 +18,10 @@ import { strings } from '../../../../../../../locales/i18n'; import { useQuickBuyContext } from './useQuickBuyContext'; import QuickBuySubScreenHeader from './components/QuickBuySubScreenHeader'; +const styles = StyleSheet.create({ + scrollView: { flex: 1 }, +}); + const QuickBuySelectQuoteScreen: React.FC = () => { const { sortedQuotes, @@ -92,7 +98,11 @@ const QuickBuySelectQuoteScreen: React.FC = () => { ) : ( - <> + { ))} - + )} ); From 5995ac555153b6e508f4b04b56aab361fd330af4 Mon Sep 17 00:00:00 2001 From: Bryan Fullam Date: Thu, 11 Jun 2026 12:49:37 +0200 Subject: [PATCH 13/17] fix: resolve same-chain Solana post-trade status (#31539) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Same-chain Solana swaps could stay stuck in the post-trade bottom sheet because they do not terminalize through `BridgeStatusController` and their placeholder `TransactionController` entry never reaches `confirmed`. This change keeps the existing bridge/EVM status flow intact, but detects same-chain Solana swaps from the bridge history quote and resolves their terminal status from `MultichainTransactionsController` using the Solana transaction signature. Solana-sourced cross-chain bridges still wait for bridge status, so a confirmed source transaction alone does not mark the bridge as successful. ## **Changelog** CHANGELOG entry: Fixed a bug that caused same-chain Solana swaps to remain stuck as in progress after completion. ## **Related issues** Refs: Slack-reported same-chain Solana post-trade status bug ## **Manual testing steps** ```gherkin Feature: Same-chain Solana post-trade status Scenario: user completes a same-chain Solana swap Given the user submits a same-chain Solana swap When the Solana transaction is confirmed on-chain Then the post-trade bottom sheet changes from in progress to success Scenario: user submits a Solana-sourced cross-chain bridge Given the user submits a Solana-sourced cross-chain bridge When only the source Solana transaction is confirmed Then the post-trade bottom sheet remains in progress until bridge status reaches a terminal state ``` ## **Screenshots/Recordings** ### **Before** N/A - logic-only status resolution fix. ### **After** N/A - logic-only status resolution fix. ## **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** - [ ] 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] > **Medium Risk** > Changes post-trade UX status logic for Solana swaps with new hash fallbacks; scope is limited to the hook and covered by tests, but wrong matching could show success/failure incorrectly. > > **Overview** > Fixes the post-trade bottom sheet staying **in progress** after same-chain Solana swaps complete. > > **`usePostTradeTxStatus`** still uses EVM `TransactionController` and bridge history for most flows. For **non-bridge** trades whose bridge-history quote is **Solana source**, it now looks up the submitted signature (meta hash, `transactionHash`, or bridge `srcChain.txHash`) in **`selectMultichainTransactions`** and maps keyring **Confirmed** / **Failed** to success or failed. **Solana-sourced cross-chain bridges** (`isBridge: true`) are unchanged and keep waiting on bridge status. > > Tests mock multichain transactions and cover success, failure, empty meta hash, cross-chain in-progress, and hash fallback paths. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 675e5d2d4ebb5b9dc9ef3cf2c09c5ab445a0b7d3. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../usePostTradeTxStatus.test.ts | 133 ++++++++++++++++-- .../usePostTradeTxStatus.ts | 68 ++++++++- 2 files changed, 190 insertions(+), 11 deletions(-) diff --git a/app/components/UI/Bridge/components/PostTradeBottomSheet/usePostTradeTxStatus.test.ts b/app/components/UI/Bridge/components/PostTradeBottomSheet/usePostTradeTxStatus.test.ts index c758b2ec411..0646282d4f8 100644 --- a/app/components/UI/Bridge/components/PostTradeBottomSheet/usePostTradeTxStatus.test.ts +++ b/app/components/UI/Bridge/components/PostTradeBottomSheet/usePostTradeTxStatus.test.ts @@ -1,11 +1,18 @@ import { renderHook } from '@testing-library/react-native'; import { StatusTypes as BridgeStatus } from '@metamask/bridge-controller'; +import { + SolScope, + TransactionStatus as KeyringTransactionStatus, +} from '@metamask/keyring-api'; import { TransactionStatus as TxStatus } from '@metamask/transaction-controller'; import { PostTradeStatus as Status } from './PostTradeBottomSheet.types'; import { usePostTradeTxStatus } from './usePostTradeTxStatus'; -let mockTransactionMeta: { status: TxStatus } | undefined; +const SOLANA_MAINNET_CHAIN_ID = 1151111081099710; + +let mockTransactionMeta: { status?: TxStatus; hash?: string } | undefined; let mockBridgeHistory = {}; +let mockNonEvmTransactions: unknown = {}; jest.mock('react-redux', () => ({ useSelector: (selector: (state: unknown) => unknown) => selector({}), @@ -16,37 +23,145 @@ jest.mock('../../../../../selectors/transactionController', () => ({ jest.mock('../../../../../selectors/bridgeStatusController', () => ({ selectBridgeHistoryForAccount: () => mockBridgeHistory, })); +jest.mock('../../../../../selectors/multichain/multichain', () => ({ + selectMultichainTransactions: () => mockNonEvmTransactions, +})); const statusOf = ( txStatus?: TxStatus, bridgeStatus?: BridgeStatus, - isBridge = false, + { + isBridge = false, + metaHash, + srcChainId, + destChainId, + statusSrcTxHash, + transactionHash, + nonEvmTransactions = {}, + }: { + isBridge?: boolean; + metaHash?: string; + srcChainId?: number; + destChainId?: number; + statusSrcTxHash?: string; + transactionHash?: string; + nonEvmTransactions?: unknown; + } = {}, ) => { - mockTransactionMeta = txStatus ? { status: txStatus } : undefined; - mockBridgeHistory = bridgeStatus - ? { 'tx-id': { status: { status: bridgeStatus } } } - : {}; + mockTransactionMeta = + txStatus || metaHash !== undefined + ? { status: txStatus, hash: metaHash } + : undefined; + mockBridgeHistory = + bridgeStatus !== undefined + ? { + 'tx-id': { + quote: { + srcChainId: srcChainId ?? 1, + destChainId: destChainId ?? 1, + }, + status: { + status: bridgeStatus, + srcChain: { txHash: statusSrcTxHash }, + }, + }, + } + : {}; + mockNonEvmTransactions = nonEvmTransactions; const params = { initialStatus: Status.InProgress, isBridge, transactionMetaId: 'tx-id', + transactionHash, }; return renderHook(() => usePostTradeTxStatus(params)).result.current; }; +const solanaTx = (status: KeyringTransactionStatus, id = 'sol-sig') => ({ + 'account-1': { + [SolScope.Mainnet]: { + transactions: [{ id, status }], + }, + }, +}); + describe('usePostTradeTxStatus', () => { it('maps transaction and bridge statuses', () => { expect(statusOf(TxStatus.confirmed)).toBe(Status.Success); expect(statusOf(TxStatus.confirmed, BridgeStatus.UNKNOWN)).toBe( Status.Success, ); - expect(statusOf(TxStatus.confirmed, undefined, true)).toBe( - Status.InProgress, - ); + expect( + statusOf(TxStatus.confirmed, BridgeStatus.UNKNOWN, { + isBridge: true, + srcChainId: 1, + destChainId: 10, + }), + ).toBe(Status.InProgress); expect(statusOf(TxStatus.failed)).toBe(Status.Failed); expect(statusOf(undefined, BridgeStatus.COMPLETE)).toBe(Status.Success); expect(statusOf(undefined, BridgeStatus.FAILED)).toBe(Status.Failed); expect(statusOf(undefined, BridgeStatus.UNKNOWN)).toBe(Status.InProgress); }); + + it('resolves same-chain Solana swaps from multichain transactions', () => { + expect( + statusOf(undefined, BridgeStatus.UNKNOWN, { + srcChainId: SOLANA_MAINNET_CHAIN_ID, + destChainId: SOLANA_MAINNET_CHAIN_ID, + transactionHash: 'sol-sig', + nonEvmTransactions: solanaTx(KeyringTransactionStatus.Confirmed), + }), + ).toBe(Status.Success); + + expect( + statusOf(undefined, BridgeStatus.UNKNOWN, { + metaHash: '', + srcChainId: SOLANA_MAINNET_CHAIN_ID, + destChainId: SOLANA_MAINNET_CHAIN_ID, + transactionHash: 'sol-sig', + nonEvmTransactions: solanaTx(KeyringTransactionStatus.Confirmed), + }), + ).toBe(Status.Success); + + expect( + statusOf(undefined, BridgeStatus.UNKNOWN, { + srcChainId: SOLANA_MAINNET_CHAIN_ID, + destChainId: SOLANA_MAINNET_CHAIN_ID, + transactionHash: 'sol-sig', + nonEvmTransactions: solanaTx(KeyringTransactionStatus.Failed), + }), + ).toBe(Status.Failed); + + expect( + statusOf(undefined, BridgeStatus.UNKNOWN, { + isBridge: true, + srcChainId: SOLANA_MAINNET_CHAIN_ID, + destChainId: 1, + transactionHash: 'sol-sig', + nonEvmTransactions: solanaTx(KeyringTransactionStatus.Confirmed), + }), + ).toBe(Status.InProgress); + + expect( + statusOf(undefined, BridgeStatus.UNKNOWN, { + srcChainId: SOLANA_MAINNET_CHAIN_ID, + destChainId: SOLANA_MAINNET_CHAIN_ID, + statusSrcTxHash: 'sol-sig', + nonEvmTransactions: solanaTx(KeyringTransactionStatus.Confirmed), + }), + ).toBe(Status.Success); + + expect( + statusOf(undefined, BridgeStatus.UNKNOWN, { + srcChainId: SOLANA_MAINNET_CHAIN_ID, + destChainId: SOLANA_MAINNET_CHAIN_ID, + nonEvmTransactions: solanaTx( + KeyringTransactionStatus.Confirmed, + 'tx-id', + ), + }), + ).toBe(Status.Success); + }); }); diff --git a/app/components/UI/Bridge/components/PostTradeBottomSheet/usePostTradeTxStatus.ts b/app/components/UI/Bridge/components/PostTradeBottomSheet/usePostTradeTxStatus.ts index bcc0dcb9905..929f91d45f4 100644 --- a/app/components/UI/Bridge/components/PostTradeBottomSheet/usePostTradeTxStatus.ts +++ b/app/components/UI/Bridge/components/PostTradeBottomSheet/usePostTradeTxStatus.ts @@ -1,11 +1,20 @@ import { useSelector } from 'react-redux'; -import { StatusTypes } from '@metamask/bridge-controller'; +import { + formatChainIdToCaip, + isSolanaChainId, + StatusTypes, +} from '@metamask/bridge-controller'; +import { + TransactionStatus as KeyringTransactionStatus, + type Transaction, +} from '@metamask/keyring-api'; import { TransactionStatus, type TransactionMeta, } from '@metamask/transaction-controller'; import type { RootState } from '../../../../../reducers'; import { selectBridgeHistoryForAccount } from '../../../../../selectors/bridgeStatusController'; +import { selectMultichainTransactions } from '../../../../../selectors/multichain/multichain'; import { selectTransactionMetadataById } from '../../../../../selectors/transactionController'; import { findBridgeHistoryItem } from '../../../../../util/bridge/findBridgeHistoryItem'; import { PostTradeStatus } from './PostTradeBottomSheet.types'; @@ -17,6 +26,37 @@ interface UsePostTradeTxStatusParams { transactionHash?: string; } +const normalizeHash = (hash?: string) => hash || undefined; + +const getMultichainPostTradeStatus = ( + state: RootState, + transactionHash?: string, + chainId?: number, +): PostTradeStatus | undefined => { + if (!transactionHash || !chainId) { + return undefined; + } + + const nonEvmTransactions = selectMultichainTransactions(state); + const sourceScope = formatChainIdToCaip(chainId); + const sourceChainTransactions = Object.values(nonEvmTransactions).flatMap( + (accountTransactions) => + accountTransactions[sourceScope]?.transactions ?? [], + ); + const transaction = sourceChainTransactions.find( + (tx: Transaction) => tx.id === transactionHash, + ); + + if (transaction?.status === KeyringTransactionStatus.Confirmed) { + return PostTradeStatus.Success; + } + if (transaction?.status === KeyringTransactionStatus.Failed) { + return PostTradeStatus.Failed; + } + + return undefined; +}; + export const usePostTradeTxStatus = ({ initialStatus, isBridge, @@ -29,14 +69,34 @@ export const usePostTradeTxStatus = ({ : undefined, ) as TransactionMeta | undefined; const bridgeHistory = useSelector(selectBridgeHistoryForAccount); + const submittedTransactionHash = + normalizeHash(transactionMeta?.hash) ?? normalizeHash(transactionHash); const bridgeHistoryItem = findBridgeHistoryItem({ bridgeHistory, transactionMetaId, transactionActionId: transactionMeta?.actionId, - transactionHash: transactionMeta?.hash ?? transactionHash, + transactionHash: submittedTransactionHash, }); + const quote = bridgeHistoryItem?.quote; + const sourceTransactionHash = + submittedTransactionHash ?? + normalizeHash(bridgeHistoryItem?.status?.srcChain?.txHash) ?? + normalizeHash(transactionMetaId); + // Same-chain Solana swaps never terminalize in `BridgeStatusController`, so + // resolve them from `MultichainTransactionsController` instead + const shouldResolveFromMultichain = Boolean( + !isBridge && quote && isSolanaChainId(quote.srcChainId), + ); + const multichainStatus = useSelector((state: RootState) => + getMultichainPostTradeStatus( + state, + shouldResolveFromMultichain ? sourceTransactionHash : undefined, + quote?.srcChainId, + ), + ); + if (initialStatus === PostTradeStatus.Failed) { return PostTradeStatus.Failed; } @@ -60,6 +120,10 @@ export const usePostTradeTxStatus = ({ return PostTradeStatus.Success; } + if (multichainStatus) { + return multichainStatus; + } + if (!isBridge && transactionStatus === TransactionStatus.confirmed) { return PostTradeStatus.Success; } From 0dd629479b8bc9fb028e384b732d1ea57637e614 Mon Sep 17 00:00:00 2001 From: Alexey Kureev Date: Thu, 11 Jun 2026 12:50:35 +0200 Subject: [PATCH 14/17] feat(money): update send action icon and copy, align add/send funds sheets (MUSD-944) (#31470) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Aligns the Money Home primary actions and both funds bottom sheets with the latest design (MUSD-944): - The Send primary action now uses the `arrow-2-up-right` icon and the label "Send" (previously `swap-horizontal` / "Transfer"). Add and Card are unchanged. - Add funds sheet: the debit card row is relabeled "Debit card or bank" with a bank icon, the mUSD row reads "Your [balance] mUSD" (balance stays dynamic via `useMusdBalance`), the external row reads "External wallet", and the per-row description subtitles are removed per design. - Send funds sheet: the title is now "Send funds to", the coming-soon rows are relabeled "External address" and "Bank", and the "Another account" row uses the `arrow-2-up-right` icon per design. Internal names (`MoneyTransferSheet`, route constants, testIDs, locale keys, analytics constants) are deliberately kept to preserve analytics continuity; only user-facing copy and icons change. Existing MUSD-868 gating logic (hiding/disabling rows based on ramp support and balances) is preserved. Only `en.json` is touched among locale files; translations flow through the localization pipeline. ## **Changelog** CHANGELOG entry: Updated the Money Home Send action icon and label, and refreshed the Add funds and Send funds bottom sheet copy ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/MUSD-944 ## **Manual testing steps** ```gherkin Feature: Money Home send action and funds sheets Scenario: user views the primary actions Given the user is on the Money Home screen When user looks at the primary action row Then the middle action shows the arrow-2-up-right icon with the label "Send", and Add and Card are unchanged Scenario: user opens the Add funds sheet Given the user is on the Money Home screen with an mUSD balance When user taps the "Add" action Then the "Add funds" bottom sheet appears with the rows "Convert crypto", "Debit card or bank", "Your [balance] mUSD" showing the current mUSD balance, and "External wallet" tagged "Coming soon" and non-tappable Scenario: user opens the Send funds sheet Given the user is on the Money Home screen When user taps the "Send" action Then the "Send funds to" bottom sheet appears with the rows "Another account", "Perps account", "Predictions account", and "External address" and "Bank" both tagged "Coming soon" and non-tappable ``` ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/e793fd54-ab86-4776-8f71-86e57bef12e9 ## **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** > User-facing strings and icons only; deposit, transfer, and feature-flag behavior are unchanged. > > **Overview** > Aligns Money Home **Send** and the Add/Send funds bottom sheets with updated design copy and icons (MUSD-944). > > The middle primary action now uses **`Arrow2UpRight`** and the label **Send** (was swap-horizontal / Transfer). **Add funds** drops per-row subtitles, relabels rows (**Debit card or bank** with a bank icon, **Your {{amount}} mUSD**, **External wallet**), and keeps the same deposit/move handlers and ramp gating. **Send funds** uses title **Send funds to**, shorter destination labels (**External address**, **Bank**), and the same arrow icon on **Another account**. English strings in `en.json` and `MoneyAddMoneySheet` tests are updated to match; internal testIDs and analytics identifiers are unchanged. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 94e790a8c8b9007dff94c5fc748704d1fde2b2a3. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../MoneyActionButtonRow.tsx | 2 +- .../MoneyAddMoneySheet.test.tsx | 36 ++++--------------- .../MoneyAddMoneySheet.testIds.ts | 4 --- .../MoneyAddMoneySheet/MoneyAddMoneySheet.tsx | 22 +----------- .../MoneyTransferSheet/MoneyTransferSheet.tsx | 2 +- locales/languages/en.json | 17 ++++----- 6 files changed, 17 insertions(+), 66 deletions(-) diff --git a/app/components/UI/Money/components/MoneyActionButtonRow/MoneyActionButtonRow.tsx b/app/components/UI/Money/components/MoneyActionButtonRow/MoneyActionButtonRow.tsx index 150c7820991..04a22bea22b 100644 --- a/app/components/UI/Money/components/MoneyActionButtonRow/MoneyActionButtonRow.tsx +++ b/app/components/UI/Money/components/MoneyActionButtonRow/MoneyActionButtonRow.tsx @@ -91,7 +91,7 @@ const MoneyActionButtonRow = ({ testID={MoneyActionButtonRowTestIds.ADD_BUTTON} /> { ); expect(getByText('Convert crypto')).toBeOnTheScreen(); - expect(getByText('Deposit funds')).toBeOnTheScreen(); - expect(getByText('Add your $1,203.89 mUSD')).toBeOnTheScreen(); - expect(getByText('Receive from external wallet')).toBeOnTheScreen(); + expect(getByText('Debit card or bank')).toBeOnTheScreen(); + expect(getByText('Your $1,203.89 mUSD')).toBeOnTheScreen(); + expect(getByText('External wallet')).toBeOnTheScreen(); expect(getByText('Coming soon')).toBeOnTheScreen(); expect( getByTestId(MoneyAddMoneySheetTestIds.RECEIVE_EXTERNAL_ROW), @@ -154,28 +154,6 @@ describe('MoneyAddMoneySheet', () => { expect(getByText('Add funds')).toBeOnTheScreen(); }); - it('renders a description under the Convert crypto row', () => { - const { getByTestId, getByText } = renderWithProvider( - , - ); - - expect( - getByTestId(MoneyAddMoneySheetTestIds.CONVERT_CRYPTO_DESCRIPTION), - ).toBeOnTheScreen(); - expect(getByText('From any account')).toBeOnTheScreen(); - }); - - it('renders a description under the Deposit funds row', () => { - const { getByTestId, getByText } = renderWithProvider( - , - ); - - expect( - getByTestId(MoneyAddMoneySheetTestIds.DEPOSIT_FUNDS_DESCRIPTION), - ).toBeOnTheScreen(); - expect(getByText('From debit card or bank')).toBeOnTheScreen(); - }); - it('preserves the locale fiat prefix in the Move mUSD row', () => { (useMusdBalance as jest.Mock).mockReturnValue({ fiatBalanceAggregated: '1500.00', @@ -186,7 +164,7 @@ describe('MoneyAddMoneySheet', () => { }); const { getByText } = renderWithProvider(); - expect(getByText('Add your CA$1,500.00 mUSD')).toBeOnTheScreen(); + expect(getByText('Your CA$1,500.00 mUSD')).toBeOnTheScreen(); }); it('shows the move-mUSD row disabled with the "Add mUSD" label when the selected EVM account has no mUSD tokens or fiat balance', () => { @@ -237,7 +215,7 @@ describe('MoneyAddMoneySheet', () => { expect(mockInitiateDeposit).not.toHaveBeenCalled(); }); - it('shows the move-mUSD row with the "Add your $X mUSD" label when the selected EVM account mUSD fiat balance is positive', () => { + it('shows the move-mUSD row with the "Your $X mUSD" label when the selected EVM account mUSD fiat balance is positive', () => { (useMusdBalance as jest.Mock).mockReturnValue({ fiatBalanceAggregated: '12.34', fiatBalanceAggregatedFormatted: '$12.34', @@ -253,7 +231,7 @@ describe('MoneyAddMoneySheet', () => { expect( getByTestId(MoneyAddMoneySheetTestIds.MOVE_MUSD_OPTION), ).toBeOnTheScreen(); - expect(getByText('Add your $12.34 mUSD')).toBeOnTheScreen(); + expect(getByText('Your $12.34 mUSD')).toBeOnTheScreen(); }); it('shows the move-mUSD row with the token amount when the user has mUSD tokens but rates are unavailable', () => { @@ -272,7 +250,7 @@ describe('MoneyAddMoneySheet', () => { expect( getByTestId(MoneyAddMoneySheetTestIds.MOVE_MUSD_OPTION), ).toBeOnTheScreen(); - expect(getByText('Add your 42.50 mUSD')).toBeOnTheScreen(); + expect(getByText('Your 42.50 mUSD')).toBeOnTheScreen(); }); it('initiates a deposit with autoSelectFiatPayment when Deposit funds is pressed', () => { diff --git a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.testIds.ts b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.testIds.ts index 7b29b060bc7..cee9159c36a 100644 --- a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.testIds.ts +++ b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.testIds.ts @@ -1,11 +1,7 @@ export const MoneyAddMoneySheetTestIds = { CONTAINER: 'money-add-money-sheet', CONVERT_CRYPTO_OPTION: 'money-add-money-sheet-convert-crypto', - CONVERT_CRYPTO_DESCRIPTION: - 'money-add-money-sheet-convert-crypto-description', DEPOSIT_FUNDS_OPTION: 'money-add-money-sheet-deposit-funds', - DEPOSIT_FUNDS_DESCRIPTION: 'money-add-money-sheet-deposit-funds-description', MOVE_MUSD_OPTION: 'money-add-money-sheet-move-musd', - MOVE_MUSD_DESCRIPTION: 'money-add-money-sheet-move-musd-description', RECEIVE_EXTERNAL_ROW: 'money-add-money-sheet-receive-external', }; diff --git a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.tsx b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.tsx index b94eeb5b68d..3497c4bcd85 100644 --- a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.tsx +++ b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.tsx @@ -47,8 +47,6 @@ import { interface Option { label: string; - description?: string; - descriptionTestID?: string; icon: IconName; onPress: () => void; testID: string; @@ -164,8 +162,6 @@ const MoneyAddMoneySheet: React.FC = () => { const baseOptions: Option[] = [ { label: strings('money.add_money_sheet.convert_crypto'), - description: strings('money.add_money_sheet.convert_crypto_description'), - descriptionTestID: MoneyAddMoneySheetTestIds.CONVERT_CRYPTO_DESCRIPTION, icon: IconName.Refresh, onPress: handleConvertCrypto, testID: MoneyAddMoneySheetTestIds.CONVERT_CRYPTO_OPTION, @@ -176,12 +172,7 @@ const MoneyAddMoneySheet: React.FC = () => { ? [ { label: strings('money.add_money_sheet.deposit_funds'), - description: strings( - 'money.add_money_sheet.deposit_funds_description', - ), - descriptionTestID: - MoneyAddMoneySheetTestIds.DEPOSIT_FUNDS_DESCRIPTION, - icon: IconName.AttachMoney, + icon: IconName.Bank, onPress: handleDepositFunds, testID: MoneyAddMoneySheetTestIds.DEPOSIT_FUNDS_OPTION, disabled: !hasNativeFiatProvider, @@ -195,8 +186,6 @@ const MoneyAddMoneySheet: React.FC = () => { ...baseOptions, { label: moveMusdLabel, - description: strings('money.add_money_sheet.move_musd_description'), - descriptionTestID: MoneyAddMoneySheetTestIds.MOVE_MUSD_DESCRIPTION, icon: IconName.Add, onPress: handleMoveMusd, testID: MoneyAddMoneySheetTestIds.MOVE_MUSD_OPTION, @@ -261,15 +250,6 @@ const MoneyAddMoneySheet: React.FC = () => { > {item.label} - {item.description ? ( - - {item.description} - - ) : null} )} diff --git a/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.tsx b/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.tsx index b4caa5ee4df..8d79b0e3a99 100644 --- a/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.tsx +++ b/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.tsx @@ -117,7 +117,7 @@ const MoneyTransferSheet = () => { const activeOptions: ActiveOption[] = [ { label: strings('money.transfer_sheet.between_accounts'), - icon: IconName.SwapHorizontal, + icon: IconName.Arrow2UpRight, onPress: handleBetweenAccounts, testID: MoneyTransferSheetTestIds.BETWEEN_ACCOUNTS_OPTION, }, diff --git a/locales/languages/en.json b/locales/languages/en.json index 702e45b1f98..78185653a36 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -6911,7 +6911,7 @@ }, "action": { "add": "Add", - "transfer": "Transfer", + "transfer": "Send", "card": "Card" }, "earnings": { @@ -6992,13 +6992,10 @@ "add_money_sheet": { "title": "Add funds", "convert_crypto": "Convert crypto", - "convert_crypto_description": "From any account", - "deposit_funds": "Deposit funds", - "deposit_funds_description": "From debit card or bank", - "move_musd": "Add your {{amount}} mUSD", + "deposit_funds": "Debit card or bank", + "move_musd": "Your {{amount}} mUSD", "add_musd": "Add mUSD", - "move_musd_description": "From your balance", - "receive_external": "Receive from external wallet", + "receive_external": "External wallet", "coming_soon": "Coming soon" }, "more_sheet": { @@ -7008,12 +7005,12 @@ "contact_support": "Contact support" }, "transfer_sheet": { - "title": "Transfer funds to", + "title": "Send funds to", "between_accounts": "Another account", "perps_account": "Perps account", "predictions_account": "Predictions account", - "send_external": "Send to external address", - "withdraw_to_bank": "Withdraw to bank" + "send_external": "External address", + "withdraw_to_bank": "Bank" }, "toasts": { "in_progress_title": "Transaction in progress", From fd1d5ad9e9ad6181d20899936bf4e4da2a9bb543 Mon Sep 17 00:00:00 2001 From: Alexey Kureev Date: Thu, 11 Jun 2026 12:53:06 +0200 Subject: [PATCH 15/17] fix(money): route home-card Add/Earn CTA to direct mUSD deposit (MUSD-913, MUSD-935) (#31266) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Removes the funding bottom sheet from the Money balance card's primary CTA on the wallet home screen and routes funding directly into the deposit/Buy flow, and simplifies the CTA so it only ever renders "Earn" or "Add". **What changed** - **MUSD-913** — Tapping the card's Add/Earn CTA no longer opens the `MoneyAddMoneySheet` bottom sheet. It now routes funding directly via the shared `routeAddMoney` from the `useMoneyAccountAddRouting` hook — the **same routing the Money "How it works" Add button uses** — so both entry points share one decision instead of the card carrying its own gate. The decision is based on mUSD holdings: when the user holds mUSD (on any supported chain, or a positive aggregated mUSD fiat balance) it opens the MM Pay deposit screen with `addMusd` intent and the highest-balance mUSD token preselected; otherwise it opens the Ramp **Buy** flow for mUSD. - **MUSD-935** — Removed the "Get Started" CTA variant entirely. The card now always renders "Earn" (zero/empty balance) or "Add" (balance > 0), never "Get Started". **Why** Reduces friction when funding the Money account from the home screen and removes an inconsistent CTA state. ## **Changelog** CHANGELOG entry: Funding the Money account from the home-screen balance card now opens the deposit flow directly instead of a bottom sheet, and the card's button always shows "Earn" or "Add". ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/MUSD-913 Refs: https://consensyssoftware.atlassian.net/browse/MUSD-935 ## **Manual testing steps** ```gherkin Feature: Money home-card funding Scenario: User holding mUSD taps the home-card CTA Given the user holds mUSD on at least one supported chain When the user taps the funding CTA on the Money balance card Then the MM Pay deposit screen opens with addMusd intent and their highest-balance mUSD token preselected And no funding bottom sheet is shown Scenario: User with no mUSD taps the home-card CTA Given the user holds no mUSD When the user taps the funding CTA on the Money balance card Then the Ramp Buy flow opens for adding mUSD And no funding bottom sheet is shown Scenario: CTA never shows "Get Started" Given a new user with a zero Money balance Then the card CTA renders as "Earn" (never "Get Started") ``` ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/2a4f13ce-062b-4661-b7b9-c29e31de4902 ## **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] > **Medium Risk** > Changes wallet-home funding navigation and deposit/Ramp entry points; mistakes could send users to the wrong flow, though logic is centralized and well covered by tests. > > **Overview** > The wallet home **Money balance card** no longer opens the add-money bottom sheet or sends new users to Money home via a **Get started** CTA. **Add** and **Earn** both call shared **`routeAddMoney`** from the new **`useMoneyAccountAddRouting`** hook—the same routing other Money entry points can use. > > That hook chooses the path from mUSD holdings: with mUSD it starts deposit via **`initiateDeposit`** (`addMusd`, highest per-chain balance preselected); without mUSD it opens Ramp **Buy** for the mapped mUSD asset. Button analytics now report **`ramp_buy`** or **`money_deposit`** instead of the add-money sheet. Empty/new-user UI still shows **Earn** (including when the home onboarding stepper is visible); only the container test ID differs for unseen onboarding. > > Tests cover the hook’s branching and updated card navigation/analytics behavior. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 5571f23afb7e539744da6b60c87bc0530e5e9454. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../MoneyBalanceCard.test.tsx | 130 ++++--- .../MoneyBalanceCard/MoneyBalanceCard.tsx | 43 +-- .../UI/Money/constants/moneyEvents.ts | 1 + .../hooks/useMoneyAccountAddRouting.test.ts | 317 ++++++++++++++++++ .../Money/hooks/useMoneyAccountAddRouting.ts | 90 +++++ 5 files changed, 501 insertions(+), 80 deletions(-) create mode 100644 app/components/UI/Money/hooks/useMoneyAccountAddRouting.test.ts create mode 100644 app/components/UI/Money/hooks/useMoneyAccountAddRouting.ts diff --git a/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.test.tsx b/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.test.tsx index 6052f5c0845..02db9fc7c77 100644 --- a/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.test.tsx +++ b/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.test.tsx @@ -11,9 +11,9 @@ import useMoneyAccountInfo from '../../hooks/useMoneyAccountInfo'; import { selectMoneyOnboardingSeen } from '../../../../../reducers/user/selectors'; import { selectWalletHomeOnboardingFlowVisible } from '../../../../../selectors/onboarding'; import { useMoneyNavigation } from '../../hooks/useMoneyNavigation'; +import { useMoneyAccountAddRouting } from '../../hooks/useMoneyAccountAddRouting'; import { useMoneyAnalytics } from '../../hooks/useMoneyAnalytics'; import { - BOTTOM_SHEET_NAMES, COMPONENT_NAMES, MONEY_BUTTON_INTENTS, MONEY_BUTTON_TYPES, @@ -22,9 +22,9 @@ import { SCREEN_NAMES, } from '../../constants/moneyEvents'; +const mockTrackButtonClicked = jest.fn(); const mockTrackComponentViewed = jest.fn(); const mockTrackSurfaceClicked = jest.fn(); -const mockTrackButtonClicked = jest.fn(); const mockTrackTooltipClicked = jest.fn(); jest.mock('../../hooks/useMoneyAnalytics', () => ({ @@ -33,6 +33,7 @@ jest.mock('../../hooks/useMoneyAnalytics', () => ({ const mockNavigate = jest.fn(); const mockNavigateToMoneyHome = jest.fn(); +const mockRouteAddMoney = jest.fn(); jest.mock('@react-navigation/native', () => { const actualReactNavigation = jest.requireActual('@react-navigation/native'); @@ -59,6 +60,11 @@ jest.mock('../../hooks/useMoneyNavigation', () => ({ useMoneyNavigation: jest.fn(), })); +jest.mock('../../hooks/useMoneyAccountAddRouting', () => ({ + __esModule: true, + useMoneyAccountAddRouting: jest.fn(), +})); + jest.mock('../../../../../reducers/user/selectors', () => ({ __esModule: true, selectMoneyOnboardingSeen: jest.fn(), @@ -76,6 +82,7 @@ const mockSelectWalletHomeOnboardingFlowVisible = jest.mocked( selectWalletHomeOnboardingFlowVisible, ); const mockUseMoneyNavigation = jest.mocked(useMoneyNavigation); +const mockUseMoneyAccountAddRouting = jest.mocked(useMoneyAccountAddRouting); const createBalanceMock = ( overrides: Partial> = {}, @@ -133,10 +140,15 @@ describe('MoneyBalanceCard', () => { mockUseMoneyNavigation.mockReturnValue({ navigateToMoneyHome: mockNavigateToMoneyHome, }); + mockRouteAddMoney.mockResolvedValue(undefined); + mockUseMoneyAccountAddRouting.mockReturnValue({ + hasMusdBalance: false, + routeAddMoney: mockRouteAddMoney, + } as unknown as ReturnType); (useMoneyAnalytics as jest.Mock).mockReturnValue({ + trackButtonClicked: mockTrackButtonClicked, trackComponentViewed: mockTrackComponentViewed, trackSurfaceClicked: mockTrackSurfaceClicked, - trackButtonClicked: mockTrackButtonClicked, trackTooltipClicked: mockTrackTooltipClicked, }); }); @@ -211,17 +223,48 @@ describe('MoneyBalanceCard', () => { ).not.toBeOnTheScreen(); }); - it('opens the Add money sheet when Add is pressed', () => { + it('initiates a deposit when Add is pressed', () => { const { getByTestId } = renderWithProvider(); fireEvent.press(getByTestId(MoneyBalanceCardTestIds.ADD_BUTTON)); - expect(mockNavigate).toHaveBeenCalledTimes(1); - expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.MODALS.ROOT, { + expect(mockRouteAddMoney).toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalledWith(Routes.MONEY.MODALS.ROOT, { screen: Routes.MONEY.MODALS.ADD_MONEY_SHEET, }); }); + it('tracks the Add click with the Buy flow redirect target when the wallet holds no mUSD', () => { + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId(MoneyBalanceCardTestIds.ADD_BUTTON)); + + expect(mockTrackButtonClicked).toHaveBeenCalledWith({ + button_type: MONEY_BUTTON_TYPES.TEXT, + button_intent: MONEY_BUTTON_INTENTS.ADD_MONEY, + label_key: 'money.balance_card.add', + redirect_target: SCREEN_NAMES.RAMP_BUY, + }); + }); + + it('tracks the Add click with the deposit redirect target when the wallet holds mUSD', () => { + mockUseMoneyAccountAddRouting.mockReturnValue({ + hasMusdBalance: true, + routeAddMoney: mockRouteAddMoney, + } as unknown as ReturnType); + + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId(MoneyBalanceCardTestIds.ADD_BUTTON)); + + expect(mockTrackButtonClicked).toHaveBeenCalledWith({ + button_type: MONEY_BUTTON_TYPES.TEXT, + button_intent: MONEY_BUTTON_INTENTS.ADD_MONEY, + label_key: 'money.balance_card.add', + redirect_target: SCREEN_NAMES.MONEY_DEPOSIT, + }); + }); + it('renders the label', () => { const { getByTestId } = renderWithProvider(); @@ -267,16 +310,29 @@ describe('MoneyBalanceCard', () => { ).not.toBeOnTheScreen(); }); - it('opens the Add money sheet when Earn is pressed', () => { + it('routes add money when Earn is pressed', () => { const { getByTestId } = renderWithProvider(); fireEvent.press(getByTestId(MoneyBalanceCardTestIds.EARN_BUTTON)); - expect(mockNavigate).toHaveBeenCalledTimes(1); - expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.MODALS.ROOT, { + expect(mockRouteAddMoney).toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalledWith(Routes.MONEY.MODALS.ROOT, { screen: Routes.MONEY.MODALS.ADD_MONEY_SHEET, }); }); + + it('tracks the Earn click with the empty-state label key', () => { + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId(MoneyBalanceCardTestIds.EARN_BUTTON)); + + expect(mockTrackButtonClicked).toHaveBeenCalledWith({ + button_type: MONEY_BUTTON_TYPES.TEXT, + button_intent: MONEY_BUTTON_INTENTS.ADD_MONEY, + label_key: 'homepage.sections.money_empty_state.earn', + redirect_target: SCREEN_NAMES.RAMP_BUY, + }); + }); }); describe('when balance is empty and onboarding has not been seen', () => { @@ -334,12 +390,13 @@ describe('MoneyBalanceCard', () => { ).toBeOnTheScreen(); }); - it('calls navigateToMoneyHome when Earn is pressed', () => { + it('routes add money when Earn is pressed', () => { const { getByTestId } = renderWithProvider(); fireEvent.press(getByTestId(MoneyBalanceCardTestIds.EARN_BUTTON)); - expect(mockNavigateToMoneyHome).toHaveBeenCalledTimes(1); + expect(mockRouteAddMoney).toHaveBeenCalled(); + expect(mockNavigateToMoneyHome).not.toHaveBeenCalled(); }); }); @@ -348,18 +405,18 @@ describe('MoneyBalanceCard', () => { mockSelectWalletHomeOnboardingFlowVisible.mockReturnValue(true); }); - it('renders the Get started button with the get_started label', () => { + it('renders the Earn button (never Get started) when the stepper is visible', () => { const { getByTestId, queryByTestId } = renderWithProvider( , ); expect( - getByTestId(MoneyBalanceCardTestIds.GET_STARTED_BUTTON), + getByTestId(MoneyBalanceCardTestIds.EARN_BUTTON), ).toHaveTextContent( - strings('homepage.sections.money_empty_state.get_started'), + strings('homepage.sections.money_empty_state.earn'), ); expect( - queryByTestId(MoneyBalanceCardTestIds.EARN_BUTTON), + queryByTestId(MoneyBalanceCardTestIds.GET_STARTED_BUTTON), ).not.toBeOnTheScreen(); }); @@ -371,14 +428,12 @@ describe('MoneyBalanceCard', () => { ).toBeOnTheScreen(); }); - it('calls navigateToMoneyHome when Get started is pressed', () => { + it('routes add money when Earn is pressed', () => { const { getByTestId } = renderWithProvider(); - fireEvent.press( - getByTestId(MoneyBalanceCardTestIds.GET_STARTED_BUTTON), - ); + fireEvent.press(getByTestId(MoneyBalanceCardTestIds.EARN_BUTTON)); - expect(mockNavigateToMoneyHome).toHaveBeenCalledTimes(1); + expect(mockRouteAddMoney).toHaveBeenCalled(); }); }); }); @@ -439,14 +494,12 @@ describe('MoneyBalanceCard', () => { expect(mockNavigateToMoneyHome).toHaveBeenCalledTimes(1); }); - it('opens the Add money sheet when Add is pressed', () => { + it('routes add money when Add is pressed', () => { const { getByTestId } = renderWithProvider(); fireEvent.press(getByTestId(MoneyBalanceCardTestIds.ADD_BUTTON)); - expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.MODALS.ROOT, { - screen: Routes.MONEY.MODALS.ADD_MONEY_SHEET, - }); + expect(mockRouteAddMoney).toHaveBeenCalled(); }); it('opens the Money balance info sheet when the info icon is pressed', () => { @@ -459,7 +512,7 @@ describe('MoneyBalanceCard', () => { }); }); - it('opens the Add money sheet (and not the Money home) when Earn is pressed in empty state', () => { + it('routes add money (and not the Money home) when Earn is pressed in empty state', () => { mockUseMoneyAccountBalance.mockReturnValue( createBalanceMock({ totalFiatRaw: '0', @@ -471,10 +524,8 @@ describe('MoneyBalanceCard', () => { fireEvent.press(getByTestId(MoneyBalanceCardTestIds.EARN_BUTTON)); - expect(mockNavigate).toHaveBeenCalledTimes(1); - expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.MODALS.ROOT, { - screen: Routes.MONEY.MODALS.ADD_MONEY_SHEET, - }); + expect(mockRouteAddMoney).toHaveBeenCalled(); + expect(mockNavigateToMoneyHome).not.toHaveBeenCalled(); }); }); @@ -648,16 +699,13 @@ describe('MoneyBalanceCard', () => { ).toBe(ButtonVariant.Primary); }); - it('renders Get started as Secondary when the onboarding stepper is visible', () => { + it('renders Earn as Secondary when the onboarding stepper is visible', () => { mockSelectWalletHomeOnboardingFlowVisible.mockReturnValue(true); const { UNSAFE_getByProps } = renderWithProvider(); expect( - getVariant( - UNSAFE_getByProps, - MoneyBalanceCardTestIds.GET_STARTED_BUTTON, - ), + getVariant(UNSAFE_getByProps, MoneyBalanceCardTestIds.EARN_BUTTON), ).toBe(ButtonVariant.Secondary); }); }); @@ -837,20 +885,6 @@ describe('MoneyBalanceCard', () => { }); }); - it('calls trackButtonClicked with ADD_MONEY intent when the Add button is pressed', () => { - const { getByTestId } = renderWithProvider(); - - fireEvent.press(getByTestId(MoneyBalanceCardTestIds.ADD_BUTTON)); - - expect(mockTrackButtonClicked).toHaveBeenCalledWith( - expect.objectContaining({ - button_type: MONEY_BUTTON_TYPES.TEXT, - button_intent: MONEY_BUTTON_INTENTS.ADD_MONEY, - redirect_target: BOTTOM_SHEET_NAMES.MONEY_ADD_MONEY_SHEET, - }), - ); - }); - it('calls trackTooltipClicked with MONEY_BALANCE name and INFO type when the info button is pressed', () => { const { getByTestId } = renderWithProvider(); diff --git a/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.tsx b/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.tsx index 341f8c4bf69..5947652c9d4 100644 --- a/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.tsx +++ b/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.tsx @@ -32,10 +32,10 @@ import useMoneyAccountInfo from '../../hooks/useMoneyAccountInfo'; import styleSheet from './MoneyBalanceCard.styles'; import { MoneyBalanceCardTestIds } from './MoneyBalanceCard.testIds'; import { useMoneyNavigation } from '../../hooks/useMoneyNavigation'; +import { useMoneyAccountAddRouting } from '../../hooks/useMoneyAccountAddRouting'; import { SCREEN_NAMES, COMPONENT_NAMES, - BOTTOM_SHEET_NAMES, MONEY_BUTTON_INTENTS, MONEY_BUTTON_TYPES, MONEY_TOOLTIP_NAMES, @@ -60,6 +60,7 @@ const MoneyBalanceCard = () => { } = useMoneyAccountBalance(); const { hasMoneyAccount } = useMoneyAccountInfo(); const { navigateToMoneyHome } = useMoneyNavigation(); + const { hasMusdBalance, routeAddMoney } = useMoneyAccountAddRouting(); const hasSeenMoneyOnboarding = useSelector(selectMoneyOnboardingSeen); const hasOtherPrimaryCtaOnHome = useSelector( selectWalletHomeOnboardingFlowVisible, @@ -113,24 +114,15 @@ const MoneyBalanceCard = () => { buttonLabelKey = 'money.balance_card.add'; buttonTestId = MoneyBalanceCardTestIds.ADD_BUTTON; containerTestId = MoneyBalanceCardTestIds.UNAVAILABLE_CONTAINER; - } else if (isNewUser) { - buttonVariant = hasOtherPrimaryCtaOnHome - ? ButtonVariant.Secondary - : ButtonVariant.Primary; - buttonLabelKey = hasOtherPrimaryCtaOnHome - ? 'homepage.sections.money_empty_state.get_started' - : 'homepage.sections.money_empty_state.earn'; - buttonTestId = hasOtherPrimaryCtaOnHome - ? MoneyBalanceCardTestIds.GET_STARTED_BUTTON - : MoneyBalanceCardTestIds.EARN_BUTTON; - containerTestId = MoneyBalanceCardTestIds.NEW_USER_CONTAINER; } else if (isEmpty) { buttonVariant = hasOtherPrimaryCtaOnHome ? ButtonVariant.Secondary : ButtonVariant.Primary; buttonLabelKey = 'homepage.sections.money_empty_state.earn'; buttonTestId = MoneyBalanceCardTestIds.EARN_BUTTON; - containerTestId = MoneyBalanceCardTestIds.EMPTY_CONTAINER; + containerTestId = isNewUser + ? MoneyBalanceCardTestIds.NEW_USER_CONTAINER + : MoneyBalanceCardTestIds.EMPTY_CONTAINER; } else { buttonVariant = hasOtherPrimaryCtaOnHome ? ButtonVariant.Secondary @@ -162,26 +154,13 @@ const MoneyBalanceCard = () => { button_type: MONEY_BUTTON_TYPES.TEXT, button_intent: MONEY_BUTTON_INTENTS.ADD_MONEY, label_key: buttonLabelKey, - redirect_target: BOTTOM_SHEET_NAMES.MONEY_ADD_MONEY_SHEET, + redirect_target: hasMusdBalance + ? SCREEN_NAMES.MONEY_DEPOSIT + : SCREEN_NAMES.RAMP_BUY, }); - navigation.navigate(Routes.MONEY.MODALS.ROOT, { - screen: Routes.MONEY.MODALS.ADD_MONEY_SHEET, - }); - }, [buttonLabelKey, navigation, trackButtonClicked]); - - const handleGetStartedPress = useCallback(() => { - trackButtonClicked({ - button_type: MONEY_BUTTON_TYPES.TEXT, - button_intent: MONEY_BUTTON_INTENTS.GET_STARTED, - label_key: buttonLabelKey, - redirect_target: SCREEN_NAMES.MONEY_ONBOARDING, - }); - - navigateToMoneyHome(); - }, [buttonLabelKey, navigateToMoneyHome, trackButtonClicked]); - - const handleButtonPress = isNewUser ? handleGetStartedPress : handleAddPress; + routeAddMoney(); + }, [buttonLabelKey, hasMusdBalance, routeAddMoney, trackButtonClicked]); const handleInfoPress = useCallback(() => { trackTooltipClicked({ @@ -339,7 +318,7 @@ const MoneyBalanceCard = () => { testID={buttonTestId} variant={buttonVariant} size={ButtonSize.Md} - onPress={handleButtonPress} + onPress={handleAddPress} > {strings(buttonLabelKey)} diff --git a/app/components/UI/Money/constants/moneyEvents.ts b/app/components/UI/Money/constants/moneyEvents.ts index 2207c32c96e..28e5cd60f36 100644 --- a/app/components/UI/Money/constants/moneyEvents.ts +++ b/app/components/UI/Money/constants/moneyEvents.ts @@ -17,6 +17,7 @@ export enum SCREEN_NAMES { MONEY_ACTIVITY_DETAILS = 'money_activity_details', MONEY_POTENTIAL_EARNINGS = 'money_potential_earnings', ASSET_OVERVIEW = 'asset_overview', + RAMP_BUY = 'ramp_buy', } export enum BOTTOM_SHEET_NAMES { diff --git a/app/components/UI/Money/hooks/useMoneyAccountAddRouting.test.ts b/app/components/UI/Money/hooks/useMoneyAccountAddRouting.test.ts new file mode 100644 index 00000000000..37c2048098e --- /dev/null +++ b/app/components/UI/Money/hooks/useMoneyAccountAddRouting.test.ts @@ -0,0 +1,317 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { useMusdBalance } from '../../Earn/hooks/useMusdBalance'; +import { useMusdConversionFlowData } from '../../Earn/hooks/useMusdConversionFlowData'; +import { useRampNavigation } from '../../Ramp/hooks/useRampNavigation'; +import { useMoneyAccountDeposit } from './useMoneyAccount'; +import { + MUSD_CONVERSION_DEFAULT_CHAIN_ID, + MUSD_TOKEN_ADDRESS_BY_CHAIN, + MUSD_TOKEN_ASSET_ID_BY_CHAIN, +} from '../../Earn/constants/musd'; +import { useMoneyAccountAddRouting } from './useMoneyAccountAddRouting'; + +jest.mock('../../Earn/hooks/useMusdBalance', () => ({ + useMusdBalance: jest.fn(), +})); + +jest.mock('../../Earn/hooks/useMusdConversionFlowData', () => ({ + useMusdConversionFlowData: jest.fn(), +})); + +jest.mock('../../Ramp/hooks/useRampNavigation', () => ({ + useRampNavigation: jest.fn(), +})); + +jest.mock('./useMoneyAccount', () => ({ + useMoneyAccountDeposit: jest.fn(), +})); + +const mockedUseMusdBalance = useMusdBalance as jest.Mock; +const mockedUseMusdConversionFlowData = useMusdConversionFlowData as jest.Mock; +const mockedUseRampNavigation = useRampNavigation as jest.Mock; +const mockedUseMoneyAccountDeposit = useMoneyAccountDeposit as jest.Mock; + +const mockInitiateDeposit = jest.fn(() => Promise.resolve()); +const mockGoToBuy = jest.fn(); +const mockGetChainIdForBuyFlow = jest.fn(); + +const setupMocks = (overrides?: { + hasMusdBalanceOnAnyChain?: boolean; + fiatBalanceAggregated?: string; + tokenBalanceByChain?: Record | undefined; + getChainIdForBuyFlow?: jest.Mock | undefined; +}) => { + mockedUseMusdBalance.mockReturnValue({ + hasMusdBalanceOnAnyChain: overrides?.hasMusdBalanceOnAnyChain ?? false, + fiatBalanceAggregated: overrides?.fiatBalanceAggregated, + tokenBalanceByChain: overrides?.tokenBalanceByChain, + }); + mockedUseMusdConversionFlowData.mockReturnValue({ + getChainIdForBuyFlow: + overrides && 'getChainIdForBuyFlow' in overrides + ? overrides.getChainIdForBuyFlow + : mockGetChainIdForBuyFlow, + }); + mockedUseRampNavigation.mockReturnValue({ + goToBuy: mockGoToBuy, + }); + mockedUseMoneyAccountDeposit.mockReturnValue({ + initiateDeposit: mockInitiateDeposit, + }); +}; + +describe('useMoneyAccountAddRouting', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetChainIdForBuyFlow.mockReturnValue(MUSD_CONVERSION_DEFAULT_CHAIN_ID); + }); + + describe('hasMusdBalance', () => { + it('is true when hasMusdBalanceOnAnyChain is true', () => { + setupMocks({ + hasMusdBalanceOnAnyChain: true, + fiatBalanceAggregated: undefined, + }); + + const { result } = renderHook(() => useMoneyAccountAddRouting()); + + expect(result.current.hasMusdBalance).toBe(true); + }); + + it('is true when the parsed fiat balance is positive', () => { + setupMocks({ + hasMusdBalanceOnAnyChain: false, + fiatBalanceAggregated: '12.34', + }); + + const { result } = renderHook(() => useMoneyAccountAddRouting()); + + expect(result.current.hasMusdBalance).toBe(true); + }); + + it('is false when there is no chain balance and the parsed fiat balance is zero', () => { + setupMocks({ + hasMusdBalanceOnAnyChain: false, + fiatBalanceAggregated: '0', + }); + + const { result } = renderHook(() => useMoneyAccountAddRouting()); + + expect(result.current.hasMusdBalance).toBe(false); + }); + + it('is false when the fiat balance is undefined and no on-chain balance', () => { + setupMocks({ + hasMusdBalanceOnAnyChain: false, + fiatBalanceAggregated: undefined, + }); + + const { result } = renderHook(() => useMoneyAccountAddRouting()); + + expect(result.current.hasMusdBalance).toBe(false); + }); + }); + + describe('convertCrypto', () => { + it('calls initiateDeposit with no options', async () => { + setupMocks(); + + const { result } = renderHook(() => useMoneyAccountAddRouting()); + + await act(async () => { + await result.current.convertCrypto(); + }); + + expect(mockInitiateDeposit).toHaveBeenCalledTimes(1); + expect(mockInitiateDeposit).toHaveBeenCalledWith(); + }); + + it('swallows initiateDeposit failures', async () => { + setupMocks(); + mockInitiateDeposit.mockRejectedValueOnce(new Error('boom')); + + const { result } = renderHook(() => useMoneyAccountAddRouting()); + + await expect(result.current.convertCrypto()).resolves.toBeUndefined(); + }); + }); + + describe('depositFunds', () => { + it('routes to the Buy flow with the mUSD asset id for the chain returned by getChainIdForBuyFlow', () => { + mockGetChainIdForBuyFlow.mockReturnValue(CHAIN_IDS.LINEA_MAINNET); + setupMocks(); + + const { result } = renderHook(() => useMoneyAccountAddRouting()); + + act(() => { + result.current.depositFunds(); + }); + + expect(mockGoToBuy).toHaveBeenCalledWith({ + assetId: MUSD_TOKEN_ASSET_ID_BY_CHAIN[CHAIN_IDS.LINEA_MAINNET], + }); + }); + + it('falls back to MUSD_CONVERSION_DEFAULT_CHAIN_ID when getChainIdForBuyFlow is not provided', () => { + setupMocks({ getChainIdForBuyFlow: undefined }); + + const { result } = renderHook(() => useMoneyAccountAddRouting()); + + act(() => { + result.current.depositFunds(); + }); + + expect(mockGoToBuy).toHaveBeenCalledWith({ + assetId: MUSD_TOKEN_ASSET_ID_BY_CHAIN[MUSD_CONVERSION_DEFAULT_CHAIN_ID], + }); + }); + + it('falls back to the default chain mUSD asset id when the resolved chain has no mapped asset id', () => { + mockGetChainIdForBuyFlow.mockReturnValue(CHAIN_IDS.ARBITRUM); + setupMocks(); + + const { result } = renderHook(() => useMoneyAccountAddRouting()); + + act(() => { + result.current.depositFunds(); + }); + + expect(mockGoToBuy).toHaveBeenCalledWith({ + assetId: MUSD_TOKEN_ASSET_ID_BY_CHAIN[MUSD_CONVERSION_DEFAULT_CHAIN_ID], + }); + }); + }); + + describe('moveMusd', () => { + it('picks the chain with the highest mUSD balance', async () => { + setupMocks({ + hasMusdBalanceOnAnyChain: true, + tokenBalanceByChain: { + [CHAIN_IDS.MAINNET]: '500.00', + [CHAIN_IDS.LINEA_MAINNET]: '1000.00', + }, + }); + + const { result } = renderHook(() => useMoneyAccountAddRouting()); + + await act(async () => { + await result.current.moveMusd(); + }); + + expect(mockInitiateDeposit).toHaveBeenCalledWith({ + intent: 'addMusd', + preferredPaymentToken: { + address: MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.LINEA_MAINNET], + chainId: CHAIN_IDS.LINEA_MAINNET, + }, + }); + }); + + it('falls back to the default chain when no per-chain balances are available', async () => { + setupMocks({ + hasMusdBalanceOnAnyChain: true, + tokenBalanceByChain: undefined, + }); + + const { result } = renderHook(() => useMoneyAccountAddRouting()); + + await act(async () => { + await result.current.moveMusd(); + }); + + expect(mockInitiateDeposit).toHaveBeenCalledWith({ + intent: 'addMusd', + preferredPaymentToken: { + address: + MUSD_TOKEN_ADDRESS_BY_CHAIN[MUSD_CONVERSION_DEFAULT_CHAIN_ID], + chainId: MUSD_CONVERSION_DEFAULT_CHAIN_ID, + }, + }); + }); + + it('swallows initiateDeposit failures', async () => { + setupMocks({ + hasMusdBalanceOnAnyChain: true, + tokenBalanceByChain: { + [CHAIN_IDS.MAINNET]: '1.00', + }, + }); + mockInitiateDeposit.mockRejectedValueOnce(new Error('boom')); + + const { result } = renderHook(() => useMoneyAccountAddRouting()); + + await expect(result.current.moveMusd()).resolves.toBeUndefined(); + }); + }); + + describe('routeAddMoney', () => { + it('delegates to moveMusd when hasMusdBalance is true', async () => { + setupMocks({ + hasMusdBalanceOnAnyChain: true, + tokenBalanceByChain: { + [CHAIN_IDS.MAINNET]: '100.00', + }, + }); + + const { result } = renderHook(() => useMoneyAccountAddRouting()); + + await act(async () => { + await result.current.routeAddMoney(); + }); + + expect(mockInitiateDeposit).toHaveBeenCalledWith({ + intent: 'addMusd', + preferredPaymentToken: { + address: MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.MAINNET], + chainId: CHAIN_IDS.MAINNET, + }, + }); + expect(mockGoToBuy).not.toHaveBeenCalled(); + }); + + it('delegates to moveMusd when only the parsed fiat balance is positive', async () => { + setupMocks({ + hasMusdBalanceOnAnyChain: false, + fiatBalanceAggregated: '5.00', + tokenBalanceByChain: undefined, + }); + + const { result } = renderHook(() => useMoneyAccountAddRouting()); + + await act(async () => { + await result.current.routeAddMoney(); + }); + + expect(mockInitiateDeposit).toHaveBeenCalledWith({ + intent: 'addMusd', + preferredPaymentToken: { + address: + MUSD_TOKEN_ADDRESS_BY_CHAIN[MUSD_CONVERSION_DEFAULT_CHAIN_ID], + chainId: MUSD_CONVERSION_DEFAULT_CHAIN_ID, + }, + }); + expect(mockGoToBuy).not.toHaveBeenCalled(); + }); + + it('delegates to depositFunds when there is no mUSD balance', () => { + mockGetChainIdForBuyFlow.mockReturnValue(CHAIN_IDS.MAINNET); + setupMocks({ + hasMusdBalanceOnAnyChain: false, + fiatBalanceAggregated: '0', + tokenBalanceByChain: {}, + }); + + const { result } = renderHook(() => useMoneyAccountAddRouting()); + + act(() => { + result.current.routeAddMoney(); + }); + + expect(mockGoToBuy).toHaveBeenCalledWith({ + assetId: MUSD_TOKEN_ASSET_ID_BY_CHAIN[CHAIN_IDS.MAINNET], + }); + expect(mockInitiateDeposit).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/app/components/UI/Money/hooks/useMoneyAccountAddRouting.ts b/app/components/UI/Money/hooks/useMoneyAccountAddRouting.ts new file mode 100644 index 00000000000..ae852b79cb3 --- /dev/null +++ b/app/components/UI/Money/hooks/useMoneyAccountAddRouting.ts @@ -0,0 +1,90 @@ +import { useCallback, useMemo } from 'react'; +import BigNumber from 'bignumber.js'; +import { Hex } from '@metamask/utils'; +import { useMusdBalance } from '../../Earn/hooks/useMusdBalance'; +import { useMusdConversionFlowData } from '../../Earn/hooks/useMusdConversionFlowData'; +import { useRampNavigation } from '../../Ramp/hooks/useRampNavigation'; +import { useMoneyAccountDeposit } from './useMoneyAccount'; +import { + MUSD_CONVERSION_DEFAULT_CHAIN_ID, + MUSD_TOKEN_ADDRESS_BY_CHAIN, + MUSD_TOKEN_ASSET_ID_BY_CHAIN, +} from '../../Earn/constants/musd'; +export interface UseMoneyAccountAddRoutingResult { + hasMusdBalance: boolean; + convertCrypto: () => Promise; + depositFunds: () => void; + moveMusd: () => Promise; + routeAddMoney: () => Promise | void; +} + +export const useMoneyAccountAddRouting = + (): UseMoneyAccountAddRoutingResult => { + const { + fiatBalanceAggregated, + hasMusdBalanceOnAnyChain, + tokenBalanceByChain, + } = useMusdBalance(); + const { getChainIdForBuyFlow } = useMusdConversionFlowData(); + const { goToBuy } = useRampNavigation(); + const { initiateDeposit } = useMoneyAccountDeposit(); + + const hasMusdBalance = useMemo(() => { + const parsedMusdFiat = Number(fiatBalanceAggregated); + const hasParsedFiatBalance = + Number.isFinite(parsedMusdFiat) && parsedMusdFiat > 0; + return hasMusdBalanceOnAnyChain || hasParsedFiatBalance; + }, [fiatBalanceAggregated, hasMusdBalanceOnAnyChain]); + + const convertCrypto = useCallback( + () => initiateDeposit().catch(() => undefined), + [initiateDeposit], + ); + + const depositFunds = useCallback(() => { + const chainId = getChainIdForBuyFlow + ? getChainIdForBuyFlow() + : MUSD_CONVERSION_DEFAULT_CHAIN_ID; + const assetId = + MUSD_TOKEN_ASSET_ID_BY_CHAIN[chainId] ?? + MUSD_TOKEN_ASSET_ID_BY_CHAIN[MUSD_CONVERSION_DEFAULT_CHAIN_ID]; + goToBuy({ assetId }); + }, [getChainIdForBuyFlow, goToBuy]); + + const moveMusd = useCallback(() => { + let sourceChainId: Hex = MUSD_CONVERSION_DEFAULT_CHAIN_ID; + let bestBalance = new BigNumber(0); + for (const [chainId, balance] of Object.entries( + tokenBalanceByChain ?? {}, + )) { + const candidate = new BigNumber(balance ?? 0); + if (candidate.isGreaterThan(bestBalance)) { + sourceChainId = chainId as Hex; + bestBalance = candidate; + } + } + + return initiateDeposit({ + intent: 'addMusd', + preferredPaymentToken: { + address: MUSD_TOKEN_ADDRESS_BY_CHAIN[sourceChainId], + chainId: sourceChainId, + }, + }).catch(() => undefined); + }, [initiateDeposit, tokenBalanceByChain]); + + const routeAddMoney = useCallback(() => { + if (hasMusdBalance) { + return moveMusd(); + } + return depositFunds(); + }, [depositFunds, hasMusdBalance, moveMusd]); + + return { + hasMusdBalance, + convertCrypto, + depositFunds, + moveMusd, + routeAddMoney, + }; + }; From 7be2ddd2bea800bed645906b9bd27245a43947c5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 04:59:31 -0600 Subject: [PATCH 16/17] test: Sync Feature Flag Registry - 2026-06-02 01:57 UTC (#30909) ## Feature Flag Registry Drift Report Feature flag drift was detected between E2E tests and production. Download the [`drift-report` artifact](https://github.com/MetaMask/metamask-mobile/actions/runs/26793563675) for the full report. --------- Co-authored-by: github-actions[bot] Co-authored-by: Ramon Acitores Co-authored-by: Ramon AC <36987446+racitores@users.noreply.github.com> --- .../mock-responses/feature-flags-mocks.ts | 7 + tests/feature-flags/feature-flag-registry.ts | 436 +++++++++++++----- tests/page-objects/Perps/PerpsView.ts | 34 +- tests/smoke/predict/predict-cash-out.spec.ts | 9 + .../predict/predict-open-position.spec.ts | 15 +- tests/smoke/trending/trending-feed.spec.ts | 5 + 6 files changed, 376 insertions(+), 130 deletions(-) diff --git a/tests/api-mocking/mock-responses/feature-flags-mocks.ts b/tests/api-mocking/mock-responses/feature-flags-mocks.ts index 3a4221b167e..7367345a830 100644 --- a/tests/api-mocking/mock-responses/feature-flags-mocks.ts +++ b/tests/api-mocking/mock-responses/feature-flags-mocks.ts @@ -134,6 +134,13 @@ export const remoteFeatureFlagPredictEnabled = (enabled = true) => ({ }, }); +export const remoteFeatureFlagHomepageSectionsV1Enabled = (enabled = true) => ({ + homepageSectionsV1: { + enabled, + minimumVersion: '0.0.0', + }, +}); + export const remoteFeatureFlagRampsUnifiedV1Enabled = (active = true) => ({ rampsUnifiedBuyV1: { active, diff --git a/tests/feature-flags/feature-flag-registry.ts b/tests/feature-flags/feature-flag-registry.ts index 058d638486e..ffa642495ae 100644 --- a/tests/feature-flags/feature-flag-registry.ts +++ b/tests/feature-flags/feature-flag-registry.ts @@ -62,7 +62,7 @@ export interface FeatureFlagRegistryEntry { * Remote flag values are stored in the exact format returned by the production * client-config API, so they can be served directly by the E2E mock server. * - * Production defaults last synced: 2026-05-19 + * Production defaults last synced: 2026-06-02 * Source: https://client-config.api.cx.metamask.io/v1/flags?client=mobile&distribution=main&environment=prod */ export const FEATURE_FLAG_REGISTRY: Record = { @@ -112,8 +112,8 @@ export const FEATURE_FLAG_REGISTRY: Record = { type: FeatureFlagType.Remote, inProd: true, productionDefault: { - enabled: false, - minimumVersion: '7.71.0', + enabled: true, + minimumVersion: '7.78.0', }, status: FeatureFlagStatus.Active, }, @@ -122,9 +122,22 @@ export const FEATURE_FLAG_REGISTRY: Record = { name: 'socialAiTSA531AbtestWhatsHappeningExplore', type: FeatureFlagType.Remote, inProd: true, - productionDefault: { - enabled: false, - }, + productionDefault: [ + { + scope: { + value: 0.5, + type: 'percentage_rollout', + }, + name: 'control', + }, + { + scope: { + type: 'percentage_rollout', + value: 1, + }, + name: 'treatment', + }, + ], status: FeatureFlagStatus.Active, }, @@ -1470,95 +1483,96 @@ export const FEATURE_FLAG_REGISTRY: Record = { type: FeatureFlagType.Remote, inProd: true, productionDefault: { - slippage: 0.02, - relayQuoteUrl: 'https://intents.api.cx.metamask.io/relay/quote', - perpsWithdrawAnyToken: false, - allowedPredictWithdrawTokens: { - '0x38': [ - '0x0000000000000000000000000000000000000000', - '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', - ], - '0x89': [ - '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', - '0x0000000000000000000000000000000000000000', - '0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619', - ], - '0x1': [ - '0x0000000000000000000000000000000000000000', - '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', - ], + relayExecuteUrl: 'https://intents.api.cx.metamask.io/relay/execute', + bufferSubsequent: 0.05, + payStrategies: { + relay: { + gaslessEnabled: true, + originGasOverhead: '500000', + pollingTimeout: 180000, + enabled: true, + }, + across: { + fallbackGas: { + max: 1500001, + estimate: 900001, + }, + apiBase: 'https://intents.api.cx.metamask.io/across', + enabled: false, + }, }, - strategyOrder: ['relay'], - stxDisabled: false, + relayQuoteUrl: 'https://intents.api.cx.metamask.io/relay/quote', bufferStep: 0.015, - attemptsMax: 4, slippageTokens: { '0x1': { - '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': 0.005, - '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2': 0.005, - '0xacA92E438df0B2401fF60dA7E4337B687a2435DA': 0.005, '0xdAC17F958D2ee523a2206206994597C13D831ec7': 0.005, '0x0000000000000000000000000000000000000000': 0.005, '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599': 0.005, + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': 0.005, + '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2': 0.005, + '0xacA92E438df0B2401fF60dA7E4337B687a2435DA': 0.005, }, '0x2105': { - '0x0000000000000000000000000000000000000000': 0.005, '0x4200000000000000000000000000000000000006': 0.005, '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913': 0.005, '0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2': 0.005, + '0x0000000000000000000000000000000000000000': 0.005, }, '0x38': { + '0x55d398326f99059fF775485246999027B3197955': 0.005, '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d': 0.005, '0x0000000000000000000000000000000000000000': 0.005, '0x0555E30da8f98308EdB960aa94C0Db47230d2B9c': 0.005, '0x2170Ed0880ac9A755fd29B2688956BD959F933F8': 0.005, - '0x55d398326f99059fF775485246999027B3197955': 0.005, }, '0x89': { + '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174': 0.005, '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359': 0.005, '0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619': 0.005, '0xc2132D05D31c914a87C6611C10748AEb04B58e8F': 0.005, '0x0000000000000000000000000000000000001010': 0.005, - '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174': 0.005, }, '0xa4b1': { + '0x82aF49447D8a07e3bd95BD0d56f35241523fBab1': 0.005, '0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9': 0.005, '0xaf88d065e77c8cC2239327C5EDb3A432268e5831': 0.005, '0x0000000000000000000000000000000000000000': 0.005, - '0x82aF49447D8a07e3bd95BD0d56f35241523fBab1': 0.005, }, '0xe708': { - '0x176211869cA2b568f2A7D4EE941E073a821EE1ff': 0.005, '0xA219439258ca9da29E9Cc4cE5596924745e12B93': 0.005, '0xacA92E438df0B2401fF60dA7E4337B687a2435DA': 0.005, '0xe5D7C2a44FfDDf6b295A15c148167daaAf5Cf34f': 0.005, '0x0000000000000000000000000000000000000000': 0.005, + '0x176211869cA2b568f2A7D4EE941E073a821EE1ff': 0.005, }, }, + stxDisabled: false, bufferInitial: 0.015, + relayDisabledGasStationChains: [], + slippage: 0.02, + allowedPredictWithdrawTokens: { + '0x89': [ + '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + '0x0000000000000000000000000000000000000000', + '0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619', + ], + '0x1': [ + '0x0000000000000000000000000000000000000000', + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + ], + '0x38': [ + '0x0000000000000000000000000000000000000000', + '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', + ], + }, relayFallbackGas: { - max: '1500001', estimate: '900001', - }, - relayDisabledGasStationChains: [], - relayExecuteUrl: 'https://intents.api.cx.metamask.io/relay/execute', - bufferSubsequent: 0.05, - payStrategies: { - relay: { - enabled: true, - gaslessEnabled: true, - originGasOverhead: '500000', - }, - across: { - apiBase: 'https://intents.api.cx.metamask.io/across', - enabled: false, - fallbackGas: { - estimate: 900001, - max: 1500001, - }, - }, + max: '1500001', }, predictWithdrawAnyToken: true, + attemptsMax: 4, + strategyOrder: ['relay'], + perpsWithdrawAnyToken: false, }, status: FeatureFlagStatus.Active, }, @@ -1569,14 +1583,23 @@ export const FEATURE_FLAG_REGISTRY: Record = { inProd: true, productionDefault: { versions: { - '7.70.0': { + '7.78.0': { default: { enabled: true, tokens: {}, }, overrides: { predictWithdraw: { + enabled: true, tokens: { + '0xe708': ['0xacA92E438df0B2401fF60dA7E4337B687a2435DA'], + '0x1': [ + '0x0000000000000000000000000000000000000000', + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + '0xdAC17F958D2ee523a2206206994597C13D831ec7', + '0xacA92E438df0B2401fF60dA7E4337B687a2435DA', + '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', + ], '0x2105': [ '0x0000000000000000000000000000000000000000', '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', @@ -1586,8 +1609,61 @@ export const FEATURE_FLAG_REGISTRY: Record = { '0x55d398326f99059fF775485246999027B3197955', '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', ], - '0x89': ['0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174'], + '0x89': [ + '0x0000000000000000000000000000000000001010', + '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359', + '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + '0xc2132d05d31c914a87c6611c10748aeb04b58e8f', + '0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB', + ], '0xa4b1': ['0xaf88d065e77c8cC2239327C5EDb3A432268e5831'], + }, + }, + perpsWithdraw: { + enabled: true, + tokens: { + '0x1': [ + '0x0000000000000000000000000000000000000000', + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + '0xdAC17F958D2ee523a2206206994597C13D831ec7', + '0xacA92E438df0B2401fF60dA7E4337B687a2435DA', + '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', + ], + '0x2105': [ + '0x0000000000000000000000000000000000000000', + '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + ], + '0x38': [ + '0x0000000000000000000000000000000000000000', + '0x55d398326f99059fF775485246999027B3197955', + '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', + ], + '0x89': [ + '0x0000000000000000000000000000000000001010', + '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359', + '0xc2132d05d31c914a87c6611c10748aeb04b58e8f', + ], + '0xa4b1': [ + '0x0000000000000000000000000000000000000000', + '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + '0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9', + ], + '0xe708': [ + '0x0000000000000000000000000000000000000000', + '0xacA92E438df0B2401fF60dA7E4337B687a2435DA', + ], + }, + }, + }, + }, + '7.70.0': { + default: { + enabled: true, + tokens: {}, + }, + overrides: { + predictWithdraw: { + tokens: { '0xe708': ['0xacA92E438df0B2401fF60dA7E4337B687a2435DA'], '0x1': [ '0x0000000000000000000000000000000000000000', @@ -1596,6 +1672,17 @@ export const FEATURE_FLAG_REGISTRY: Record = { '0xacA92E438df0B2401fF60dA7E4337B687a2435DA', '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', ], + '0x2105': [ + '0x0000000000000000000000000000000000000000', + '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + ], + '0x38': [ + '0x0000000000000000000000000000000000000000', + '0x55d398326f99059fF775485246999027B3197955', + '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', + ], + '0x89': ['0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174'], + '0xa4b1': ['0xaf88d065e77c8cC2239327C5EDb3A432268e5831'], }, enabled: true, }, @@ -1605,10 +1692,23 @@ export const FEATURE_FLAG_REGISTRY: Record = { }, }, '7.74.0': { + default: { + enabled: true, + tokens: {}, + }, overrides: { predictWithdraw: { enabled: true, tokens: { + '0xa4b1': ['0xaf88d065e77c8cC2239327C5EDb3A432268e5831'], + '0xe708': ['0xacA92E438df0B2401fF60dA7E4337B687a2435DA'], + '0x1': [ + '0x0000000000000000000000000000000000000000', + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + '0xdAC17F958D2ee523a2206206994597C13D831ec7', + '0xacA92E438df0B2401fF60dA7E4337B687a2435DA', + '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', + ], '0x2105': [ '0x0000000000000000000000000000000000000000', '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', @@ -1619,8 +1719,15 @@ export const FEATURE_FLAG_REGISTRY: Record = { '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', ], '0x89': ['0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174'], - '0xa4b1': ['0xaf88d065e77c8cC2239327C5EDb3A432268e5831'], - '0xe708': ['0xacA92E438df0B2401fF60dA7E4337B687a2435DA'], + }, + }, + perpsWithdraw: { + enabled: true, + tokens: { + '0xe708': [ + '0x0000000000000000000000000000000000000000', + '0xacA92E438df0B2401fF60dA7E4337B687a2435DA', + ], '0x1': [ '0x0000000000000000000000000000000000000000', '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', @@ -1628,10 +1735,6 @@ export const FEATURE_FLAG_REGISTRY: Record = { '0xacA92E438df0B2401fF60dA7E4337B687a2435DA', '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', ], - }, - }, - perpsWithdraw: { - tokens: { '0x2105': [ '0x0000000000000000000000000000000000000000', '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', @@ -1651,25 +1754,9 @@ export const FEATURE_FLAG_REGISTRY: Record = { '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', '0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9', ], - '0xe708': [ - '0x0000000000000000000000000000000000000000', - '0xacA92E438df0B2401fF60dA7E4337B687a2435DA', - ], - '0x1': [ - '0x0000000000000000000000000000000000000000', - '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', - '0xdAC17F958D2ee523a2206206994597C13D831ec7', - '0xacA92E438df0B2401fF60dA7E4337B687a2435DA', - '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', - ], }, - enabled: true, }, }, - default: { - tokens: {}, - enabled: true, - }, }, }, }, @@ -3040,7 +3127,12 @@ export const FEATURE_FLAG_REGISTRY: Record = { name: 'earnMoneyPaymentTokensBlocklist', type: FeatureFlagType.Remote, inProd: true, - productionDefault: {}, + productionDefault: { + '0x1': ['USDC', 'USDT', 'DAI', 'aUSDC', 'aUSDT', 'aDAI'], + '0x2105': ['USDC'], + '0x38': ['USDC', 'USDT'], + '0xa4b1': ['USDC'], + }, status: FeatureFlagStatus.Active, }, @@ -3048,7 +3140,7 @@ export const FEATURE_FLAG_REGISTRY: Record = { name: 'earnMoneyTokensSortMode', type: FeatureFlagType.Remote, inProd: true, - productionDefault: 'fiatBalanceDesc', + productionDefault: 'noFeePriority', status: FeatureFlagStatus.Active, }, @@ -3222,8 +3314,8 @@ export const FEATURE_FLAG_REGISTRY: Record = { enableFiatToggle: { name: 'enableFiatToggle', type: FeatureFlagType.Remote, - inProd: false, - productionDefault: false, + inProd: true, + productionDefault: true, status: FeatureFlagStatus.Active, }, @@ -3316,9 +3408,22 @@ export const FEATURE_FLAG_REGISTRY: Record = { name: 'homeTMCU610AbtestWalletHomePostOnboardingSteps', type: FeatureFlagType.Remote, inProd: true, - productionDefault: { - enabled: false, - }, + productionDefault: [ + { + name: 'control', + scope: { + type: 'percentage_rollout', + value: 0, + }, + }, + { + name: 'postOnboardingSteps', + scope: { + type: 'percentage_rollout', + value: 1, + }, + }, + ], status: FeatureFlagStatus.Active, }, @@ -3335,8 +3440,8 @@ export const FEATURE_FLAG_REGISTRY: Record = { type: FeatureFlagType.Remote, inProd: true, productionDefault: { - minimumVersion: '7.64.0', - enabled: true, + enabled: false, + minimumVersion: '0.0.0', }, status: FeatureFlagStatus.Active, }, @@ -3380,11 +3485,11 @@ export const FEATURE_FLAG_REGISTRY: Record = { type: FeatureFlagType.Remote, inProd: true, productionDefault: { - boringVault: '0x', - chainId: '0x', - lensAddress: '0x', - tellerAddress: '0x', - accountantAddress: '0x', + chainId: '0xa4b1', + lensAddress: '0x846a7832022350434B5cC006d07cc9c782469660', + tellerAddress: '0x86821F179eaD9F0b3C79b2f8deF0227eEBFDc9f9', + accountantAddress: '0x800ebc3B74F67EaC27C9CCE4E4FF28b17CdCA173', + boringVault: '0xB5F07d769dD60fE54c97dd53101181073DDf21b2', }, status: FeatureFlagStatus.Active, }, @@ -3517,8 +3622,8 @@ export const FEATURE_FLAG_REGISTRY: Record = { type: FeatureFlagType.Remote, inProd: true, productionDefault: { - minimumVersion: '0.0.0', enabled: false, + minimumVersion: '7.0.0', }, status: FeatureFlagStatus.Active, }, @@ -3747,7 +3852,7 @@ export const FEATURE_FLAG_REGISTRY: Record = { inProd: true, productionDefault: { enabled: true, - minimumVersion: '0.0.0', + minimumVersion: '7.77.0', }, status: FeatureFlagStatus.Active, }, @@ -3758,18 +3863,18 @@ export const FEATURE_FLAG_REGISTRY: Record = { inProd: true, productionDefault: [ { - name: 'control', scope: { type: 'percentage_rollout', - value: 0.5, + value: 0, }, + name: 'control', }, { - name: 'treatment', scope: { type: 'percentage_rollout', value: 1, }, + name: 'treatment', }, ], status: FeatureFlagStatus.Active, @@ -3844,8 +3949,8 @@ export const FEATURE_FLAG_REGISTRY: Record = { type: FeatureFlagType.Remote, inProd: true, productionDefault: { - minimumVersion: '0.0.0', - enabled: false, + minimumVersion: '7.78.0', + enabled: true, }, status: FeatureFlagStatus.Active, }, @@ -3855,8 +3960,8 @@ export const FEATURE_FLAG_REGISTRY: Record = { type: FeatureFlagType.Remote, inProd: true, productionDefault: { - minimumVersion: '0.0.0', - enabled: false, + enabled: true, + minimumVersion: '7.79.0', }, status: FeatureFlagStatus.Active, }, @@ -3866,8 +3971,8 @@ export const FEATURE_FLAG_REGISTRY: Record = { type: FeatureFlagType.Remote, inProd: true, productionDefault: { - minimumVersion: '0.0.0', - enabled: false, + minimumVersion: '7.78.0', + enabled: true, }, status: FeatureFlagStatus.Active, }, @@ -4109,8 +4214,10 @@ export const FEATURE_FLAG_REGISTRY: Record = { socialAiAssetDetailsQuickBuy: { name: 'socialAiAssetDetailsQuickBuy', type: FeatureFlagType.Remote, - inProd: false, - productionDefault: false, + inProd: true, + productionDefault: { + enabled: false, + }, status: FeatureFlagStatus.Active, }, @@ -4301,7 +4408,7 @@ export const FEATURE_FLAG_REGISTRY: Record = { vipProgramEnabled: { name: 'vipProgramEnabled', type: FeatureFlagType.Remote, - inProd: false, + inProd: true, productionDefault: { enabled: false, minimumVersion: '0.0.0', @@ -4498,9 +4605,22 @@ export const FEATURE_FLAG_REGISTRY: Record = { type: FeatureFlagType.Remote, inProd: true, productionDefault: { - payStrategies: { - relay: { - gaslessEnabled: true, + versions: { + '0.0.0': { + enableDepositWalletWithdraw: false, + payStrategies: { + relay: { + gaslessEnabled: true, + }, + }, + }, + '7.77.0': { + payStrategies: { + relay: { + gaslessEnabled: true, + }, + }, + enableDepositWalletWithdraw: true, }, }, }, @@ -4539,22 +4659,9 @@ export const FEATURE_FLAG_REGISTRY: Record = { name: 'homeTMCU470AbtestTrendingSections', type: FeatureFlagType.Remote, inProd: true, - productionDefault: [ - { - scope: { - value: 0.9, - type: 'percentage_rollout', - }, - name: 'control', - }, - { - scope: { - value: 1, - type: 'percentage_rollout', - }, - name: 'trendingSections', - }, - ], + productionDefault: { + enabled: false, + }, status: FeatureFlagStatus.Active, }, @@ -4825,6 +4932,79 @@ export const FEATURE_FLAG_REGISTRY: Record = { status: FeatureFlagStatus.Active, }, + explorePageV2Enabled: { + name: 'explorePageV2Enabled', + type: FeatureFlagType.Remote, + inProd: true, + productionDefault: true, + status: FeatureFlagStatus.Active, + }, + + exploreSearchV2: { + name: 'exploreSearchV2', + type: FeatureFlagType.Remote, + inProd: true, + productionDefault: { + minimumVersion: '7.79.0', + enabled: true, + }, + status: FeatureFlagStatus.Active, + }, + + exploreSectionsOrder: { + name: 'exploreSectionsOrder', + type: FeatureFlagType.Remote, + inProd: true, + productionDefault: { + home: ['predictions', 'tokens', 'perps', 'stocks', 'sites'], + quickActions: ['tokens', 'perps', 'stocks', 'predictions', 'sites'], + search: ['tokens', 'perps', 'stocks', 'predictions', 'sites'], + }, + status: FeatureFlagStatus.Active, + }, + + homeTMCU828AbtestOnboardingChecklistStepper: { + name: 'homeTMCU828AbtestOnboardingChecklistStepper', + type: FeatureFlagType.Remote, + inProd: true, + productionDefault: { + enabled: false, + }, + status: FeatureFlagStatus.Active, + }, + + moneyHomeScreenEnabled: { + name: 'moneyHomeScreenEnabled', + type: FeatureFlagType.Remote, + inProd: true, + productionDefault: { + minimumVersion: '0.0.0', + enabled: false, + }, + status: FeatureFlagStatus.Active, + }, + + predictHomeRedesign: { + name: 'predictHomeRedesign', + type: FeatureFlagType.Remote, + inProd: true, + productionDefault: { + enabled: false, + }, + status: FeatureFlagStatus.Active, + }, + + predictPortfolio: { + name: 'predictPortfolio', + type: FeatureFlagType.Remote, + inProd: true, + productionDefault: { + minimumVersion: '0.0.0', + enabled: false, + }, + status: FeatureFlagStatus.Active, + }, + smartTransactionsAllowedRpcHosts: { name: 'smartTransactionsAllowedRpcHosts', type: FeatureFlagType.Remote, @@ -4833,6 +5013,14 @@ export const FEATURE_FLAG_REGISTRY: Record = { status: FeatureFlagStatus.Active, }, + swapsSWAPS4543AbtestPostTradeModal: { + name: 'swapsSWAPS4543AbtestPostTradeModal', + type: FeatureFlagType.Remote, + inProd: true, + productionDefault: [], + status: FeatureFlagStatus.Active, + }, + prePushPromptEnabled: { name: 'prePushPromptEnabled', type: FeatureFlagType.Remote, diff --git a/tests/page-objects/Perps/PerpsView.ts b/tests/page-objects/Perps/PerpsView.ts index d1d2027a212..b098c79e056 100644 --- a/tests/page-objects/Perps/PerpsView.ts +++ b/tests/page-objects/Perps/PerpsView.ts @@ -289,11 +289,35 @@ class PerpsView { } async tapClosePositionButton() { - await Gestures.waitAndTap(this.closePositionButton, { - elemDescription: 'Close position button', - checkStability: true, - timeout: 15000, - }); + await Utilities.waitUntil( + async () => { + if ( + await Utilities.isElementVisible( + this.closePositionBottomSheetButton, + 400, + ) + ) { + return true; + } + + if (await Utilities.isElementVisible(this.closePositionButton, 400)) { + await Gestures.waitAndTap(this.closePositionButton, { + elemDescription: 'Close position button', + checkStability: true, + timeout: 5000, + }); + } + + return Utilities.isElementVisible( + this.closePositionBottomSheetButton, + 800, + ); + }, + { + interval: 500, + timeout: 15000, + }, + ); } async tapConfirmClosePositionButton() { diff --git a/tests/smoke/predict/predict-cash-out.spec.ts b/tests/smoke/predict/predict-cash-out.spec.ts index f73194cba86..b0f10dec1b6 100644 --- a/tests/smoke/predict/predict-cash-out.spec.ts +++ b/tests/smoke/predict/predict-cash-out.spec.ts @@ -40,6 +40,15 @@ const positionDetails = { const PredictionMarketFeature = async (mockServer: Mockttp) => { await setupRemoteFeatureFlagsMock(mockServer, { ...remoteFeatureFlagPredictEnabled(true), + // TODO: Fix this test to support the FF-enabled Predict bottom sheet / any-token flow. + predictBottomSheet: { + enabled: false, + minimumVersion: '0.0.0', + }, + predictWithAnyToken: { + enabled: false, + minimumVersion: '0.0.0', + }, carouselBanners: false, }); await POLYMARKET_COMPLETE_MOCKS(mockServer); diff --git a/tests/smoke/predict/predict-open-position.spec.ts b/tests/smoke/predict/predict-open-position.spec.ts index 0cd5ebcbd57..0080c92b22d 100644 --- a/tests/smoke/predict/predict-open-position.spec.ts +++ b/tests/smoke/predict/predict-open-position.spec.ts @@ -5,7 +5,10 @@ import { loginToApp } from '../../flows/wallet.flow'; import PredictMarketList from '../../page-objects/Predict/PredictMarketList'; import PredictDetailsPage from '../../page-objects/Predict/PredictDetailsPage'; import Assertions from '../../framework/Assertions'; -import { remoteFeatureFlagPredictEnabled } from '../../api-mocking/mock-responses/feature-flags-mocks'; +import { + remoteFeatureFlagHomepageSectionsV1Enabled, + remoteFeatureFlagPredictEnabled, +} from '../../api-mocking/mock-responses/feature-flags-mocks'; import { Mockttp } from 'mockttp'; import { setupRemoteFeatureFlagsMock } from '../../api-mocking/helpers/remoteFeatureFlagsHelper'; import { @@ -38,6 +41,16 @@ const positionDetails = { const PredictionMarketFeature = async (mockServer: Mockttp) => { await setupRemoteFeatureFlagsMock(mockServer, { ...remoteFeatureFlagPredictEnabled(true), + ...remoteFeatureFlagHomepageSectionsV1Enabled(), + // TODO: Fix this test to support the FF-enabled Predict bottom sheet / any-token flow. + predictBottomSheet: { + enabled: false, + minimumVersion: '0.0.0', + }, + predictWithAnyToken: { + enabled: false, + minimumVersion: '0.0.0', + }, carouselBanners: false, }); await POLYMARKET_COMPLETE_MOCKS(mockServer); diff --git a/tests/smoke/trending/trending-feed.spec.ts b/tests/smoke/trending/trending-feed.spec.ts index 8a93cb0cef6..31442022168 100644 --- a/tests/smoke/trending/trending-feed.spec.ts +++ b/tests/smoke/trending/trending-feed.spec.ts @@ -37,6 +37,11 @@ describe(SmokeWalletPlatform('Trending Feed View All Navigation'), () => { await setupRemoteFeatureFlagsMock(mockServer, { ...remoteFeatureFlagTrendingTokensEnabled(), ...remoteFeatureFlagPredictEnabled(), + // TODO: Fix this test to support the FF-enabled What's Happening Explore section. + aiSocialWhatsHappeningEnabled: { + enabled: false, + minimumVersion: '0.0.0', + }, }); // Setup API mocks using centralized definition From 782493c8ec058db289e9c3fef968e8540aac4b5c Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Thu, 11 Jun 2026 16:49:38 +0530 Subject: [PATCH 17/17] fix: token selection in withdraw money account page (#31535) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Fix token selection in withdraw money account page. ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/CONF-1510 ## **Manual testing steps** NA ## **Screenshots/Recordings** NA ## **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** - [ ] 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** > Small, localized change to default chain ID for one transaction type in the confirmation pay-with flow, with tests updated. > > **Overview** > Fixes **money account withdraw** confirmation so the pay-with flow defaults to **mUSD on Monad** instead of mUSD on **Ethereum mainnet**. > > `resolvePreferredPayToken` now returns `CHAIN_IDS.MONAD` for `moneyAccountWithdraw` when no override is set, aligning the preferred-token row in the pay-with sheet with the withdraw-default asset. Unit tests for `transaction-pay` and `usePayWithCryptoSection` were updated to match. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 1c8d041ea9ef18c5e65679b1999ae5d33ee1799f. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../hooks/pay/sections/usePayWithCryptoSection.test.tsx | 2 +- .../Views/confirmations/utils/transaction-pay.test.ts | 4 ++-- app/components/Views/confirmations/utils/transaction-pay.ts | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.test.tsx b/app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.test.tsx index b0dec556567..fd4613f8094 100644 --- a/app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.test.tsx +++ b/app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.test.tsx @@ -190,7 +190,7 @@ describe('usePayWithCryptoSection', () => { const expectedPreferred = { address: MUSD_TOKEN_ADDRESS, - chainId: CHAIN_IDS.MAINNET, + chainId: CHAIN_IDS.MONAD, }; expect(usePayWithPreferredTokenMock).toHaveBeenCalledWith({ preferredToken: expectedPreferred, diff --git a/app/components/Views/confirmations/utils/transaction-pay.test.ts b/app/components/Views/confirmations/utils/transaction-pay.test.ts index 35201e13c70..c39b432f386 100644 --- a/app/components/Views/confirmations/utils/transaction-pay.test.ts +++ b/app/components/Views/confirmations/utils/transaction-pay.test.ts @@ -868,7 +868,7 @@ describe('Transaction Pay Utils', () => { expect(result).toBe(OVERRIDE); }); - it('returns the mUSD mainnet fallback for moneyAccountWithdraw transactions when no override is provided', () => { + it('returns the mUSD Monad fallback for moneyAccountWithdraw transactions when no override is provided', () => { const result = resolvePreferredPayToken({ override: undefined, transactionMeta: withdrawTransactionMeta, @@ -876,7 +876,7 @@ describe('Transaction Pay Utils', () => { expect(result).toStrictEqual({ address: MUSD_TOKEN_ADDRESS, - chainId: CHAIN_IDS.MAINNET, + chainId: CHAIN_IDS.MONAD, }); }); diff --git a/app/components/Views/confirmations/utils/transaction-pay.ts b/app/components/Views/confirmations/utils/transaction-pay.ts index 04582c6f2be..cfa55c351b4 100644 --- a/app/components/Views/confirmations/utils/transaction-pay.ts +++ b/app/components/Views/confirmations/utils/transaction-pay.ts @@ -318,8 +318,8 @@ export function isMatchingPayToken( * Resolves the preferred pay token for a transaction. * * Returns the explicit override when provided. Otherwise, falls back to - * mUSD on mainnet for moneyAccountWithdraw transactions so the preferred-token - * row in the pay-with bottom sheet reflects the deposit-default asset. + * mUSD on Monad for moneyAccountWithdraw transactions so the preferred-token + * row in the pay-with bottom sheet reflects the withdraw-default asset. * */ export function resolvePreferredPayToken({ @@ -338,7 +338,7 @@ export function resolvePreferredPayToken({ ) { return { address: MUSD_TOKEN_ADDRESS, - chainId: CHAIN_IDS.MAINNET, + chainId: CHAIN_IDS.MONAD, }; }