From 15edc39daf4c59b69cd269295d718b3068c967b3 Mon Sep 17 00:00:00 2001 From: Matthew Grainger <46547583+Matt561@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:48:17 -0400 Subject: [PATCH 001/206] feat: MUSD-454 add quick convert event tracking (#27305) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** ### Changes: - Adds Segment event tracking for the mUSD Quick Convert flow - Enriches generic `Transaction*` events for `musdConversion` transactions - Adds `confirmation_source` to differentiate between the "Max" convert bottom sheet and custom amount confirmations - Adds `is_max` which is `true` when "Max" conversion flow is used or when custom amount is used and user clicks "Max" button in percentage button row - Adds mUSD quote tracking data ### Events | Event | Type | Location | Description | |---|---|---|---| | `mUSD Quick Convert Screen Viewed` | New standalone event | `MusdQuickConvertView` (on mount) | Fires when the quick convert token list screen is viewed | | `mUSD Bonus Terms of Use Pressed` | New standalone event | `MusdQuickConvertView` (`quick_convert_home_screen`), `EarnMusdConversionEducationView` (`conversion_education_screen`), `useMusdConversionNavbar` (`custom_amount_navbar`), `PercentageRow` (`percentage_row`) | Fires when user presses the bonus terms of use link; `location` property differentiates the source | | `mUSD Quick Convert Token Row Button Clicked` | New standalone event | `MusdQuickConvertView` | Fires on "Max" or "Edit" button tap; includes `button_action`, `redirects_to`, asset details | | `confirmation_source` | New property on `Transaction*` events | `useMusdConversionConfirmationMetrics` | `'quick_convert_max_bottom_sheet_confirmation_screen'` or `'custom_amount_screen'` — only attached to `musdConversion` transactions | | `is_max` | New property on `Transaction*` events | `useMusdConversionConfirmationMetrics` | Derived from `TransactionPayController.isMaxAmount` — only attached to `musdConversion` transactions | | Quote tracking data | New properties on `Transaction*` events | `useMusdConversionConfirmationMetrics` | Standardized quote/pay data via `getMusdConversionQuoteTrackingData` — only attached to `musdConversion` transactions | ## **Changelog** CHANGELOG entry: Added Segment event tracking for mUSD Quick Convert flow and enrich generic Transaction* events for mUSD conversion transactions ## **Related issues** Fixes: [MUSD-454: Add segment events for Quick Convert flow](https://consensyssoftware.atlassian.net/browse/MUSD-454) ## **Manual testing steps** ```gherkin Feature: mUSD Quick Convert Segment event tracking Scenario: user views the quick convert screen Given user navigates to the mUSD Quick Convert screen When the screen mounts Then "mUSD Quick Convert Screen Viewed" event fires with location "quick_convert_home_screen" Scenario: user taps Max on a token row Given user is on the mUSD Quick Convert screen with convertible tokens When user taps "Max" on a token row Then "mUSD Quick Convert Token Row Button Clicked" event fires with button_action "max" and redirects_to "quick_convert_max_bottom_sheet_confirmation_screen" Scenario: user taps Edit on a token row Given user is on the mUSD Quick Convert screen with convertible tokens When user taps the edit icon on a token row Then "mUSD Quick Convert Token Row Button Clicked" event fires with button_action "custom" and redirects_to "custom_amount_screen" Scenario: user taps "Terms apply" link on the quick convert screen Given user is on a screen displaying the mUSD bonus "Terms apply" link When user taps "Terms apply" Then "mUSD Bonus Terms of Use Pressed" event fires with the location of the current screen Scenario: user confirms a max mUSD conversion Given user is on the max convert bottom sheet confirmation When user taps "Convert" Then "Transaction Approved" event includes confirmation_source "quick_convert_max_bottom_sheet_confirmation_screen", "is_max: true", and quote tracking data Scenario: user confirms a custom amount mUSD conversion Given user is on the custom amount conversion screen When user taps "Convert" Then "Transaction Approved" event includes confirmation_source "custom_amount_screen", "is_max: false" ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Adds new MetaMetrics events and confirmation-metric dispatches across mUSD conversion/confirmation screens; while behavior is mostly observational, it touches confirmations flow and transaction status tracking and could affect analytics payloads or introduce unintended side effects if hooks fire unexpectedly. > > **Overview** > Adds **MetaMetrics tracking for the mUSD Quick Convert flow**, including `MUSD_QUICK_CONVERT_SCREEN_VIEWED`, `MUSD_QUICK_CONVERT_TOKEN_ROW_BUTTON_CLICKED` (Max/Edit), and `MUSD_BONUS_TERMS_OF_USE_PRESSED` with location/context properties. > > Introduces a shared analytics utility (`getMusdConversionQuoteTrackingData` + `deepSnakeCaseKeys`) and refactors `useMusdConversionStatus` to use it when emitting `MUSD_CONVERSION_STATUS_UPDATED`, standardizing quote-derived properties. > > Enriches **confirmation metrics for `musdConversion`** by adding a new `useMusdConversionConfirmationMetrics` hook (wired into `MusdConversionInfoRoot`) that dispatches `confirmation_source`, `is_max`, and select quote fields into `confirmationMetrics`. Tests are updated/added accordingly, and `EVENT_LOCATIONS`/`MetaMetricsEvents` are extended to support the new instrumentation. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 6e2e686d9ce46fbe55a1b10ec6cb0ecb1f9a15d3. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../index.test.tsx | 15 ++ .../EarnMusdConversionEducationView/index.tsx | 9 + .../MusdQuickConvertView.test.tsx | 171 ++++++++++++ .../Earn/Views/MusdQuickConvertView/index.tsx | 77 +++++- .../UI/Earn/constants/events/musdEvents.ts | 4 + .../UI/Earn/hooks/useMusdConversion.ts | 6 - .../hooks/useMusdConversionNavbar.test.tsx | 55 ++++ .../UI/Earn/hooks/useMusdConversionNavbar.tsx | 21 +- .../UI/Earn/hooks/useMusdConversionStatus.ts | 150 +---------- .../UI/Earn/utils/analytics.test.ts | 242 +++++++++++++++++ app/components/UI/Earn/utils/analytics.ts | 106 +++++++- .../UI/Earn/utils/analytics.types.ts | 18 ++ .../custom-amount-info.test.tsx | 8 + .../musd-conversion-info-root.test.tsx | 17 ++ .../musd-conversion-info-root.tsx | 2 + .../percentage-row/percentage-row.test.tsx | 51 ++++ .../rows/percentage-row/percentage-row.tsx | 19 +- ...eMusdConversionConfirmationMetrics.test.ts | 245 ++++++++++++++++++ .../useMusdConversionConfirmationMetrics.ts | 80 ++++++ app/core/Analytics/MetaMetrics.events.ts | 12 + 20 files changed, 1150 insertions(+), 158 deletions(-) create mode 100644 app/components/UI/Earn/utils/analytics.test.ts create mode 100644 app/components/UI/Earn/utils/analytics.types.ts create mode 100644 app/components/Views/confirmations/hooks/metrics/useMusdConversionConfirmationMetrics.test.ts create mode 100644 app/components/Views/confirmations/hooks/metrics/useMusdConversionConfirmationMetrics.ts diff --git a/app/components/UI/Earn/Views/EarnMusdConversionEducationView/index.test.tsx b/app/components/UI/Earn/Views/EarnMusdConversionEducationView/index.test.tsx index ff8365edd91..6dbed37d9af 100644 --- a/app/components/UI/Earn/Views/EarnMusdConversionEducationView/index.test.tsx +++ b/app/components/UI/Earn/Views/EarnMusdConversionEducationView/index.test.tsx @@ -659,10 +659,25 @@ describe('EarnMusdConversionEducationView', () => { { state: {} }, ); + mockTrackEvent.mockClear(); + mockCreateEventBuilder.mockClear(); + mockAddProperties.mockClear(); + mockBuild.mockClear(); + fireEvent.press( getByText(strings('earn.musd_conversion.education.terms_apply')), ); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.MUSD_BONUS_TERMS_OF_USE_PRESSED, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + location: + MUSD_EVENTS_CONSTANTS.EVENT_LOCATIONS.CONVERSION_EDUCATION_SCREEN, + url: AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE, + }); + expect(mockTrackEvent).toHaveBeenCalledWith({ name: 'mock-built-event' }); + expect(openUrlSpy).toHaveBeenCalledTimes(1); expect(openUrlSpy).toHaveBeenCalledWith( AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE, diff --git a/app/components/UI/Earn/Views/EarnMusdConversionEducationView/index.tsx b/app/components/UI/Earn/Views/EarnMusdConversionEducationView/index.tsx index 359229480a8..ada352c47f3 100644 --- a/app/components/UI/Earn/Views/EarnMusdConversionEducationView/index.tsx +++ b/app/components/UI/Earn/Views/EarnMusdConversionEducationView/index.tsx @@ -316,6 +316,15 @@ const EarnMusdConversionEducationView = () => { }; const handleTermsOfUsePressed = () => { + trackEvent( + createEventBuilder(MetaMetricsEvents.MUSD_BONUS_TERMS_OF_USE_PRESSED) + .addProperties({ + location: EVENT_LOCATIONS.CONVERSION_EDUCATION_SCREEN, + url: AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE, + }) + .build(), + ); + Linking.openURL(AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE); }; diff --git a/app/components/UI/Earn/Views/MusdQuickConvertView/MusdQuickConvertView.test.tsx b/app/components/UI/Earn/Views/MusdQuickConvertView/MusdQuickConvertView.test.tsx index b06f4efd5b9..cfe0b54a992 100644 --- a/app/components/UI/Earn/Views/MusdQuickConvertView/MusdQuickConvertView.test.tsx +++ b/app/components/UI/Earn/Views/MusdQuickConvertView/MusdQuickConvertView.test.tsx @@ -19,10 +19,25 @@ import { MUSD_CONVERSION_APY } from '../../constants/musd'; import AppConstants from '../../../../../core/AppConstants'; import { Linking } from 'react-native'; import { useNavigation, useFocusEffect } from '@react-navigation/native'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import { MUSD_EVENTS_CONSTANTS } from '../../constants/events'; import { strings } from '../../../../../../locales/i18n'; import { MUSD_CONVERSION_NAVIGATION_OVERRIDE } from '../../types/musd.types'; import { ConvertTokenRowTestIds } from '../../components/Musd/ConvertTokenRow'; import { useMusdBalance } from '../../hooks/useMusdBalance'; +import { IconName } from '@metamask/design-system-react-native'; + +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn(); +const mockAddProperties = jest.fn(); +const mockBuild = jest.fn(); + +jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); jest.mock('@react-navigation/native', () => { const actual = jest.requireActual('@react-navigation/native'); @@ -70,6 +85,9 @@ jest.mock('../../../../hooks/useStyles', () => ({ theme: { colors: {} }, })), })); +jest.mock('../../utils/network', () => ({ + getNetworkName: jest.fn(() => 'Ethereum'), +})); jest.mock('react-native/Libraries/Linking/Linking', () => ({ addEventListener: jest.fn(), removeEventListener: jest.fn(), @@ -133,6 +151,14 @@ describe('MusdQuickConvertView', () => { beforeEach(() => { jest.clearAllMocks(); + + mockBuild.mockReturnValue({ name: 'mock-built-event' }); + mockAddProperties.mockImplementation(() => ({ build: mockBuild })); + mockCreateEventBuilder.mockImplementation(() => ({ + addProperties: mockAddProperties, + build: mockBuild, + })); + mockUseNavigation.mockReturnValue({ navigate: jest.fn(), goBack: jest.fn(), @@ -518,4 +544,149 @@ describe('MusdQuickConvertView', () => { ); }); }); + + describe('MetaMetrics', () => { + it('tracks MUSD_BONUS_TERMS_OF_USE_PRESSED event when terms apply text is pressed', () => { + const { getByText } = renderWithProvider(, { + state: initialRootState, + }); + + mockTrackEvent.mockClear(); + mockCreateEventBuilder.mockClear(); + mockAddProperties.mockClear(); + mockBuild.mockClear(); + + const termsApplyText = getByText( + strings('earn.musd_conversion.education.terms_apply'), + ); + + act(() => { + fireEvent.press(termsApplyText); + }); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.MUSD_BONUS_TERMS_OF_USE_PRESSED, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + location: + MUSD_EVENTS_CONSTANTS.EVENT_LOCATIONS.QUICK_CONVERT_HOME_SCREEN, + url: AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE, + }); + expect(mockTrackEvent).toHaveBeenCalledWith({ name: 'mock-built-event' }); + }); + + it('tracks MUSD_QUICK_CONVERT_SCREEN_VIEWED event on mount', () => { + renderWithProvider(, { + state: initialRootState, + }); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.MUSD_QUICK_CONVERT_SCREEN_VIEWED, + ); + expect(mockTrackEvent).toHaveBeenCalledWith({ name: 'mock-built-event' }); + }); + + it('does not track MUSD_QUICK_CONVERT_SCREEN_VIEWED when feature flag is disabled', () => { + mockSelectMusdQuickConvertEnabledFlag.mockReturnValue(false); + + renderWithProvider(, { + state: initialRootState, + }); + + expect(mockCreateEventBuilder).not.toHaveBeenCalledWith( + MetaMetricsEvents.MUSD_QUICK_CONVERT_SCREEN_VIEWED, + ); + }); + + it('tracks MUSD_QUICK_CONVERT_TOKEN_ROW_BUTTON_CLICKED event when Max button is pressed', async () => { + const token = createMockToken(); + mockUseMusdConversionTokens.mockReturnValue({ + tokens: [token], + filterAllowedTokens: jest.fn(), + isConversionToken: jest.fn(), + isMusdSupportedOnChain: jest.fn(), + hasConvertibleTokensByChainId: jest.fn(), + }); + mockInitiateMaxConversion.mockResolvedValue({ + transactionId: 'tx-max-123', + }); + + const { getAllByTestId } = renderWithProvider(, { + state: initialRootState, + }); + + mockTrackEvent.mockClear(); + mockCreateEventBuilder.mockClear(); + mockAddProperties.mockClear(); + mockBuild.mockClear(); + + const maxButton = getAllByTestId(ConvertTokenRowTestIds.MAX_BUTTON)[0]; + + await act(async () => { + fireEvent.press(maxButton); + }); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.MUSD_QUICK_CONVERT_TOKEN_ROW_BUTTON_CLICKED, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + location: + MUSD_EVENTS_CONSTANTS.EVENT_LOCATIONS.QUICK_CONVERT_HOME_SCREEN, + button_type: 'text_button', + button_action: 'max', + button_text: strings('earn.musd_conversion.max'), + redirects_to: + MUSD_EVENTS_CONSTANTS.EVENT_LOCATIONS + .QUICK_CONVERT_MAX_BOTTOM_SHEET_CONFIRMATION_SCREEN, + asset_symbol: token.symbol, + network_chain_id: token.chainId, + network_name: 'Ethereum', + }); + expect(mockTrackEvent).toHaveBeenCalledWith({ name: 'mock-built-event' }); + }); + + it('tracks MUSD_QUICK_CONVERT_TOKEN_ROW_BUTTON_CLICKED event when Edit button is pressed', async () => { + const token = createMockToken(); + mockUseMusdConversionTokens.mockReturnValue({ + tokens: [token], + filterAllowedTokens: jest.fn(), + isConversionToken: jest.fn(), + isMusdSupportedOnChain: jest.fn(), + hasConvertibleTokensByChainId: jest.fn(), + }); + mockInitiateCustomConversion.mockResolvedValue('tx-edit-789'); + + const { getAllByTestId } = renderWithProvider(, { + state: initialRootState, + }); + + mockTrackEvent.mockClear(); + mockCreateEventBuilder.mockClear(); + mockAddProperties.mockClear(); + mockBuild.mockClear(); + + const editButton = getAllByTestId(ConvertTokenRowTestIds.EDIT_BUTTON)[0]; + + await act(async () => { + fireEvent.press(editButton); + }); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.MUSD_QUICK_CONVERT_TOKEN_ROW_BUTTON_CLICKED, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + location: + MUSD_EVENTS_CONSTANTS.EVENT_LOCATIONS.QUICK_CONVERT_HOME_SCREEN, + button_type: 'icon_button', + icon: IconName.Edit, + button_action: 'custom', + redirects_to: + MUSD_EVENTS_CONSTANTS.EVENT_LOCATIONS.CUSTOM_AMOUNT_SCREEN, + asset_symbol: token.symbol, + network_chain_id: token.chainId, + network_name: 'Ethereum', + }); + expect(mockTrackEvent).toHaveBeenCalledWith({ name: 'mock-built-event' }); + }); + }); }); diff --git a/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx b/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx index d13e718d22d..a6221510000 100644 --- a/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx +++ b/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo } from 'react'; import { View, SectionList, Linking } from 'react-native'; import { useSelector } from 'react-redux'; import { useNavigation, useFocusEffect } from '@react-navigation/native'; @@ -31,6 +31,13 @@ import MusdBalanceCard from './components/MusdBalanceCard'; import { MUSD_CONVERSION_NAVIGATION_OVERRIDE } from '../../types/musd.types'; import Logger from '../../../../../util/Logger'; import { useMusdBalance } from '../../hooks/useMusdBalance'; +import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import { MUSD_EVENTS_CONSTANTS } from '../../constants/events'; +import { getNetworkName } from '../../utils/network'; +import { IconName } from '@metamask/design-system-react-native'; + +const { EVENT_LOCATIONS } = MUSD_EVENTS_CONSTANTS; export const MusdQuickConvertViewTestIds = { CONTAINER: 'musd-quick-convert-view-container', @@ -76,6 +83,8 @@ const MusdQuickConvertView = () => { const { initiateCustomConversion, initiateMaxConversion } = useMusdConversion(); + const { trackEvent, createEventBuilder } = useAnalytics(); + // Feature flags const isQuickConvertEnabled = useSelector(selectMusdQuickConvertEnabledFlag); @@ -104,13 +113,38 @@ const MusdQuickConvertView = () => { }, [navigation, colors]), ); + useEffect(() => { + if (!isQuickConvertEnabled) return; + + trackEvent( + createEventBuilder( + MetaMetricsEvents.MUSD_QUICK_CONVERT_SCREEN_VIEWED, + ).build(), + ); + }, [createEventBuilder, isQuickConvertEnabled, trackEvent]); + // navigate to max conversion bottom sheet const handleMaxPress = useCallback( async (token: AssetType) => { - if (!token.rawBalance) { - // TODO: Handle error instead of returning silently. - return; - } + trackEvent( + createEventBuilder( + MetaMetricsEvents.MUSD_QUICK_CONVERT_TOKEN_ROW_BUTTON_CLICKED, + ) + .addProperties({ + location: EVENT_LOCATIONS.QUICK_CONVERT_HOME_SCREEN, + button_type: 'text_button', + button_action: 'max', + button_text: strings('earn.musd_conversion.max'), + redirects_to: + EVENT_LOCATIONS.QUICK_CONVERT_MAX_BOTTOM_SHEET_CONFIRMATION_SCREEN, + asset_symbol: token.symbol, + network_chain_id: token.chainId, + network_name: token.chainId + ? getNetworkName(token.chainId as Hex) + : 'unknown', + }) + .build(), + ); try { await initiateMaxConversion(token); @@ -129,12 +163,31 @@ const MusdQuickConvertView = () => { }); } }, - [initiateMaxConversion], + [createEventBuilder, initiateMaxConversion, trackEvent], ); // navigate to existing confirmation screen const handleEditPress = useCallback( async (token: AssetType) => { + trackEvent( + createEventBuilder( + MetaMetricsEvents.MUSD_QUICK_CONVERT_TOKEN_ROW_BUTTON_CLICKED, + ) + .addProperties({ + location: EVENT_LOCATIONS.QUICK_CONVERT_HOME_SCREEN, + button_type: 'icon_button', + icon: IconName.Edit, + button_action: 'custom', + redirects_to: EVENT_LOCATIONS.CUSTOM_AMOUNT_SCREEN, + asset_symbol: token.symbol, + network_chain_id: token.chainId, + network_name: token.chainId + ? getNetworkName(token.chainId as Hex) + : 'unknown', + }) + .build(), + ); + try { await initiateCustomConversion({ preferredPaymentToken: { @@ -158,7 +211,7 @@ const MusdQuickConvertView = () => { }); } }, - [initiateCustomConversion], + [createEventBuilder, initiateCustomConversion, trackEvent], ); const tokensWithBalance = useMemo( @@ -246,8 +299,16 @@ const MusdQuickConvertView = () => { ); const handleTermsOfUsePressed = useCallback(() => { + trackEvent( + createEventBuilder(MetaMetricsEvents.MUSD_BONUS_TERMS_OF_USE_PRESSED) + .addProperties({ + location: EVENT_LOCATIONS.QUICK_CONVERT_HOME_SCREEN, + url: AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE, + }) + .build(), + ); Linking.openURL(AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE); - }, []); + }, [createEventBuilder, trackEvent]); // If feature flags are not enabled, don't render if (!isQuickConvertEnabled) { diff --git a/app/components/UI/Earn/constants/events/musdEvents.ts b/app/components/UI/Earn/constants/events/musdEvents.ts index ae5e370c332..0abf0ed12c0 100644 --- a/app/components/UI/Earn/constants/events/musdEvents.ts +++ b/app/components/UI/Earn/constants/events/musdEvents.ts @@ -12,6 +12,10 @@ const EVENT_LOCATIONS = { CUSTOM_AMOUNT_SCREEN: 'custom_amount_screen', // Single convert screen. BUY_SCREEN: 'buy_screen', // Buy mUSD screen. QUICK_CONVERT_HOME_SCREEN: 'quick_convert_home_screen', + QUICK_CONVERT_MAX_BOTTOM_SHEET_CONFIRMATION_SCREEN: + 'quick_convert_max_bottom_sheet_confirmation_screen', + CUSTOM_AMOUNT_NAVBAR: 'custom_amount_navbar', + PERCENTAGE_ROW: 'percentage_row', }; const MUSD_CTA_TYPES = { diff --git a/app/components/UI/Earn/hooks/useMusdConversion.ts b/app/components/UI/Earn/hooks/useMusdConversion.ts index 506dcb2cba5..b8d033a8553 100644 --- a/app/components/UI/Earn/hooks/useMusdConversion.ts +++ b/app/components/UI/Earn/hooks/useMusdConversion.ts @@ -280,12 +280,6 @@ export const useMusdConversion = () => { } = Engine.context; try { - Logger.log('[mUSD Max Conversion] Setting payment token:', { - transactionId, - tokenAddress, - chainId: tokenChainId, - }); - // Must be called BEFORE updatePaymentToken. TransactionPayController.setTransactionConfig( transactionId, diff --git a/app/components/UI/Earn/hooks/useMusdConversionNavbar.test.tsx b/app/components/UI/Earn/hooks/useMusdConversionNavbar.test.tsx index 0aedd62b9c5..1193e5abcf1 100644 --- a/app/components/UI/Earn/hooks/useMusdConversionNavbar.test.tsx +++ b/app/components/UI/Earn/hooks/useMusdConversionNavbar.test.tsx @@ -9,6 +9,20 @@ import { NavbarOverrides } from '../../../Views/confirmations/components/UI/navb import useTooltipModal from '../../../hooks/useTooltipModal'; import { MUSD_CONVERSION_APY } from '../constants/musd'; import AppConstants from '../../../../core/AppConstants'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import { MUSD_EVENTS_CONSTANTS } from '../constants/events'; + +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn(); +const mockAddProperties = jest.fn(); +const mockBuild = jest.fn(); + +jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); jest.mock('../../../../../locales/i18n', () => ({ strings: jest.fn((key: string) => key), @@ -29,6 +43,13 @@ describe('useMusdConversionNavbar', () => { beforeEach(() => { jest.clearAllMocks(); + + mockBuild.mockReturnValue({ name: 'mock-built-event' }); + mockAddProperties.mockImplementation(() => ({ build: mockBuild })); + mockCreateEventBuilder.mockImplementation(() => ({ + addProperties: mockAddProperties, + })); + mockUseTooltipModal.mockReturnValue({ openTooltipModal: mockOpenTooltipModal, }); @@ -185,4 +206,38 @@ describe('useMusdConversionNavbar', () => { AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE, ); }); + + it('tracks MUSD_BONUS_TERMS_OF_USE_PRESSED event when "Terms apply" is pressed in tooltip content', () => { + let capturedOverrides: NavbarOverrides | undefined; + mockUseNavbar.mockImplementation((_title, _addBackButton, overrides) => { + capturedOverrides = overrides; + }); + + renderHook(() => useMusdConversionNavbar()); + + const HeaderRight = capturedOverrides?.headerRight as React.FC; + const { getByTestId } = render(); + + fireEvent.press(getByTestId('button-icon')); + + const tooltipBody = mockOpenTooltipModal.mock + .calls[0][1] as React.ReactElement; + const { getByText } = render(tooltipBody); + + mockTrackEvent.mockClear(); + mockCreateEventBuilder.mockClear(); + mockAddProperties.mockClear(); + mockBuild.mockClear(); + + fireEvent.press(getByText('earn.musd_conversion.education.terms_apply')); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.MUSD_BONUS_TERMS_OF_USE_PRESSED, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + location: MUSD_EVENTS_CONSTANTS.EVENT_LOCATIONS.CUSTOM_AMOUNT_NAVBAR, + url: AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE, + }); + expect(mockTrackEvent).toHaveBeenCalledWith({ name: 'mock-built-event' }); + }); }); diff --git a/app/components/UI/Earn/hooks/useMusdConversionNavbar.tsx b/app/components/UI/Earn/hooks/useMusdConversionNavbar.tsx index a6594f5fb31..cd910f976ad 100644 --- a/app/components/UI/Earn/hooks/useMusdConversionNavbar.tsx +++ b/app/components/UI/Earn/hooks/useMusdConversionNavbar.tsx @@ -14,6 +14,11 @@ import { import useNavbar from '../../../Views/confirmations/hooks/ui/useNavbar'; import useTooltipModal from '../../../hooks/useTooltipModal'; import AppConstants from '../../../../core/AppConstants'; +import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import { MUSD_EVENTS_CONSTANTS } from '../constants/events'; + +const { EVENT_LOCATIONS } = MUSD_EVENTS_CONSTANTS; const styles = StyleSheet.create({ headerTitle: { @@ -39,6 +44,8 @@ const styles = StyleSheet.create({ export function useMusdConversionNavbar() { const { openTooltipModal } = useTooltipModal(); + const { trackEvent, createEventBuilder } = useAnalytics(); + const renderHeaderTitle = useCallback( () => ( @@ -66,9 +73,17 @@ export function useMusdConversionNavbar() { [], ); - const handleTermsOfUsePressed = () => { + const handleTermsOfUsePressed = useCallback(() => { + trackEvent( + createEventBuilder(MetaMetricsEvents.MUSD_BONUS_TERMS_OF_USE_PRESSED) + .addProperties({ + location: EVENT_LOCATIONS.CUSTOM_AMOUNT_NAVBAR, + url: AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE, + }) + .build(), + ); Linking.openURL(AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE); - }; + }, [createEventBuilder, trackEvent]); const onInfoPress = useCallback(() => { openTooltipModal( @@ -88,7 +103,7 @@ export function useMusdConversionNavbar() { strings('earn.musd_conversion.powered_by_relay'), strings('earn.musd_conversion.ok'), ); - }, [openTooltipModal]); + }, [handleTermsOfUsePressed, openTooltipModal]); const renderHeaderRight = useCallback( () => ( diff --git a/app/components/UI/Earn/hooks/useMusdConversionStatus.ts b/app/components/UI/Earn/hooks/useMusdConversionStatus.ts index 3571b3d41ec..31b7bfcec3f 100644 --- a/app/components/UI/Earn/hooks/useMusdConversionStatus.ts +++ b/app/components/UI/Earn/hooks/useMusdConversionStatus.ts @@ -4,7 +4,6 @@ import { TransactionType, } from '@metamask/transaction-controller'; import { Hex } from '@metamask/utils'; -import type { TransactionPayQuote } from '@metamask/transaction-pay-controller'; import { useCallback, useEffect, useRef } from 'react'; import { useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; @@ -24,99 +23,11 @@ import { import { store } from '../../../../store'; import { selectTransactionPayQuotesByTransactionId } from '../../../../selectors/transactionPayController'; import { getNetworkName } from '../utils/network'; +import { getMusdConversionQuoteTrackingData } from '../utils/analytics'; -type PayQuote = TransactionPayQuote; - -function chainIdsMatch(a?: Hex, b?: Hex): boolean | undefined { - if (!a || !b) return undefined; - return a.toLowerCase() === b.toLowerCase(); -} - -function getTransactionPayQuotes(transactionId: string): PayQuote[] { +function getTransactionPayQuotes(transactionId: string) { const state = store.getState(); - return ( - (selectTransactionPayQuotesByTransactionId(state, transactionId) as - | PayQuote[] - | undefined) ?? [] - ); -} - -function getMusdConversionQuoteTrackingData(transactionMeta: TransactionMeta): { - quotePaymentChainId?: Hex; - quoteOutputChainId?: Hex; - quotePaymentTokenAddress?: Hex; - quoteOutputTokenAddress?: Hex; - quoteIsSameChain?: boolean; - strategy: string; - paymentAmountUsd?: string; - outputAmountUsd?: string; - selectedPaymentChainId?: Hex; - selectedPaymentChainMatchesQuotePaymentChain?: boolean; - txExecutionChainMatchesQuoteOutputChain?: boolean; - paymentTokenAddress?: Hex; - paymentTokenChainId?: Hex; - outputTokenAddress?: Hex; - outputTokenChainId?: Hex; -} { - const quote = getTransactionPayQuotes(transactionMeta.id)[0]; - const quoteRequest: PayQuote['request'] | undefined = quote?.request; - - const quotePaymentChainId = quoteRequest?.sourceChainId; - const quoteOutputChainId = quoteRequest?.targetChainId; - const quotePaymentTokenAddress = quoteRequest?.sourceTokenAddress; - const quoteOutputTokenAddress = quoteRequest?.targetTokenAddress; - - const quoteIsSameChain = chainIdsMatch( - quotePaymentChainId, - quoteOutputChainId, - ); - - const strategy = quote?.strategy - ? String(quote.strategy).toLowerCase() - : 'unknown'; - - const paymentAmountUsd = quote?.sourceAmount?.usd; - const outputAmountUsd = quote?.targetAmount?.usd; - - const selectedPaymentChainId = transactionMeta.metamaskPay?.chainId; - const selectedPaymentTokenAddress = transactionMeta.metamaskPay?.tokenAddress; - - const selectedPaymentChainMatchesQuotePaymentChain = chainIdsMatch( - selectedPaymentChainId, - quotePaymentChainId, - ); - - const txExecutionChainMatchesQuoteOutputChain = chainIdsMatch( - transactionMeta?.chainId, - quoteOutputChainId, - ); - - const paymentTokenAddress = - selectedPaymentTokenAddress ?? quotePaymentTokenAddress; - const paymentTokenChainId = selectedPaymentChainId ?? quotePaymentChainId; - - const outputTokenAddress = - quoteOutputTokenAddress ?? - (transactionMeta?.txParams?.to as Hex | undefined); - const outputTokenChainId = quoteOutputChainId ?? transactionMeta?.chainId; - - return { - quotePaymentChainId, - quoteOutputChainId, - quotePaymentTokenAddress, - quoteOutputTokenAddress, - quoteIsSameChain, - strategy, - paymentAmountUsd, - outputAmountUsd, - selectedPaymentChainId, - selectedPaymentChainMatchesQuotePaymentChain, - txExecutionChainMatchesQuoteOutputChain, - paymentTokenAddress, - paymentTokenChainId, - outputTokenAddress, - outputTokenChainId, - }; + return selectTransactionPayQuotesByTransactionId(state, transactionId) ?? []; } /** @@ -163,23 +74,12 @@ export const useMusdConversionStatus = () => { // If txParams.data is malformed or missing, keep amounts empty. } - const { - quotePaymentChainId, - quoteOutputChainId, - quotePaymentTokenAddress, - quoteOutputTokenAddress, - quoteIsSameChain, - strategy, - paymentAmountUsd, - outputAmountUsd, - selectedPaymentChainId, - selectedPaymentChainMatchesQuotePaymentChain, - txExecutionChainMatchesQuoteOutputChain, - paymentTokenAddress, - paymentTokenChainId, - outputTokenAddress, - outputTokenChainId, - } = getMusdConversionQuoteTrackingData(transactionMeta); + const quotes = getTransactionPayQuotes(transactionMeta.id); + + const quoteTrackingData = getMusdConversionQuoteTrackingData( + transactionMeta, + quotes, + ); trackEvent( createEventBuilder(MetaMetricsEvents.MUSD_CONVERSION_STATUS_UPDATED) @@ -192,30 +92,7 @@ export const useMusdConversionStatus = () => { network_name: getNetworkName(transactionMeta?.chainId), amount_decimal: amountDecimalString, amount_hex: amountHexString, - - // Quote-derived (primary) - quote_payment_chain_id: quotePaymentChainId, - quote_output_chain_id: quoteOutputChainId, - quote_is_same_chain: quoteIsSameChain, - quote_payment_token_address: quotePaymentTokenAddress, - quote_output_token_address: quoteOutputTokenAddress, - payment_amount_usd: paymentAmountUsd, - output_amount_usd: outputAmountUsd, - pay_quote_strategy: strategy, - - // Secondary consistency checks. - selected_payment_chain_id: selectedPaymentChainId, - selected_payment_chain_matches_quote_payment_chain: - selectedPaymentChainMatchesQuotePaymentChain, - tx_execution_chain_matches_quote_output_chain: - txExecutionChainMatchesQuoteOutputChain, - - // Explicit token identity (in/out). - payment_token_address: paymentTokenAddress, - payment_token_chain_id: paymentTokenChainId, - - output_token_address: outputTokenAddress, - output_token_chain_id: outputTokenChainId, + ...quoteTrackingData, }) .build(), ); @@ -302,12 +179,7 @@ export const useMusdConversionStatus = () => { ); shownToastsRef.current.add(toastKey); - // Get quotes from state to include strategy in trace - const state = store.getState(); - const quotes = selectTransactionPayQuotesByTransactionId( - state, - transactionId, - ); + const quotes = getTransactionPayQuotes(transactionId); // Start confirmation trace (approved fires immediately after user confirms) trace({ diff --git a/app/components/UI/Earn/utils/analytics.test.ts b/app/components/UI/Earn/utils/analytics.test.ts new file mode 100644 index 00000000000..b48a5c60917 --- /dev/null +++ b/app/components/UI/Earn/utils/analytics.test.ts @@ -0,0 +1,242 @@ +import { TransactionMeta } from '@metamask/transaction-controller'; +import { TransactionPayQuote } from '@metamask/transaction-pay-controller'; +import { Hex, Json } from '@metamask/utils'; +import { + deepSnakeCaseKeys, + getMusdConversionQuoteTrackingData, +} from './analytics'; + +describe('deepSnakeCaseKeys', () => { + it('converts camelCase keys to snake_case', () => { + const input = { myKey: 'value', anotherKey: 42 }; + + expect(deepSnakeCaseKeys(input)).toStrictEqual({ + my_key: 'value', + another_key: 42, + }); + }); + + it('handles nested objects recursively', () => { + const input = { + outerKey: { + innerKey: 'value', + deeperLevel: { + deepKey: true, + }, + }, + }; + + expect(deepSnakeCaseKeys(input)).toStrictEqual({ + outer_key: { + inner_key: 'value', + deeper_level: { + deep_key: true, + }, + }, + }); + }); + + it('handles arrays by converting each element', () => { + const input = [{ itemKey: 1 }, { itemKey: 2 }]; + + expect(deepSnakeCaseKeys(input)).toStrictEqual([ + { item_key: 1 }, + { item_key: 2 }, + ]); + }); + + it('handles arrays nested inside objects', () => { + const input = { + myList: [{ listItemKey: 'a' }, { listItemKey: 'b' }], + }; + + expect(deepSnakeCaseKeys(input)).toStrictEqual({ + my_list: [{ list_item_key: 'a' }, { list_item_key: 'b' }], + }); + }); + + it('returns primitive values unchanged', () => { + expect(deepSnakeCaseKeys('hello')).toBe('hello'); + expect(deepSnakeCaseKeys(42)).toBe(42); + expect(deepSnakeCaseKeys(true)).toBe(true); + expect(deepSnakeCaseKeys(null)).toBeNull(); + expect(deepSnakeCaseKeys(undefined)).toBeUndefined(); + }); + + it('handles empty objects', () => { + expect(deepSnakeCaseKeys({})).toStrictEqual({}); + }); + + it('handles empty arrays', () => { + expect(deepSnakeCaseKeys([])).toStrictEqual([]); + }); + + it('preserves keys already in snake_case', () => { + const input = { already_snake: 'value' }; + + expect(deepSnakeCaseKeys(input)).toStrictEqual({ + already_snake: 'value', + }); + }); +}); + +describe('getMusdConversionQuoteTrackingData', () => { + const SOURCE_CHAIN_ID = '0x1' as Hex; + const TARGET_CHAIN_ID = '0xa' as Hex; + const SOURCE_TOKEN_ADDRESS = '0xabc' as Hex; + const TARGET_TOKEN_ADDRESS = '0xdef' as Hex; + const TX_TO_ADDRESS = '0x999' as Hex; + + const buildQuote = ( + overrides: Partial<{ + sourceChainId: Hex; + targetChainId: Hex; + sourceTokenAddress: Hex; + targetTokenAddress: Hex; + strategy: string; + sourceAmountUsd: string; + targetAmountUsd: string; + }> = {}, + ): TransactionPayQuote => + ({ + strategy: overrides.strategy ?? 'bridge', + sourceAmount: { usd: overrides.sourceAmountUsd ?? '100.00' }, + targetAmount: { usd: overrides.targetAmountUsd ?? '99.50' }, + request: { + sourceChainId: overrides.sourceChainId ?? SOURCE_CHAIN_ID, + targetChainId: overrides.targetChainId ?? TARGET_CHAIN_ID, + sourceTokenAddress: + overrides.sourceTokenAddress ?? SOURCE_TOKEN_ADDRESS, + targetTokenAddress: + overrides.targetTokenAddress ?? TARGET_TOKEN_ADDRESS, + }, + }) as unknown as TransactionPayQuote; + + const buildTxMeta = ( + overrides: Partial<{ + chainId: Hex; + to: Hex; + metamaskPayChainId: Hex; + metamaskPayTokenAddress: Hex; + }> = {}, + ): TransactionMeta => + ({ + chainId: overrides.chainId ?? TARGET_CHAIN_ID, + txParams: { + to: overrides.to ?? TX_TO_ADDRESS, + }, + metamaskPay: { + chainId: overrides.metamaskPayChainId ?? SOURCE_CHAIN_ID, + tokenAddress: overrides.metamaskPayTokenAddress ?? SOURCE_TOKEN_ADDRESS, + }, + }) as unknown as TransactionMeta; + + it('returns all snake_cased quote tracking properties', () => { + const txMeta = buildTxMeta(); + const quotes = [buildQuote()]; + + const result = getMusdConversionQuoteTrackingData(txMeta, quotes); + + expect(result).toStrictEqual({ + quote_payment_chain_id: SOURCE_CHAIN_ID, + quote_output_chain_id: TARGET_CHAIN_ID, + quote_payment_token_address: SOURCE_TOKEN_ADDRESS, + quote_output_token_address: TARGET_TOKEN_ADDRESS, + quote_is_same_chain: false, + pay_quote_strategy: 'bridge', + payment_amount_usd: '100.00', + output_amount_usd: '99.50', + selected_payment_chain_id: SOURCE_CHAIN_ID, + selected_payment_chain_matches_quote_payment_chain: true, + tx_execution_chain_matches_quote_output_chain: true, + payment_token_address: SOURCE_TOKEN_ADDRESS, + payment_token_chain_id: SOURCE_CHAIN_ID, + output_token_address: TARGET_TOKEN_ADDRESS, + output_token_chain_id: TARGET_CHAIN_ID, + }); + }); + + it('detects same-chain when source and target chain match', () => { + const txMeta = buildTxMeta({ chainId: SOURCE_CHAIN_ID }); + const quotes = [ + buildQuote({ + sourceChainId: SOURCE_CHAIN_ID, + targetChainId: SOURCE_CHAIN_ID, + }), + ]; + + const result = getMusdConversionQuoteTrackingData(txMeta, quotes); + + expect(result.quote_is_same_chain).toBe(true); + }); + + it('returns "unknown" strategy when quote has no strategy', () => { + const txMeta = buildTxMeta(); + const quote = buildQuote(); + (quote as unknown as Record).strategy = undefined; + + const result = getMusdConversionQuoteTrackingData(txMeta, [quote]); + + expect(result.pay_quote_strategy).toBe('unknown'); + }); + + it('lowercases the strategy string', () => { + const txMeta = buildTxMeta(); + const quotes = [buildQuote({ strategy: 'RELAY' })]; + + const result = getMusdConversionQuoteTrackingData(txMeta, quotes); + + expect(result.pay_quote_strategy).toBe('relay'); + }); + + it('falls back to quote token address when metamaskPay is absent', () => { + const txMeta = { + chainId: TARGET_CHAIN_ID, + txParams: { to: TX_TO_ADDRESS }, + } as unknown as TransactionMeta; + const quotes = [buildQuote()]; + + const result = getMusdConversionQuoteTrackingData(txMeta, quotes); + + expect(result.payment_token_address).toBe(SOURCE_TOKEN_ADDRESS); + expect(result.payment_token_chain_id).toBe(SOURCE_CHAIN_ID); + }); + + // TODO: We don't want this behaviour. If the quote has no target token address, we should not use the txParams.to. + it('falls back to txParams.to when quote has no target token address', () => { + const txMeta = buildTxMeta(); + const quote = buildQuote(); + ( + quote as unknown as { request: Record } + ).request.targetTokenAddress = undefined; + + const result = getMusdConversionQuoteTrackingData(txMeta, [quote]); + + expect(result.output_token_address).toBe(TX_TO_ADDRESS); + }); + + it('falls back to transactionMeta.chainId when quote has no target chain', () => { + const txMeta = buildTxMeta(); + const quote = buildQuote(); + ( + quote as unknown as { request: Record } + ).request.targetChainId = undefined; + + const result = getMusdConversionQuoteTrackingData(txMeta, [quote]); + + expect(result.output_token_chain_id).toBe(TARGET_CHAIN_ID); + }); + + it('uses the first quote when multiple quotes are provided', () => { + const txMeta = buildTxMeta(); + const firstQuote = buildQuote({ strategy: 'bridge' }); + const secondQuote = buildQuote({ strategy: 'relay' }); + + const result = getMusdConversionQuoteTrackingData(txMeta, [ + firstQuote, + secondQuote, + ]); + + expect(result.pay_quote_strategy).toBe('bridge'); + }); +}); diff --git a/app/components/UI/Earn/utils/analytics.ts b/app/components/UI/Earn/utils/analytics.ts index 2260df50e89..dfb9f0b4257 100644 --- a/app/components/UI/Earn/utils/analytics.ts +++ b/app/components/UI/Earn/utils/analytics.ts @@ -1,5 +1,9 @@ -import { toHex } from '@metamask/controller-utils'; +import { toHex, isEqualCaseInsensitive } from '@metamask/controller-utils'; import { isNonEvmChainId } from '../../../../core/Multichain/utils'; +import { Hex, Json } from '@metamask/utils'; +import { TransactionMeta } from '@metamask/transaction-controller'; +import { TransactionPayQuote } from '@metamask/transaction-pay-controller'; +import type { DeepSnakeCaseKeys } from './analytics.types'; /** * Formats a chain ID for analytics tracking. @@ -18,3 +22,103 @@ export const formatChainIdForAnalytics = ( const chainIdStr = String(chainId); return isNonEvmChainId(chainIdStr) ? chainIdStr : toHex(chainId); }; + +const camelToSnakeCase = (str: string): string => + str.replace(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`); + +export const deepSnakeCaseKeys = (obj: T): DeepSnakeCaseKeys => { + if (Array.isArray(obj)) { + return obj.map(deepSnakeCaseKeys) as DeepSnakeCaseKeys; + } + if (obj !== null && typeof obj === 'object') { + return Object.entries(obj).reduce( + (acc, [key, value]) => { + acc[camelToSnakeCase(key)] = deepSnakeCaseKeys(value); + return acc; + }, + {} as Record, + ) as DeepSnakeCaseKeys; + } + return obj as DeepSnakeCaseKeys; +}; + +export function getMusdConversionQuoteTrackingData( + transactionMeta: TransactionMeta, + quotes: TransactionPayQuote[], +): { + quote_payment_chain_id?: Hex; + quote_output_chain_id?: Hex; + quote_is_same_chain?: boolean; + quote_payment_token_address?: Hex; + quote_output_token_address?: Hex; + payment_amount_usd?: string; + output_amount_usd?: string; + pay_quote_strategy: string; + selected_payment_chain_id?: Hex; + selected_payment_chain_matches_quote_payment_chain?: boolean; + tx_execution_chain_matches_quote_output_chain?: boolean; + payment_token_address?: Hex; + payment_token_chain_id?: Hex; + output_token_address?: Hex; + output_token_chain_id?: Hex; +} { + const quote = quotes?.[0]; + const quoteRequest = quote?.request; + + const quotePaymentChainId = quoteRequest?.sourceChainId; + const quoteOutputChainId = quoteRequest?.targetChainId; + const quotePaymentTokenAddress = quoteRequest?.sourceTokenAddress; + const quoteOutputTokenAddress = quoteRequest?.targetTokenAddress; + + const quoteIsSameChain = + quotePaymentChainId && quoteOutputChainId + ? isEqualCaseInsensitive(quotePaymentChainId, quoteOutputChainId) + : undefined; + + const payQuoteStrategy = quote?.strategy + ? String(quote.strategy).toLowerCase() + : 'unknown'; + + const paymentAmountUsd = quote?.sourceAmount?.usd; + const outputAmountUsd = quote?.targetAmount?.usd; + + const selectedPaymentChainId = transactionMeta.metamaskPay?.chainId; + const selectedPaymentTokenAddress = transactionMeta.metamaskPay?.tokenAddress; + + const selectedPaymentChainMatchesQuotePaymentChain = + selectedPaymentChainId && quotePaymentChainId + ? isEqualCaseInsensitive(selectedPaymentChainId, quotePaymentChainId) + : undefined; + + const txExecutionChainMatchesQuoteOutputChain = + transactionMeta?.chainId && quoteOutputChainId + ? isEqualCaseInsensitive(transactionMeta.chainId, quoteOutputChainId) + : undefined; + + const paymentTokenAddress = + selectedPaymentTokenAddress ?? quotePaymentTokenAddress; + const paymentTokenChainId = selectedPaymentChainId ?? quotePaymentChainId; + + const outputTokenAddress = + quoteOutputTokenAddress ?? + (transactionMeta?.txParams?.to as Hex | undefined); + const outputTokenChainId = quoteOutputChainId ?? transactionMeta?.chainId; + + return deepSnakeCaseKeys({ + quotePaymentChainId, + quoteOutputChainId, + quotePaymentTokenAddress, + quoteOutputTokenAddress, + quoteIsSameChain, + payQuoteStrategy, + paymentAmountUsd, + outputAmountUsd, + selectedPaymentChainId, + selectedPaymentChainMatchesQuotePaymentChain, + txExecutionChainMatchesQuoteOutputChain, + paymentTokenAddress, + paymentTokenChainId, + outputTokenAddress, + outputTokenChainId, + }); +} diff --git a/app/components/UI/Earn/utils/analytics.types.ts b/app/components/UI/Earn/utils/analytics.types.ts new file mode 100644 index 00000000000..cb114284e23 --- /dev/null +++ b/app/components/UI/Earn/utils/analytics.types.ts @@ -0,0 +1,18 @@ +/** + * Converts a camelCase string to snake_case at the type level. + * + * Inserts an underscore before each uppercase letter, then lowercases. + */ +type CamelToSnakeCase = S extends `${infer T}${infer U}` + ? U extends Uncapitalize + ? `${Lowercase}${CamelToSnakeCase}` + : `${Lowercase}_${CamelToSnakeCase}` + : S; + +export type DeepSnakeCaseKeys = T extends readonly (infer U)[] + ? DeepSnakeCaseKeys[] + : T extends object + ? { + [K in keyof T as CamelToSnakeCase]: DeepSnakeCaseKeys; + } + : T; diff --git a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx index 3245da47117..7878a6d99e7 100644 --- a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx +++ b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx @@ -56,6 +56,14 @@ jest.mock('../../../hooks/metrics/useConfirmationMetricEvents', () => ({ setConfirmationMetric: jest.fn(), }), })); +jest.mock('../../../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: jest.fn(), + createEventBuilder: jest.fn(() => ({ + addProperties: jest.fn(() => ({ build: jest.fn() })), + })), + }), +})); const mockGoToBuy = jest.fn(); diff --git a/app/components/Views/confirmations/components/info/musd-conversion-info-root/musd-conversion-info-root.test.tsx b/app/components/Views/confirmations/components/info/musd-conversion-info-root/musd-conversion-info-root.test.tsx index a9d74e1bf85..9302e8b8de9 100644 --- a/app/components/Views/confirmations/components/info/musd-conversion-info-root/musd-conversion-info-root.test.tsx +++ b/app/components/Views/confirmations/components/info/musd-conversion-info-root/musd-conversion-info-root.test.tsx @@ -6,6 +6,15 @@ import { MusdConversionInfoRoot } from './musd-conversion-info-root'; const MUSD_CONVERSION_INFO_TEST_ID = 'musd-conversion-info'; const MUSD_MAX_CONVERSION_INFO_TEST_ID = 'musd-max-conversion-info'; +const mockUseMusdConversionConfirmationMetrics = jest.fn(); +jest.mock( + '../../../hooks/metrics/useMusdConversionConfirmationMetrics', + () => ({ + useMusdConversionConfirmationMetrics: (...args: unknown[]) => + mockUseMusdConversionConfirmationMetrics(...args), + }), +); + jest.mock('../musd-conversion-info', () => { const { View } = jest.requireActual('react-native'); @@ -69,4 +78,12 @@ describe('MusdConversionInfoRoot', () => { expect(getByTestId(MUSD_CONVERSION_INFO_TEST_ID)).toBeOnTheScreen(); expect(queryByTestId(MUSD_MAX_CONVERSION_INFO_TEST_ID)).toBeNull(); }); + + it('calls useMusdConversionConfirmationMetrics on render', () => { + mockUseParams.mockReturnValue({ forceBottomSheet: false }); + + renderWithProvider(); + + expect(mockUseMusdConversionConfirmationMetrics).toHaveBeenCalled(); + }); }); diff --git a/app/components/Views/confirmations/components/info/musd-conversion-info-root/musd-conversion-info-root.tsx b/app/components/Views/confirmations/components/info/musd-conversion-info-root/musd-conversion-info-root.tsx index c6b249ab882..9a0d30c222f 100644 --- a/app/components/Views/confirmations/components/info/musd-conversion-info-root/musd-conversion-info-root.tsx +++ b/app/components/Views/confirmations/components/info/musd-conversion-info-root/musd-conversion-info-root.tsx @@ -2,9 +2,11 @@ import React from 'react'; import { MusdConversionInfo } from '../musd-conversion-info'; import { MusdMaxConversionInfo } from '../musd-max-conversion-info'; import { useParams } from '../../../../../../util/navigation/navUtils'; +import { useMusdConversionConfirmationMetrics } from '../../../hooks/metrics/useMusdConversionConfirmationMetrics'; export const MusdConversionInfoRoot = () => { const { forceBottomSheet } = useParams<{ forceBottomSheet?: boolean }>(); + useMusdConversionConfirmationMetrics(); return forceBottomSheet ? : ; }; diff --git a/app/components/Views/confirmations/components/rows/percentage-row/percentage-row.test.tsx b/app/components/Views/confirmations/components/rows/percentage-row/percentage-row.test.tsx index f425bc42b99..d23b1b25f4f 100644 --- a/app/components/Views/confirmations/components/rows/percentage-row/percentage-row.test.tsx +++ b/app/components/Views/confirmations/components/rows/percentage-row/percentage-row.test.tsx @@ -1,4 +1,6 @@ import React from 'react'; +import { fireEvent } from '@testing-library/react-native'; +import { Linking } from 'react-native'; import renderWithProvider from '../../../../../../util/test/renderWithProvider'; import { PercentageRow } from './percentage-row'; import { useIsTransactionPayLoading } from '../../../hooks/pay/useTransactionPayData'; @@ -6,6 +8,21 @@ import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTr import { strings } from '../../../../../../../locales/i18n'; import { MUSD_CONVERSION_APY } from '../../../../../UI/Earn/constants/musd'; import { TransactionType } from '@metamask/transaction-controller'; +import { MetaMetricsEvents } from '../../../../../../core/Analytics'; +import { MUSD_EVENTS_CONSTANTS } from '../../../../../UI/Earn/constants/events'; +import AppConstants from '../../../../../../core/AppConstants'; + +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn(); +const mockAddProperties = jest.fn(); +const mockBuild = jest.fn(); + +jest.mock('../../../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); jest.mock('../../../hooks/pay/useTransactionPayData'); jest.mock('../../../hooks/transactions/useTransactionMetadataRequest'); @@ -25,6 +42,12 @@ describe('PercentageRow', () => { beforeEach(() => { jest.resetAllMocks(); + mockBuild.mockReturnValue({ name: 'mock-built-event' }); + mockAddProperties.mockImplementation(() => ({ build: mockBuild })); + mockCreateEventBuilder.mockImplementation(() => ({ + addProperties: mockAddProperties, + })); + useIsTransactionPayLoadingMock.mockReturnValue(false); useTransactionMetadataRequestMock.mockReturnValue({ type: TransactionType.musdConversion, @@ -47,6 +70,34 @@ describe('PercentageRow', () => { expect(getByTestId('percentage-row-skeleton')).toBeOnTheScreen(); }); + it('tracks event and opens URL when terms apply tooltip link is pressed', () => { + const openUrlSpy = jest + .spyOn(Linking, 'openURL') + .mockResolvedValueOnce(undefined); + + const { getByTestId, getByText } = render(); + + fireEvent.press(getByTestId('info-row-tooltip-open-btn')); + + fireEvent.press( + getByText(strings('earn.musd_conversion.education.terms_apply')), + ); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.MUSD_BONUS_TERMS_OF_USE_PRESSED, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + location: MUSD_EVENTS_CONSTANTS.EVENT_LOCATIONS.PERCENTAGE_ROW, + url: AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE, + }); + expect(mockTrackEvent).toHaveBeenCalledWith({ name: 'mock-built-event' }); + + expect(openUrlSpy).toHaveBeenCalledTimes(1); + expect(openUrlSpy).toHaveBeenCalledWith( + AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE, + ); + }); + it('renders nothing for non-musdConversion transactions', () => { useTransactionMetadataRequestMock.mockReturnValue({ type: TransactionType.simpleSend, diff --git a/app/components/Views/confirmations/components/rows/percentage-row/percentage-row.tsx b/app/components/Views/confirmations/components/rows/percentage-row/percentage-row.tsx index 83f89ad2751..59a922ed3be 100644 --- a/app/components/Views/confirmations/components/rows/percentage-row/percentage-row.tsx +++ b/app/components/Views/confirmations/components/rows/percentage-row/percentage-row.tsx @@ -14,6 +14,11 @@ import AppConstants from '../../../../../../core/AppConstants'; import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest'; import { TransactionType } from '@metamask/transaction-controller'; import { hasTransactionType } from '../../../utils/transaction'; +import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics'; +import { MetaMetricsEvents } from '../../../../../../core/Analytics'; +import { MUSD_EVENTS_CONSTANTS } from '../../../../../UI/Earn/constants/events'; + +const { EVENT_LOCATIONS } = MUSD_EVENTS_CONSTANTS; const styles = StyleSheet.create({ termsText: { @@ -26,14 +31,26 @@ export function PercentageRow() { const transactionMetadata = useTransactionMetadataRequest(); + const { trackEvent, createEventBuilder } = useAnalytics(); + if ( !hasTransactionType(transactionMetadata, [TransactionType.musdConversion]) ) { return null; } - const redirectToBonusFaq = () => + const redirectToBonusFaq = () => { + trackEvent( + createEventBuilder(MetaMetricsEvents.MUSD_BONUS_TERMS_OF_USE_PRESSED) + .addProperties({ + location: EVENT_LOCATIONS.PERCENTAGE_ROW, + url: AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE, + }) + .build(), + ); + Linking.openURL(AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE); + }; if (isLoading) { return ; diff --git a/app/components/Views/confirmations/hooks/metrics/useMusdConversionConfirmationMetrics.test.ts b/app/components/Views/confirmations/hooks/metrics/useMusdConversionConfirmationMetrics.test.ts new file mode 100644 index 00000000000..d832e098931 --- /dev/null +++ b/app/components/Views/confirmations/hooks/metrics/useMusdConversionConfirmationMetrics.test.ts @@ -0,0 +1,245 @@ +import { merge } from 'lodash'; +import { updateConfirmationMetric } from '../../../../../core/redux/slices/confirmationMetrics'; +import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; +import { otherControllersMock } from '../../__mocks__/controllers/other-controllers-mock'; +import { useMusdConversionConfirmationMetrics } from './useMusdConversionConfirmationMetrics'; +import { + simpleSendTransactionControllerMock, + transactionIdMock, +} from '../../__mocks__/controllers/transaction-controller-mock'; +import { transactionApprovalControllerMock } from '../../__mocks__/controllers/approval-controller-mock'; +import { + useTransactionPayIsMaxAmount, + useTransactionPayQuotes, +} from '../pay/useTransactionPayData'; +import { getMusdConversionQuoteTrackingData } from '../../../../UI/Earn/utils/analytics'; +import { useParams } from '../../../../../util/navigation/navUtils'; +import { MUSD_EVENTS_CONSTANTS } from '../../../../UI/Earn/constants/events'; +import { Json } from '@metamask/utils'; +import { TransactionPayQuote } from '@metamask/transaction-pay-controller'; + +const { EVENT_LOCATIONS } = MUSD_EVENTS_CONSTANTS; + +jest.mock('../../../../../core/redux/slices/confirmationMetrics', () => ({ + ...(jest.requireActual( + '../../../../../core/redux/slices/confirmationMetrics', + ) as object), + updateConfirmationMetric: jest.fn(), +})); + +jest.mock('../pay/useTransactionPayData', () => ({ + ...(jest.requireActual('../pay/useTransactionPayData') as object), + useTransactionPayQuotes: jest.fn(), + useTransactionPayIsMaxAmount: jest.fn(), +})); + +jest.mock('../../../../UI/Earn/utils/analytics', () => ({ + ...(jest.requireActual('../../../../UI/Earn/utils/analytics') as object), + getMusdConversionQuoteTrackingData: jest.fn(), +})); + +jest.mock('../../../../../util/navigation/navUtils', () => ({ + ...(jest.requireActual('../../../../../util/navigation/navUtils') as object), + useParams: jest.fn(), +})); + +const QUOTE_TRACKING_DATA_MOCK = { + quote_is_same_chain: true, + payment_amount_usd: '5', + output_amount_usd: '5', + tx_execution_chain_matches_quote_output_chain: true, + pay_quote_strategy: 'relay', +}; + +function runHook() { + return renderHookWithProvider(useMusdConversionConfirmationMetrics, { + state: merge( + {}, + simpleSendTransactionControllerMock, + transactionApprovalControllerMock, + otherControllersMock, + ), + }); +} + +function runHookWithoutTransaction() { + return renderHookWithProvider(useMusdConversionConfirmationMetrics, { + state: merge( + {}, + simpleSendTransactionControllerMock, + otherControllersMock, + { + engine: { + backgroundState: { + ApprovalController: { + pendingApprovals: {}, + pendingApprovalCount: 0, + approvalFlows: [], + }, + }, + }, + }, + ), + }); +} + +describe('useMusdConversionConfirmationMetrics', () => { + const updateConfirmationMetricMock = jest.mocked(updateConfirmationMetric); + const useTransactionPayQuotesMock = jest.mocked(useTransactionPayQuotes); + const useTransactionPayIsMaxAmountMock = jest.mocked( + useTransactionPayIsMaxAmount, + ); + const getMusdConversionQuoteTrackingDataMock = jest.mocked( + getMusdConversionQuoteTrackingData, + ); + const useParamsMock = jest.mocked(useParams); + + beforeEach(() => { + jest.resetAllMocks(); + + updateConfirmationMetricMock.mockReturnValue({ + type: 'mockedAction', + } as never); + + useTransactionPayQuotesMock.mockReturnValue([]); + useTransactionPayIsMaxAmountMock.mockReturnValue(false); + getMusdConversionQuoteTrackingDataMock.mockReturnValue( + QUOTE_TRACKING_DATA_MOCK, + ); + useParamsMock.mockReturnValue({ forceBottomSheet: false }); + }); + + it('dispatches custom_amount_screen source when forceBottomSheet is false', () => { + useParamsMock.mockReturnValue({ forceBottomSheet: false }); + + runHook(); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: { + confirmation_source: EVENT_LOCATIONS.CUSTOM_AMOUNT_SCREEN, + is_max: false, + }, + sensitiveProperties: {}, + }, + }); + }); + + it('dispatches max_convert_bottom_sheet source when forceBottomSheet is true', () => { + useParamsMock.mockReturnValue({ forceBottomSheet: true }); + + runHook(); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: { + confirmation_source: + EVENT_LOCATIONS.QUICK_CONVERT_MAX_BOTTOM_SHEET_CONFIRMATION_SCREEN, + is_max: false, + }, + sensitiveProperties: {}, + }, + }); + }); + + it('dispatches is_max as true when transaction pay config has isMaxAmount', () => { + useTransactionPayIsMaxAmountMock.mockReturnValue(true); + + runHook(); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: { + confirmation_source: EVENT_LOCATIONS.CUSTOM_AMOUNT_SCREEN, + is_max: true, + }, + sensitiveProperties: {}, + }, + }); + }); + + it('dispatches is_max as false when isMaxAmount is false', () => { + useTransactionPayIsMaxAmountMock.mockReturnValue(false); + + runHook(); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: { + confirmation_source: EVENT_LOCATIONS.CUSTOM_AMOUNT_SCREEN, + is_max: false, + }, + sensitiveProperties: {}, + }, + }); + }); + + it('includes quote tracking data when quotes are available', () => { + useTransactionPayQuotesMock.mockReturnValue([ + { strategy: 'relay' } as unknown as TransactionPayQuote, + ]); + + runHook(); + + expect(getMusdConversionQuoteTrackingDataMock).toHaveBeenCalled(); + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: { + confirmation_source: EVENT_LOCATIONS.CUSTOM_AMOUNT_SCREEN, + is_max: false, + quote_is_same_chain: true, + payment_amount_usd: '5', + output_amount_usd: '5', + tx_execution_chain_matches_quote_output_chain: true, + }, + sensitiveProperties: {}, + }, + }); + }); + + it('dispatches only base properties when no quotes exist', () => { + useTransactionPayQuotesMock.mockReturnValue([]); + + runHook(); + + expect(getMusdConversionQuoteTrackingDataMock).not.toHaveBeenCalled(); + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: { + confirmation_source: EVENT_LOCATIONS.CUSTOM_AMOUNT_SCREEN, + is_max: false, + }, + sensitiveProperties: {}, + }, + }); + }); + + it('dispatches custom_amount_screen source when forceBottomSheet is undefined', () => { + useParamsMock.mockReturnValue({}); + + runHook(); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: { + confirmation_source: EVENT_LOCATIONS.CUSTOM_AMOUNT_SCREEN, + is_max: false, + }, + sensitiveProperties: {}, + }, + }); + }); + + it('does not dispatch updateConfirmationMetric when txMeta is undefined', () => { + runHookWithoutTransaction(); + + expect(updateConfirmationMetricMock).not.toHaveBeenCalled(); + }); +}); diff --git a/app/components/Views/confirmations/hooks/metrics/useMusdConversionConfirmationMetrics.ts b/app/components/Views/confirmations/hooks/metrics/useMusdConversionConfirmationMetrics.ts new file mode 100644 index 00000000000..c2b7569cf81 --- /dev/null +++ b/app/components/Views/confirmations/hooks/metrics/useMusdConversionConfirmationMetrics.ts @@ -0,0 +1,80 @@ +import { useEffect, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { useParams } from '../../../../../util/navigation/navUtils'; +import { updateConfirmationMetric } from '../../../../../core/redux/slices/confirmationMetrics'; +import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; +import { + useTransactionPayIsMaxAmount, + useTransactionPayQuotes, +} from '../pay/useTransactionPayData'; +import { getMusdConversionQuoteTrackingData } from '../../../../UI/Earn/utils/analytics'; +import { ConfirmationParams } from '../../components/confirm/confirm-component'; +import { MUSD_EVENTS_CONSTANTS } from '../../../../UI/Earn/constants/events'; + +const { EVENT_LOCATIONS } = MUSD_EVENTS_CONSTANTS; + +/** + * Enriches mUSD conversion confirmation metrics with quote tracking data. + * + * Dispatches {@link updateConfirmationMetric} to attach confirmation source, + * max-amount flag, and quote-level properties (selected quote, exchange rate, etc.) + * to the transaction's confirmation metric entry. Re-dispatches whenever the + * underlying transaction metadata, quotes, or max-amount state change. + */ +export function useMusdConversionConfirmationMetrics() { + const dispatch = useDispatch(); + const { forceBottomSheet } = useParams(); + const txMeta = useTransactionMetadataRequest(); + const quotes = useTransactionPayQuotes(); + const isMaxAmount = useTransactionPayIsMaxAmount(); + const transactionId = txMeta?.id ?? ''; + + const confirmationSource = forceBottomSheet + ? EVENT_LOCATIONS.QUICK_CONVERT_MAX_BOTTOM_SHEET_CONFIRMATION_SCREEN + : EVENT_LOCATIONS.CUSTOM_AMOUNT_SCREEN; + + const quoteTrackingData = useMemo(() => { + if (!txMeta || !quotes?.length) { + return {}; + } + const { + quote_is_same_chain, + payment_amount_usd, + output_amount_usd, + tx_execution_chain_matches_quote_output_chain, + } = getMusdConversionQuoteTrackingData(txMeta, quotes); + + return { + quote_is_same_chain, + payment_amount_usd, + output_amount_usd, + tx_execution_chain_matches_quote_output_chain, + }; + }, [txMeta, quotes]); + + useEffect(() => { + if (!transactionId) { + return; + } + + dispatch( + updateConfirmationMetric({ + id: transactionId, + params: { + properties: { + confirmation_source: confirmationSource, + is_max: isMaxAmount, + ...quoteTrackingData, + }, + sensitiveProperties: {}, + }, + }), + ); + }, [ + dispatch, + transactionId, + confirmationSource, + isMaxAmount, + quoteTrackingData, + ]); +} diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index 53387cc656f..c76a106bea7 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -642,6 +642,9 @@ enum EVENT_NAME { MUSD_CONVERSION_STATUS_UPDATED = 'mUSD Conversion Status Updated', MUSD_CLAIM_BONUS_BUTTON_CLICKED = 'mUSD Claim Bonus Button Clicked', MUSD_CLAIM_BONUS_STATUS_UPDATED = 'mUSD Claim Bonus Status Updated', + MUSD_QUICK_CONVERT_SCREEN_VIEWED = 'mUSD Quick Convert Screen Viewed', + MUSD_BONUS_TERMS_OF_USE_PRESSED = 'mUSD Bonus Terms of Use Pressed', + MUSD_QUICK_CONVERT_TOKEN_ROW_BUTTON_CLICKED = 'mUSD Quick Convert Token Row Button Clicked', // Assets ASSETS_FIRST_INIT_FETCH_COMPLETED = 'Assets First Init Fetch Completed', @@ -1670,6 +1673,15 @@ const events = { MUSD_CLAIM_BONUS_STATUS_UPDATED: generateOpt( EVENT_NAME.MUSD_CLAIM_BONUS_STATUS_UPDATED, ), + MUSD_QUICK_CONVERT_SCREEN_VIEWED: generateOpt( + EVENT_NAME.MUSD_QUICK_CONVERT_SCREEN_VIEWED, + ), + MUSD_BONUS_TERMS_OF_USE_PRESSED: generateOpt( + EVENT_NAME.MUSD_BONUS_TERMS_OF_USE_PRESSED, + ), + MUSD_QUICK_CONVERT_TOKEN_ROW_BUTTON_CLICKED: generateOpt( + EVENT_NAME.MUSD_QUICK_CONVERT_TOKEN_ROW_BUTTON_CLICKED, + ), }; /** From 1ea19b61ead6103d8eb79adbebaa66825500ebb8 Mon Sep 17 00:00:00 2001 From: Hassan Malik <41640681+hmalik88@users.noreply.github.com> Date: Thu, 12 Mar 2026 15:20:33 -0400 Subject: [PATCH 002/206] chore: bump core dependencies (#26849) ## **Description** Updated the following dependencies (with required integration work, if any): 1. `@metamask/accounts-controller` - Update `getInternalAccountByAddress` selector. 2. `@metamask/preferences-controller` - Update `PreferencesController` state type. 3. `@metamask/account-tree-controller` 4. `@metamask/delegation-controller` 5. `@metamask/multichain-network-controller` 6. `@metamask/multichain-transactions-controller` ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: N/A ## **Manual testing steps** 1. Build from branch. 2. Create an account 3. Verify nothing breaks ## **Screenshots/Recordings** N/A ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Bumps multiple core controller dependencies and changes account lookup to rely on the new `AccountsController.state.accountIdByAddress` map, which could affect any code paths that resolve accounts by address if the map is missing or stale. > > **Overview** > Updates several MetaMask core dependencies (notably `@metamask/accounts-controller` to `37.0.0` and `@metamask/account-tree-controller` to `5.x`). > > Adopts the new `AccountsController` state field `accountIdByAddress` by adding it to default/fixture state and updating `getInternalAccountByAddress` to resolve accounts via this map (with lowercase fallback) instead of scanning all accounts. > > Adjusts tests, fixtures, and snapshots to include `accountIdByAddress`, and updates migration test typings to reflect that this field is non-persisted. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit bc2a1ab48ffb793668945499ffbba40028004cb6. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../AccountConnectMultiSelector.test.tsx | 4 ++ .../AccountPermissions.test.tsx | 4 ++ .../accounts-controller/constants.ts | 1 + ...ofile-metrics-controller-messenger.test.ts | 17 +---- app/core/Engine/utils/test/logger.test.ts | 2 + app/selectors/accountsController.test.ts | 9 +++ app/store/migrations/066.test.ts | 3 +- app/store/migrations/067.test.ts | 3 +- app/util/address/index.ts | 11 +-- .../logs/__snapshots__/index.test.ts.snap | 2 + app/util/test/accountsControllerTestUtils.ts | 22 ++++++ app/util/test/initial-background-state.json | 7 +- package.json | 12 ++-- tests/framework/fixtures/FixtureBuilder.ts | 4 ++ .../framework/fixtures/fixture-validation.ts | 1 + .../fixtures/json/default-fixture.json | 3 + yarn.lock | 68 ++++++------------- 17 files changed, 98 insertions(+), 75 deletions(-) diff --git a/app/components/Views/AccountConnect/AccountConnectMultiSelector/AccountConnectMultiSelector.test.tsx b/app/components/Views/AccountConnect/AccountConnectMultiSelector/AccountConnectMultiSelector.test.tsx index 87f4a8681d2..4462cbd6ab0 100644 --- a/app/components/Views/AccountConnect/AccountConnectMultiSelector/AccountConnectMultiSelector.test.tsx +++ b/app/components/Views/AccountConnect/AccountConnectMultiSelector/AccountConnectMultiSelector.test.tsx @@ -56,6 +56,10 @@ jest.mock('../../../../core/Engine', () => ({ }, }, }, + accountIdByAddress: { + '0x1234': '0x1234', + '0x5678': '0x5678', + }, }, }, }, diff --git a/app/components/Views/AccountPermissions/AccountPermissions.test.tsx b/app/components/Views/AccountPermissions/AccountPermissions.test.tsx index 70aa9ad9250..c14b10700c2 100644 --- a/app/components/Views/AccountPermissions/AccountPermissions.test.tsx +++ b/app/components/Views/AccountPermissions/AccountPermissions.test.tsx @@ -185,6 +185,10 @@ jest.mock('../../../core/Engine', () => ({ }, selectedAccount: 'mock-id-1', }, + accountIdByAddress: { + '0xc4955c0d639d99699bfd7ec54d9fafee40e4d272': 'mock-id-1', + '0xd018538c87232ff95acbce4870629b75640a78e7': 'mock-id-2', + }, }, }, AccountTrackerController: { diff --git a/app/core/Engine/controllers/accounts-controller/constants.ts b/app/core/Engine/controllers/accounts-controller/constants.ts index a54b1ad8c87..9af99929c2c 100644 --- a/app/core/Engine/controllers/accounts-controller/constants.ts +++ b/app/core/Engine/controllers/accounts-controller/constants.ts @@ -6,4 +6,5 @@ export const defaultAccountsControllerState: AccountsControllerState = { accounts: {}, selectedAccount: '', }, + accountIdByAddress: {}, }; diff --git a/app/core/Engine/messengers/profile-metrics-controller-messenger.test.ts b/app/core/Engine/messengers/profile-metrics-controller-messenger.test.ts index e94dab2f47f..8857354b260 100644 --- a/app/core/Engine/messengers/profile-metrics-controller-messenger.test.ts +++ b/app/core/Engine/messengers/profile-metrics-controller-messenger.test.ts @@ -1,20 +1,7 @@ -import { - MOCK_ANY_NAMESPACE, - Messenger, - MessengerActions, - MessengerEvents, - MockAnyNamespace, -} from '@metamask/messenger'; +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; import { getProfileMetricsControllerMessenger } from './profile-metrics-controller-messenger'; -import { ProfileMetricsControllerMessenger } from '@metamask/profile-metrics-controller'; -type RootMessenger = Messenger< - MockAnyNamespace, - MessengerActions, - MessengerEvents ->; - -const getRootMessenger = (): RootMessenger => +const getRootMessenger = () => new Messenger({ namespace: MOCK_ANY_NAMESPACE, }); diff --git a/app/core/Engine/utils/test/logger.test.ts b/app/core/Engine/utils/test/logger.test.ts index 855646ebc5e..26260353778 100644 --- a/app/core/Engine/utils/test/logger.test.ts +++ b/app/core/Engine/utils/test/logger.test.ts @@ -44,6 +44,7 @@ describe('logEngineCreation', () => { accounts: {}, selectedAccount: '', }, + accountIdByAddress: {}, }, }; @@ -87,6 +88,7 @@ describe('logEngineCreation', () => { accounts: {}, selectedAccount: '', }, + accountIdByAddress: {}, }, KeyringController: { vault: 'test-vault', diff --git a/app/selectors/accountsController.test.ts b/app/selectors/accountsController.test.ts index 8ff014325d9..9544fc31000 100644 --- a/app/selectors/accountsController.test.ts +++ b/app/selectors/accountsController.test.ts @@ -72,11 +72,17 @@ const MOCK_GENERATED_ACCOUNTS_CONTROLLER_REVERSED = }, {} as Record, ); + const accountIdByAddress: Record = {}; + Object.values(accountsForInternalAccounts).forEach((account) => { + accountIdByAddress[account.address] = account.id; + }); + return { internalAccounts: { accounts: accountsForInternalAccounts, selectedAccount: Object.values(accountsForInternalAccounts)[0].id, }, + accountIdByAddress, }; }; @@ -110,6 +116,9 @@ describe('Accounts Controller Selectors', () => { }, selectedAccount: 'non-existent-id', }, + accountIdByAddress: { + [internalAccount1.address]: internalAccount1.id, + }, }; const errorMessage = 'selectSelectedInternalAccount: Account with ID non-existent-id not found.'; diff --git a/app/store/migrations/066.test.ts b/app/store/migrations/066.test.ts index 91930983476..0b214cc0e0a 100644 --- a/app/store/migrations/066.test.ts +++ b/app/store/migrations/066.test.ts @@ -19,7 +19,8 @@ const mockedCaptureException = jest.mocked(captureException); interface StateType { engine: { backgroundState: { - AccountsController: AccountsControllerState; + // accountIdByAddress is non-persisted state, so it is not present in migration fixtures + AccountsController: Omit; }; }; } diff --git a/app/store/migrations/067.test.ts b/app/store/migrations/067.test.ts index 277919eabf6..2ab4ecd1ff4 100644 --- a/app/store/migrations/067.test.ts +++ b/app/store/migrations/067.test.ts @@ -19,7 +19,8 @@ const mockedCaptureException = jest.mocked(captureException); interface StateType { engine: { backgroundState: { - AccountsController: AccountsControllerState; + // accountIdByAddress is non-persisted state, so it is not present in migration fixtures + AccountsController: Omit; }; }; } diff --git a/app/util/address/index.ts b/app/util/address/index.ts index cc6b977ac49..503abc2fcb9 100644 --- a/app/util/address/index.ts +++ b/app/util/address/index.ts @@ -361,10 +361,13 @@ export function isAddressCompatibleWithChainId( export function getInternalAccountByAddress( address: string, ): InternalAccount | undefined { - const { accounts } = Engine.context.AccountsController.state.internalAccounts; - return Object.values(accounts).find((a: InternalAccount) => - areAddressesEqual(a.address, address), - ); + const { + internalAccounts: { accounts }, + accountIdByAddress, + } = Engine.context.AccountsController.state; + const id = + accountIdByAddress[address] ?? accountIdByAddress[address?.toLowerCase()]; + return id ? accounts[id] : undefined; } /** diff --git a/app/util/logs/__snapshots__/index.test.ts.snap b/app/util/logs/__snapshots__/index.test.ts.snap index 94c5d558d76..7254f9cc576 100644 --- a/app/util/logs/__snapshots__/index.test.ts.snap +++ b/app/util/logs/__snapshots__/index.test.ts.snap @@ -20,6 +20,7 @@ exports[`logs :: generateStateLogs Sanitized SeedlessOnboardingController State "isAccountTreeSyncingInProgress": false, }, "AccountsController": { + "accountIdByAddress": {}, "internalAccounts": { "accounts": {}, "selectedAccount": "", @@ -885,6 +886,7 @@ exports[`logs :: generateStateLogs generates a valid json export 1`] = ` "isAccountTreeSyncingInProgress": false, }, "AccountsController": { + "accountIdByAddress": {}, "internalAccounts": { "accounts": {}, "selectedAccount": "", diff --git a/app/util/test/accountsControllerTestUtils.ts b/app/util/test/accountsControllerTestUtils.ts index 7bc1c4b28b4..bd1870306e0 100644 --- a/app/util/test/accountsControllerTestUtils.ts +++ b/app/util/test/accountsControllerTestUtils.ts @@ -321,6 +321,10 @@ export const MOCK_ACCOUNTS_CONTROLLER_STATE: AccountsControllerState = { }, selectedAccount: internalAccount2.id, }, + accountIdByAddress: { + [internalAccount1.address]: internalAccount1.id, + [internalAccount2.address]: internalAccount2.id, + }, }; export const MOCK_ACCOUNTS_CONTROLLER_STATE_WITH_SOLANA: AccountsControllerState = @@ -333,6 +337,10 @@ export const MOCK_ACCOUNTS_CONTROLLER_STATE_WITH_SOLANA: AccountsControllerState [internalSolanaAccount1.id]: internalSolanaAccount1, }, }, + accountIdByAddress: { + ...MOCK_ACCOUNTS_CONTROLLER_STATE.accountIdByAddress, + [internalSolanaAccount1.address]: internalSolanaAccount1.id, + }, }; export const MOCK_KEYRING_CONTROLLER_STATE: KeyringControllerState = { @@ -423,6 +431,14 @@ export const MOCK_ACCOUNTS_CONTROLLER_STATE_WITH_KEYRING_TYPES: AccountsControll [expectedSecondHDKeyringUuid]: mockSecondHDKeyringInternalAccount, }, }, + accountIdByAddress: { + ...MOCK_ACCOUNTS_CONTROLLER_STATE_WITH_SOLANA.accountIdByAddress, + [mockQRHardwareInternalAccount.address]: mockQRHardwareAccountId, + [mockSimpleKeyringInternalAccount.address]: mockSimpleKeyringAccountId, + [mockSnapAccount1InternalAccount.address]: mockSnapAccount1Id, + [mockSnapAccount2InternalAccount.address]: mockSnapAccount2Id, + [mockSecondHDKeyringInternalAccount.address]: expectedSecondHDKeyringUuid, + }, }; export function createMockAccountsControllerState( @@ -447,11 +463,17 @@ export function createMockAccountsControllerState( ? createMockUuidFromAddress(selectedAddress.toLowerCase()) : createMockUuidFromAddress(addresses[0].toLowerCase()); + const accountIdByAddress: Record = {}; + Object.values(accounts).forEach((account) => { + accountIdByAddress[account.address] = account.id; + }); + return { internalAccounts: { accounts, selectedAccount, }, + accountIdByAddress, }; } diff --git a/app/util/test/initial-background-state.json b/app/util/test/initial-background-state.json index ceb1d182835..5d6a838a52c 100644 --- a/app/util/test/initial-background-state.json +++ b/app/util/test/initial-background-state.json @@ -13,7 +13,8 @@ "internalAccounts": { "accounts": {}, "selectedAccount": "" - } + }, + "accountIdByAddress": {} }, "AccountTreeController": { "accountTree": { @@ -768,7 +769,9 @@ "rewardsEnvUrl": null }, "PredictController": { - "eligibility": { "eligible": false }, + "eligibility": { + "eligible": false + }, "lastError": null, "lastUpdateTimestamp": 0, "balances": {}, diff --git a/package.json b/package.json index 954481736e4..b4a123665ef 100644 --- a/package.json +++ b/package.json @@ -182,7 +182,7 @@ "viem": "2.31.3", "@metamask/bridge-controller@npm:^66.2.0": "patch:@metamask/bridge-controller@npm%3A66.2.0#~/.yarn/patches/@metamask-bridge-controller-npm-66.2.0-8ed148964f.patch", "@metamask/bridge-status-controller@npm:^66.0.2": "patch:@metamask/bridge-status-controller@npm%3A66.1.0#~/.yarn/patches/@metamask-bridge-status-controller-npm-66.1.0-a2a478ef94.patch", - "@metamask/accounts-controller": "^36.0.0", + "@metamask/accounts-controller": "37.0.0", "@metamask/core-backend": "^5.0.0", "bn.js@npm:4.11.6": "4.12.3", "bn.js@npm:5.2.1": "5.2.3", @@ -205,8 +205,8 @@ "@ledgerhq/react-native-hw-transport-ble": "^6.37.0", "@metamask/abi-utils": "^3.0.0", "@metamask/account-api": "^1.0.0", - "@metamask/account-tree-controller": "^4.1.1", - "@metamask/accounts-controller": "^36.0.0", + "@metamask/account-tree-controller": "^5.0.0", + "@metamask/accounts-controller": "^37.0.0", "@metamask/address-book-controller": "^7.0.1", "@metamask/ai-controllers": "^0.2.0", "@metamask/analytics-controller": "^1.0.0", @@ -222,7 +222,7 @@ "@metamask/connectivity-controller": "^0.1.0", "@metamask/controller-utils": "^11.18.0", "@metamask/core-backend": "^5.0.0", - "@metamask/delegation-controller": "^2.0.1", + "@metamask/delegation-controller": "^2.0.2", "@metamask/delegation-deployments": "^0.15.0", "@metamask/design-system-react-native": "^0.10.0", "@metamask/design-system-twrnc-preset": "^0.3.0", @@ -265,8 +265,8 @@ "@metamask/multichain-account-service": "^7.0.0", "@metamask/multichain-api-client": "^0.10.1", "@metamask/multichain-api-middleware": "1.2.5", - "@metamask/multichain-network-controller": "^3.0.3", - "@metamask/multichain-transactions-controller": "^7.0.1", + "@metamask/multichain-network-controller": "^3.0.5", + "@metamask/multichain-transactions-controller": "^7.0.2", "@metamask/native-utils": "^0.8.0", "@metamask/network-controller": "^30.0.0", "@metamask/network-enablement-controller": "^4.2.0", diff --git a/tests/framework/fixtures/FixtureBuilder.ts b/tests/framework/fixtures/FixtureBuilder.ts index ef1995e3f61..3b12f1a54bf 100644 --- a/tests/framework/fixtures/FixtureBuilder.ts +++ b/tests/framework/fixtures/FixtureBuilder.ts @@ -1368,6 +1368,10 @@ class FixtureBuilder { }, selectedAccount: '4d7a5e0b-b261-4aed-8126-43972b0fa0a1', // Default to Ethereum account }, + accountIdByAddress: { + '0xbacec2e26c5c794de6e82a1a7e21b9c329fa8cf6': + '4d7a5e0b-b261-4aed-8126-43972b0fa0a1', + }, }; // Configure for Ethereum mainnet only diff --git a/tests/framework/fixtures/fixture-validation.ts b/tests/framework/fixtures/fixture-validation.ts index f8bea0b5d12..ff64f1383a8 100644 --- a/tests/framework/fixtures/fixture-validation.ts +++ b/tests/framework/fixtures/fixture-validation.ts @@ -208,6 +208,7 @@ export function getMobileFixtureIgnoredKeys(): string[] { // ── Per-wallet secrets and dynamic IDs (change every onboarding) ── 'engine.backgroundState.AccountsController.internalAccounts.selectedAccount', 'engine.backgroundState.AccountsController.internalAccounts.accounts', + 'engine.backgroundState.AccountsController.accountIdByAddress', 'engine.backgroundState.AccountTrackerController.accountsByChainId', 'engine.backgroundState.KeyringController.keyrings', 'engine.backgroundState.KeyringController.vault', diff --git a/tests/framework/fixtures/json/default-fixture.json b/tests/framework/fixtures/json/default-fixture.json index d317cd8d8bb..ee5e110fd05 100644 --- a/tests/framework/fixtures/json/default-fixture.json +++ b/tests/framework/fixtures/json/default-fixture.json @@ -101,6 +101,9 @@ "isAccountTreeSyncingInProgress": false }, "AccountsController": { + "accountIdByAddress": { + "0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3": "4d7a5e0b-b261-4aed-8126-43972b0fa0a1" + }, "internalAccounts": { "accounts": { "4d7a5e0b-b261-4aed-8126-43972b0fa0a1": { diff --git a/yarn.lock b/yarn.lock index d32027d4819..ee03b84bfdd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7556,30 +7556,6 @@ __metadata: languageName: node linkType: hard -"@metamask/account-tree-controller@npm:^4.1.1": - version: 4.1.1 - resolution: "@metamask/account-tree-controller@npm:4.1.1" - dependencies: - "@metamask/accounts-controller": "npm:^36.0.0" - "@metamask/base-controller": "npm:^9.0.0" - "@metamask/keyring-controller": "npm:^25.1.0" - "@metamask/messenger": "npm:^0.3.0" - "@metamask/multichain-account-service": "npm:^7.0.0" - "@metamask/profile-sync-controller": "npm:^27.1.0" - "@metamask/snaps-controllers": "npm:^17.2.0" - "@metamask/snaps-sdk": "npm:^10.3.0" - "@metamask/snaps-utils": "npm:^11.7.0" - "@metamask/superstruct": "npm:^3.1.0" - "@metamask/utils": "npm:^11.9.0" - fast-deep-equal: "npm:^3.1.3" - lodash: "npm:^4.17.21" - peerDependencies: - "@metamask/providers": ^22.0.0 - webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/2fca1352cfe9fe89950a88af546c12258c922cd16aa40e8d1c0b98b0a6457e8bf4c5e78edccbc80e744fe7c0ffa45ddec1abdb5e0d5037a06c268042350d33a5 - languageName: node - linkType: hard - "@metamask/account-tree-controller@npm:^5.0.0, @metamask/account-tree-controller@npm:^5.0.1": version: 5.0.1 resolution: "@metamask/account-tree-controller@npm:5.0.1" @@ -7604,9 +7580,9 @@ __metadata: languageName: node linkType: hard -"@metamask/accounts-controller@npm:^36.0.0": - version: 36.0.1 - resolution: "@metamask/accounts-controller@npm:36.0.1" +"@metamask/accounts-controller@npm:37.0.0": + version: 37.0.0 + resolution: "@metamask/accounts-controller@npm:37.0.0" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@metamask/base-controller": "npm:^9.0.0" @@ -7630,7 +7606,7 @@ __metadata: peerDependencies: "@metamask/providers": ^22.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/b60d45d06d85d481c2f6b397a1a2845866f8cfa18dd6d02ca0ab81809a1e543667c5d2bba82abc06ef53600bcd4e6c233268a530a81dce2779c3721d81946465 + checksum: 10/4ea9a310d707160b05a314090a7a1e7eee9bcf68e0cc82e1aa471fc2932560fde856176748328932ed4d10a16a6ff8cb9288d10be8821a4460ee76290bf8d747 languageName: node linkType: hard @@ -8169,16 +8145,16 @@ __metadata: languageName: node linkType: hard -"@metamask/delegation-controller@npm:^2.0.1": - version: 2.0.1 - resolution: "@metamask/delegation-controller@npm:2.0.1" +"@metamask/delegation-controller@npm:^2.0.2": + version: 2.0.2 + resolution: "@metamask/delegation-controller@npm:2.0.2" dependencies: - "@metamask/accounts-controller": "npm:^36.0.0" + "@metamask/accounts-controller": "npm:^37.0.0" "@metamask/base-controller": "npm:^9.0.0" "@metamask/keyring-controller": "npm:^25.1.0" "@metamask/messenger": "npm:^0.3.0" "@metamask/utils": "npm:^11.9.0" - checksum: 10/0d9edc3177234844cba8e48684647398df676044e6bd9ca244c0c2d891fcaf088a8a8210913116d10b333403488257a7387901d8e4ead86de9d823b9b99d65cd + checksum: 10/a5bfece3aadbfd2a4b079dc5672b75ef7acbe71e8af92e1c533884d59eadaf4d56cfa89641212cff0776f91964eb2e71d55d45fa0f813dfd9610fd6c0fdff05d languageName: node linkType: hard @@ -9058,7 +9034,7 @@ __metadata: languageName: node linkType: hard -"@metamask/multichain-network-controller@npm:^3.0.3, @metamask/multichain-network-controller@npm:^3.0.4, @metamask/multichain-network-controller@npm:^3.0.5": +"@metamask/multichain-network-controller@npm:^3.0.4, @metamask/multichain-network-controller@npm:^3.0.5": version: 3.0.5 resolution: "@metamask/multichain-network-controller@npm:3.0.5" dependencies: @@ -9077,17 +9053,17 @@ __metadata: languageName: node linkType: hard -"@metamask/multichain-transactions-controller@npm:^7.0.1": - version: 7.0.1 - resolution: "@metamask/multichain-transactions-controller@npm:7.0.1" +"@metamask/multichain-transactions-controller@npm:^7.0.2": + version: 7.0.2 + resolution: "@metamask/multichain-transactions-controller@npm:7.0.2" dependencies: - "@metamask/accounts-controller": "npm:^36.0.0" + "@metamask/accounts-controller": "npm:^37.0.0" "@metamask/base-controller": "npm:^9.0.0" "@metamask/keyring-api": "npm:^21.5.0" "@metamask/keyring-internal-api": "npm:^10.0.0" "@metamask/keyring-snap-client": "npm:^8.2.0" "@metamask/messenger": "npm:^0.3.0" - "@metamask/polling-controller": "npm:^16.0.2" + "@metamask/polling-controller": "npm:^16.0.3" "@metamask/snaps-controllers": "npm:^17.2.0" "@metamask/snaps-sdk": "npm:^10.3.0" "@metamask/snaps-utils": "npm:^11.7.0" @@ -9095,7 +9071,7 @@ __metadata: "@types/uuid": "npm:^8.3.0" immer: "npm:^9.0.6" uuid: "npm:^8.3.2" - checksum: 10/c46227c9ecc59a114338348be0b209670bd0cded5f51afc17ad406817bb56d19480634ec57727bfbe74f7e24fec27237c1580ed33102dda050f60ee811c96401 + checksum: 10/6111a63600c74d7db80437fafb42eb71016210a0a5d1d860cee09f35266c07ba111a89523a62688c7cb4a1c28fdcc8033a50bd44fd88232a04b9e81cecda0e74 languageName: node linkType: hard @@ -9326,7 +9302,7 @@ __metadata: languageName: node linkType: hard -"@metamask/polling-controller@npm:^16.0.0, @metamask/polling-controller@npm:^16.0.2, @metamask/polling-controller@npm:^16.0.3": +"@metamask/polling-controller@npm:^16.0.0, @metamask/polling-controller@npm:^16.0.3": version: 16.0.3 resolution: "@metamask/polling-controller@npm:16.0.3" dependencies: @@ -35430,8 +35406,8 @@ __metadata: "@ledgerhq/react-native-hw-transport-ble": "npm:^6.37.0" "@metamask/abi-utils": "npm:^3.0.0" "@metamask/account-api": "npm:^1.0.0" - "@metamask/account-tree-controller": "npm:^4.1.1" - "@metamask/accounts-controller": "npm:^36.0.0" + "@metamask/account-tree-controller": "npm:^5.0.0" + "@metamask/accounts-controller": "npm:^37.0.0" "@metamask/address-book-controller": "npm:^7.0.1" "@metamask/ai-controllers": "npm:^0.2.0" "@metamask/analytics-controller": "npm:^1.0.0" @@ -35451,7 +35427,7 @@ __metadata: "@metamask/connectivity-controller": "npm:^0.1.0" "@metamask/controller-utils": "npm:^11.18.0" "@metamask/core-backend": "npm:^5.0.0" - "@metamask/delegation-controller": "npm:^2.0.1" + "@metamask/delegation-controller": "npm:^2.0.2" "@metamask/delegation-deployments": "npm:^0.15.0" "@metamask/design-system-react-native": "npm:^0.10.0" "@metamask/design-system-twrnc-preset": "npm:^0.3.0" @@ -35498,8 +35474,8 @@ __metadata: "@metamask/multichain-account-service": "npm:^7.0.0" "@metamask/multichain-api-client": "npm:^0.10.1" "@metamask/multichain-api-middleware": "npm:1.2.5" - "@metamask/multichain-network-controller": "npm:^3.0.3" - "@metamask/multichain-transactions-controller": "npm:^7.0.1" + "@metamask/multichain-network-controller": "npm:^3.0.5" + "@metamask/multichain-transactions-controller": "npm:^7.0.2" "@metamask/native-utils": "npm:^0.8.0" "@metamask/network-controller": "npm:^30.0.0" "@metamask/network-enablement-controller": "npm:^4.2.0" From 4e42befa2a0f9cc60b2c9aa14f30e5f90c4094c4 Mon Sep 17 00:00:00 2001 From: Bruno Nascimento Date: Thu, 12 Mar 2026 16:23:11 -0300 Subject: [PATCH 003/206] feat(card): embed Metal Card checkout flow into onboarding flow (#27420) ## **Description** This PR embed the metal card checkout flow into the Card onboarding/sign-up flow. ## **Changelog** CHANGELOG entry: embed the metal card checkout flow into the Card onboarding/sign-up flow. ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/776a82dc-b814-4dd1-96da-b902932c31cc ## **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. ## **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 card onboarding navigation (including new `home` flow params and StackActions replacement) and adds new UI/animation behavior, which could affect user progression through card setup if eligibility logic or routing params are wrong. > > **Overview** > Eligible US users who press **Enable Card** from `CardHome` are now redirected to `Routes.CARD.CHOOSE_YOUR_CARD` (new `flow: 'home'`) instead of always starting delegation/spending-limit setup; the route passes through `shippingAddress` plus token/delegation/external wallet context. > > `ChooseYourCard` is updated to support the new `home` flow, adds a swipe/peek affordance and an *upgrade-to-metal* link, refreshes metal/virtual copy, and routes virtual-card selection into `SPENDING_LIMIT` with `flow: 'manage'` and the forwarded params. > > Post-order behavior changes so `OrderCompleted` uses `StackActions.replace` to take onboarding users directly to `SPENDING_LIMIT` (while upgrade users still go back to `CARD.HOME`), and `SpendingLimit` now blocks back navigation during loading using a ref to avoid stale listener state. Tests and English strings are updated accordingly. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit c52fcb51e2561068414b00a630d524b0854ed814. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../UI/Card/Views/CardHome/CardHome.test.tsx | 275 +++++++++++++++++ .../UI/Card/Views/CardHome/CardHome.tsx | 60 +++- .../ChooseYourCard/ChooseYourCard.test.tsx | 172 +++++++++-- .../ChooseYourCard/ChooseYourCard.testIds.ts | 1 + .../Views/ChooseYourCard/ChooseYourCard.tsx | 281 ++++++++++++++++-- .../OrderCompleted/OrderCompleted.test.tsx | 35 ++- .../Views/OrderCompleted/OrderCompleted.tsx | 16 +- .../Views/SpendingLimit/SpendingLimit.tsx | 12 +- app/components/UI/Card/routes/index.tsx | 6 +- locales/languages/en.json | 11 +- 10 files changed, 809 insertions(+), 60 deletions(-) diff --git a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx index 27d1479bc0c..7280faff6f3 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx @@ -668,6 +668,7 @@ function setupLoadCardDataMock( externalWalletDetailsData: { mappedWalletDetails?: Record[]; } | null; + delegationSettings: Record | null; }>, ) { const defaults = { @@ -682,6 +683,7 @@ function setupLoadCardDataMock( isCardholder: true, kycStatus: { verificationState: 'VERIFIED' as const, userId: 'user-123' }, externalWalletDetailsData: null, + delegationSettings: null, }; const config = { ...defaults, ...overrides }; @@ -5776,4 +5778,277 @@ describe('CardHome Component', () => { expect(mockTrackEvent).toHaveBeenCalled(); }); }); + + describe('Enable Card - ChooseYourCard Redirect', () => { + it('navigates to ChooseYourCard when eligible US user presses Enable Card', async () => { + // Given: Verified, authenticated US user with shipping address, metal card enabled, no card + const priorityTokenForNav = { ...mockPriorityToken }; + const allTokensForNav = [mockPriorityToken]; + const delegationSettingsForNav = { networks: [] }; + const externalWalletDetailsForNav = { mappedWalletDetails: [] }; + + setupMockSelectors({ + isAuthenticated: true, + userLocation: 'us', + isMetalCardCheckoutEnabled: true, + }); + + (useLoadCardData as jest.Mock).mockReturnValueOnce({ + priorityToken: priorityTokenForNav, + allTokens: allTokensForNav, + cardDetails: null, + isLoading: false, + error: null, + warning: CardStateWarning.NoCard, + isAuthenticated: true, + isBaanxLoginEnabled: true, + isCardholder: false, + kycStatus: { + verificationState: 'VERIFIED', + userId: 'user-123', + userDetails: { + id: 'user-123', + addressLine1: '123 Main St', + city: 'New York', + zip: '10001', + usState: 'NY', + }, + }, + externalWalletDetailsData: externalWalletDetailsForNav, + delegationSettings: delegationSettingsForNav, + fetchAllData: mockFetchAllData, + refetchAllData: mockRefetchAllData, + fetchCardDetails: mockFetchCardDetails, + }); + + // When: user presses Enable Card button + render(); + const enableButton = screen.getByTestId( + CardHomeSelectors.ENABLE_CARD_BUTTON, + ); + fireEvent.press(enableButton); + + // Then: navigates to ChooseYourCard with home flow and card data params + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith( + Routes.CARD.CHOOSE_YOUR_CARD, + expect.objectContaining({ + flow: 'home', + shippingAddress: expect.objectContaining({ + line1: '123 Main St', + city: 'New York', + zip: '10001', + }), + priorityToken: priorityTokenForNav, + allTokens: allTokensForNav, + delegationSettings: delegationSettingsForNav, + externalWalletDetailsData: externalWalletDetailsForNav, + }), + ); + }); + }); + + it('navigates to delegation when warning is NeedDelegation even with metal card enabled', async () => { + // Given: US user with shipping address and metal card enabled, but warning is NeedDelegation (not NoCard) + setupMockSelectors({ + isAuthenticated: true, + userLocation: 'us', + isMetalCardCheckoutEnabled: true, + }); + + (useLoadCardData as jest.Mock).mockReturnValueOnce({ + priorityToken: null, + allTokens: [], + cardDetails: null, + isLoading: false, + error: null, + warning: CardStateWarning.NeedDelegation, + isAuthenticated: true, + isBaanxLoginEnabled: true, + isCardholder: true, + kycStatus: { + verificationState: 'VERIFIED', + userId: 'user-123', + userDetails: { + id: 'user-123', + addressLine1: '123 Main St', + city: 'New York', + zip: '10001', + usState: 'NY', + }, + }, + externalWalletDetailsData: null, + delegationSettings: null, + fetchAllData: mockFetchAllData, + refetchAllData: mockRefetchAllData, + fetchCardDetails: mockFetchCardDetails, + }); + + // When: user presses Enable Card button + render(); + const enableButton = screen.getByTestId( + CardHomeSelectors.ENABLE_ASSETS_BUTTON, + ); + fireEvent.press(enableButton); + + // Then: navigates to SpendingLimit (delegation) instead of ChooseYourCard + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith( + 'CardSpendingLimit', + expect.objectContaining({ + flow: 'manage', + }), + ); + }); + }); + + it('navigates to delegation when metal card checkout is disabled', async () => { + // Given: Verified US user but metal card checkout is disabled + setupMockSelectors({ + isAuthenticated: true, + userLocation: 'us', + isMetalCardCheckoutEnabled: false, + }); + + (useLoadCardData as jest.Mock).mockReturnValueOnce({ + priorityToken: null, + allTokens: [], + cardDetails: null, + isLoading: false, + error: null, + warning: CardStateWarning.NeedDelegation, + isAuthenticated: true, + isBaanxLoginEnabled: true, + isCardholder: true, + kycStatus: { + verificationState: 'VERIFIED', + userId: 'user-123', + userDetails: { + id: 'user-123', + addressLine1: '123 Main St', + city: 'New York', + zip: '10001', + usState: 'NY', + }, + }, + fetchAllData: mockFetchAllData, + refetchAllData: mockRefetchAllData, + fetchCardDetails: mockFetchCardDetails, + }); + + // When: user presses Enable Card button + render(); + const enableButton = screen.getByTestId( + CardHomeSelectors.ENABLE_ASSETS_BUTTON, + ); + fireEvent.press(enableButton); + + // Then: navigates to SpendingLimit (delegation) instead of ChooseYourCard + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith( + 'CardSpendingLimit', + expect.objectContaining({ + flow: 'manage', + }), + ); + }); + }); + + it('navigates to delegation for international user even with metal card enabled', async () => { + // Given: Verified international user with metal card checkout enabled + setupMockSelectors({ + isAuthenticated: true, + userLocation: 'international', + isMetalCardCheckoutEnabled: true, + }); + + (useLoadCardData as jest.Mock).mockReturnValueOnce({ + priorityToken: null, + allTokens: [], + cardDetails: null, + isLoading: false, + error: null, + warning: CardStateWarning.NeedDelegation, + isAuthenticated: true, + isBaanxLoginEnabled: true, + isCardholder: true, + kycStatus: { + verificationState: 'VERIFIED', + userId: 'user-123', + userDetails: { + id: 'user-123', + addressLine1: '123 Main St', + city: 'London', + zip: 'SW1A 1AA', + }, + }, + fetchAllData: mockFetchAllData, + refetchAllData: mockRefetchAllData, + fetchCardDetails: mockFetchCardDetails, + }); + + // When: user presses Enable Card button + render(); + const enableButton = screen.getByTestId( + CardHomeSelectors.ENABLE_ASSETS_BUTTON, + ); + fireEvent.press(enableButton); + + // Then: navigates to SpendingLimit (delegation), not ChooseYourCard + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith( + 'CardSpendingLimit', + expect.objectContaining({ + flow: 'manage', + }), + ); + }); + }); + + it('navigates to delegation when US user has no shipping address', async () => { + // Given: Verified US user with metal card enabled but no address data + setupMockSelectors({ + isAuthenticated: true, + userLocation: 'us', + isMetalCardCheckoutEnabled: true, + }); + + (useLoadCardData as jest.Mock).mockReturnValueOnce({ + priorityToken: null, + allTokens: [], + cardDetails: null, + isLoading: false, + error: null, + warning: CardStateWarning.NeedDelegation, + isAuthenticated: true, + isBaanxLoginEnabled: true, + isCardholder: true, + kycStatus: { + verificationState: 'VERIFIED', + userId: 'user-123', + userDetails: null, + }, + fetchAllData: mockFetchAllData, + refetchAllData: mockRefetchAllData, + fetchCardDetails: mockFetchCardDetails, + }); + + // When: user presses Enable Card button + render(); + const enableButton = screen.getByTestId( + CardHomeSelectors.ENABLE_ASSETS_BUTTON, + ); + fireEvent.press(enableButton); + + // Then: navigates to delegation since no shipping address is available + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith( + 'CardSpendingLimit', + expect.objectContaining({ + flow: 'manage', + }), + ); + }); + }); + }); }); diff --git a/app/components/UI/Card/Views/CardHome/CardHome.tsx b/app/components/UI/Card/Views/CardHome/CardHome.tsx index 9268ccee8ac..23e8e504812 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.tsx @@ -950,6 +950,58 @@ const CardHome = () => { [isAuthenticated, kycStatus, warning, externalWalletDetailsData], ); + const shouldRedirectToChooseCard = useMemo( + () => + !isLoading && + !cardSetupState.isKYCPending && + !isCardProvisioning && + isMetalCardCheckoutEnabled && + isBaanxLoginEnabled && + isAuthenticated && + warning === CardStateWarning.NoCard && + userLocation === 'us' && + !!userShippingAddress, + [ + isLoading, + cardSetupState.isKYCPending, + isCardProvisioning, + isMetalCardCheckoutEnabled, + isBaanxLoginEnabled, + isAuthenticated, + warning, + userLocation, + userShippingAddress, + ], + ); + + const navigateToChooseYourCard = useCallback(() => { + trackEvent( + createEventBuilder(MetaMetricsEvents.CARD_BUTTON_CLICKED) + .addProperties({ + action: CardActions.ORDER_METAL_CARD_BUTTON, + }) + .build(), + ); + + navigation.navigate(Routes.CARD.CHOOSE_YOUR_CARD, { + flow: 'home', + shippingAddress: userShippingAddress, + priorityToken, + allTokens, + delegationSettings, + externalWalletDetailsData, + }); + }, [ + navigation, + trackEvent, + createEventBuilder, + userShippingAddress, + priorityToken, + allTokens, + delegationSettings, + externalWalletDetailsData, + ]); + const ButtonsSection = useMemo(() => { if (isLoading) { return ( @@ -989,7 +1041,11 @@ const CardHome = () => { variant={ButtonVariants.Primary} label={strings('card.card_home.enable_card_button_label')} size={ButtonSize.Lg} - onPress={openOnboardingDelegationAction} + onPress={ + shouldRedirectToChooseCard + ? navigateToChooseYourCard + : openOnboardingDelegationAction + } width={ButtonWidthTypes.Full} testID={cardSetupState.setupTestId} /> @@ -1032,6 +1088,8 @@ const CardHome = () => { tw, openOnboardingDelegationAction, isCardProvisioning, + shouldRedirectToChooseCard, + navigateToChooseYourCard, ]); const isUserEligibleForMetalCard = useMemo( diff --git a/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.test.tsx b/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.test.tsx index 9037fc36450..84e7b8be547 100644 --- a/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.test.tsx +++ b/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.test.tsx @@ -1,11 +1,11 @@ import React from 'react'; -import { render, fireEvent } from '@testing-library/react-native'; +import { render, fireEvent, waitFor } from '@testing-library/react-native'; import ChooseYourCard from './ChooseYourCard'; import { ChooseYourCardSelectors } from './ChooseYourCard.testIds'; import { strings } from '../../../../../../locales/i18n'; import Routes from '../../../../../constants/navigation/Routes'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; -import { CardType } from '../../types'; +import { AllowanceState, CardType } from '../../types'; import { CardActions, CardScreens } from '../../util/metrics'; const mockNavigate = jest.fn(); @@ -26,11 +26,13 @@ jest.mock('@react-navigation/native', () => { }; }); +const mockUseParams = jest.fn(() => ({ + flow: 'onboarding', + shippingAddress: undefined, +})); + jest.mock('../../../../../util/navigation/navUtils', () => ({ - useParams: () => ({ - flow: 'onboarding', - shippingAddress: undefined, - }), + useParams: () => mockUseParams(), })); jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({ @@ -44,8 +46,9 @@ jest.mock('../../../../../../locales/i18n', () => ({ strings: (key: string) => { const map: Record = { 'card.choose_your_card.title': 'Choose your card', + 'card.choose_your_card.upgrade_title': 'Upgrade to Metal', 'card.choose_your_card.continue_button': 'Continue', - 'card.choose_your_card.virtual_card.name': 'Orange Virtual Card', + 'card.choose_your_card.virtual_card.name': 'Virtual Card', 'card.choose_your_card.virtual_card.price': 'Free', 'card.choose_your_card.virtual_card.feature_1': 'Virtual card for Apple Pay and Google Pay', @@ -55,17 +58,37 @@ jest.mock('../../../../../../locales/i18n', () => ({ '1% USDC cashback on every purchase', 'card.choose_your_card.metal_card.name': 'Metal Card', 'card.choose_your_card.metal_card.price': '$199/year', + 'card.choose_your_card.metal_card.everything_in_virtual': + 'Everything in virtual, plus:', 'card.choose_your_card.metal_card.feature_1': - 'Engraved metal card and virtual card for Apple Pay and Google Pay', + 'Premium engraved metal card', 'card.choose_your_card.metal_card.feature_2': - '3% cashback on the first $10,000 spent each year, then 1% after that', + '3% cashback on first $10,000/year', 'card.choose_your_card.metal_card.feature_3': 'No foreign transaction fees', + 'card.choose_your_card.earn_up_to_badge': + 'Earn up to $300 in cashback annually', + 'card.choose_your_card.upgrade_to_metal_label': + 'Or upgrade to Metal for 3x rewards', }; return map[key] || key; }, })); +jest.mock('react-native-linear-gradient', () => { + // eslint-disable-next-line @typescript-eslint/no-shadow + const React = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + children, + ...props + }: React.PropsWithChildren>) => + React.createElement(View, props, children), + }; +}); + // Mock CardImage component jest.mock('../../components/CardImage/CardImage', () => { // eslint-disable-next-line @typescript-eslint/no-shadow @@ -156,12 +179,16 @@ describe('ChooseYourCard', () => { it('renders all required UI elements', () => { const { getByTestId } = render(); - expect(getByTestId(ChooseYourCardSelectors.CONTAINER)).toBeTruthy(); - expect(getByTestId(ChooseYourCardSelectors.TITLE)).toBeTruthy(); - expect(getByTestId(ChooseYourCardSelectors.CARD_CAROUSEL)).toBeTruthy(); - expect(getByTestId(ChooseYourCardSelectors.CARD_NAME)).toBeTruthy(); - expect(getByTestId(ChooseYourCardSelectors.CARD_PRICE)).toBeTruthy(); - expect(getByTestId(ChooseYourCardSelectors.CONTINUE_BUTTON)).toBeTruthy(); + expect(getByTestId(ChooseYourCardSelectors.CONTAINER)).toBeOnTheScreen(); + expect(getByTestId(ChooseYourCardSelectors.TITLE)).toBeOnTheScreen(); + expect( + getByTestId(ChooseYourCardSelectors.CARD_CAROUSEL), + ).toBeOnTheScreen(); + expect(getByTestId(ChooseYourCardSelectors.CARD_NAME)).toBeOnTheScreen(); + expect(getByTestId(ChooseYourCardSelectors.CARD_PRICE)).toBeOnTheScreen(); + expect( + getByTestId(ChooseYourCardSelectors.CONTINUE_BUTTON), + ).toBeOnTheScreen(); }); it('displays correct title text', () => { @@ -190,10 +217,10 @@ describe('ChooseYourCard', () => { getByTestId( `${ChooseYourCardSelectors.CARD_IMAGE}-${CardType.VIRTUAL}`, ), - ).toBeTruthy(); + ).toBeOnTheScreen(); expect( getByTestId(`${ChooseYourCardSelectors.CARD_IMAGE}-${CardType.METAL}`), - ).toBeTruthy(); + ).toBeOnTheScreen(); }); it('displays virtual card features by default', () => { @@ -201,13 +228,13 @@ describe('ChooseYourCard', () => { expect( getByText(strings('card.choose_your_card.virtual_card.feature_1')), - ).toBeTruthy(); + ).toBeOnTheScreen(); expect( getByText(strings('card.choose_your_card.virtual_card.feature_2')), - ).toBeTruthy(); + ).toBeOnTheScreen(); expect( getByText(strings('card.choose_your_card.virtual_card.feature_3')), - ).toBeTruthy(); + ).toBeOnTheScreen(); }); }); @@ -251,5 +278,110 @@ describe('ChooseYourCard', () => { flow: 'onboarding', }); }); + + it('navigates to spending limit with manage flow params when flow is home and virtual card selected', () => { + const priorityToken = { + caipChainId: 'eip155:1', + symbol: 'USDC', + name: 'USD Coin', + address: '0x123', + decimals: 6, + allowanceState: AllowanceState.Enabled, + allowance: '1000', + }; + const allTokens = [priorityToken]; + const delegationSettings = { networks: [] }; + const externalWalletDetailsData = { + walletDetails: {}, + mappedWalletDetails: [priorityToken], + priorityWalletDetail: priorityToken, + }; + + mockUseParams.mockImplementationOnce(() => ({ + flow: 'home', + shippingAddress: undefined, + priorityToken, + allTokens, + delegationSettings, + externalWalletDetailsData, + })); + + const { getByTestId } = render(); + + fireEvent.press(getByTestId(ChooseYourCardSelectors.CONTINUE_BUTTON)); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.SPENDING_LIMIT, { + flow: 'manage', + priorityToken, + allTokens, + delegationSettings, + externalWalletDetailsData, + }); + }); + }); + + describe('Button Variant', () => { + it('renders Secondary variant when virtual card is selected', () => { + const { getByTestId } = render(); + + const continueButton = getByTestId( + ChooseYourCardSelectors.CONTINUE_BUTTON, + ); + expect(continueButton.props.children).toBeDefined(); + }); + + it('renders continue button for default virtual selection', () => { + const { getByTestId } = render(); + + expect( + getByTestId(ChooseYourCardSelectors.CONTINUE_BUTTON), + ).toBeOnTheScreen(); + }); + }); + + describe('Upgrade to Metal Link', () => { + it('shows upgrade link when virtual card is selected in onboarding flow', () => { + const { getByTestId } = render(); + + expect( + getByTestId(ChooseYourCardSelectors.UPGRADE_TO_METAL_BUTTON), + ).toBeOnTheScreen(); + }); + + it('displays correct upgrade link label', () => { + const { getByText } = render(); + + expect( + getByText(strings('card.choose_your_card.upgrade_to_metal_label')), + ).toBeOnTheScreen(); + }); + + it('scrolls to metal card when upgrade link is pressed', async () => { + const { getByTestId } = render(); + + fireEvent.press( + getByTestId(ChooseYourCardSelectors.UPGRADE_TO_METAL_BUTTON), + ); + + await waitFor(() => { + expect( + getByTestId(ChooseYourCardSelectors.CARD_NAME), + ).toHaveTextContent(strings('card.choose_your_card.metal_card.name')); + }); + }); + + it('hides upgrade link after scrolling to metal card', async () => { + const { getByTestId, queryByTestId } = render(); + + fireEvent.press( + getByTestId(ChooseYourCardSelectors.UPGRADE_TO_METAL_BUTTON), + ); + + await waitFor(() => { + expect( + queryByTestId(ChooseYourCardSelectors.UPGRADE_TO_METAL_BUTTON), + ).not.toBeOnTheScreen(); + }); + }); }); }); diff --git a/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.testIds.ts b/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.testIds.ts index a386ac74435..1c89b86863f 100644 --- a/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.testIds.ts +++ b/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.testIds.ts @@ -6,4 +6,5 @@ export const ChooseYourCardSelectors = { CARD_NAME: 'choose-your-card-name', CARD_PRICE: 'choose-your-card-price', CONTINUE_BUTTON: 'choose-your-card-continue-button', + UPGRADE_TO_METAL_BUTTON: 'choose-your-card-upgrade-to-metal-button', }; diff --git a/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.tsx b/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.tsx index 290173210e4..96e51c63162 100644 --- a/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.tsx +++ b/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.tsx @@ -13,7 +13,10 @@ import { FlatList, ListRenderItem, View, + TouchableOpacity, + Animated, } from 'react-native'; +import LinearGradient from 'react-native-linear-gradient'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { @@ -38,23 +41,44 @@ import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { CardActions, CardScreens } from '../../util/metrics'; import { ChooseYourCardSelectors } from './ChooseYourCard.testIds'; -import { CardType, CardStatus } from '../../types'; +import { + CardType, + CardStatus, + DelegationSettingsResponse, + CardExternalWalletDetailsResponse, + CardTokenAllowance, +} from '../../types'; import CardImage from '../../components/CardImage/CardImage'; import { useParams } from '../../../../../util/navigation/navUtils'; import type { ShippingAddress } from '../ReviewOrder'; -export type ChooseYourCardFlow = 'onboarding' | 'upgrade'; +export type ChooseYourCardFlow = 'onboarding' | 'upgrade' | 'home'; export interface ChooseYourCardParams { flow?: ChooseYourCardFlow; shippingAddress?: ShippingAddress; + priorityToken?: CardTokenAllowance | null; + allTokens?: CardTokenAllowance[]; + delegationSettings?: DelegationSettingsResponse | null; + externalWalletDetailsData?: + | { + walletDetails: never[]; + mappedWalletDetails: never[]; + priorityWalletDetail: null; + } + | { + walletDetails: CardExternalWalletDetailsResponse; + mappedWalletDetails: CardTokenAllowance[]; + priorityWalletDetail: CardTokenAllowance | undefined; + } + | null; } interface CardOption { id: CardType; name: string; price: string; - features: string[]; + features: { label: string; isHighlighted: boolean }[]; } const ItemSeparator = ({ width }: { width: number }) => ( @@ -68,11 +92,42 @@ const ChooseYourCard = () => { const { width: screenWidth } = useWindowDimensions(); const flatListRef = useRef(null); const [activeIndex, setActiveIndex] = useState(0); + const [hasUserSwiped, setHasUserSwiped] = useState(false); + const arrowAnimValue = useRef(new Animated.Value(0)).current; - const { flow = 'onboarding', shippingAddress } = - useParams(); + const { + flow = 'onboarding', + shippingAddress, + priorityToken, + allTokens, + delegationSettings, + externalWalletDetailsData, + } = useParams(); const isUpgradeFlow = flow === 'upgrade'; + // Arrow bounce animation for swipe indicator + useEffect(() => { + if (activeIndex !== 0 || isUpgradeFlow || hasUserSwiped) return; + + const animation = Animated.loop( + Animated.sequence([ + Animated.timing(arrowAnimValue, { + toValue: 8, + duration: 600, + useNativeDriver: true, + }), + Animated.timing(arrowAnimValue, { + toValue: 0, + duration: 600, + useNativeDriver: true, + }), + ]), + ); + animation.start(); + + return () => animation.stop(); + }, [activeIndex, isUpgradeFlow, arrowAnimValue, hasUserSwiped]); + const CARD_WIDTH = screenWidth - 64; const CARD_SPACING = 16; @@ -83,9 +138,18 @@ const ChooseYourCard = () => { name: strings('card.choose_your_card.virtual_card.name'), price: strings('card.choose_your_card.virtual_card.price'), features: [ - strings('card.choose_your_card.virtual_card.feature_1'), - strings('card.choose_your_card.virtual_card.feature_2'), - strings('card.choose_your_card.virtual_card.feature_3'), + { + label: strings('card.choose_your_card.virtual_card.feature_1'), + isHighlighted: false, + }, + { + label: strings('card.choose_your_card.virtual_card.feature_2'), + isHighlighted: false, + }, + { + label: strings('card.choose_your_card.virtual_card.feature_3'), + isHighlighted: false, + }, ], }, { @@ -93,9 +157,24 @@ const ChooseYourCard = () => { name: strings('card.choose_your_card.metal_card.name'), price: strings('card.choose_your_card.metal_card.price'), features: [ - strings('card.choose_your_card.metal_card.feature_1'), - strings('card.choose_your_card.metal_card.feature_2'), - strings('card.choose_your_card.metal_card.feature_3'), + { + label: strings( + 'card.choose_your_card.metal_card.everything_in_virtual', + ), + isHighlighted: false, + }, + { + label: strings('card.choose_your_card.metal_card.feature_1'), + isHighlighted: true, + }, + { + label: strings('card.choose_your_card.metal_card.feature_2'), + isHighlighted: true, + }, + { + label: strings('card.choose_your_card.metal_card.feature_3'), + isHighlighted: true, + }, ], }, ], @@ -121,7 +200,65 @@ const ChooseYourCard = () => { ); }, [trackEvent, createEventBuilder, flow]); + const peekTimersRef = useRef[]>([]); + const peekStoppedRef = useRef(false); + + const stopPeekAnimation = useCallback(() => { + peekStoppedRef.current = true; + peekTimersRef.current.forEach(clearTimeout); + peekTimersRef.current = []; + }, []); + + useEffect(() => { + if (isUpgradeFlow || cardOptions.length <= 1) return; + + const peekDistance = (CARD_WIDTH + CARD_SPACING) * 0.15; + const BOUNCE_HOLD = 600; + const PAUSE_BETWEEN_BOUNCES = 3000; + const cycleDuration = BOUNCE_HOLD + PAUSE_BETWEEN_BOUNCES; + + const scheduleBounce = (delay: number) => { + peekTimersRef.current.push( + setTimeout(() => { + if (peekStoppedRef.current) return; + flatListRef.current?.scrollToOffset({ + offset: peekDistance, + animated: true, + }); + }, delay), + ); + + peekTimersRef.current.push( + setTimeout(() => { + if (peekStoppedRef.current) return; + flatListRef.current?.scrollToOffset({ + offset: 0, + animated: true, + }); + }, delay + BOUNCE_HOLD), + ); + + peekTimersRef.current.push( + setTimeout(() => { + if (peekStoppedRef.current) return; + scheduleBounce(0); + }, delay + cycleDuration), + ); + }; + + scheduleBounce(800); + + return stopPeekAnimation; + }, [ + isUpgradeFlow, + cardOptions.length, + CARD_WIDTH, + CARD_SPACING, + stopPeekAnimation, + ]); + const handleContinue = useCallback(() => { + stopPeekAnimation(); const selectedCard = cardOptions[activeIndex]; trackEvent( @@ -135,7 +272,18 @@ const ChooseYourCard = () => { ); if (selectedCard.id === CardType.VIRTUAL) { - navigate(Routes.CARD.SPENDING_LIMIT, { flow: 'onboarding' }); + navigate( + Routes.CARD.SPENDING_LIMIT, + flow === 'onboarding' + ? { flow: 'onboarding' } + : { + flow: 'manage', + priorityToken, + allTokens, + delegationSettings, + externalWalletDetailsData, + }, + ); } else { navigate(Routes.CARD.REVIEW_ORDER, { shippingAddress, @@ -151,17 +299,37 @@ const ChooseYourCard = () => { flow, shippingAddress, isUpgradeFlow, + stopPeekAnimation, + priorityToken, + allTokens, + delegationSettings, + externalWalletDetailsData, ]); + const handleScrollToMetal = useCallback(() => { + stopPeekAnimation(); + setHasUserSwiped(true); + flatListRef.current?.scrollToIndex({ index: 1, animated: true }); + setTimeout(() => setActiveIndex(1), 300); + }, [stopPeekAnimation]); + const handleScroll = useCallback( (event: NativeSyntheticEvent) => { const contentOffsetX = event.nativeEvent.contentOffset.x; const index = Math.round(contentOffsetX / (CARD_WIDTH + CARD_SPACING)); if (index !== activeIndex && index >= 0 && index < cardOptions.length) { + stopPeekAnimation(); + setHasUserSwiped(true); setActiveIndex(index); } }, - [activeIndex, cardOptions.length, CARD_WIDTH, CARD_SPACING], + [ + activeIndex, + cardOptions.length, + CARD_WIDTH, + CARD_SPACING, + stopPeekAnimation, + ], ); const renderCardItem: ListRenderItem = useCallback( @@ -178,17 +346,17 @@ const ChooseYourCard = () => { ); const renderFeatureItem = useCallback( - (feature: string, index: number) => ( + (feature: string, index: number, isHighlighted: boolean) => ( {feature} @@ -235,7 +403,6 @@ const ChooseYourCard = () => { ); const selectedCard = cardOptions[activeIndex]; - const showPagination = cardOptions.length > 1; return ( @@ -257,12 +424,14 @@ const ChooseYourCard = () => { - + item.id} horizontal showsHorizontalScrollIndicator={false} @@ -276,6 +445,36 @@ const ChooseYourCard = () => { getItemLayout={getItemLayout} testID={ChooseYourCardSelectors.CARD_CAROUSEL} /> + {activeIndex === 0 && + !isUpgradeFlow && + !hasUserSwiped && + cardOptions.length > 1 && ( + + + + + + + )} {showPagination && ( @@ -302,21 +501,59 @@ const ChooseYourCard = () => { - + {selectedCard.id === CardType.METAL && ( + + + + + {strings('card.choose_your_card.earn_up_to_badge')} + + + + )} + + {selectedCard.features.map((feature, index) => - renderFeatureItem(feature, index), + renderFeatureItem(feature.label, index, feature.isHighlighted), )} - + )} + diff --git a/app/components/Views/confirmations/components/send/send-alert-modal/index.ts b/app/components/Views/confirmations/components/send/send-alert-modal/index.ts new file mode 100644 index 00000000000..4ea22997925 --- /dev/null +++ b/app/components/Views/confirmations/components/send/send-alert-modal/index.ts @@ -0,0 +1,2 @@ +export { SendAlertModal } from './send-alert-modal'; +export type { SendAlertModalProps } from './send-alert-modal.types'; diff --git a/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.test.tsx b/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.test.tsx new file mode 100644 index 00000000000..4327e5fbe17 --- /dev/null +++ b/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.test.tsx @@ -0,0 +1,176 @@ +import React from 'react'; +import { fireEvent } from '@testing-library/react-native'; + +import renderWithProvider from '../../../../../../util/test/renderWithProvider'; +import { SendAlertModal } from './send-alert-modal'; + +jest.mock('../../../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => { + const mockStrings: Record = { + 'send.cancel': 'Cancel', + 'send.i_understand': 'I understand', + }; + return mockStrings[key] || key; + }), +})); + +jest.mock( + '../../../../../../component-library/components/BottomSheets/BottomSheet', + () => { + const mockReact = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + const MockBottomSheet = mockReact.forwardRef( + ( + { children, onClose }: { children: unknown; onClose?: () => void }, + _ref: unknown, + ) => + mockReact.createElement( + View, + { testID: 'bottom-sheet', onTouchEnd: onClose }, + children, + ), + ); + MockBottomSheet.displayName = 'MockBottomSheet'; + return { + __esModule: true, + default: MockBottomSheet, + }; + }, +); + +jest.mock( + '../../../../../../component-library/components/BottomSheets/BottomSheetFooter', + () => { + const mockReact = jest.requireActual('react'); + const { View, Pressable, Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + buttonPropsArray, + }: { + buttonPropsArray: { + label: string; + onPress: () => void; + testID?: string; + }[]; + }) => + mockReact.createElement( + View, + { testID: 'bottom-sheet-footer' }, + buttonPropsArray.map( + (btn: { label: string; onPress: () => void; testID?: string }) => + mockReact.createElement( + Pressable, + { key: btn.label, testID: btn.testID, onPress: btn.onPress }, + mockReact.createElement(Text, null, btn.label), + ), + ), + ), + }; + }, +); + +describe('SendAlertModal', () => { + const defaultProps = { + isOpen: true, + title: 'Token Contract Address', + errorMessage: 'Sending to a token contract may result in lost tokens.', + onAcknowledge: jest.fn(), + onClose: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns null when isOpen is false', () => { + const { toJSON } = renderWithProvider( + , + ); + + expect(toJSON()).toBeNull(); + }); + + it('renders modal content when isOpen is true', () => { + const { getByText } = renderWithProvider( + , + ); + + expect(getByText('Token Contract Address')).toBeOnTheScreen(); + expect( + getByText('Sending to a token contract may result in lost tokens.'), + ).toBeOnTheScreen(); + }); + + it('displays the title text', () => { + const { getByText } = renderWithProvider( + , + ); + + expect(getByText('Custom Title')).toBeOnTheScreen(); + }); + + it('displays the error message text', () => { + const { getByText } = renderWithProvider( + , + ); + + expect(getByText('Custom error message')).toBeOnTheScreen(); + }); + + it('calls onClose when cancel button is pressed', () => { + const onClose = jest.fn(); + const { getByTestId } = renderWithProvider( + , + ); + + fireEvent.press(getByTestId('send-alert-modal-cancel-button')); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('calls onAcknowledge when acknowledge button is pressed', () => { + const onAcknowledge = jest.fn(); + const { getByTestId } = renderWithProvider( + , + ); + + fireEvent.press(getByTestId('send-alert-modal-acknowledge-button')); + + expect(onAcknowledge).toHaveBeenCalledTimes(1); + }); + + it('does not call onAcknowledge when cancel is pressed', () => { + const onAcknowledge = jest.fn(); + const onClose = jest.fn(); + const { getByTestId } = renderWithProvider( + , + ); + + fireEvent.press(getByTestId('send-alert-modal-cancel-button')); + + expect(onAcknowledge).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('does not call onClose when acknowledge is pressed', () => { + const onAcknowledge = jest.fn(); + const onClose = jest.fn(); + const { getByTestId } = renderWithProvider( + , + ); + + fireEvent.press(getByTestId('send-alert-modal-acknowledge-button')); + + expect(onClose).not.toHaveBeenCalled(); + expect(onAcknowledge).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.tsx b/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.tsx new file mode 100644 index 00000000000..5fa80651a23 --- /dev/null +++ b/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.tsx @@ -0,0 +1,77 @@ +import React, { useRef } from 'react'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + Icon, + IconColor, + IconName, + IconSize, + Text, + TextVariant, +} from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../../locales/i18n'; +import BottomSheet, { + BottomSheetRef, +} from '../../../../../../component-library/components/BottomSheets/BottomSheet'; +import BottomSheetFooter from '../../../../../../component-library/components/BottomSheets/BottomSheetFooter'; +import { ButtonsAlignment } from '../../../../../../component-library/components/BottomSheets/BottomSheetFooter/BottomSheetFooter.types'; +import { + ButtonSize, + ButtonVariants, +} from '../../../../../../component-library/components/Buttons/Button'; +import { SendAlertModalProps } from './send-alert-modal.types'; + +export const SendAlertModal = ({ + isOpen, + title, + errorMessage, + onAcknowledge, + onClose, +}: SendAlertModalProps) => { + const bottomSheetRef = useRef(null); + + if (!isOpen) { + return null; + } + + return ( + + + + {title} + + {errorMessage} + + + + + ); +}; diff --git a/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.types.ts b/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.types.ts new file mode 100644 index 00000000000..ebb4cd421c3 --- /dev/null +++ b/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.types.ts @@ -0,0 +1,7 @@ +export interface SendAlertModalProps { + isOpen: boolean; + title: string; + errorMessage: string; + onAcknowledge: () => void; + onClose: () => void; +} diff --git a/app/components/Views/confirmations/constants/alerts.ts b/app/components/Views/confirmations/constants/alerts.ts index db1a9af50f7..f95af9de9f1 100644 --- a/app/components/Views/confirmations/constants/alerts.ts +++ b/app/components/Views/confirmations/constants/alerts.ts @@ -20,6 +20,7 @@ export enum AlertKeys { PerpsDepositMinimum = 'perps_deposit_minimum', PerpsHardwareAccount = 'perps_hardware_account', SignedOrSubmitted = 'signed_or_submitted', + TokenContractAddress = 'token_contract_address', TokenTrustSignalMalicious = 'token_trust_signal_malicious', TokenTrustSignalWarning = 'token_trust_signal_warning', } diff --git a/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.test.ts b/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.test.ts index e390ff456dc..2841baebcf6 100644 --- a/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.test.ts +++ b/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.test.ts @@ -23,6 +23,7 @@ import { useOriginTrustSignalAlerts } from './useOriginTrustSignalAlerts'; import { useGasEstimateFailedAlert } from './useGasEstimateFailedAlert'; import { useGasSponsorshipWarningAlert } from './useGasSponsorshipWarningAlert'; import { useFirstTimeInteractionAlert } from './useFirstTimeInteractionAlert'; +import { useTokenContractAlert } from './useTokenContractAlert'; jest.mock('./useBlockaidAlerts'); jest.mock('./useGasEstimateFailedAlert'); @@ -41,6 +42,7 @@ jest.mock('./useTokenTrustSignalAlerts'); jest.mock('./useAddressTrustSignalAlerts'); jest.mock('./useOriginTrustSignalAlerts'); jest.mock('./useFirstTimeInteractionAlert'); +jest.mock('./useTokenContractAlert'); describe('useConfirmationAlerts', () => { const ALERT_MESSAGE_MOCK = 'This is a test alert message.'; @@ -170,6 +172,14 @@ describe('useConfirmationAlerts', () => { severity: Severity.Danger, }, ]; + const mockTokenContractAlert: Alert[] = [ + { + key: 'TokenContractAlert', + title: 'Test Token Contract Alert', + message: ALERT_MESSAGE_MOCK, + severity: Severity.Warning, + }, + ]; beforeEach(() => { jest.clearAllMocks(); (useBlockaidAlerts as jest.Mock).mockReturnValue([]); @@ -189,6 +199,7 @@ describe('useConfirmationAlerts', () => { (useAddressTrustSignalAlerts as jest.Mock).mockReturnValue([]); (useOriginTrustSignalAlerts as jest.Mock).mockReturnValue([]); (useFirstTimeInteractionAlert as jest.Mock).mockReturnValue([]); + (useTokenContractAlert as jest.Mock).mockReturnValue([]); }); it('returns empty array if no alerts', () => { @@ -263,6 +274,9 @@ describe('useConfirmationAlerts', () => { (useOriginTrustSignalAlerts as jest.Mock).mockReturnValue( mockOriginTrustSignalAlerts, ); + (useTokenContractAlert as jest.Mock).mockReturnValue( + mockTokenContractAlert, + ); const { result } = renderHookWithProvider(() => useConfirmationAlerts(), { state: siweSignatureConfirmationState, }); @@ -278,6 +292,7 @@ describe('useConfirmationAlerts', () => { ...mockInsufficientPredictBalanceAlert, ...mockBurnAddressAlert, ...mockTokenTrustSignalAlerts, + ...mockTokenContractAlert, ...mockUpgradeAccountAlert, ...mockOriginTrustSignalAlerts, ...mockAddressTrustSignalAlerts, diff --git a/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.ts b/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.ts index 89778771ed5..e7f0d83247d 100644 --- a/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.ts +++ b/app/components/Views/confirmations/hooks/alerts/useConfirmationAlerts.ts @@ -17,6 +17,7 @@ import { useTokenTrustSignalAlerts } from './useTokenTrustSignalAlerts'; import { useAddressTrustSignalAlerts } from './useAddressTrustSignalAlerts'; import { useOriginTrustSignalAlerts } from './useOriginTrustSignalAlerts'; import { useFirstTimeInteractionAlert } from './useFirstTimeInteractionAlert'; +import { useTokenContractAlert } from './useTokenContractAlert'; function useSignatureAlerts(): Alert[] { const domainMismatchAlerts = useDomainMismatchAlerts(); @@ -38,6 +39,7 @@ function useTransactionAlerts(): Alert[] { const burnAddressAlert = useBurnAddressAlert(); const tokenTrustSignalAlerts = useTokenTrustSignalAlerts(); const firstTimeInteractionAlert = useFirstTimeInteractionAlert(); + const tokenContractAlert = useTokenContractAlert(); return useMemo( () => [ @@ -53,6 +55,7 @@ function useTransactionAlerts(): Alert[] { ...burnAddressAlert, ...tokenTrustSignalAlerts, ...firstTimeInteractionAlert, + ...tokenContractAlert, ], [ gasEstimateFailedAlert, @@ -67,6 +70,7 @@ function useTransactionAlerts(): Alert[] { burnAddressAlert, tokenTrustSignalAlerts, firstTimeInteractionAlert, + tokenContractAlert, ], ); } diff --git a/app/components/Views/confirmations/hooks/alerts/useTokenContractAlert.test.ts b/app/components/Views/confirmations/hooks/alerts/useTokenContractAlert.test.ts new file mode 100644 index 00000000000..969849e34e1 --- /dev/null +++ b/app/components/Views/confirmations/hooks/alerts/useTokenContractAlert.test.ts @@ -0,0 +1,256 @@ +import { TransactionType } from '@metamask/transaction-controller'; +import { waitFor } from '@testing-library/react-native'; + +import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; +import { RowAlertKey } from '../../components/UI/info-row/alert-row/constants'; +import { AlertKeys } from '../../constants/alerts'; +import { Severity } from '../../types/alerts'; +import { memoizedGetTokenStandardAndDetails } from '../../utils/token'; +import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; +import { useTransferRecipient } from '../transactions/useTransferRecipient'; +import { useTokenContractAlert } from './useTokenContractAlert'; + +jest.mock('../transactions/useTransactionMetadataRequest'); +jest.mock('../transactions/useTransferRecipient'); +jest.mock('../../utils/token'); + +jest.mock('../../../../../core/Engine', () => ({ + context: { + NetworkController: { + findNetworkClientIdByChainId: jest.fn().mockReturnValue('mainnet'), + }, + }, +})); + +jest.mock('../../../../../util/address', () => ({ + ...jest.requireActual('../../../../../util/address'), + toChecksumAddress: jest.fn((addr: string) => addr), +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => key), +})); + +const mockUseTransactionMetadataRequest = + useTransactionMetadataRequest as jest.Mock; +const mockUseTransferRecipient = useTransferRecipient as jest.Mock; +const mockGetTokenDetails = + memoizedGetTokenStandardAndDetails as unknown as jest.Mock; + +describe('useTokenContractAlert', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseTransactionMetadataRequest.mockReturnValue(undefined); + mockUseTransferRecipient.mockReturnValue(undefined); + mockGetTokenDetails.mockResolvedValue(undefined); + }); + + it('returns empty array when transaction metadata is undefined', async () => { + mockUseTransactionMetadataRequest.mockReturnValue(undefined); + + const { result } = renderHookWithProvider(() => useTokenContractAlert()); + + await waitFor(() => { + expect(result.current).toEqual([]); + }); + }); + + it('returns empty array when transaction type is not a transfer type', async () => { + mockUseTransactionMetadataRequest.mockReturnValue({ + type: TransactionType.contractInteraction, + chainId: '0x1', + }); + mockUseTransferRecipient.mockReturnValue( + '0x935E73EDb9fF52E23BaC7F7e043A1ecD06d05477', + ); + + const { result } = renderHookWithProvider(() => useTokenContractAlert()); + + await waitFor(() => { + expect(result.current).toEqual([]); + }); + }); + + it('returns empty array when recipient is undefined', async () => { + mockUseTransactionMetadataRequest.mockReturnValue({ + type: TransactionType.simpleSend, + chainId: '0x1', + }); + mockUseTransferRecipient.mockReturnValue(undefined); + + const { result } = renderHookWithProvider(() => useTokenContractAlert()); + + await waitFor(() => { + expect(result.current).toEqual([]); + }); + }); + + it('returns empty array when chainId is undefined', async () => { + mockUseTransactionMetadataRequest.mockReturnValue({ + type: TransactionType.simpleSend, + chainId: undefined, + }); + mockUseTransferRecipient.mockReturnValue( + '0x935E73EDb9fF52E23BaC7F7e043A1ecD06d05477', + ); + + const { result } = renderHookWithProvider(() => useTokenContractAlert()); + + await waitFor(() => { + expect(result.current).toEqual([]); + }); + }); + + it('returns empty array when address is not a token contract', async () => { + mockUseTransactionMetadataRequest.mockReturnValue({ + type: TransactionType.simpleSend, + chainId: '0x1', + }); + mockUseTransferRecipient.mockReturnValue( + '0x935E73EDb9fF52E23BaC7F7e043A1ecD06d05477', + ); + mockGetTokenDetails.mockResolvedValue(undefined); + + const { result } = renderHookWithProvider(() => useTokenContractAlert()); + + await waitFor(() => { + expect(result.current).toEqual([]); + }); + }); + + it('returns empty array when token lookup throws an error', async () => { + mockUseTransactionMetadataRequest.mockReturnValue({ + type: TransactionType.simpleSend, + chainId: '0x1', + }); + mockUseTransferRecipient.mockReturnValue( + '0x935E73EDb9fF52E23BaC7F7e043A1ecD06d05477', + ); + mockGetTokenDetails.mockRejectedValue(new Error('Network error')); + + const { result } = renderHookWithProvider(() => useTokenContractAlert()); + + await waitFor(() => { + expect(result.current).toEqual([]); + }); + }); + + it('returns alert when recipient is a token contract for simpleSend', async () => { + mockUseTransactionMetadataRequest.mockReturnValue({ + type: TransactionType.simpleSend, + chainId: '0x1', + }); + mockUseTransferRecipient.mockReturnValue( + '0x935E73EDb9fF52E23BaC7F7e043A1ecD06d05477', + ); + mockGetTokenDetails.mockResolvedValue({ standard: 'ERC20' }); + + const { result } = renderHookWithProvider(() => useTokenContractAlert()); + + await waitFor(() => { + expect(result.current).toHaveLength(1); + }); + expect(result.current[0]).toMatchObject({ + key: AlertKeys.TokenContractAddress, + field: RowAlertKey.InteractingWith, + severity: Severity.Warning, + isBlocking: false, + }); + expect(result.current[0].title).toBeDefined(); + expect(result.current[0].message).toBeDefined(); + }); + + it('returns alert when recipient is a token contract for tokenMethodTransfer', async () => { + mockUseTransactionMetadataRequest.mockReturnValue({ + type: TransactionType.tokenMethodTransfer, + chainId: '0x1', + }); + mockUseTransferRecipient.mockReturnValue( + '0x935E73EDb9fF52E23BaC7F7e043A1ecD06d05477', + ); + mockGetTokenDetails.mockResolvedValue({ standard: 'ERC20' }); + + const { result } = renderHookWithProvider(() => useTokenContractAlert()); + + await waitFor(() => { + expect(result.current).toHaveLength(1); + }); + expect(result.current[0].key).toBe(AlertKeys.TokenContractAddress); + }); + + it('returns alert when recipient is a token contract for tokenMethodTransferFrom', async () => { + mockUseTransactionMetadataRequest.mockReturnValue({ + type: TransactionType.tokenMethodTransferFrom, + chainId: '0x1', + }); + mockUseTransferRecipient.mockReturnValue( + '0x935E73EDb9fF52E23BaC7F7e043A1ecD06d05477', + ); + mockGetTokenDetails.mockResolvedValue({ standard: 'ERC20' }); + + const { result } = renderHookWithProvider(() => useTokenContractAlert()); + + await waitFor(() => { + expect(result.current).toHaveLength(1); + }); + expect(result.current[0].key).toBe(AlertKeys.TokenContractAddress); + }); + + it('returns empty array when token has no standard field', async () => { + mockUseTransactionMetadataRequest.mockReturnValue({ + type: TransactionType.simpleSend, + chainId: '0x1', + }); + mockUseTransferRecipient.mockReturnValue( + '0x935E73EDb9fF52E23BaC7F7e043A1ecD06d05477', + ); + mockGetTokenDetails.mockResolvedValue({ name: 'SomeToken' }); + + const { result } = renderHookWithProvider(() => useTokenContractAlert()); + + await waitFor(() => { + expect(result.current).toEqual([]); + }); + }); + + it('calls memoizedGetTokenStandardAndDetails with checksummed address', async () => { + mockUseTransactionMetadataRequest.mockReturnValue({ + type: TransactionType.simpleSend, + chainId: '0x1', + }); + mockUseTransferRecipient.mockReturnValue( + '0x935E73EDb9fF52E23BaC7F7e043A1ecD06d05477', + ); + mockGetTokenDetails.mockResolvedValue(undefined); + + renderHookWithProvider(() => useTokenContractAlert()); + + await waitFor(() => { + expect(mockGetTokenDetails).toHaveBeenCalledWith({ + tokenAddress: '0x935E73EDb9fF52E23BaC7F7e043A1ecD06d05477', + networkClientId: 'mainnet', + }); + }); + }); + + it('returns non-blocking warning alert with correct structure', async () => { + mockUseTransactionMetadataRequest.mockReturnValue({ + type: TransactionType.simpleSend, + chainId: '0x1', + }); + mockUseTransferRecipient.mockReturnValue( + '0x935E73EDb9fF52E23BaC7F7e043A1ecD06d05477', + ); + mockGetTokenDetails.mockResolvedValue({ standard: 'ERC721' }); + + const { result } = renderHookWithProvider(() => useTokenContractAlert()); + + await waitFor(() => { + expect(result.current).toHaveLength(1); + }); + const alert = result.current[0]; + expect(alert.isBlocking).toBe(false); + expect(alert.severity).toBe(Severity.Warning); + expect(alert.field).toBe(RowAlertKey.InteractingWith); + }); +}); diff --git a/app/components/Views/confirmations/hooks/alerts/useTokenContractAlert.ts b/app/components/Views/confirmations/hooks/alerts/useTokenContractAlert.ts new file mode 100644 index 00000000000..29dc6863022 --- /dev/null +++ b/app/components/Views/confirmations/hooks/alerts/useTokenContractAlert.ts @@ -0,0 +1,75 @@ +import { + TransactionMeta, + TransactionType, +} from '@metamask/transaction-controller'; +import { Hex } from '@metamask/utils'; +import { useMemo } from 'react'; + +import { strings } from '../../../../../../locales/i18n'; +import Engine from '../../../../../core/Engine'; +import { toChecksumAddress } from '../../../../../util/address'; +import { useAsyncResult } from '../../../../hooks/useAsyncResult'; +import { RowAlertKey } from '../../components/UI/info-row/alert-row/constants'; +import { AlertKeys } from '../../constants/alerts'; +import { Alert, Severity } from '../../types/alerts'; +import { memoizedGetTokenStandardAndDetails } from '../../utils/token'; +import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; +import { useTransferRecipient } from '../transactions/useTransferRecipient'; + +const TRANSFER_TRANSACTION_TYPES: TransactionType[] = [ + TransactionType.simpleSend, + TransactionType.tokenMethodTransfer, + TransactionType.tokenMethodTransferFrom, +]; + +export function useTokenContractAlert(): Alert[] { + const transactionMetadata = + useTransactionMetadataRequest() as TransactionMeta; + const recipient = useTransferRecipient(); + const chainId = transactionMetadata?.chainId; + const transactionType = transactionMetadata?.type; + + const isTransfer = + transactionType !== undefined && + TRANSFER_TRANSACTION_TYPES.includes(transactionType as TransactionType); + + const { value: isTokenContract } = useAsyncResult(async () => { + if (!isTransfer || !recipient || !chainId) { + return false; + } + + try { + const { NetworkController } = Engine.context; + const networkClientId = NetworkController.findNetworkClientIdByChainId( + chainId as Hex, + ); + + const checksummedRecipient = toChecksumAddress(recipient); + const token = await memoizedGetTokenStandardAndDetails({ + tokenAddress: checksummedRecipient, + networkClientId, + }); + + return Boolean(token?.standard); + } catch { + return false; + } + }, [isTransfer, recipient, chainId]); + + return useMemo(() => { + if (!isTokenContract) { + return []; + } + + return [ + { + key: AlertKeys.TokenContractAddress, + field: RowAlertKey.InteractingWith, + message: strings('alert_system.token_contract_warning.message'), + title: strings('alert_system.token_contract_warning.title'), + severity: Severity.Warning, + isBlocking: false, + }, + ]; + }, [isTokenContract]); +} diff --git a/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.test.ts b/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.test.ts index d40a56b0676..19144ece22e 100644 --- a/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.test.ts +++ b/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.test.ts @@ -4,6 +4,7 @@ import { useAlerts } from '../../context/alert-system-context'; import { useConfirmationMetricEvents } from './useConfirmationMetricEvents'; import { useConfirmationAlertMetrics } from './useConfirmationAlertMetrics'; import { AlertKeys } from '../../constants/alerts'; +import { useSignatureRequest } from '../signatures/useSignatureRequest'; jest.mock('react-redux', () => ({ useSelector: jest.fn(), @@ -17,6 +18,10 @@ jest.mock('./useConfirmationMetricEvents', () => ({ useConfirmationMetricEvents: jest.fn(), })); +jest.mock('../signatures/useSignatureRequest', () => ({ + useSignatureRequest: jest.fn(), +})); + describe('useConfirmationAlertMetrics', () => { const ALERT_FIELD_FROM_MOCK = 'from'; const mockSetConfirmationMetric = jest.fn(); @@ -32,6 +37,7 @@ describe('useConfirmationAlertMetrics', () => { setConfirmationMetric: mockSetConfirmationMetric, }); (useAlerts as jest.Mock).mockReturnValue(mockUseAlerts); + (useSignatureRequest as jest.Mock).mockReturnValue({ id: 'test-id' }); }); const baseAlertProperties = { @@ -214,4 +220,55 @@ describe('useConfirmationAlertMetrics', () => { properties: baseAlertProperties, }); }); + + it('trackAlertMetrics does not call setConfirmationMetric when no alerts', () => { + (useAlerts as jest.Mock).mockReturnValue({ + alerts: [], + isAlertConfirmed: jest.fn(), + alertKey: '', + }); + (useSelector as jest.Mock).mockReturnValue({}); + + const { result } = renderHook(() => useConfirmationAlertMetrics()); + result.current.trackAlertMetrics(); + + expect(mockSetConfirmationMetric).not.toHaveBeenCalled(); + }); + + it('resolves alert name using prefix matching for composite keys', () => { + const compositeKey = `${AlertKeys.Blockaid}_extra_suffix`; + (useAlerts as jest.Mock).mockReturnValue({ + alerts: [{ key: compositeKey }], + isAlertConfirmed: jest.fn(), + alertKey: compositeKey, + }); + (useSelector as jest.Mock).mockReturnValue({ + properties: {}, + }); + + const { result } = renderHook(() => useConfirmationAlertMetrics()); + result.current.trackInlineAlertClicked('field'); + + expect(mockSetConfirmationMetric).toHaveBeenCalledWith({ + properties: expect.objectContaining({ + alert_trigger_name: ['blockaid'], + alert_key_clicked: ['blockaid'], + }), + }); + }); + + it('handles undefined signatureRequest', () => { + (useSignatureRequest as jest.Mock).mockReturnValue(undefined); + (useSelector as jest.Mock).mockReturnValue({ + properties: baseAlertProperties, + }); + + const { result } = renderHook(() => useConfirmationAlertMetrics()); + + result.current.trackAlertMetrics(); + + expect(mockSetConfirmationMetric).toHaveBeenCalledWith({ + properties: baseAlertProperties, + }); + }); }); diff --git a/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts b/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts index e94e9250623..d59160ffa94 100644 --- a/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts +++ b/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts @@ -127,6 +127,7 @@ const ALERTS_NAME_METRICS: AlertNameMetrics = { [AlertKeys.PerpsDepositMinimum]: 'minimum_deposit', [AlertKeys.PerpsHardwareAccount]: 'perps_hardware_account', [AlertKeys.SignedOrSubmitted]: 'signed_or_submitted', + [AlertKeys.TokenContractAddress]: 'token_contract_address', [AlertKeys.TokenTrustSignalMalicious]: 'token_trust_signal_malicious', [AlertKeys.TokenTrustSignalWarning]: 'token_trust_signal_warning', }; diff --git a/app/components/Views/confirmations/hooks/send/useToAddressValidation.test.ts b/app/components/Views/confirmations/hooks/send/useToAddressValidation.test.ts index fb79267c211..560cbcfeb55 100644 --- a/app/components/Views/confirmations/hooks/send/useToAddressValidation.test.ts +++ b/app/components/Views/confirmations/hooks/send/useToAddressValidation.test.ts @@ -46,6 +46,7 @@ describe('useToAddressValidation', () => { loading: false, resolvedAddress: undefined, toAddressError: undefined, + toAddressErrorAllowAcknowledge: false, toAddressValidated: undefined, toAddressWarning: undefined, }); @@ -63,6 +64,7 @@ describe('useToAddressValidation', () => { loading: false, resolvedAddress: undefined, toAddressError: undefined, + toAddressErrorAllowAcknowledge: false, toAddressValidated: undefined, toAddressWarning: undefined, }); @@ -90,6 +92,7 @@ describe('useToAddressValidation', () => { loading: false, resolvedAddress: undefined, toAddressError: 'Invalid address', + toAddressErrorAllowAcknowledge: false, toAddressValidated: '0x123', toAddressWarning: undefined, }); @@ -111,6 +114,7 @@ describe('useToAddressValidation', () => { loading: false, resolvedAddress: undefined, toAddressError: 'Invalid address', + toAddressErrorAllowAcknowledge: false, toAddressValidated: 'dummy', toAddressWarning: undefined, }); @@ -139,9 +143,101 @@ describe('useToAddressValidation', () => { loading: false, resolvedAddress: undefined, toAddressError: 'Invalid address', + toAddressErrorAllowAcknowledge: false, toAddressValidated: 'dummy', toAddressWarning: undefined, }); }); }); + + it('validate valid evm hex address through validateHexAddress', async () => { + mockUseSendContext.mockReturnValue({ + asset: { + name: 'Ethereum', + address: ETHEREUM_ADDRESS, + isNative: true, + chainId: '0x1', + symbol: 'ETH', + decimals: 18, + }, + to: '0xdB055877e6c13b6A6B25aBcAA29B393777dD0a73', + chainId: '0x1', + } as unknown as ReturnType); + const { result } = renderHookWithProvider( + () => useToAddressValidation(), + mockState, + ); + await waitFor(() => { + expect(result.current.toAddressValidated).toBe( + '0xdB055877e6c13b6A6B25aBcAA29B393777dD0a73', + ); + expect(result.current.loading).toBe(false); + }); + }); + + it('validate valid solana address through validateSolanaAddress', async () => { + mockUseSendContext.mockReturnValue({ + asset: SOLANA_ASSET, + to: '14grJpemFaf88c8tiVb77W7TYg2W3ir6pfkKz3YjhhZ5', + chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + } as unknown as ReturnType); + const { result } = renderHookWithProvider( + () => useToAddressValidation(), + mockState, + ); + await waitFor(() => { + expect(result.current.toAddressValidated).toBe( + '14grJpemFaf88c8tiVb77W7TYg2W3ir6pfkKz3YjhhZ5', + ); + expect(result.current.loading).toBe(false); + }); + }); + + it('validate ENS name through validateName', async () => { + mockUseSendContext.mockReturnValue({ + asset: { + name: 'Ethereum', + address: ETHEREUM_ADDRESS, + isNative: true, + chainId: '0x1', + symbol: 'ETH', + decimals: 18, + }, + to: 'test.eth', + chainId: '0x1', + } as unknown as ReturnType); + const { result } = renderHookWithProvider( + () => useToAddressValidation(), + mockState, + ); + await waitFor(() => { + expect(result.current.toAddressValidated).toBe('test.eth'); + expect(result.current.loading).toBe(false); + }); + }); + + it('returns no validation when chainId is missing', async () => { + mockUseSendContext.mockReturnValue({ + asset: { + name: 'Ethereum', + address: ETHEREUM_ADDRESS, + isNative: true, + chainId: '0x1', + symbol: 'ETH', + decimals: 18, + }, + to: '0xdB055877e6c13b6A6B25aBcAA29B393777dD0a73', + chainId: undefined, + } as unknown as ReturnType); + const { result } = renderHookWithProvider( + () => useToAddressValidation(), + mockState, + ); + await waitFor(() => { + expect(result.current.toAddressValidated).toBe( + '0xdB055877e6c13b6A6B25aBcAA29B393777dD0a73', + ); + expect(result.current.toAddressError).toBeUndefined(); + }); + }); }); diff --git a/app/components/Views/confirmations/hooks/send/useToAddressValidation.ts b/app/components/Views/confirmations/hooks/send/useToAddressValidation.ts index 8dbb2082833..bdd9eca301d 100644 --- a/app/components/Views/confirmations/hooks/send/useToAddressValidation.ts +++ b/app/components/Views/confirmations/hooks/send/useToAddressValidation.ts @@ -20,6 +20,7 @@ interface ValidationResult { error?: string; warning?: string; resolvedAddress?: string; + allowAcknowledge?: boolean; } export const useToAddressValidation = () => { @@ -109,15 +110,17 @@ export const useToAddressValidation = () => { const { toAddressValidated, - error: toAddressError, + error, warning: toAddressWarning, resolvedAddress, + allowAcknowledge, } = result ?? {}; return { loading, resolvedAddress, - toAddressError, + toAddressError: error, + toAddressErrorAllowAcknowledge: allowAcknowledge === true, toAddressValidated, toAddressWarning, }; diff --git a/app/components/Views/confirmations/utils/send-address-validations.test.ts b/app/components/Views/confirmations/utils/send-address-validations.test.ts index 09f5e60e463..4e33b5df039 100644 --- a/app/components/Views/confirmations/utils/send-address-validations.test.ts +++ b/app/components/Views/confirmations/utils/send-address-validations.test.ts @@ -2,6 +2,7 @@ import * as ConfusablesUtils from '../../../../util/confusables'; import { getConfusableCharacterInfo, + validateBitcoinAddress, validateHexAddress, validateSolanaAddress, validateTronAddress, @@ -63,6 +64,12 @@ describe('validateHexAddress', () => { "You are sending tokens to the token's contract address. This may result in the loss of these tokens.", }); }); + it('returns empty object when chainId is undefined', async () => { + expect( + await validateHexAddress('0xdB055877e6c13b6A6B25aBcAA29B393777dD0a73'), + ).toStrictEqual({}); + }); + it('returns warning if address is contract address', async () => { mockMemoizedGetTokenStandardAndDetails.mockResolvedValue({ standard: 'ERC20', @@ -73,6 +80,7 @@ describe('validateHexAddress', () => { '0x1', ), ).toStrictEqual({ + allowAcknowledge: true, error: 'This address is a token contract address. If you send tokens to this address, you will lose them.', }); @@ -122,6 +130,20 @@ describe('validateTronAddress', () => { }); }); +describe('validateBitcoinAddress', () => { + it('returns error if address is not a valid Bitcoin mainnet address', () => { + expect(validateBitcoinAddress('not-a-bitcoin-address')).toStrictEqual({ + error: 'Invalid address', + }); + }); + + it('returns empty object for valid Bitcoin mainnet address', () => { + expect( + validateBitcoinAddress('bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq'), + ).toStrictEqual({}); + }); +}); + describe('getConfusableCharacterInfo', () => { it('returns empty object if there is no error', async () => { expect(getConfusableCharacterInfo('test.eth')).toStrictEqual({}); diff --git a/app/components/Views/confirmations/utils/send-address-validations.ts b/app/components/Views/confirmations/utils/send-address-validations.ts index 13b9cc1e947..d3cb04483c8 100644 --- a/app/components/Views/confirmations/utils/send-address-validations.ts +++ b/app/components/Views/confirmations/utils/send-address-validations.ts @@ -38,6 +38,7 @@ export const validateHexAddress = async ( ): Promise<{ error?: string; warning?: string; + allowAcknowledge?: boolean; }> => { if (LOWER_CASED_BURN_ADDRESSES.includes(toAddress?.toLowerCase())) { return { @@ -68,6 +69,7 @@ export const validateHexAddress = async ( if (token?.standard) { return { error: strings('send.token_contract_warning'), + allowAcknowledge: true, }; } } catch { diff --git a/locales/languages/en.json b/locales/languages/en.json index 2bf942ccacd..2c5c7978d6c 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -119,6 +119,10 @@ "message": "You're sending your assets to a burn address. If you continue, you'll lose your assets.", "title": "Sending assets to burn address" }, + "token_contract_warning": { + "title": "Token contract warning", + "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + }, "gas_sponsorship_reserve_balance": { "message": "Gas sponsorship isn't available for this transaction. You'll need to keep at least %{minBalance} %{nativeTokenSymbol} in your account.", "title": "Gas sponsorship unavailable" @@ -689,7 +693,11 @@ "invisible_character_error": "We detected an invisible character in the ENS name. Check the ENS name to avoid a potential scam.", "could_not_resolve_name": "Couldn't resolve name", "invalid_address": "Invalid address", - "contractAddressError": "You are sending tokens to the token's contract address. This may result in the loss of these tokens." + "contractAddressError": "You are sending tokens to the token's contract address. This may result in the loss of these tokens.", + "smart_contract_address": "Smart contract address", + "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "i_understand": "I understand", + "cancel": "Cancel" }, "unified_ramp": { "networks_filter_bar": { @@ -3398,8 +3406,7 @@ ], "private_key_explanation": "Save it somewhere safe and secret.", "private_key_warning": "This is the private key for the current selected account: {{accountName}}. Never disclose this key. Anyone with your private key can fully control your account, including transferring away any of your funds.", - "seed_phrase_warning_explanation": - "Make sure nobody is looking at your screen. MetaMask Support will never ask for this.", + "seed_phrase_warning_explanation": "Make sure nobody is looking at your screen. MetaMask Support will never ask for this.", "private_key_warning_explanation": "Never disclose this key. Anyone with your private key can fully control your account, including transferring away any of your funds.", "reveal_srp_description": "Your Secret Recovery Phrase gives full access to your wallet. Do not share it with anyone.", "reveal_credential_modal": [ @@ -8049,4 +8056,4 @@ "retry": "Retry" } } -} \ No newline at end of file +} From 045f96c3691d54e7f47dd9ca6306ad409ddcb407 Mon Sep 17 00:00:00 2001 From: Vinicius Stevam <45455812+vinistevam@users.noreply.github.com> Date: Mon, 16 Mar 2026 10:11:16 +0000 Subject: [PATCH 017/206] refactor: remove legacy cancel speed up components and related files (#26353) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Removes legacy cancel/speed-up code and consolidates on the single `CancelSpeedupModal` flow. This includes dropping unused EIP-1559 modal state and deleting legacy confirmation components that are no longer used. ## Motivation - The cancel/speed-up flow now uses only `CancelSpeedupModal` (with `useCancelSpeedupGas`). The previous EIP-1559–specific modal state (`speedUp1559IsOpen`, `cancel1559IsOpen`) was never set to `true` and only reset to `false`, so it was dead code. - Legacy confirmation components (`EditGasFee1559Update`, `EditGasFeeLegacyUpdate`, `UpdateEIP1559Tx`) have been superseded by the unified cancel/speed-up flow and are no longer referenced. ## Changes ### Legacy state cleanup - **`useUnifiedTxActions`**: Removed `speedUp1559IsOpen` and `cancel1559IsOpen` state, their setters in open/close/error paths, and from the hook’s return value. Only `speedUpIsOpen` and `cancelIsOpen` are used with `CancelSpeedupModal`. - **`Transactions/index.js`**: Removed `speedUp1559IsOpen` and `cancel1559IsOpen` from component state and from all `setState` calls in `onSpeedUpCompleted`, `onCancelCompleted`, and the speed-up/cancel failure handlers. - **Tests**: Updated `useUnifiedTxActions.test.ts`, `UnifiedTransactionsView.test.tsx`, and `Transactions/index.test.tsx` to drop assertions and mocks for the removed 1559 modal state. ### Legacy component removal - **Deleted** `app/components/Views/confirmations/legacy/components/EditGasFee1559Update/` (index, styles, types). - **Deleted** `app/components/Views/confirmations/legacy/components/EditGasFeeLegacyUpdate/` (component, styles, types, test, snapshot). - **Deleted** `app/components/Views/confirmations/legacy/components/UpdateEIP1559Tx/` (index, types). Cancel/speed-up is now handled solely by `CancelSpeedupModal` in both `UnifiedTransactionsView` and the legacy `Transactions` list. Manual: open Activity, trigger Speed up or Cancel on a pending transaction and confirm the modal and flow behave as before. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://github.com/MetaMask/mobile-planning/issues/2411 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** [test speed up.webm](https://github.com/user-attachments/assets/446714f1-5a00-4610-bfda-7daa8d935c12) [test-cancel.webm](https://github.com/user-attachments/assets/12b1e62a-cc56-4f15-997c-eadee620f771) ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Touches the speed-up/cancel transaction flow by removing legacy UI/components and slightly refactoring param handling; regressions would affect replacing pending transactions if any removed paths were still reachable. > > **Overview** > Removes the deprecated legacy gas-edit components and snapshots for speed-up/cancel flows (including `EditGasFee1559`, `EditGasFeeLegacy`, `EditGasFee*Update`, and `UpdateEIP1559Tx`), consolidating on the single `CancelSpeedupModal`-based path. > > Cleans up related code and tests by dropping now-unused mocks/state and minor refactoring in `useUnifiedTxActions` to compute gas params once before dispatching speed-up/cancel actions. Also fixes formatting in `docs/readme/deeplinking.md` by converting an admonition block into plain blockquoted lines. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit c46bdd81f2ac5b438d935abeb4fe33d99f0c4412. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../__snapshots__/index.test.tsx.snap | 581 ---------- app/components/UI/EditGasFee1559/index.js | 1006 ----------------- .../UI/EditGasFee1559/index.test.tsx | 21 - .../__snapshots__/index.test.tsx.snap | 289 ----- app/components/UI/EditGasFeeLegacy/index.js | 624 ---------- .../UI/EditGasFeeLegacy/index.test.tsx | 21 - app/components/UI/Transactions/index.js | 1 - app/components/UI/Transactions/index.test.tsx | 9 - .../UnifiedTransactionsView.test.tsx | 1 + .../useUnifiedTxActions.ts | 11 +- .../components/EditGasFee1559Update/index.jsx | 787 ------------- .../components/EditGasFee1559Update/styles.ts | 110 -- .../components/EditGasFee1559Update/types.ts | 121 -- .../EditGasFeeLegacyUpdate.test.tsx | 141 --- .../EditGasFeeLegacyUpdate.test.tsx.snap | 893 --------------- .../EditGasFeeLegacyUpdate/index.jsx | 420 ------- .../EditGasFeeLegacyUpdate/styles.ts | 83 -- .../EditGasFeeLegacyUpdate/types.ts | 68 -- .../components/UpdateEIP1559Tx/index.jsx | 269 ----- .../components/UpdateEIP1559Tx/types.ts | 81 -- 20 files changed, 7 insertions(+), 5530 deletions(-) delete mode 100644 app/components/UI/EditGasFee1559/__snapshots__/index.test.tsx.snap delete mode 100644 app/components/UI/EditGasFee1559/index.js delete mode 100644 app/components/UI/EditGasFee1559/index.test.tsx delete mode 100644 app/components/UI/EditGasFeeLegacy/__snapshots__/index.test.tsx.snap delete mode 100644 app/components/UI/EditGasFeeLegacy/index.js delete mode 100644 app/components/UI/EditGasFeeLegacy/index.test.tsx delete mode 100644 app/components/Views/confirmations/legacy/components/EditGasFee1559Update/index.jsx delete mode 100644 app/components/Views/confirmations/legacy/components/EditGasFee1559Update/styles.ts delete mode 100644 app/components/Views/confirmations/legacy/components/EditGasFee1559Update/types.ts delete mode 100644 app/components/Views/confirmations/legacy/components/EditGasFeeLegacyUpdate/EditGasFeeLegacyUpdate.test.tsx delete mode 100644 app/components/Views/confirmations/legacy/components/EditGasFeeLegacyUpdate/__snapshots__/EditGasFeeLegacyUpdate.test.tsx.snap delete mode 100644 app/components/Views/confirmations/legacy/components/EditGasFeeLegacyUpdate/index.jsx delete mode 100644 app/components/Views/confirmations/legacy/components/EditGasFeeLegacyUpdate/styles.ts delete mode 100644 app/components/Views/confirmations/legacy/components/EditGasFeeLegacyUpdate/types.ts delete mode 100644 app/components/Views/confirmations/legacy/components/UpdateEIP1559Tx/index.jsx delete mode 100644 app/components/Views/confirmations/legacy/components/UpdateEIP1559Tx/types.ts diff --git a/app/components/UI/EditGasFee1559/__snapshots__/index.test.tsx.snap b/app/components/UI/EditGasFee1559/__snapshots__/index.test.tsx.snap deleted file mode 100644 index a3ac625917f..00000000000 --- a/app/components/UI/EditGasFee1559/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,581 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`EditGasFee1559 should render correctly 1`] = ` - - - - - - - - - - - - - - - - - - - - - ~ - - - - - Max fee - : - - - ( - ) - - - - - - - - - - Low - , - "name": "low", - "topLabel": false, - }, - { - "label": - Market - , - "name": "medium", - "topLabel": false, - }, - { - "label": - Aggressive - , - "name": "high", - "topLabel": false, - }, - ] - } - /> - - - - - Advanced options - - - - - - - - - - Gas limit - - - - - - - } - min={"21000"} - name="Gas limit" - onChangeValue={[Function]} - /> - - - - - Max priority fee - - - - - - - } - min={"0"} - name="Max priority fee" - onChangeValue={[Function]} - rightLabelComponent={ - - - Estimate - : - - - - GWEI - - } - unit="GWEI" - value="2" - /> - - - - - Max fee - - - - - - - } - min={"0"} - name="Max fee" - onChangeValue={[Function]} - rightLabelComponent={ - - - Estimate - : - - - - GWEI - - } - unit="GWEI" - value="50" - /> - - - - - - - - How should I choose? - - - - Save - - - - - - We have updated the gas fee based on current network conditions and have increased it by at least 10% (required by the network). - - - } - isVisible={false} - title={null} - toggleModal={[Function]} - /> - - - - - - Selecting the right gas fee depends on the type of transaction and how important it is to you. - - - Low - - - Use low to wait for a cheaper price. Time estimates are much less accurate as prices are somewhat unpredictable. - - - Market - - - Use market for fast processing at current market price. - - - Aggressive - - - High probability, even in volatile markets. Use Aggressive to cover surges in network traffic due to things like popular NFT drops. - - - - - - } - isVisible={false} - propagateSwipe={true} - title="How should I choose?" - toggleModal={[Function]} - /> - - - - - -`; diff --git a/app/components/UI/EditGasFee1559/index.js b/app/components/UI/EditGasFee1559/index.js deleted file mode 100644 index 797caeab358..00000000000 --- a/app/components/UI/EditGasFee1559/index.js +++ /dev/null @@ -1,1006 +0,0 @@ -/* eslint-disable react/display-name */ -import React, { useState } from 'react'; -import { - View, - StyleSheet, - TouchableOpacity, - ScrollView, - TouchableWithoutFeedback, -} from 'react-native'; -import Text from '../../Base/Text'; -import StyledButton from '../StyledButton'; -import RangeInput from '../../Base/RangeInput'; -import MaterialCommunityIcon from 'react-native-vector-icons/MaterialCommunityIcons'; -import InfoModal from '../../Base/InfoModal'; -import Icon from 'react-native-vector-icons/Ionicons'; -import { strings } from '../../../../locales/i18n'; -import Alert, { AlertType } from '../../Base/Alert'; -import HorizontalSelector from '../../Base/HorizontalSelector'; -import Device from '../../../util/device'; -import { getDecimalChainId, isMainnetByChainId } from '../../../util/networks'; -import PropTypes from 'prop-types'; -import BigNumber from 'bignumber.js'; -import FadeAnimationView from '../FadeAnimationView'; -import { MetaMetricsEvents } from '../../../core/Analytics'; - -import TimeEstimateInfoModal from '../TimeEstimateInfoModal'; -import useModalHandler from '../../Base/hooks/useModalHandler'; -import AppConstants from '../../../core/AppConstants'; -import { useTheme } from '../../../util/theme'; -import { - GAS_LIMIT_INCREMENT, - GAS_PRICE_INCREMENT as GAS_INCREMENT, - GAS_LIMIT_MIN, - GAS_PRICE_MIN as GAS_MIN, -} from '../../../util/gasUtils'; -import { useMetrics } from '../../../components/hooks/useMetrics'; - -const createStyles = (colors) => - StyleSheet.create({ - root: { - backgroundColor: colors.background.default, - borderTopLeftRadius: 20, - borderTopRightRadius: 20, - minHeight: 200, - maxHeight: '95%', - paddingTop: 24, - paddingBottom: Device.isIphoneX() ? 32 : 24, - }, - wrapper: { - paddingHorizontal: 24, - }, - customGasHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - width: '100%', - paddingBottom: 20, - }, - newGasFeeHeader: { - flexDirection: 'row', - alignItems: 'center', - width: '100%', - justifyContent: 'center', - }, - headerContainer: { - alignItems: 'center', - marginBottom: 22, - }, - headerText: { - fontSize: 48, - flex: 1, - textAlign: 'center', - }, - headerTitle: { - flexDirection: 'row', - }, - saveButton: { - marginBottom: 20, - }, - labelTextContainer: { - flexDirection: 'row', - alignItems: 'center', - }, - hitSlop: { - top: 10, - left: 10, - bottom: 10, - right: 10, - }, - labelInfo: { - color: colors.text.muted, - }, - advancedOptionsContainer: { - marginTop: 25, - marginBottom: 30, - }, - advancedOptionsInputsContainer: { - marginTop: 14, - }, - rangeInputContainer: { - marginBottom: 20, - }, - advancedOptionsButton: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - }, - advancedOptionsIcon: { - paddingTop: 1, - marginLeft: 5, - }, - learnMoreLabels: { - marginTop: 9, - }, - /* Add when the learn more link is ready - learnMoreLink: { - marginTop: 14 - },*/ - warningTextContainer: { - lineHeight: 20, - paddingLeft: 4, - flex: 1, - }, - warningText: { - lineHeight: 20, - flex: 1, - color: colors.text.default, - }, - warningContainer: { - marginBottom: 20, - }, - dappEditGasContainer: { - marginVertical: 20, - }, - subheader: { - marginBottom: 6, - }, - learnMoreModal: { - maxHeight: Device.getDeviceHeight() * 0.7, - }, - redInfo: { - marginLeft: 2, - color: colors.error.default, - }, - }); - -/** - * The EditGasFee1559 component will be deprecated in favor of EditGasFee1559Update as part of the gas polling refactor code that moves gas fee modifications to `app/core/GasPolling`. When the refactoring is completed, the EditGasFee1559Update will be renamed EditGasFee1559 and this component will be removed. The EditGasFee1559Update is currently being used in the Update Transaction(Speed Up/Cancel) flow. - */ - -const EditGasFee1559 = ({ - selected, - gasFee, - gasOptions, - onChange, - onCancel, - onSave, - gasFeeNative, - gasFeeConversion, - gasFeeMaxNative, - gasFeeMaxConversion, - maxPriorityFeeNative, - maxPriorityFeeConversion, - maxFeePerGasNative, - maxFeePerGasConversion, - primaryCurrency, - chainId, - timeEstimate, - timeEstimateColor, - timeEstimateId, - error, - warning, - dappSuggestedGas, - ignoreOptions, - updateOption, - extendOptions = {}, - recommended, - warningMinimumEstimateOption, - suggestedEstimateOption, - animateOnChange, - isAnimating, - onUpdatingValuesStart, - onUpdatingValuesEnd, - analyticsParams, - view, -}) => { - const [showInfoModal, setShowInfoModal] = useState(false); - const [showAdvancedOptions, setShowAdvancedOptions] = useState(!selected); - const [maxPriorityFeeError, setMaxPriorityFeeError] = useState(null); - const [maxFeeError, setMaxFeeError] = useState(null); - const [showLearnMoreModal, setShowLearnMoreModal] = useState(false); - const [selectedOption, setSelectedOption] = useState(selected); - const [showInputs, setShowInputs] = useState(!dappSuggestedGas); - const [ - isVisibleTimeEstimateInfoModal, - , - showTimeEstimateInfoModal, - hideTimeEstimateInfoModal, - ] = useModalHandler(false); - const { colors } = useTheme(); - const { trackEvent, createEventBuilder } = useMetrics(); - - const styles = createStyles(colors); - - const getAnalyticsParams = () => { - try { - return { - ...analyticsParams, - chain_id: getDecimalChainId(chainId), - function_type: view, - gas_mode: selectedOption ? 'Basic' : 'Advanced', - speed_set: selectedOption || undefined, - }; - } catch (error) { - return {}; - } - }; - - const toggleAdvancedOptions = () => { - if (!showAdvancedOptions) { - trackEvent( - createEventBuilder(MetaMetricsEvents.GAS_ADVANCED_OPTIONS_CLICKED) - .addProperties(getAnalyticsParams()) - .build(), - ); - } - setShowAdvancedOptions((showAdvancedOptions) => !showAdvancedOptions); - }; - - const toggleLearnMoreModal = () => { - setShowLearnMoreModal((showLearnMoreModal) => !showLearnMoreModal); - }; - - const save = () => { - trackEvent( - createEventBuilder(MetaMetricsEvents.GAS_FEE_CHANGED) - .addProperties(getAnalyticsParams()) - .build(), - ); - - onSave(selectedOption); - }; - - const changeGas = (gas, selectedOption) => { - setSelectedOption(selectedOption); - onChange(gas, selectedOption); - }; - - const changedMaxPriorityFee = (value) => { - const lowerValue = new BigNumber( - gasOptions?.[warningMinimumEstimateOption]?.suggestedMaxPriorityFeePerGas, - ); - const higherValue = new BigNumber( - gasOptions?.high?.suggestedMaxPriorityFeePerGas, - ).multipliedBy(new BigNumber(1.5)); - const updateFloor = new BigNumber(updateOption?.maxPriortyFeeThreshold); - - const valueBN = new BigNumber(value); - - if (updateFloor && !updateFloor.isNaN() && valueBN.lt(updateFloor)) { - setMaxPriorityFeeError( - updateOption?.isCancel - ? strings('edit_gas_fee_eip1559.max_priority_fee_cancel_low', { - cancel_value: updateFloor, - }) - : strings('edit_gas_fee_eip1559.max_priority_fee_speed_up_low', { - speed_up_floor_value: updateFloor, - }), - ); - } else if (!lowerValue.isNaN() && valueBN.lt(lowerValue)) { - setMaxPriorityFeeError( - strings('edit_gas_fee_eip1559.max_priority_fee_low'), - ); - } else if (!higherValue.isNaN() && valueBN.gt(higherValue)) { - setMaxPriorityFeeError( - strings('edit_gas_fee_eip1559.max_priority_fee_high'), - ); - } else { - setMaxPriorityFeeError(''); - } - - const newGas = { ...gasFee, suggestedMaxPriorityFeePerGas: value }; - - changeGas(newGas, null); - }; - - const changedMaxFeePerGas = (value) => { - const lowerValue = new BigNumber( - gasOptions?.[warningMinimumEstimateOption]?.suggestedMaxFeePerGas, - ); - const higherValue = new BigNumber( - gasOptions?.high?.suggestedMaxFeePerGas, - ).multipliedBy(new BigNumber(1.5)); - const updateFloor = new BigNumber(updateOption?.maxFeeThreshold); - - const valueBN = new BigNumber(value); - - if (updateFloor && !updateFloor.isNaN() && valueBN.lt(updateFloor)) { - setMaxFeeError( - updateOption?.isCancel - ? strings('edit_gas_fee_eip1559.max_fee_cancel_low', { - cancel_value: updateFloor, - }) - : strings('edit_gas_fee_eip1559.max_fee_speed_up_low', { - speed_up_floor_value: updateFloor, - }), - ); - } else if (!lowerValue.isNaN() && valueBN.lt(lowerValue)) { - setMaxFeeError(strings('edit_gas_fee_eip1559.max_fee_low')); - } else if (!higherValue.isNaN() && valueBN.gt(higherValue)) { - setMaxFeeError(strings('edit_gas_fee_eip1559.max_fee_high')); - } else { - setMaxFeeError(''); - } - - const newGas = { ...gasFee, suggestedMaxFeePerGas: value }; - changeGas(newGas, null); - }; - - const changedGasLimit = (value) => { - const newGas = { ...gasFee, suggestedGasLimit: value }; - changeGas(newGas, null); - }; - - const selectOption = (option) => { - setSelectedOption(option); - setMaxFeeError(''); - setMaxPriorityFeeError(''); - changeGas({ ...gasOptions[option] }, option); - }; - - const shouldIgnore = (option) => - ignoreOptions.find((item) => item === option); - - const renderLabel = (selected, disabled, label) => ( - - {label} - - ); - - const renderOptions = () => - [ - { - name: AppConstants.GAS_OPTIONS.LOW, - label: strings('edit_gas_fee_eip1559.low'), - }, - { - name: AppConstants.GAS_OPTIONS.MEDIUM, - label: strings('edit_gas_fee_eip1559.market'), - }, - { - name: AppConstants.GAS_OPTIONS.HIGH, - label: strings('edit_gas_fee_eip1559.aggressive'), - }, - ] - .filter(({ name }) => !shouldIgnore(name)) - .map(({ name, label, ...option }) => ({ - name, - label: renderLabel(selectedOption === name, false, label), - topLabel: recommended?.name === name && recommended.render, - ...option, - ...extendOptions[name], - })); - - const isMainnet = isMainnetByChainId(chainId); - const nativeCurrencySelected = primaryCurrency === 'ETH' || !isMainnet; - let gasFeePrimary, - gasFeeMaxPrimary, - maxFeePerGasPrimary, - maxPriorityFeePerGasPrimary, - gasFeeMaxSecondary; - if (nativeCurrencySelected) { - gasFeePrimary = gasFeeNative; - gasFeeMaxPrimary = gasFeeMaxNative; - gasFeeMaxSecondary = gasFeeMaxConversion; - maxFeePerGasPrimary = maxFeePerGasNative; - maxPriorityFeePerGasPrimary = maxPriorityFeeNative; - } else { - gasFeePrimary = gasFeeConversion; - gasFeeMaxPrimary = gasFeeMaxConversion; - gasFeeMaxSecondary = gasFeeMaxNative; - maxFeePerGasPrimary = maxFeePerGasConversion; - maxPriorityFeePerGasPrimary = maxPriorityFeeConversion; - } - - const valueToWatch = `${gasFeeNative}${gasFeeMaxNative}`; - - const renderInputs = () => ( - - - - {/* TODO(eip1559) hook with strings i18n */} - - - - - - {strings('edit_gas_fee_eip1559.advanced_options')} - - - - - - {(showAdvancedOptions || updateOption?.showAdvanced) && ( - - - - - {strings('edit_gas_fee_eip1559.gas_limit')}{' '} - - - setShowInfoModal('gas_limit')} - > - - - - } - min={GAS_LIMIT_MIN} - value={gasFee.suggestedGasLimit} - onChangeValue={changedGasLimit} - name={strings('edit_gas_fee_eip1559.gas_limit')} - increment={GAS_LIMIT_INCREMENT} - /> - - - - - {strings('edit_gas_fee_eip1559.max_priority_fee')}{' '} - - - setShowInfoModal('max_priority_fee')} - > - - - - } - rightLabelComponent={ - - - {strings('edit_gas_fee_eip1559.estimate')}: - {' '} - { - gasOptions?.[suggestedEstimateOption] - ?.suggestedMaxPriorityFeePerGas - }{' '} - GWEI - - } - value={gasFee.suggestedMaxPriorityFeePerGas} - name={strings('edit_gas_fee_eip1559.max_priority_fee')} - unit={'GWEI'} - min={GAS_MIN} - increment={GAS_INCREMENT} - inputInsideLabel={ - maxPriorityFeePerGasPrimary && - `≈ ${maxPriorityFeePerGasPrimary}` - } - error={maxPriorityFeeError} - onChangeValue={changedMaxPriorityFee} - /> - - - - - {strings('edit_gas_fee_eip1559.max_fee')}{' '} - - - setShowInfoModal('max_fee')} - > - - - - } - rightLabelComponent={ - - - {strings('edit_gas_fee_eip1559.estimate')}: - {' '} - { - gasOptions?.[suggestedEstimateOption] - ?.suggestedMaxFeePerGas - }{' '} - GWEI - - } - value={gasFee.suggestedMaxFeePerGas} - name={strings('edit_gas_fee_eip1559.max_fee')} - unit={'GWEI'} - min={GAS_MIN} - increment={GAS_INCREMENT} - error={maxFeeError} - onChangeValue={changedMaxFeePerGas} - inputInsideLabel={ - maxFeePerGasPrimary && `≈ ${maxFeePerGasPrimary}` - } - /> - - - )} - - - - - - {strings('edit_gas_fee_eip1559.learn_more.title')} - - - - {updateOption - ? strings('edit_gas_fee_eip1559.submit') - : strings('edit_gas_fee_eip1559.save')} - - - - ); - - const renderWarning = () => { - if (!warning) return null; - if (typeof warning === 'string') - return ( - ( - - )} - style={styles.warningContainer} - > - {() => ( - - - {warning} - - - )} - - ); - - return warning; - }; - - const renderError = () => { - if (!error) return null; - if (typeof error === 'string') - return ( - ( - - )} - style={styles.warningContainer} - > - {() => ( - - - {error} - - - )} - - ); - - return error; - }; - - const renderDisplayTitle = () => { - if (updateOption) - return updateOption.isCancel - ? strings('edit_gas_fee_eip1559.cancel_transaction') - : strings('edit_gas_fee_eip1559.speed_up_transaction'); - return strings('edit_gas_fee_eip1559.edit_priority'); - }; - - return ( - - - - - - - - - - - {renderDisplayTitle} - - - - {updateOption && ( - - - {strings('edit_gas_fee_eip1559.new_gas_fee')}{' '} - - - setShowInfoModal('new_gas_fee')} - > - - - - )} - - {renderWarning} - {renderError} - - - - ~{gasFeePrimary} - - - - - {strings('edit_gas_fee_eip1559.max_fee')}:{' '} - - {gasFeeMaxPrimary} ({gasFeeMaxSecondary}) - - - - {timeEstimate} - - {(timeEstimateId === AppConstants.GAS_TIMES.MAYBE || - timeEstimateId === AppConstants.GAS_TIMES.UNKNOWN) && ( - - - - )} - - - {!showInputs ? ( - - setShowInputs(true)} - > - {strings('edit_gas_fee_eip1559.edit_suggested_gas_fee')} - - - ) : ( - renderInputs() - )} - setShowInfoModal(null)} - body={ - - - {showInfoModal === 'gas_limit' && - strings('edit_gas_fee_eip1559.learn_more_gas_limit')} - {showInfoModal === 'max_priority_fee' && - strings( - 'edit_gas_fee_eip1559.learn_more_max_priority_fee', - )} - {showInfoModal === 'max_fee' && - strings('edit_gas_fee_eip1559.learn_more_max_fee')} - {showInfoModal === 'new_gas_fee' && - updateOption && - updateOption.isCancel - ? strings( - 'edit_gas_fee_eip1559.learn_more_cancel_gas_fee', - ) - : strings('edit_gas_fee_eip1559.learn_more_new_gas_fee')} - - - } - /> - - - - - - {strings('edit_gas_fee_eip1559.learn_more.intro')} - - - {strings('edit_gas_fee_eip1559.learn_more.low_label')} - - - {strings('edit_gas_fee_eip1559.learn_more.low_text')} - - - {strings( - 'edit_gas_fee_eip1559.learn_more.market_label', - )} - - - {strings( - 'edit_gas_fee_eip1559.learn_more.market_text', - )} - - - {strings( - 'edit_gas_fee_eip1559.learn_more.aggressive_label', - )} - - - {strings( - 'edit_gas_fee_eip1559.learn_more.aggressive_text', - )} - - {/* TODO(eip1559) add link when available - - - {strings('edit_gas_fee_eip1559.learn_more.link')} - - */} - - - - - } - /> - - - - - - ); -}; - -EditGasFee1559.defaultProps = { - ignoreOptions: [], - warningMinimumEstimateOption: AppConstants.GAS_OPTIONS.LOW, - suggestedEstimateOption: AppConstants.GAS_OPTIONS.MEDIUM, -}; - -EditGasFee1559.propTypes = { - /** - * Gas option selected (low, medium, high) - */ - selected: PropTypes.string, - /** - * Gas fee currently active - */ - gasFee: PropTypes.object, - /** - * Gas fee options to select from - */ - gasOptions: PropTypes.object, - /** - * Function called when user selected or changed the gas - */ - onChange: PropTypes.func, - /** - * Function called when user cancels - */ - onCancel: PropTypes.func, - /** - * Function called when user saves the new gas - */ - onSave: PropTypes.func, - /** - * Gas fee in native currency - */ - gasFeeNative: PropTypes.string, - /** - * Gas fee converted to chosen currency - */ - gasFeeConversion: PropTypes.string, - /** - * Maximum gas fee in native currency - */ - gasFeeMaxNative: PropTypes.string, - /** - * Maximum gas fee converted to chosen currency - */ - gasFeeMaxConversion: PropTypes.string, - /** - * Maximum priority gas fee in native currency - */ - maxPriorityFeeNative: PropTypes.string, - /** - * Maximum priority gas fee converted to chosen currency - */ - maxPriorityFeeConversion: PropTypes.string, - /** - * Maximum fee per gas fee in native currency - */ - maxFeePerGasNative: PropTypes.string, - /** - * Maximum fee per gas fee converted to chosen currency - */ - maxFeePerGasConversion: PropTypes.string, - /** - * Primary currency, either ETH or Fiat - */ - primaryCurrency: PropTypes.string, - /** - * A string representing the network chainId - */ - chainId: PropTypes.string, - /** - * String that represents the time estimates - */ - timeEstimate: PropTypes.string, - /** - * String that represents the color of the time estimate - */ - timeEstimateColor: PropTypes.string, - /** - * Time estimate name (unknown, low, medium, high, less_than, range) - */ - timeEstimateId: PropTypes.string, - /** - * Error message to show - */ - error: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.bool, - PropTypes.node, - ]), - /** - * Warning message to show - */ - warning: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.bool, - PropTypes.node, - ]), - /** - * Boolean that specifies if the gas price was suggested by the dapp - */ - dappSuggestedGas: PropTypes.bool, - /** - * Ignore option array - */ - ignoreOptions: PropTypes.array, - /** - * Option to display speed up/cancel view - */ - updateOption: PropTypes.object, - /** - * Extend options object. Object has option keys and properties will be spread - */ - extendOptions: PropTypes.object, - /** - * Recommended object with type and render function - */ - recommended: PropTypes.object, - /** - * Estimate option to compare with for too low warning - */ - warningMinimumEstimateOption: PropTypes.string, - /** - * Suggested estimate option to show recommended values - */ - suggestedEstimateOption: PropTypes.string, - /** - * Function to call when update animation starts - */ - onUpdatingValuesStart: PropTypes.func, - /** - * Function to call when update animation ends - */ - onUpdatingValuesEnd: PropTypes.func, - /** - * If the values should animate upon update or not - */ - animateOnChange: PropTypes.bool, - /** - * Boolean to determine if the animation is happening - */ - isAnimating: PropTypes.bool, - /** - * Extra analytics params to be send with the gas analytics - */ - analyticsParams: PropTypes.object, - /** - * (For analytics purposes) View (Approve, Transfer, Confirm) where this component is being used - */ - view: PropTypes.string.isRequired, -}; - -export default EditGasFee1559; diff --git a/app/components/UI/EditGasFee1559/index.test.tsx b/app/components/UI/EditGasFee1559/index.test.tsx deleted file mode 100644 index 155e2825e8b..00000000000 --- a/app/components/UI/EditGasFee1559/index.test.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { shallow } from 'enzyme'; -import React from 'react'; - -import EditGasFee1559 from './'; - -describe('EditGasFee1559', () => { - it('should render correctly', () => { - const wrapper = shallow( - , - ); - expect(wrapper).toMatchSnapshot(); - }); -}); diff --git a/app/components/UI/EditGasFeeLegacy/__snapshots__/index.test.tsx.snap b/app/components/UI/EditGasFeeLegacy/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 883a9d010a3..00000000000 --- a/app/components/UI/EditGasFeeLegacy/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,289 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`EditGasFeeLegacy should render correctly 1`] = ` - - - - - - - - - - - Edit gas fee - - - - - - - - - - - - ~ - - - - - - - - - - - - - - - Gas limit - - - - - - - } - min={"21000"} - name="Gas limit" - onChangeValue={[Function]} - value="21000" - /> - - - - - Gas price - - - - - - - } - min={"0"} - name="Gas price" - onChangeValue={[Function]} - unit="GWEI" - value="10" - /> - - - - - - - Save - - - - - - } - isVisible={false} - title={null} - toggleModal={[Function]} - /> - - - - -`; diff --git a/app/components/UI/EditGasFeeLegacy/index.js b/app/components/UI/EditGasFeeLegacy/index.js deleted file mode 100644 index f4920d400b4..00000000000 --- a/app/components/UI/EditGasFeeLegacy/index.js +++ /dev/null @@ -1,624 +0,0 @@ -/* eslint-disable react/display-name */ -import React, { useState } from 'react'; -import { - View, - StyleSheet, - TouchableOpacity, - ScrollView, - TouchableWithoutFeedback, -} from 'react-native'; -import PropTypes from 'prop-types'; -import { GAS_ESTIMATE_TYPES } from '@metamask/gas-fee-controller'; -import BigNumber from 'bignumber.js'; -import Text from '../../Base/Text'; -import StyledButton from '../StyledButton'; -import RangeInput from '../../Base/RangeInput'; -import MaterialCommunityIcon from 'react-native-vector-icons/MaterialCommunityIcons'; -import InfoModal from '../../Base/InfoModal'; -import Icon from 'react-native-vector-icons/Ionicons'; -import { strings } from '../../../../locales/i18n'; -import Alert, { AlertType } from '../../Base/Alert'; -import HorizontalSelector from '../../Base/HorizontalSelector'; -import Device from '../../../util/device'; -import { getDecimalChainId, isMainnetByChainId } from '../../../util/networks'; -import FadeAnimationView from '../FadeAnimationView'; -import { MetaMetricsEvents } from '../../../core/Analytics'; - -import AppConstants from '../../../core/AppConstants'; -import { useTheme } from '../../../util/theme'; -import { - GAS_LIMIT_INCREMENT, - GAS_PRICE_INCREMENT, - GAS_LIMIT_MIN, - GAS_PRICE_MIN, -} from '../../../util/gasUtils'; -import { useMetrics } from '../../../components/hooks/useMetrics'; - -const createStyles = (colors) => - StyleSheet.create({ - root: { - backgroundColor: colors.background.default, - borderTopLeftRadius: 20, - borderTopRightRadius: 20, - minHeight: 200, - maxHeight: '95%', - paddingTop: 24, - paddingBottom: Device.isIphoneX() ? 32 : 24, - }, - wrapper: { - paddingHorizontal: 24, - }, - customGasHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - width: '100%', - paddingBottom: 20, - }, - headerContainer: { - alignItems: 'center', - marginBottom: 22, - }, - headerText: { - fontSize: 48, - }, - headerTitle: { - flexDirection: 'row', - }, - headerTitleSide: { - flex: 1, - }, - labelTextContainer: { - flexDirection: 'row', - alignItems: 'center', - }, - hitSlop: { - top: 10, - left: 10, - bottom: 10, - right: 10, - }, - labelInfo: { - color: colors.text.muted, - }, - advancedOptionsContainer: { - marginTop: 25, - marginBottom: 30, - }, - advancedOptionsInputsContainer: { - marginTop: 14, - }, - rangeInputContainer: { - marginBottom: 20, - }, - advancedOptionsButton: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - }, - advancedOptionsIcon: { - paddingTop: 1, - marginLeft: 5, - }, - warningTextContainer: { - paddingLeft: 4, - lineHeight: 20, - textAlign: 'center', - }, - warningText: { - lineHeight: 20, - color: colors.text.default, - }, - }); - -/** - * The EditGasFeeLegacy component will be deprecated in favor of EditGasFeeLegacyUpdate as part of the gas polling refactor code that moves gas fee modifications to `app/core/GasPolling`. When the refactoring is completed, the EditGasFeeLegacyUpdate will be renamed EditGasFeeLegacy and this component will be removed. The EditGasFeeLegacyUpdate is currently being used in the Update Transaction(Speed Up/Cancel) flow. - */ - -const EditGasFeeLegacy = ({ - selected, - gasFee, - gasOptions, - onChange, - onCancel, - onSave, - gasFeeNative, - gasFeeConversion, - primaryCurrency, - chainId, - gasEstimateType, - error, - warning, - ignoreOptions, - extendOptions = {}, - recommended, - warningMinimumEstimateOption, - onUpdatingValuesStart, - onUpdatingValuesEnd, - animateOnChange, - isAnimating, - analyticsParams, - view, -}) => { - const onlyAdvanced = gasEstimateType !== GAS_ESTIMATE_TYPES.LEGACY; - const [showRangeInfoModal, setShowRangeInfoModal] = useState(false); - const [showAdvancedOptions, setShowAdvancedOptions] = useState( - !selected || onlyAdvanced, - ); - const [selectedOption, setSelectedOption] = useState(selected); - const [gasPriceError, setGasPriceError] = useState(); - const { colors } = useTheme(); - const { trackEvent, createEventBuilder } = useMetrics(); - const styles = createStyles(colors); - - const getAnalyticsParams = () => { - try { - return { - ...analyticsParams, - chain_id: getDecimalChainId(chainId), - function_type: view, - gas_mode: selectedOption ? 'Basic' : 'Advanced', - speed_set: selectedOption || undefined, - }; - } catch (error) { - return {}; - } - }; - - const toggleAdvancedOptions = () => { - if (!showAdvancedOptions) { - trackEvent( - createEventBuilder(MetaMetricsEvents.GAS_ADVANCED_OPTIONS_CLICKED) - .addProperties(getAnalyticsParams()) - .build(), - ); - } - setShowAdvancedOptions((showAdvancedOptions) => !showAdvancedOptions); - }; - - const save = () => { - trackEvent( - createEventBuilder(MetaMetricsEvents.GAS_FEE_CHANGED) - .addProperties(getAnalyticsParams()) - .build(), - ); - - onSave(selectedOption); - }; - - const changeGas = (gas, selectedOption) => { - setSelectedOption(selectedOption); - onChange(gas, selectedOption); - }; - - const changedGasPrice = (value) => { - const lowerValue = new BigNumber( - gasEstimateType === GAS_ESTIMATE_TYPES.LEGACY - ? gasOptions?.[warningMinimumEstimateOption] - : gasOptions?.gasPrice, - ); - const higherValue = new BigNumber( - gasEstimateType === GAS_ESTIMATE_TYPES.LEGACY - ? gasOptions?.high - : gasOptions?.gasPrice, - ).multipliedBy(new BigNumber(1.5)); - - const valueBN = new BigNumber(value); - - if (!lowerValue.isNaN() && valueBN.lt(lowerValue)) { - setGasPriceError(strings('edit_gas_fee_eip1559.gas_price_low')); - } else if (!higherValue.isNaN() && valueBN.gt(higherValue)) { - setGasPriceError(strings('edit_gas_fee_eip1559.gas_price_high')); - } else { - setGasPriceError(''); - } - - const newGas = { ...gasFee, suggestedGasPrice: value }; - - changeGas(newGas, null); - }; - - const changedGasLimit = (value) => { - const newGas = { ...gasFee, suggestedGasLimit: value }; - - changeGas(newGas, null); - }; - - const selectOption = (option) => { - setGasPriceError(''); - setSelectedOption(option); - changeGas({ ...gasFee, suggestedGasPrice: gasOptions[option] }, option); - }; - - const shouldIgnore = (option) => - ignoreOptions.find((item) => item === option); - - const renderLabel = (selected, disabled, label) => ( - - {label} - - ); - - const renderOptions = () => - [ - { - name: AppConstants.GAS_OPTIONS.LOW, - label: strings('edit_gas_fee_eip1559.low'), - }, - { - name: AppConstants.GAS_OPTIONS.MEDIUM, - label: strings('edit_gas_fee_eip1559.medium'), - }, - { - name: AppConstants.GAS_OPTIONS.HIGH, - label: strings('edit_gas_fee_eip1559.high'), - }, - ] - .filter(({ name }) => !shouldIgnore(name)) - .map(({ name, label, ...option }) => ({ - name, - label: renderLabel(selectedOption === name, false, label), - topLabel: recommended?.name === name && recommended.render, - ...option, - ...extendOptions[name], - })); - - const renderWarning = () => { - if (!warning) return null; - if (typeof warning === 'string') - return ( - ( - - )} - style={styles.warningContainer} - > - {() => ( - - - {warning} - - - )} - - ); - - return warning; - }; - - const renderError = () => { - if (!error) return null; - if (typeof error === 'string') - return ( - ( - - )} - style={styles.warningContainer} - > - {() => ( - - - {error} - - - )} - - ); - - return error; - }; - - const isMainnet = isMainnetByChainId(chainId); - const nativeCurrencySelected = primaryCurrency === 'ETH' || !isMainnet; - let gasFeePrimary, gasFeeSecondary; - if (nativeCurrencySelected) { - gasFeePrimary = gasFeeNative; - gasFeeSecondary = gasFeeConversion; - } else { - gasFeePrimary = gasFeeConversion; - gasFeeSecondary = gasFeeNative; - } - - const valueToWatch = gasFeeNative; - - return ( - - - - - - - - - - - {strings('transaction.edit_network_fee')} - - - - - {renderWarning} - {renderError} - - - - - - ~ - - - - {gasFeePrimary} - - - - - - {gasFeeSecondary} - - - - {!onlyAdvanced && ( - - - - )} - - {!onlyAdvanced && ( - - - {strings('edit_gas_fee_eip1559.advanced_options')} - - - - - - )} - {showAdvancedOptions && ( - - - - - {strings('edit_gas_fee_eip1559.gas_limit')}{' '} - - - setShowRangeInfoModal('gas_limit')} - > - - - - } - value={gasFee.suggestedGasLimit} - onChangeValue={changedGasLimit} - min={GAS_LIMIT_MIN} - name={strings('edit_gas_fee_eip1559.gas_limit')} - increment={GAS_LIMIT_INCREMENT} - /> - - - - - {strings('edit_gas_fee_eip1559.gas_price')}{' '} - - - setShowRangeInfoModal('gas_price')} - > - - - - } - value={gasFee.suggestedGasPrice} - name={strings('edit_gas_fee_eip1559.gas_price')} - unit={'GWEI'} - increment={GAS_PRICE_INCREMENT} - min={GAS_PRICE_MIN} - inputInsideLabel={ - gasFeeConversion && `≈ ${gasFeeConversion}` - } - onChangeValue={changedGasPrice} - error={gasPriceError} - /> - - - )} - - - - - {strings('edit_gas_fee_eip1559.save')} - - - setShowRangeInfoModal(null)} - body={ - - - {showRangeInfoModal === 'gas_limit' && - strings( - 'edit_gas_fee_eip1559.learn_more_gas_limit_legacy', - )} - {showRangeInfoModal === 'gas_price' && - strings('edit_gas_fee_eip1559.learn_more_gas_price')} - - - } - /> - - - - - ); -}; - -EditGasFeeLegacy.defaultProps = { - ignoreOptions: [], - warningMinimumEstimateOption: AppConstants.GAS_OPTIONS.LOW, -}; - -EditGasFeeLegacy.propTypes = { - /** - * Gas option selected (low, medium, high) - */ - selected: PropTypes.string, - /** - * Gas fee currently active - */ - gasFee: PropTypes.object, - /** - * Gas fee options to select from - */ - gasOptions: PropTypes.object, - /** - * Function called when user selected or changed the gas - */ - onChange: PropTypes.func, - /** - * Function called when user cancels - */ - onCancel: PropTypes.func, - /** - * Function called when user saves the new gas - */ - onSave: PropTypes.func, - /** - * Gas fee in native currency - */ - gasFeeNative: PropTypes.string, - /** - * Gas fee converted to chosen currency - */ - gasFeeConversion: PropTypes.string, - /** - * Primary currency, either ETH or Fiat - */ - primaryCurrency: PropTypes.string, - /** - * A string representing the network chainId - */ - chainId: PropTypes.string, - /** - * Estimate type returned by the gas fee controller, can be market-fee, legacy or eth_gasPrice - */ - gasEstimateType: PropTypes.string, - /** - * Error message to show - */ - error: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.bool, - PropTypes.node, - ]), - /** - * Warning message to show - */ - warning: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.bool, - PropTypes.node, - ]), - /** - * Ignore option array - */ - ignoreOptions: PropTypes.array, - /** - * Extend options object. Object has option keys and properties will be spread - */ - extendOptions: PropTypes.object, - /** - * Recommended object with type and render function - */ - recommended: PropTypes.object, - /** - * Estimate option to compare with for too low warning - */ - warningMinimumEstimateOption: PropTypes.string, - /** - * Function to call when update animation starts - */ - onUpdatingValuesStart: PropTypes.func, - /** - * Function to call when update animation ends - */ - onUpdatingValuesEnd: PropTypes.func, - /** - * If the values should animate upon update or not - */ - animateOnChange: PropTypes.bool, - /** - * Boolean to determine if the animation is happening - */ - isAnimating: PropTypes.bool, - /** - * Extra analytics params to be send with the gas analytics - */ - analyticsParams: PropTypes.object, - /** - * (For analytics purposes) View (Approve, Transfer, Confirm) where this component is being used - */ - view: PropTypes.string.isRequired, -}; - -export default EditGasFeeLegacy; diff --git a/app/components/UI/EditGasFeeLegacy/index.test.tsx b/app/components/UI/EditGasFeeLegacy/index.test.tsx deleted file mode 100644 index 6977b8db548..00000000000 --- a/app/components/UI/EditGasFeeLegacy/index.test.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { shallow } from 'enzyme'; -import React from 'react'; - -import EditGasFeeLegacy from './'; - -describe('EditGasFeeLegacy', () => { - it('should render correctly', () => { - const wrapper = shallow( - , - ); - expect(wrapper).toMatchSnapshot(); - }); -}); diff --git a/app/components/UI/Transactions/index.js b/app/components/UI/Transactions/index.js index 0f77cbb705c..787324f3331 100644 --- a/app/components/UI/Transactions/index.js +++ b/app/components/UI/Transactions/index.js @@ -470,7 +470,6 @@ class Transactions extends PureComponent { }; getParamsToSend = (transactionObject) => { - // Legacy tx with gasPrice 0x0 would produce 0 from the modal; fall back to market estimate so the replacement gets mined. if ( transactionObject && transactionObject.gasPrice !== undefined && diff --git a/app/components/UI/Transactions/index.test.tsx b/app/components/UI/Transactions/index.test.tsx index e83af62696a..0890fa98738 100644 --- a/app/components/UI/Transactions/index.test.tsx +++ b/app/components/UI/Transactions/index.test.tsx @@ -79,14 +79,6 @@ jest.mock('../TransactionElement', () => ({ })); // Mock other connected components -jest.mock( - '../../Views/confirmations/legacy/components/UpdateEIP1559Tx', - () => ({ - __esModule: true, - default: () => null, - }), -); - jest.mock('../TransactionActionModal', () => ({ __esModule: true, default: () => null, @@ -2105,7 +2097,6 @@ describe('UnconnectedTransactions Component Direct Method Testing', () => { instance.props = { ...defaultTestProps, loading: false }; instance.renderLoader = jest.fn(); instance.renderList = jest.fn(); - instance.renderUpdateTxEIP1559Gas = jest.fn(); instance.toggleRetry = jest.fn(); instance.retry = jest.fn(); diff --git a/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.test.tsx b/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.test.tsx index 5ee291123d8..3efd772e4d2 100644 --- a/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.test.tsx +++ b/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.test.tsx @@ -125,6 +125,7 @@ jest.mock('../confirmations/components/modals/cancel-speedup-modal', () => { : null, }; }); + jest.mock('../../UI/Transactions/RetryModal', () => 'RetryModal'); jest.mock( '../../UI/Transactions/TransactionsFooter', diff --git a/app/components/Views/UnifiedTransactionsView/useUnifiedTxActions.ts b/app/components/Views/UnifiedTransactionsView/useUnifiedTxActions.ts index 1205f473e68..9b7ebc4606c 100644 --- a/app/components/Views/UnifiedTransactionsView/useUnifiedTxActions.ts +++ b/app/components/Views/UnifiedTransactionsView/useUnifiedTxActions.ts @@ -190,7 +190,6 @@ export function useUnifiedTxActions() { if (params?.error) { return undefined; } - // Legacy tx with gasPrice 0x0 would produce 0 from the modal; fall back to market estimate so the replacement gets mined. if ( params && 'gasPrice' in params && @@ -213,8 +212,9 @@ export function useUnifiedTxActions() { throw new Error('Missing transaction id for speed up'); } + const gasValues = getParamsToSend(params); + if (isLedgerAccount) { - const gasValues = getParamsToSend(params); const isEip1559 = gasValues && 'maxFeePerGas' in gasValues; await signLedgerTransaction({ @@ -230,7 +230,7 @@ export function useUnifiedTxActions() { return; } - await speedUpTx(speedUpTxId, getParamsToSend(params)); + await speedUpTx(speedUpTxId, gasValues); onSpeedUpCancelCompleted(); } catch (error: unknown) { toggleRetry(getErrorMessage(error)); @@ -247,8 +247,9 @@ export function useUnifiedTxActions() { throw new Error('Missing transaction id for cancel'); } + const gasValues = getParamsToSend(params); + if (isLedgerAccount) { - const gasValues = getParamsToSend(params); const isEip1559 = gasValues && 'maxFeePerGas' in gasValues; await signLedgerTransaction({ @@ -265,7 +266,7 @@ export function useUnifiedTxActions() { await Engine.context.TransactionController.stopTransaction( cancelTxId, - getParamsToSend(params), + gasValues, ); onSpeedUpCancelCompleted(); } catch (error: unknown) { diff --git a/app/components/Views/confirmations/legacy/components/EditGasFee1559Update/index.jsx b/app/components/Views/confirmations/legacy/components/EditGasFee1559Update/index.jsx deleted file mode 100644 index 4737b404131..00000000000 --- a/app/components/Views/confirmations/legacy/components/EditGasFee1559Update/index.jsx +++ /dev/null @@ -1,787 +0,0 @@ -/* eslint-disable react/prop-types */ -/* eslint-disable react/no-unstable-nested-components */ -import BigNumber from 'bignumber.js'; -import React, { useCallback, useMemo, useState } from 'react'; -import { - ScrollView, - TouchableOpacity, - TouchableWithoutFeedback, - View, -} from 'react-native'; -import Icon from 'react-native-vector-icons/Ionicons'; -import MaterialCommunityIcon from 'react-native-vector-icons/MaterialCommunityIcons'; -import { EditGasViewSelectorsIDs } from '../EditGasView.testIds'; -import { strings } from '../../../../../../../locales/i18n'; -import { MetaMetricsEvents } from '../../../../../../core/Analytics'; -import AppConstants from '../../../../../../core/AppConstants'; -import { useGasTransaction } from '../../../../../../core/GasPolling/GasPolling'; -import { - GAS_PRICE_INCREMENT as GAS_INCREMENT, - GAS_LIMIT_INCREMENT, - GAS_LIMIT_MIN, - GAS_PRICE_MIN as GAS_MIN, -} from '../../../../../../util/gasUtils'; -import { - getDecimalChainId, - isMainnetByChainId, -} from '../../../../../../util/networks'; -import { - mockTheme, - useAppThemeFromContext, -} from '../../../../../../util/theme'; -import Alert, { AlertType } from '../../../../../Base/Alert'; -import useModalHandler from '../../../../../Base/hooks/useModalHandler'; -import HorizontalSelector from '../../../../../Base/HorizontalSelector'; -import RangeInput from '../../../../../Base/RangeInput'; -import Text from '../../../../../Base/Text'; -import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics'; -import FadeAnimationView from '../../../../../UI/FadeAnimationView'; -import StyledButton from '../../../../../UI/StyledButton'; -import InfoModal from '../../../../../Base/InfoModal'; -import TimeEstimateInfoModal from '../../../../../UI/TimeEstimateInfoModal'; -import createStyles from './styles'; - -const EditGasFee1559Update = ({ - selectedGasValue, - gasOptions, - primaryCurrency, - chainId, - onCancel, - onChange, - onSave, - error, - dappSuggestedGas, - ignoreOptions, - updateOption, - extendOptions = {}, - recommended, - warningMinimumEstimateOption, - suggestedEstimateOption, - animateOnChange, - isAnimating, - analyticsParams, - warning, - selectedGasObject, - onlyGas, -}) => { - const [modalInfo, updateModalInfo] = useState({ - isVisible: false, - value: '', - }); - const [showAdvancedOptions, setShowAdvancedOptions] = - useState(!selectedGasValue); - const [maxPriorityFeeError, setMaxPriorityFeeError] = useState(''); - const [maxFeeError, setMaxFeeError] = useState(''); - const [showLearnMoreModal, setShowLearnMoreModal] = useState(false); - const [selectedOption, setSelectedOption] = useState(selectedGasValue); - const [showInputs, setShowInputs] = useState(!dappSuggestedGas); - const [gasObject, updateGasObject] = useState({ - suggestedMaxFeePerGas: selectedGasObject.suggestedMaxFeePerGas, - suggestedMaxPriorityFeePerGas: - selectedGasObject.suggestedMaxPriorityFeePerGas, - suggestedGasLimit: selectedGasObject.suggestedGasLimit, - }); - - const [ - isVisibleTimeEstimateInfoModal, - showTimeEstimateInfoModal, - hideTimeEstimateInfoModal, - ] = useModalHandler(false); - const { colors } = useAppThemeFromContext() || mockTheme; - const { trackEvent, createEventBuilder } = useAnalytics(); - const styles = createStyles(colors); - - const gasTransaction = useGasTransaction({ - onlyGas, - gasSelected: selectedOption, - legacy: false, - gasObject, - }); - - const { - renderableGasFeeMinNative, - renderableGasFeeMaxNative, - renderableGasFeeMaxConversion, - renderableMaxFeePerGasNative, - renderableGasFeeMinConversion, - renderableMaxPriorityFeeNative, - renderableMaxFeePerGasConversion, - renderableMaxPriorityFeeConversion, - timeEstimateColor, - timeEstimate, - timeEstimateId, - suggestedMaxFeePerGas, - suggestedMaxPriorityFeePerGas, - suggestedGasLimit, - } = gasTransaction; - - const getAnalyticsParams = useCallback(() => { - try { - return { - ...analyticsParams, - chain_id: getDecimalChainId(chainId), - function_type: analyticsParams.view, - gas_mode: selectedOption ? 'Basic' : 'Advanced', - speed_set: selectedOption || undefined, - }; - } catch (err) { - return {}; - } - }, [analyticsParams, chainId, selectedOption]); - - const toggleAdvancedOptions = useCallback(() => { - if (!showAdvancedOptions) { - trackEvent( - createEventBuilder(MetaMetricsEvents.GAS_ADVANCED_OPTIONS_CLICKED) - .addProperties(getAnalyticsParams()) - .build(), - ); - } - setShowAdvancedOptions(!showAdvancedOptions); - }, [getAnalyticsParams, showAdvancedOptions, trackEvent, createEventBuilder]); - - const toggleLearnMoreModal = useCallback(() => { - setShowLearnMoreModal(!showLearnMoreModal); - }, [showLearnMoreModal]); - - const toggleInfoModal = useCallback( - (value) => { - updateModalInfo({ isVisible: !modalInfo.isVisible, value }); - }, - [updateModalInfo, modalInfo.isVisible], - ); - - const save = useCallback(() => { - trackEvent( - createEventBuilder(MetaMetricsEvents.GAS_FEE_CHANGED) - .addProperties(getAnalyticsParams()) - .build(), - ); - - const newGasPriceObject = { - suggestedMaxFeePerGas: gasObject?.suggestedMaxFeePerGas, - suggestedMaxPriorityFeePerGas: gasObject?.suggestedMaxPriorityFeePerGas, - suggestedGasLimit: gasObject?.suggestedGasLimit, - }; - - onSave(gasTransaction, newGasPriceObject); - }, [ - getAnalyticsParams, - onSave, - gasTransaction, - gasObject, - trackEvent, - createEventBuilder, - ]); - - const changeGas = useCallback( - (gas, option) => { - setSelectedOption(option); - updateGasObject({ - ...gasObject, - suggestedMaxFeePerGas: gas.suggestedMaxFeePerGas, - suggestedMaxPriorityFeePerGas: gas.suggestedMaxPriorityFeePerGas, - suggestedGasLimit: gas.suggestedGasLimit || gasObject.suggestedGasLimit, - }); - onChange(option); - }, - [onChange, gasObject], - ); - - const changedGasLimit = useCallback( - (value) => { - const newGas = { ...gasTransaction, suggestedGasLimit: value }; - changeGas(newGas, null); - }, - [changeGas, gasTransaction], - ); - - const changedMaxPriorityFee = useCallback( - (value) => { - const lowerValue = new BigNumber( - gasOptions?.[ - warningMinimumEstimateOption - ]?.suggestedMaxPriorityFeePerGas, - ); - - const higherValue = new BigNumber( - gasOptions?.high?.suggestedMaxPriorityFeePerGas, - ).multipliedBy(new BigNumber(1.5)); - const updateFloor = new BigNumber(updateOption?.maxPriortyFeeThreshold); - - const valueBN = new BigNumber(value); - - if (updateFloor && !updateFloor.isNaN() && valueBN.lt(updateFloor)) { - setMaxPriorityFeeError( - updateOption?.isCancel - ? strings('edit_gas_fee_eip1559.max_priority_fee_cancel_low', { - cancel_value: updateFloor, - }) - : strings('edit_gas_fee_eip1559.max_priority_fee_speed_up_low', { - speed_up_floor_value: updateFloor, - }), - ); - } else if (!lowerValue.isNaN() && valueBN.lt(lowerValue)) { - setMaxPriorityFeeError( - strings('edit_gas_fee_eip1559.max_priority_fee_low'), - ); - } else if (!higherValue.isNaN() && valueBN.gt(higherValue)) { - setMaxPriorityFeeError( - strings('edit_gas_fee_eip1559.max_priority_fee_high'), - ); - } else { - setMaxPriorityFeeError(null); - } - - const newGas = { - ...gasTransaction, - suggestedMaxPriorityFeePerGas: value, - }; - - changeGas(newGas, null); - }, - [ - changeGas, - gasTransaction, - gasOptions, - updateOption, - warningMinimumEstimateOption, - ], - ); - - const changedMaxFeePerGas = useCallback( - (value) => { - const lowerValue = new BigNumber( - gasOptions?.[warningMinimumEstimateOption]?.suggestedMaxFeePerGas, - ); - const higherValue = new BigNumber( - gasOptions?.high?.suggestedMaxFeePerGas, - ).multipliedBy(new BigNumber(1.5)); - const updateFloor = new BigNumber(updateOption?.maxFeeThreshold); - - const valueBN = new BigNumber(value); - - if (updateFloor && !updateFloor.isNaN() && valueBN.lt(updateFloor)) { - setMaxFeeError( - updateOption?.isCancel - ? strings('edit_gas_fee_eip1559.max_fee_cancel_low', { - cancel_value: updateFloor, - }) - : strings('edit_gas_fee_eip1559.max_fee_speed_up_low', { - speed_up_floor_value: updateFloor, - }), - ); - } else if (!lowerValue.isNaN() && valueBN.lt(lowerValue)) { - setMaxFeeError(strings('edit_gas_fee_eip1559.max_fee_low')); - } else if (!higherValue.isNaN() && valueBN.gt(higherValue)) { - setMaxFeeError(strings('edit_gas_fee_eip1559.max_fee_high')); - } else { - setMaxFeeError(''); - } - - const newGas = { - ...gasTransaction, - suggestedMaxFeePerGas: value, - }; - - changeGas(newGas, null); - }, - [ - changeGas, - gasTransaction, - gasOptions, - updateOption, - warningMinimumEstimateOption, - ], - ); - - const selectOption = useCallback( - (option) => { - setSelectedOption(option); - setMaxFeeError(''); - setMaxPriorityFeeError(''); - changeGas({ ...gasOptions?.[option] }, option); - }, - [changeGas, gasOptions], - ); - - const shouldIgnore = useCallback( - (option) => ignoreOptions?.find((item) => item === option), - [ignoreOptions], - ); - - const renderOptions = useMemo( - () => - [ - { - name: AppConstants.GAS_OPTIONS.LOW, - label: strings('edit_gas_fee_eip1559.low'), - }, - { - name: AppConstants.GAS_OPTIONS.MEDIUM, - label: strings('edit_gas_fee_eip1559.market'), - }, - { - name: AppConstants.GAS_OPTIONS.HIGH, - label: strings('edit_gas_fee_eip1559.aggressive'), - }, - ] - .filter(({ name }) => !shouldIgnore(name)) - .map(({ name, label, ...option }) => ({ - name, - label: function LabelComponent(selected, disabled) { - return ( - - {label} - - ); - }, - topLabel: recommended?.name === name && recommended.render, - ...option, - ...extendOptions[name], - })), - [recommended, extendOptions, shouldIgnore], - ); - - const isMainnet = isMainnetByChainId(chainId); - const nativeCurrencySelected = primaryCurrency === 'ETH' || !isMainnet; - - const switchNativeCurrencyDisplayOptions = (nativeValue, fiatValue) => { - if (nativeCurrencySelected) return nativeValue; - return fiatValue; - }; - - const valueToWatch = `${renderableGasFeeMinNative}${renderableGasFeeMaxNative}`; - - const LeftLabelComponent = ({ value, infoValue }) => ( - - - {strings(value)} - - toggleInfoModal(infoValue)} - > - - - - ); - - const RightLabelComponent = ({ value }) => ( - - - {strings(value)}: - {' '} - {gasOptions?.[suggestedEstimateOption]?.suggestedMaxFeePerGas} GWEI - - ); - - const TextComponent = ({ title, value }) => ( - <> - - {strings(title)} - - - {strings(value)} - - - ); - - const renderInputs = (option) => ( - - - - - - - - - {strings('edit_gas_fee_eip1559.advanced_options')} - - - - - - {(showAdvancedOptions || option?.maxFeeThreshold) && ( - - - - } - min={GAS_LIMIT_MIN} - value={suggestedGasLimit} - onChangeValue={changedGasLimit} - name={strings('edit_gas_fee_eip1559.gas_limit')} - increment={GAS_LIMIT_INCREMENT} - /> - - - - } - rightLabelComponent={ - - } - value={suggestedMaxPriorityFeePerGas} - name={strings('edit_gas_fee_eip1559.max_priority_fee')} - unit={'GWEI'} - min={GAS_MIN} - increment={GAS_INCREMENT} - inputInsideLabel={ - renderableMaxPriorityFeeNative && - `≈ ${switchNativeCurrencyDisplayOptions( - renderableMaxPriorityFeeNative, - renderableMaxPriorityFeeConversion, - )}` - } - error={maxPriorityFeeError} - onChangeValue={changedMaxPriorityFee} - /> - - - - } - rightLabelComponent={ - - } - value={suggestedMaxFeePerGas} - name={strings('edit_gas_fee_eip1559.max_fee')} - unit={'GWEI'} - min={GAS_MIN} - increment={GAS_INCREMENT} - error={maxFeeError} - onChangeValue={changedMaxFeePerGas} - inputInsideLabel={ - renderableMaxFeePerGasNative && - `≈ ${switchNativeCurrencyDisplayOptions( - renderableMaxFeePerGasNative, - renderableMaxFeePerGasConversion, - )}` - } - /> - - - )} - - - - - - {strings('edit_gas_fee_eip1559.learn_more.title')} - - - - {option - ? strings('edit_gas_fee_eip1559.submit') - : strings('edit_gas_fee_eip1559.save')} - - - - ); - - const renderWarning = useMemo(() => { - if (!warning) return null; - if (typeof warning === 'string') - return ( - ( - - )} - style={styles.warningContainer} - > - {() => ( - - - {warning} - - - )} - - ); - - return warning; - }, [warning, styles, colors]); - - const renderError = useMemo(() => { - if (!error) return null; - if (typeof error === 'string') - return ( - ( - - )} - style={styles.warningContainer} - > - {() => ( - - - {error} - - - )} - - ); - - return error; - }, [error, styles, colors]); - - const renderDisplayTitle = useMemo(() => { - if (updateOption) - return updateOption.isCancel - ? strings('edit_gas_fee_eip1559.cancel_transaction') - : strings('edit_gas_fee_eip1559.speed_up_transaction'); - return strings('edit_gas_fee_eip1559.edit_priority'); - }, [updateOption]); - - return ( - - - - - - - - - - - {renderDisplayTitle} - - - - {updateOption && ( - - - {strings('edit_gas_fee_eip1559.new_gas_fee')}{' '} - - - toggleInfoModal('new_gas_fee')} - > - - - - )} - - {renderWarning} - {renderError} - - - - ~ - {switchNativeCurrencyDisplayOptions( - renderableGasFeeMinNative, - renderableGasFeeMinConversion, - )} - - - - - {strings('edit_gas_fee_eip1559.max_fee')}:{' '} - - {switchNativeCurrencyDisplayOptions( - renderableGasFeeMaxNative, - renderableGasFeeMaxConversion, - )}{' '} - ( - {switchNativeCurrencyDisplayOptions( - renderableGasFeeMaxConversion, - renderableGasFeeMaxNative, - )} - ) - - - - {timeEstimate} - - {timeEstimateId === - (AppConstants.GAS_TIMES.MAYBE || - AppConstants.GAS_TIMES.UNKNOWN) && ( - showTimeEstimateInfoModal()} - > - - - )} - - - {!showInputs ? ( - - setShowInputs(true)} - > - {strings('edit_gas_fee_eip1559.edit_suggested_gas_fee')} - - - ) : ( - renderInputs(updateOption) - )} - - - updateModalInfo({ ...modalInfo, isVisible: false }) - } - body={ - - - {modalInfo.value === 'gas_limit' && - strings('edit_gas_fee_eip1559.learn_more_gas_limit')} - {modalInfo.value === 'max_priority_fee' && - strings( - 'edit_gas_fee_eip1559.learn_more_max_priority_fee', - )} - {modalInfo.value === 'max_fee' && - strings('edit_gas_fee_eip1559.learn_more_max_fee')} - {modalInfo.value === 'new_gas_fee' && updateOption?.isCancel - ? strings( - 'edit_gas_fee_eip1559.learn_more_cancel_gas_fee', - ) - : strings('edit_gas_fee_eip1559.learn_more_new_gas_fee')} - - - } - /> - - - - - - {strings('edit_gas_fee_eip1559.learn_more.intro')} - - - - - - - - - } - /> - - - - - - ); -}; - -export default EditGasFee1559Update; diff --git a/app/components/Views/confirmations/legacy/components/EditGasFee1559Update/styles.ts b/app/components/Views/confirmations/legacy/components/EditGasFee1559Update/styles.ts deleted file mode 100644 index 4263cfaaf8e..00000000000 --- a/app/components/Views/confirmations/legacy/components/EditGasFee1559Update/styles.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { StyleSheet } from 'react-native'; -import Device from '../../../../../../util/device'; -// TODO: Replace "any" with type -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const createStyles = (colors: any) => - StyleSheet.create({ - root: { - backgroundColor: colors.background.default, - borderTopLeftRadius: 20, - borderTopRightRadius: 20, - minHeight: 200, - maxHeight: '95%', - paddingTop: 24, - paddingBottom: Device.isIphoneX() ? 32 : 24, - }, - wrapper: { - paddingHorizontal: 24, - }, - customGasHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - width: '100%', - paddingBottom: 20, - }, - newGasFeeHeader: { - flexDirection: 'row', - alignItems: 'center', - width: '100%', - justifyContent: 'center', - }, - headerContainer: { - alignItems: 'center', - marginBottom: 22, - }, - headerText: { - fontSize: 48, - flex: 1, - textAlign: 'center', - }, - headerTitle: { - flexDirection: 'row', - }, - saveButton: { - marginBottom: 20, - }, - labelTextContainer: { - flexDirection: 'row', - alignItems: 'center', - }, - hitSlop: { - top: 10, - left: 10, - bottom: 10, - right: 10, - }, - labelInfo: { - color: colors.text.muted, - }, - advancedOptionsContainer: { - marginTop: 25, - marginBottom: 30, - }, - advancedOptionsInputsContainer: { - marginTop: 14, - }, - rangeInputContainer: { - marginBottom: 20, - }, - advancedOptionsButton: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - }, - advancedOptionsIcon: { - paddingTop: 1, - marginLeft: 5, - }, - learnMoreLabels: { - marginTop: 9, - }, - warningTextContainer: { - lineHeight: 20, - paddingLeft: 4, - flex: 1, - }, - warningText: { - lineHeight: 20, - flex: 1, - color: colors.text.default, - }, - warningContainer: { - marginBottom: 20, - }, - dappEditGasContainer: { - marginVertical: 20, - }, - subheader: { - marginBottom: 6, - }, - learnMoreModal: { - maxHeight: Device.getDeviceHeight() * 0.7, - }, - redInfo: { - marginLeft: 2, - color: colors.error.default, - }, - }); - -export default createStyles; diff --git a/app/components/Views/confirmations/legacy/components/EditGasFee1559Update/types.ts b/app/components/Views/confirmations/legacy/components/EditGasFee1559Update/types.ts deleted file mode 100644 index e134502a368..00000000000 --- a/app/components/Views/confirmations/legacy/components/EditGasFee1559Update/types.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { GasFeeOptions } from '../../../../../../core/GasPolling/types'; - -export interface RenderInputProps { - updateOption: - | { - isCancel: boolean; - maxFeeThreshold: string; - maxPriortyFeeThreshold: string; - showAdvanced: boolean | undefined; - } - | undefined; -} -export interface EditGasFee1559UpdateProps { - /** - * The selected gas value (low, medium, high) - */ - selectedGasValue: string; - /** - * Gas fee options. - */ - gasOptions: GasFeeOptions; - /** - * Primary currency, either ETH or Fiat - */ - primaryCurrency: string; - /** - * Option to display speed up/cancel view - */ - updateOption: RenderInputProps; - /** - * If the values should animate upon update or not - */ - animateOnChange: boolean | undefined; - /** - * A string representing the network chainId - */ - chainId: string; - /** - * Function to set the gas selected value - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onChange: any; - /** - * Function called when user cancels - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onCancel: any; - /** - * Function called when user saves the new gas data - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onSave: any; - /** - * Error message to show - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - error: any; - /** - * Warning message to show - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - warning: any; - /** - * Boolean that specifies if the gas price was suggested by the dapp - */ - dappSuggestedGas: boolean | undefined; - /** - * An array of selected gas value and lower that should be ignored. - */ - ignoreOptions: string[] | undefined; - /** - * Extend options object. Object has option keys and properties will be spread - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - extendOptions: any; - /** - * Recommended object with type and render function - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - recommended: any; - /** - * Estimate option to compare with for too low warning - */ - warningMinimumEstimateOption: string; - /** - * Suggested estimate option to show recommended values - */ - suggestedEstimateOption: string; - /** - * Boolean to determine if the animation is happening - */ - isAnimating: boolean; - /** - * Extra analytics params to be send with the gas analytics - */ - analyticsParams: { - chain_id: string; - gas_estimate_type: string; - gas_mode: string; - speed_set: string; - view: string; - }; - /** - * This is used in calculating the new gas price from the advanced view. - * The maxFeePerGas is the max fee per gas that the user can set. - * The maxPriorityFeePerGas is the max fee per gas that the user can set for priority transactions. - */ - selectedGasObject: { - suggestedMaxFeePerGas: string; - suggestedMaxPriorityFeePerGas: string; - suggestedGasLimit: string; - }; - onlyGas?: boolean; -} diff --git a/app/components/Views/confirmations/legacy/components/EditGasFeeLegacyUpdate/EditGasFeeLegacyUpdate.test.tsx b/app/components/Views/confirmations/legacy/components/EditGasFeeLegacyUpdate/EditGasFeeLegacyUpdate.test.tsx deleted file mode 100644 index 8b05a9c3819..00000000000 --- a/app/components/Views/confirmations/legacy/components/EditGasFeeLegacyUpdate/EditGasFeeLegacyUpdate.test.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import React from 'react'; - -import { backgroundState } from '../../../../../../util/test/initial-root-state'; -import renderWithProvider, { - DeepPartial, -} from '../../../../../../util/test/renderWithProvider'; -import EditGasFeeLegacyUpdate from '.'; -import { RootState } from '../../../../../../reducers'; - -// Mock useGasTransaction since legacy transaction state has been removed -jest.mock('../../../../../../core/GasPolling/GasPolling', () => ({ - ...jest.requireActual('../../../../../../core/GasPolling/GasPolling'), - useGasTransaction: jest.fn(), -})); - -import { useGasTransaction } from '../../../../../../core/GasPolling/GasPolling'; - -const mockUseGasTransaction = useGasTransaction as jest.MockedFunction< - typeof useGasTransaction ->; - -const mockInitialState: ( - txnType?: 'none' | 'eth_gasPrice' | 'fee-market' | 'legacy' | undefined, -) => DeepPartial = (txnType = 'none') => ({ - engine: { - backgroundState: { - ...backgroundState, - GasFeeController: { - gasEstimateType: txnType, - }, - }, - }, -}); - -const selectedGasObjectForFeeMarket = { - legacyGasLimit: undefined, - suggestedMaxFeePerGas: '10', -}; - -const selectedGasObjectForLegacy = { - legacyGasLimit: undefined, - suggestedGasPrice: '3', -}; - -const sharedProps = { - view: 'Transaction', - analyticsParams: undefined, - onSave: () => undefined, - error: undefined, - onCancel: () => undefined, - onUpdatingValuesStart: () => undefined, - onUpdatingValuesEnd: () => undefined, - animateOnChange: undefined, - isAnimating: true, - hasDappSuggestedGas: false, - warning: 'test', - onlyGas: true, - chainId: '0x1', -}; - -const editGasFeeLegacyForFeeMarket = { - ...sharedProps, - selectedGasObject: selectedGasObjectForFeeMarket, -}; - -const editGasFeeLegacyForLegacy = { - ...sharedProps, - selectedGasObject: selectedGasObjectForLegacy, -}; - -// Mock gas transaction data for fee-market type (~ 0.00021 ETH = 21000 gas * 10 gwei) -const mockGasTransactionFeeMarket = { - transactionFee: '0.00021 ETH', - transactionFeeFiat: '$0.50', - suggestedGasPrice: '10', - suggestedGasPriceHex: '0x2540be400', - suggestedGasLimit: '21000', - suggestedGasLimitHex: '0x5208', - totalHex: '0x4e3b29200000', -}; - -// Mock gas transaction data for legacy type (~ 0.00006 ETH = 21000 gas * 3 gwei) -const mockGasTransactionLegacy = { - transactionFee: '0.00006 ETH', - transactionFeeFiat: '$0.15', - suggestedGasPrice: '3', - suggestedGasPriceHex: '0xb2d05e00', - suggestedGasLimit: '21000', - suggestedGasLimitHex: '0x5208', - totalHex: '0x174876e800', -}; - -describe('EditGasFeeLegacyUpdate', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should match snapshot', async () => { - mockUseGasTransaction.mockReturnValue( - mockGasTransactionFeeMarket as unknown as ReturnType< - typeof useGasTransaction - >, - ); - const initialState = mockInitialState(); - const container = renderWithProvider( - , - { state: initialState }, - ); - expect(container).toMatchSnapshot(); - }); - - it('should calculate the correct gas transaction fee for 1559 transaction', async () => { - mockUseGasTransaction.mockReturnValue( - mockGasTransactionFeeMarket as unknown as ReturnType< - typeof useGasTransaction - >, - ); - const initialState = mockInitialState('fee-market'); - const { findByText } = renderWithProvider( - , - { state: initialState }, - ); - - expect(await findByText('~ 0.00021 ETH')).toBeDefined(); - }); - - it('should calculate the correct gas transaction fee for legacy transaction', async () => { - mockUseGasTransaction.mockReturnValue( - mockGasTransactionLegacy as unknown as ReturnType< - typeof useGasTransaction - >, - ); - const initialState = mockInitialState('legacy'); - const { findByText } = renderWithProvider( - , - { state: initialState }, - ); - - expect(await findByText('~ 0.00006 ETH')).toBeDefined(); - }); -}); diff --git a/app/components/Views/confirmations/legacy/components/EditGasFeeLegacyUpdate/__snapshots__/EditGasFeeLegacyUpdate.test.tsx.snap b/app/components/Views/confirmations/legacy/components/EditGasFeeLegacyUpdate/__snapshots__/EditGasFeeLegacyUpdate.test.tsx.snap deleted file mode 100644 index 8ac551743e9..00000000000 --- a/app/components/Views/confirmations/legacy/components/EditGasFeeLegacyUpdate/__snapshots__/EditGasFeeLegacyUpdate.test.tsx.snap +++ /dev/null @@ -1,893 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`EditGasFeeLegacyUpdate should match snapshot 1`] = ` - - - - - - - - -  - - - - Edit priority - - -  - - - - - - - 󰋼 - - - - - test - - - - - - - - ~ - 0.00021 ETH - - - - $0.50 - - - - - - - - Gas limit - - - - - 󰋼 - - - - - - - - -  - - - - - - - - - - -  - - - - - - - - - - - - Gas price - - - - (GWEI) - - - - 󰋼 - - - - - - - - -  - - - - - - - - - ≈ $0.50 - - - -  - - - - - - - - - - - Save - - - - - - - - - -`; diff --git a/app/components/Views/confirmations/legacy/components/EditGasFeeLegacyUpdate/index.jsx b/app/components/Views/confirmations/legacy/components/EditGasFeeLegacyUpdate/index.jsx deleted file mode 100644 index 7eb5e0cc5c6..00000000000 --- a/app/components/Views/confirmations/legacy/components/EditGasFeeLegacyUpdate/index.jsx +++ /dev/null @@ -1,420 +0,0 @@ -/* eslint-disable react/prop-types */ -/* eslint-disable react/display-name */ -import { GAS_ESTIMATE_TYPES } from '@metamask/gas-fee-controller'; -import BigNumber from 'bignumber.js'; -import React, { useCallback, useMemo, useState } from 'react'; -import { - ScrollView, - TouchableOpacity, - TouchableWithoutFeedback, - View, -} from 'react-native'; -import Icon from 'react-native-vector-icons/Ionicons'; -import MaterialCommunityIcon from 'react-native-vector-icons/MaterialCommunityIcons'; -import { useSelector } from 'react-redux'; -import { EditGasViewSelectorsIDs } from '../EditGasView.testIds'; -import { strings } from '../../../../../../../locales/i18n'; -import Text, { - TextColor, - TextVariant, -} from '../../../../../../component-library/components/Texts/Text'; -import { MetaMetricsEvents } from '../../../../../../core/Analytics'; -import { useGasTransaction } from '../../../../../../core/GasPolling/GasPolling'; -import { selectGasFeeEstimates } from '../../../../../../selectors/confirmTransaction'; -import { selectGasFeeControllerEstimateType } from '../../../../../../selectors/gasFeeController'; -import { selectPrimaryCurrency } from '../../../../../../selectors/settings'; -import { - GAS_LIMIT_INCREMENT, - GAS_LIMIT_MIN, - GAS_PRICE_INCREMENT, - GAS_PRICE_MIN, -} from '../../../../../../util/gasUtils'; -import { - getDecimalChainId, - isMainnetByChainId, -} from '../../../../../../util/networks'; -import { useTheme } from '../../../../../../util/theme'; -import Alert, { AlertType } from '../../../../../Base/Alert'; -import RangeInput from '../../../../../Base/RangeInput'; -import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics'; -import FadeAnimationView from '../../../../../UI/FadeAnimationView'; -import StyledButton from '../../../../../UI/StyledButton'; -import InfoModal from '../../../../../Base/InfoModal'; -import createStyles from './styles'; - -const EditGasFeeLegacy = ({ - onCancel, - onSave, - error, - warning, - onUpdatingValuesStart, - onUpdatingValuesEnd, - animateOnChange, - isAnimating, - analyticsParams, - view, - onlyGas, - selectedGasObject, - hasDappSuggestedGas, - chainId, -}) => { - const { trackEvent, createEventBuilder } = useAnalytics(); - const [showRangeInfoModal, setShowRangeInfoModal] = useState(false); - const [infoText, setInfoText] = useState(''); - const [gasPriceError, setGasPriceError] = useState(''); - const [showEditUI, setShowEditUI] = useState(!hasDappSuggestedGas); - const [gasObjectLegacy, updateGasObjectLegacy] = useState({ - legacyGasLimit: selectedGasObject.legacyGasLimit, - suggestedGasPrice: - selectedGasObject.suggestedGasPrice || - selectedGasObject.suggestedMaxFeePerGas, - }); - - const { colors } = useTheme(); - const styles = createStyles(colors); - const gasFeeEstimate = useSelector(selectGasFeeEstimates); - - const primaryCurrency = useSelector(selectPrimaryCurrency); - - const gasEstimateType = useSelector(selectGasFeeControllerEstimateType); - - const gasTransaction = useGasTransaction({ - onlyGas, - legacy: true, - gasObjectLegacy, - }); - - const save = useCallback(() => { - trackEvent( - createEventBuilder(MetaMetricsEvents.GAS_FEE_CHANGED) - .addProperties({ - ...analyticsParams, - chain_id: getDecimalChainId(chainId), - function_type: view, - gas_mode: 'Basic', - }) - .build(), - ); - - const newGasPriceObject = { - suggestedGasPrice: gasObjectLegacy?.suggestedGasPrice, - legacyGasLimit: gasObjectLegacy?.legacyGasLimit, - }; - onSave(gasTransaction, newGasPriceObject); - }, [ - onSave, - gasTransaction, - gasObjectLegacy, - analyticsParams, - chainId, - view, - trackEvent, - createEventBuilder, - ]); - - const changeGas = useCallback((gas) => { - updateGasObjectLegacy({ - legacyGasLimit: gas.suggestedGasLimit, - suggestedGasPrice: gas.suggestedGasPrice, - }); - }, []); - - const changedGasPrice = useCallback( - (value) => { - let newGas; - - const lowerValue = new BigNumber( - gasEstimateType === GAS_ESTIMATE_TYPES.LEGACY - ? gasFeeEstimate?.low - : gasFeeEstimate?.gasPrice, - ); - const higherValue = new BigNumber( - gasEstimateType === GAS_ESTIMATE_TYPES.LEGACY - ? gasFeeEstimate?.high - : gasFeeEstimate?.gasPrice, - ).multipliedBy(new BigNumber(1.5)); - - const valueBN = new BigNumber(value); - - if (!lowerValue.isNaN() && valueBN.lt(lowerValue)) { - setGasPriceError(strings('edit_gas_fee_eip1559.gas_price_low')); - } else if (!higherValue.isNaN() && valueBN.gt(higherValue)) { - setGasPriceError(strings('edit_gas_fee_eip1559.gas_price_high')); - } else { - setGasPriceError(''); - } - - if (typeof gasTransaction === 'object') { - newGas = { ...gasTransaction, suggestedGasPrice: value }; - } else { - newGas = { suggestedGasPrice: value }; - } - - changeGas(newGas); - }, - [changeGas, gasEstimateType, gasTransaction, gasFeeEstimate], - ); - - const changedGasLimit = useCallback( - (value) => { - const newGas = - typeof gasTransaction === 'object' - ? { ...gasTransaction, suggestedGasLimit: value } - : { suggestedGasLimit: value }; - - changeGas(newGas); - }, - [changeGas, gasTransaction], - ); - - const showTransactionWarning = useMemo(() => { - if (!warning) return null; - if (typeof warning === 'string') - return ( - ( - - )} - > - {() => ( - - - {warning} - - - )} - - ); - - return warning; - }, [warning, styles, colors]); - - const showTransactionError = useMemo(() => { - if (!error) return null; - if (typeof error === 'string') - return ( - ( - - )} - > - {() => ( - - - {error} - - - )} - - ); - - return error; - }, [error, styles, colors]); - - const { - suggestedGasLimit, - suggestedGasPrice, - transactionFee, - transactionFeeFiat, - } = gasTransaction; - - const isMainnet = isMainnetByChainId(chainId); - const nativeCurrencySelected = primaryCurrency === 'ETH' || !isMainnet; - let gasFeePrimary, gasFeeSecondary; - if (nativeCurrencySelected) { - gasFeePrimary = transactionFee; - gasFeeSecondary = transactionFeeFiat; - } else { - gasFeePrimary = transactionFeeFiat; - gasFeeSecondary = transactionFee; - } - - const valueToWatch = transactionFee; - - const handleInfoModalPress = (text) => { - setShowRangeInfoModal(true); - setInfoText(text); - }; - - return ( - - - - - - - - - - - {strings('transaction.edit_priority')} - - - - - {showTransactionWarning} - {showTransactionError} - - {!showEditUI ? ( - - - - ~ {gasFeePrimary} - - - - {gasFeeSecondary} - - - setShowEditUI(true)} - > - {strings('edit_gas_fee_eip1559.edit_suggested_gas_fee')} - - - ) : ( - - - - - ~ {gasFeePrimary} - - - - {gasFeeSecondary} - - - - - - {strings('edit_gas_fee_eip1559.gas_limit')}{' '} - - - handleInfoModalPress('gas_limit')} - > - - - - } - value={suggestedGasLimit} - onChangeValue={changedGasLimit} - min={GAS_LIMIT_MIN} - name={strings('edit_gas_fee_eip1559.gas_limit')} - increment={GAS_LIMIT_INCREMENT} - /> - - - - - {strings('edit_gas_fee_eip1559.gas_price')}{' '} - - (GWEI) - - handleInfoModalPress('gas_price')} - > - - - - } - value={suggestedGasPrice} - name={strings('edit_gas_fee_eip1559.gas_price')} - increment={GAS_PRICE_INCREMENT} - min={GAS_PRICE_MIN} - inputInsideLabel={ - transactionFeeFiat && `≈ ${transactionFeeFiat}` - } - onChangeValue={changedGasPrice} - error={gasPriceError} - /> - - - - - {strings('edit_gas_fee_eip1559.save')} - - - - )} - setShowRangeInfoModal(false)} - body={ - - - {infoText === 'gas_limit' && - strings( - 'edit_gas_fee_eip1559.learn_more_gas_limit_legacy', - )} - {infoText === 'gas_price' && - strings('edit_gas_fee_eip1559.learn_more_gas_price')} - - - } - /> - - - - - ); -}; - -export default EditGasFeeLegacy; diff --git a/app/components/Views/confirmations/legacy/components/EditGasFeeLegacyUpdate/styles.ts b/app/components/Views/confirmations/legacy/components/EditGasFeeLegacyUpdate/styles.ts deleted file mode 100644 index c4029ac78c7..00000000000 --- a/app/components/Views/confirmations/legacy/components/EditGasFeeLegacyUpdate/styles.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { StyleSheet } from 'react-native'; - -import Device from '../../../../../../util/device'; - -// TODO: Replace "any" with type -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const createStyles = (colors: any) => - StyleSheet.create({ - root: { - backgroundColor: colors.background.default, - borderTopLeftRadius: 20, - borderTopRightRadius: 20, - minHeight: 200, - maxHeight: '95%', - paddingTop: 24, - paddingBottom: Device.isIphoneX() ? 32 : 24, - }, - wrapper: { - paddingHorizontal: 24, - }, - customGasHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - width: '100%', - paddingBottom: 20, - }, - headerContainer: { - alignItems: 'center', - marginBottom: 22, - }, - headerText: { - fontSize: 48, - }, - headerTitleSide: { - flex: 1, - }, - labelTextContainer: { - flexDirection: 'row', - alignItems: 'center', - }, - hitSlop: { - top: 10, - left: 10, - bottom: 10, - right: 10, - }, - labelInfo: { - color: colors.text.muted, - }, - advancedOptionsContainer: { - marginTop: 25, - marginBottom: 30, - }, - advancedOptionsInputsContainer: { - marginTop: 14, - }, - rangeInputContainer: { - marginBottom: 20, - }, - advancedOptionsButton: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - }, - advancedOptionsIcon: { - paddingTop: 1, - marginLeft: 5, - }, - textContainer: { - lineHeight: 20, - textAlign: 'center', - }, - text: { - lineHeight: 20, - marginHorizontal: 4, - }, - dappEditGasContainer: { - marginVertical: 20, - }, - }); - -export default createStyles; diff --git a/app/components/Views/confirmations/legacy/components/EditGasFeeLegacyUpdate/types.ts b/app/components/Views/confirmations/legacy/components/EditGasFeeLegacyUpdate/types.ts deleted file mode 100644 index 6090db68ecf..00000000000 --- a/app/components/Views/confirmations/legacy/components/EditGasFeeLegacyUpdate/types.ts +++ /dev/null @@ -1,68 +0,0 @@ -export interface EditGasFeeLegacyUpdateProps { - /** - * Function called when user cancels - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onCancel: any; - /** - * Function called when user saves the new gas - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onSave: (gasTxn: any, newGasObject: any) => void; - /** - * Error message to show - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - error: any; - /** - * Warning message to show - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - warning?: any; - /** - * Extend options object. Object has option keys and properties will be spread - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - extendOptions?: any; - /** - * Function to call when update animation starts - */ - onUpdatingValuesStart: () => void; - /** - * Function to call when update animation ends - */ - onUpdatingValuesEnd: () => void; - /** - * If the values should animate upon update or not - */ - animateOnChange: boolean | undefined; - /** - * Boolean to determine if the animation is happening - */ - isAnimating: boolean; - /** - * Extra analytics params to be send with the gas analytics - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - analyticsParams: any; - view: string; - onlyGas?: boolean; - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - selectedGasObject: any; - hasDappSuggestedGas?: boolean; - chainId: string; -} - -export interface EditLegacyGasTransaction { - suggestedGasLimit: string; - suggestedGasPrice: string; - transactionFee: string; - transactionFeeFiat: string; -} diff --git a/app/components/Views/confirmations/legacy/components/UpdateEIP1559Tx/index.jsx b/app/components/Views/confirmations/legacy/components/UpdateEIP1559Tx/index.jsx deleted file mode 100644 index d478dc21324..00000000000 --- a/app/components/Views/confirmations/legacy/components/UpdateEIP1559Tx/index.jsx +++ /dev/null @@ -1,269 +0,0 @@ -import { GAS_ESTIMATE_TYPES } from '@metamask/gas-fee-controller'; -import { CANCEL_RATE, SPEED_UP_RATE } from '@metamask/transaction-controller'; -import { isHexString } from '@metamask/utils'; -import BigNumber from 'bignumber.js'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { connect } from 'react-redux'; -import { strings } from '../../../../../../../locales/i18n'; -import AppConstants from '../../../../../../core/AppConstants'; -import { - startGasPolling, - stopGasPolling, -} from '../../../../../../core/GasPolling/GasPolling'; -import { selectAccounts } from '../../../../../../selectors/accountTrackerController'; -import { selectSelectedInternalAccountFormattedAddress } from '../../../../../../selectors/accountsController'; -import { selectGasFeeEstimates } from '../../../../../../selectors/confirmTransaction'; -import { selectGasFeeControllerEstimateType } from '../../../../../../selectors/gasFeeController'; -import { selectNativeCurrencyByChainId } from '../../../../../../selectors/networkController'; -import { getDecimalChainId } from '../../../../../../util/networks'; -import { - addHexPrefix, - fromWei, - hexToBN, - renderFromWei, -} from '../../../../../../util/number'; -import { getTicker } from '../../../../../../util/transactions'; -import EditGasFee1559Update from '../EditGasFee1559Update'; - -const UpdateEIP1559Tx = ({ - gas, - accounts, - selectedAddress, - ticker, - existingGas, - gasFeeEstimates, - gasEstimateType, - primaryCurrency, - isCancel, - chainId, - onCancel, - onSave, -}) => { - const [animateOnGasChange, setAnimateOnGasChange] = useState(false); - const [gasSelected, setGasSelected] = useState( - AppConstants.GAS_OPTIONS.MEDIUM, - ); - const stopUpdateGas = useRef(false); - /** - * Flag to only display high gas selection option if the legacy is higher then low/med - */ - const onlyDisplayHigh = useRef(false); - /** - * Options - */ - const updateTx1559Options = useRef(); - const pollToken = useRef(); - const firstTime = useRef(true); - - const suggestedGasLimit = fromWei(gas, 'wei'); - - useEffect(() => { - if (animateOnGasChange) setAnimateOnGasChange(false); - }, [animateOnGasChange]); - - useEffect(() => { - const startGasEstimatePolling = async () => { - pollToken.current = await startGasPolling(pollToken.current); - }; - startGasEstimatePolling(); - - return () => { - stopGasPolling(); - }; - }, []); - - const isMaxFeePerGasMoreThanLegacy = useCallback( - (maxFeePerGas) => { - const newDecMaxFeePerGas = new BigNumber(existingGas.maxFeePerGas).times( - new BigNumber(isCancel ? CANCEL_RATE : SPEED_UP_RATE), - ); - return { - result: maxFeePerGas.gte(newDecMaxFeePerGas), - value: newDecMaxFeePerGas, - }; - }, - [existingGas.maxFeePerGas, isCancel], - ); - - const isMaxPriorityFeePerGasMoreThanLegacy = useCallback( - (maxPriorityFeePerGas) => { - const newDecMaxPriorityFeePerGas = new BigNumber( - existingGas.maxPriorityFeePerGas, - ).times(new BigNumber(isCancel ? CANCEL_RATE : SPEED_UP_RATE)); - return { - result: maxPriorityFeePerGas.gte(newDecMaxPriorityFeePerGas), - value: newDecMaxPriorityFeePerGas, - }; - }, - [existingGas.maxPriorityFeePerGas, isCancel], - ); - - const validateAmount = useCallback( - (updateTx) => { - let error; - const totalMaxHexPrefixed = addHexPrefix(updateTx.totalMaxHex); - - if (!isHexString(totalMaxHexPrefixed)) { - return strings('transaction.invalid_amount'); - } - const updateTxCost = hexToBN(totalMaxHexPrefixed); - const accountBalance = hexToBN(accounts[selectedAddress].balance); - const isMaxFeePerGasMoreThanLegacyResult = isMaxFeePerGasMoreThanLegacy( - new BigNumber(updateTx.suggestedMaxFeePerGas), - ); - const isMaxPriorityFeePerGasMoreThanLegacyResult = - isMaxPriorityFeePerGasMoreThanLegacy( - new BigNumber(updateTx.suggestedMaxPriorityFeePerGas), - ); - if (accountBalance.lt(updateTxCost)) { - const amount = renderFromWei(updateTxCost.sub(accountBalance)); - const tokenSymbol = getTicker(ticker); - error = strings('transaction.insufficient_amount', { - amount, - tokenSymbol, - }); - } else if (!isMaxFeePerGasMoreThanLegacyResult.result) { - error = isCancel - ? strings('edit_gas_fee_eip1559.max_fee_cancel_low', { - cancel_value: isMaxFeePerGasMoreThanLegacyResult.value, - }) - : strings('edit_gas_fee_eip1559.max_fee_speed_up_low', { - speed_up_floor_value: isMaxFeePerGasMoreThanLegacyResult.value, - }); - } else if (!isMaxPriorityFeePerGasMoreThanLegacyResult.result) { - error = isCancel - ? strings('edit_gas_fee_eip1559.max_priority_fee_cancel_low', { - cancel_value: isMaxPriorityFeePerGasMoreThanLegacyResult.value, - }) - : strings('edit_gas_fee_eip1559.max_priority_fee_speed_up_low', { - speed_up_floor_value: - isMaxPriorityFeePerGasMoreThanLegacyResult.value, - }); - } - - return error; - }, - [ - accounts, - selectedAddress, - isMaxFeePerGasMoreThanLegacy, - isMaxPriorityFeePerGasMoreThanLegacy, - ticker, - isCancel, - ], - ); - - useEffect(() => { - if (stopUpdateGas.current) return; - if (gasEstimateType === GAS_ESTIMATE_TYPES.FEE_MARKET) { - if (firstTime.current) { - const newDecMaxFeePerGas = new BigNumber( - existingGas.maxFeePerGas, - ).times(new BigNumber(isCancel ? CANCEL_RATE : SPEED_UP_RATE)); - const newDecMaxPriorityFeePerGas = new BigNumber( - existingGas.maxPriorityFeePerGas, - ).times(new BigNumber(isCancel ? CANCEL_RATE : SPEED_UP_RATE)); - - //Check to see if default SPEED_UP_RATE/CANCEL_RATE is greater than current market medium value - if ( - !isMaxFeePerGasMoreThanLegacy( - new BigNumber(gasFeeEstimates.medium.suggestedMaxPriorityFeePerGas), - ).result || - !isMaxPriorityFeePerGasMoreThanLegacy( - new BigNumber(gasFeeEstimates.medium.suggestedMaxFeePerGas), - ).result - ) { - updateTx1559Options.current = { - maxPriortyFeeThreshold: newDecMaxPriorityFeePerGas, - maxFeeThreshold: newDecMaxFeePerGas, - showAdvanced: true, - isCancel, - }; - - onlyDisplayHigh.current = true; - //Disable polling - stopUpdateGas.current = true; - setGasSelected(''); - } else { - updateTx1559Options.current = { - maxPriortyFeeThreshold: - gasFeeEstimates.medium.suggestedMaxPriorityFeePerGas, - maxFeeThreshold: gasFeeEstimates.medium.suggestedMaxFeePerGas, - showAdvanced: false, - isCancel, - }; - setAnimateOnGasChange(true); - } - } - - firstTime.current = false; - } - }, [ - existingGas.maxFeePerGas, - existingGas.maxPriorityFeePerGas, - gasEstimateType, - gasFeeEstimates, - gasSelected, - isCancel, - gas, - suggestedGasLimit, - isMaxFeePerGasMoreThanLegacy, - isMaxPriorityFeePerGasMoreThanLegacy, - ]); - - const update1559TempGasValue = (selected) => { - stopUpdateGas.current = !selected; - setGasSelected(selected); - }; - - const onSaveTxnWithError = (gasTxn) => { - gasTxn.error = validateAmount(gasTxn); - onSave(gasTxn); - }; - - const getGasAnalyticsParams = () => ({ - chain_id: getDecimalChainId(chainId), - gas_estimate_type: gasEstimateType, - gas_mode: gasSelected ? 'Basic' : 'Advanced', - speed_set: gasSelected || undefined, - view: isCancel ? AppConstants.CANCEL_RATE : AppConstants.SPEED_UP_RATE, - }); - - const selectedGasObject = { - suggestedMaxFeePerGas: existingGas.maxFeePerGas, - suggestedMaxPriorityFeePerGas: existingGas.maxPriorityFeePerGas, - suggestedGasLimit, - }; - return ( - - ); -}; - -const mapStateToProps = (state, ownProps) => ({ - accounts: selectAccounts(state), - selectedAddress: selectSelectedInternalAccountFormattedAddress(state), - ticker: selectNativeCurrencyByChainId(state, ownProps.chainId), - gasFeeEstimates: selectGasFeeEstimates(state), - gasEstimateType: selectGasFeeControllerEstimateType(state), - primaryCurrency: state.settings.primaryCurrency, -}); - -export default connect(mapStateToProps)(UpdateEIP1559Tx); diff --git a/app/components/Views/confirmations/legacy/components/UpdateEIP1559Tx/types.ts b/app/components/Views/confirmations/legacy/components/UpdateEIP1559Tx/types.ts deleted file mode 100644 index 5f86a5f6193..00000000000 --- a/app/components/Views/confirmations/legacy/components/UpdateEIP1559Tx/types.ts +++ /dev/null @@ -1,81 +0,0 @@ -import BigNumber from 'bignumber.js'; - -export interface UpdateEIP1559Props { - /** - * Map of accounts to information objects including balances - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - accounts: any; - /** - * Chain Id - */ - chainId: string; - /** - * ETH or fiat, depending on user setting - */ - primaryCurrency: string; - /** - * Gas fee estimates returned by the gas fee controller - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - gasFeeEstimates: any; - /** - * Estimate type returned by the gas fee controller, can be market-fee, legacy or eth_gasPrice - */ - gasEstimateType: string; - /** - * A string that represents the selected address - */ - selectedAddress: string; - /** - * A bool indicates whether tx is speed up/cancel - */ - isCancel: boolean; - /** - * Current provider ticker - */ - ticker: string; - /** - * The max fee and max priorty fee selected tx - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - existingGas: any; - /** - * Gas object used to get suggestedGasLimit - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - gas: any; - /** - * Function that cancels the tx update - */ - onCancel: () => void; - /** - * Function that performs the rest of the tx update - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onSave: (tx: any) => void; -} - -export interface UpdateTx1559Options { - /** - * The legacy calculated max priorty fee used in subcomponent for threshold warning messages - */ - maxPriortyFeeThreshold: BigNumber; - /** - * The legacy calculated max fee used in subcomponent for threshold warning messages - */ - maxFeeThreshold: BigNumber; - /** - * Boolean to indicate to sumcomponent if the view should display only advanced settings - */ - showAdvanced: boolean; - /** - * Boolean to indicate if this is a cancel tx update - */ - isCancel: boolean; -} From 477da9e16c1d9b470d1846a14fd8c28e01e297e3 Mon Sep 17 00:00:00 2001 From: TylerC Date: Mon, 16 Mar 2026 18:34:52 +0800 Subject: [PATCH 018/206] refactor: migrate onboarding screens to design system with Tailwind CSS (Part 1) (#26673) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Replace StyleSheet.create() and raw View/Text components with design-system-react-native Box/Text and useTailwind() in Onboarding, OnboardingSheet, OnboardingSuccess, and OnboardingSuccessEndAnimation. Delete the now-unused .styles.ts files. Part 1 of 2. Part 2 will cover ImportFromSecretRecoveryPhrase and ImportNewSecretRecoveryPhrase. ## **Changelog** CHANGELOG entry: null ## **Related issues** Refs: [TO-555 - Migrate Onboarding screen to Design System (Box/Text + Tailwind)](https://consensyssoftware.atlassian.net/browse/TO-555) Refs: [TO-556 - Migrate OnboardingSuccess screen to Design System (Box/Text + Tailwind)](https://consensyssoftware.atlassian.net/browse/TO-556) Refs: [TO-557 - Migrate OnboardingSuccessEndAnimation to Design System (Box/Text + Tailwind)](https://consensyssoftware.atlassian.net/browse/TO-557) ## **Manual testing steps** No functional changes. Visual regression only — verify onboarding screens render correctly. ```gherkin Feature: Onboarding screens styling Scenario: user views onboarding screens Given app is freshly installed When user launches the app and reaches onboarding Then all onboarding screens render identically to before ``` ## **Screenshots/Recordings** Before (left) vs After (right) Screenshot 2026-03-04 at 7 02 57 PM Screenshot 2026-03-04 at 7 06 40 PM Screenshot 2026-03-04 at 7 40 13 PM ## **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. ## **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. Made with [Cursor](https://cursor.com) --- > [!NOTE] > **Medium Risk** > Mostly a UI refactor, but it touches the onboarding entry flow (CTA buttons, loading overlay, and success notification padding/animation), so layout/interaction regressions are possible across device sizes. > > **Overview** > Migrates `Onboarding`, `OnboardingSheet`, and `OnboardingSuccessEndAnimation` from `StyleSheet`/raw `View`/custom component-library buttons to `@metamask/design-system-react-native` (`Box`, `Button`, `Text`, `TextButton`) styled via `useTailwind()`, and deletes the now-unused styles files. > > Updates onboarding UI behavior/styling to be device-aware in Tailwind (medium-device CTA spacing/button size, iPhoneX vs non-iPhoneX notification padding) and adds a cleanup for the notification hide timer on unmount. > > Expands unit tests and snapshots to cover the new responsive styling, the loading overlay message, iPhoneX notification padding variants, and safe handling when `OnboardingSheet` route params are `undefined`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 792bfdf3db2ca599a0c5b2a8410f83bd343ab520. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../__snapshots__/index.test.tsx.snap | 2922 +++++++++++++++-- .../Views/Onboarding/index.test.tsx | 134 + app/components/Views/Onboarding/index.tsx | 182 +- app/components/Views/Onboarding/styles.ts | 143 - .../__snapshots__/index.test.tsx.snap | 1064 ++++-- .../Views/OnboardingSheet/index.test.tsx | 19 + .../Views/OnboardingSheet/index.tsx | 264 +- .../index.styles.ts | 23 - .../OnboardingSuccessEndAnimation/index.tsx | 41 +- .../__snapshots__/index.test.tsx.snap | 2034 ++++++++---- .../Views/OnboardingSuccess/index.styles.ts | 37 - .../Views/OnboardingSuccess/index.test.tsx | 18 +- .../Views/OnboardingSuccess/index.tsx | 89 +- 13 files changed, 5226 insertions(+), 1744 deletions(-) delete mode 100644 app/components/Views/Onboarding/styles.ts delete mode 100644 app/components/Views/OnboardingSuccess/OnboardingSuccessEndAnimation/index.styles.ts delete mode 100644 app/components/Views/OnboardingSuccess/index.styles.ts diff --git a/app/components/Views/Onboarding/__snapshots__/index.test.tsx.snap b/app/components/Views/Onboarding/__snapshots__/index.test.tsx.snap index e91c320ae73..a44d0aa36d5 100644 --- a/app/components/Views/Onboarding/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/Onboarding/__snapshots__/index.test.tsx.snap @@ -1,5 +1,1823 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Onboarding applies compact gap and medium button size on medium device 1`] = ` + + + + + + + + + Create a new wallet + + + + + Import using Secret Recovery Phrase + + + + + + + + + + + +`; + +exports[`Onboarding applies iPhoneX notification padding when on iPhoneX 1`] = ` + + + + + + + + + Create a new wallet + + + + + Import using Secret Recovery Phrase + + + + + + + + + + Loading... + + + + + + + + + + + + + + + + + +  + + + + + Success + + + You successfully reset your wallet! + + + + + + + + + + +`; + +exports[`Onboarding applies standard gap and large button size on non-medium device 1`] = ` + + + + + + + + + Create a new wallet + + + + + Import using Secret Recovery Phrase + + + + + + + + + + + +`; + +exports[`Onboarding applies standard notification padding when not on iPhoneX 1`] = ` + + + + + + + + + Create a new wallet + + + + + Import using Secret Recovery Phrase + + + + + + + + + + Loading... + + + + + + + + + + + + + + + + + +  + + + + + Success + + + You successfully reset your wallet! + + + + + + + + + + +`; + exports[`Onboarding renders correctly 1`] = ` - Create a new wallet - - + Import using Secret Recovery Phrase - + @@ -193,7 +2126,16 @@ exports[`Onboarding renders correctly 1`] = ` } testID="fox-animation-mock" /> - + `; @@ -208,51 +2150,66 @@ exports[`Onboarding renders correctly with android 1`] = ` } } style={ - [ - { - "flex": 1, - }, - { - "backgroundColor": "#FFF2EB", - }, - ] + { + "backgroundColor": "#FFF2EB", + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, + } } testID="onboarding-screen" > - Create a new wallet - - + Import using Secret Recovery Phrase - + @@ -390,7 +2447,16 @@ exports[`Onboarding renders correctly with android 1`] = ` } testID="fox-animation-mock" /> - + `; @@ -405,51 +2471,66 @@ exports[`Onboarding renders correctly with large device and iphoneX 1`] = ` } } style={ - [ - { - "flex": 1, - }, - { - "backgroundColor": "#FFF2EB", - }, - ] + { + "backgroundColor": "#FFF2EB", + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, + } } testID="onboarding-screen" > - Create a new wallet - - + Import using Secret Recovery Phrase - + @@ -587,7 +2768,16 @@ exports[`Onboarding renders correctly with large device and iphoneX 1`] = ` } testID="fox-animation-mock" /> - + `; @@ -602,51 +2792,66 @@ exports[`Onboarding renders correctly with medium device and android 1`] = ` } } style={ - [ - { - "flex": 1, - }, - { - "backgroundColor": "#FFF2EB", - }, - ] + { + "backgroundColor": "#FFF2EB", + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, + } } testID="onboarding-screen" > - Create a new wallet - - + Import using Secret Recovery Phrase - + @@ -784,6 +3089,15 @@ exports[`Onboarding renders correctly with medium device and android 1`] = ` } testID="fox-animation-mock" /> - + `; diff --git a/app/components/Views/Onboarding/index.test.tsx b/app/components/Views/Onboarding/index.test.tsx index 7320bb02f5e..a52f199efae 100644 --- a/app/components/Views/Onboarding/index.test.tsx +++ b/app/components/Views/Onboarding/index.test.tsx @@ -18,6 +18,19 @@ jest.mock('../../../core/BackupVault', () => ({ ), })); +let mockSkipLoadingUnset = false; +jest.mock('../../../actions/user', () => { + const actualUserActions = jest.requireActual('../../../actions/user'); + return { + ...actualUserActions, + loadingUnset: jest.fn(() => + mockSkipLoadingUnset + ? { type: 'UNIT_TEST_NOOP' } + : actualUserActions.loadingUnset(), + ), + }; +}); + // Mock animation components - using existing mocks jest.mock('../../UI/FoxAnimation/FoxAnimation'); jest.mock('../../UI/OnboardingAnimation/OnboardingAnimation'); @@ -388,6 +401,127 @@ describe('Onboarding', () => { expect(toJSON()).toMatchSnapshot(); }); + it('applies compact gap and medium button size on medium device', () => { + (Device.isMediumDevice as jest.Mock).mockReturnValue(true); + + const { toJSON } = renderScreen( + Onboarding, + { name: 'Onboarding' }, + { + state: mockInitialState, + }, + ); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('applies standard gap and large button size on non-medium device', () => { + (Device.isMediumDevice as jest.Mock).mockReturnValue(false); + + const { toJSON } = renderScreen( + Onboarding, + { name: 'Onboarding' }, + { + state: mockInitialState, + }, + ); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders loading overlay with loading message', async () => { + mockSkipLoadingUnset = true; + const loadingMessage = 'Creating your wallet...'; + const loadingState = { + ...mockInitialState, + user: { + ...mockInitialState.user, + loadingSet: true, + loadingMsg: loadingMessage, + }, + }; + mockRoute.params = { delete: true }; + + try { + const { getByText } = renderScreen( + Onboarding, + { name: 'Onboarding' }, + { + state: loadingState, + }, + ); + + await waitFor(() => { + expect(getByText(loadingMessage)).toBeOnTheScreen(); + }); + } finally { + mockRoute.params = {}; + mockSkipLoadingUnset = false; + } + }); + + it('applies iPhoneX notification padding when on iPhoneX', async () => { + mockSkipLoadingUnset = true; + const loadingState = { + ...mockInitialState, + user: { + ...mockInitialState.user, + loadingSet: true, + loadingMsg: 'Loading...', + }, + }; + mockRoute.params = { delete: true }; + (Device.isIphoneX as jest.Mock).mockReturnValue(true); + + try { + const { toJSON } = renderScreen( + Onboarding, + { name: 'Onboarding' }, + { + state: loadingState, + }, + ); + + await waitFor(() => { + expect(toJSON()).toMatchSnapshot(); + }); + } finally { + mockRoute.params = {}; + mockSkipLoadingUnset = false; + } + }); + + it('applies standard notification padding when not on iPhoneX', async () => { + mockSkipLoadingUnset = true; + const loadingState = { + ...mockInitialState, + user: { + ...mockInitialState.user, + loadingSet: true, + loadingMsg: 'Loading...', + }, + }; + mockRoute.params = { delete: true }; + (Device.isIphoneX as jest.Mock).mockReturnValue(false); + + try { + const { toJSON } = renderScreen( + Onboarding, + { name: 'Onboarding' }, + { + state: loadingState, + }, + ); + + await waitFor(() => { + expect(toJSON()).toMatchSnapshot(); + }); + } finally { + mockRoute.params = {}; + mockSkipLoadingUnset = false; + } + }); + it('handles click on create wallet button', () => { (Device.isAndroid as jest.Mock).mockReturnValue(true); (Device.isIos as jest.Mock).mockReturnValue(false); diff --git a/app/components/Views/Onboarding/index.tsx b/app/components/Views/Onboarding/index.tsx index 53abd5c3400..8c5c157d026 100644 --- a/app/components/Views/Onboarding/index.tsx +++ b/app/components/Views/Onboarding/index.tsx @@ -8,7 +8,6 @@ import React, { import { ActivityIndicator, BackHandler, - View, ScrollView, InteractionManager, Animated, @@ -16,10 +15,7 @@ import { Platform, } from 'react-native'; import { captureException } from '@sentry/react-native'; -import Text, { - TextVariant, -} from '../../../component-library/components/Texts/Text'; -import { baseStyles, colors as importedColors } from '../../../styles/common'; +import { colors as importedColors } from '../../../styles/common'; import { strings } from '../../../../locales/i18n'; import { useSelector, useDispatch } from 'react-redux'; import FadeOutOverlay from '../../UI/FadeOutOverlay'; @@ -52,7 +48,7 @@ import { markMetricsOptInUISeen, resetMetricsOptInUISeen, } from '../../../util/metrics/metricsOptInUIUtils'; -import { ThemeContext, mockTheme } from '../../../util/theme'; +import { ThemeContext } from '../../../util/theme'; import { isE2E } from '../../../util/test/utils'; import { OnboardingSelectorIDs } from './Onboarding.testIds'; import Routes from '../../../constants/navigation/Routes'; @@ -83,16 +79,11 @@ import { ITrackingEvent, } from '../../../core/Analytics/MetaMetrics.types'; import { JsonMap } from '@segment/analytics-react-native'; -import Button, { - ButtonVariants, - ButtonWidthTypes, - ButtonSize, -} from '../../../component-library/components/Buttons/Button'; +import { SEEDLESS_ONBOARDING_ENABLED } from '../../../core/OAuthService/OAuthLoginHandlers/constants'; import OAuthLoginService from '../../../core/OAuthService/OAuthService'; import { OAuthError, OAuthErrorType } from '../../../core/OAuthService/error'; import { createLoginHandler } from '../../../core/OAuthService/OAuthLoginHandlers'; import { AuthConnection } from '../../../core/OAuthService/OAuthInterface'; -import { SEEDLESS_ONBOARDING_ENABLED } from '../../../core/OAuthService/OAuthLoginHandlers/constants'; import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics'; import { setupSentry } from '../../../util/sentry/utils'; import ErrorBoundary from '../ErrorBoundary'; @@ -100,7 +91,18 @@ import FastOnboarding from './FastOnboarding'; import { SafeAreaView } from 'react-native-safe-area-context'; import FoxAnimation from '../../UI/FoxAnimation/FoxAnimation'; import OnboardingAnimation from '../../UI/OnboardingAnimation/OnboardingAnimation'; -import { createStyles } from './styles'; +import { + Box, + BoxAlignItems, + BoxJustifyContent, + Button, + ButtonSize, + ButtonVariant, + Text, + TextButton, + TextVariant, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; interface OnboardingState { warningModalVisible: boolean; @@ -162,8 +164,7 @@ const Onboarding = () => { ); const themeContext = useContext(ThemeContext); - const colors = themeContext.colors || mockTheme.colors; - const styles = createStyles(colors); + const tw = useTailwind(); const [state, setState] = useState({ warningModalVisible: false, @@ -185,6 +186,7 @@ const Onboarding = () => { const mounted = useRef(false); const hasCheckedVaultBackup = useRef(false); const warningCallback = useRef<() => boolean>(() => true); + const notificationTimer = useRef | null>(null); const animatedTimingStart = useCallback( (animatedRef: Animated.Value, toValue: number): void => { @@ -205,10 +207,8 @@ const Onboarding = () => { }, []); const showNotification = useCallback((): void => { - // show notification animatedTimingStart(notificationAnimated, 0); - // hide notification - setTimeout(() => { + notificationTimer.current = setTimeout(() => { animatedTimingStart(notificationAnimated, 200); }, 4000); disableBackPress(); @@ -809,65 +809,70 @@ const Onboarding = () => { const renderLoader = useCallback( (): React.ReactElement => ( - - + + - {loadingMsg} - - + + {loadingMsg} + + + ), - [styles, loadingMsg], + [loadingMsg, tw], ); const renderContent = useCallback( (): React.ReactElement => ( - + - + ), [ - styles, state.startOnboardingAnimation, setStartFoxAnimation, handleCtaActions, + tw, ], ); @@ -891,11 +896,17 @@ const Onboarding = () => { return ( - + @@ -903,8 +914,8 @@ const Onboarding = () => { }, [ route?.params?.delete, route?.params?.showErrorReportSentToast, - styles, notificationAnimated, + tw, ]); useEffect(() => { @@ -934,6 +945,9 @@ const Onboarding = () => { return () => { mounted.current = false; + if (notificationTimer.current) { + clearTimeout(notificationTimer.current); + } unsetLoading(); InteractionManager.runAfterInteractions(PreventScreenshot.allow); }; @@ -970,51 +984,51 @@ const Onboarding = () => { > - + {renderContent()} {loading && ( - {renderLoader()} - + )} - + {existingUser && !loading && ( - - + + + + {strings('onboarding.or')} - - - + + + + + {strings('onboarding.by_continuing')}{' '} {strings('onboarding.terms_of_use')} {' '} {strings('onboarding.and')}{' '} {strings('onboarding.privacy_notice')} - - + + ); }; diff --git a/app/components/Views/OnboardingSuccess/OnboardingSuccessEndAnimation/index.styles.ts b/app/components/Views/OnboardingSuccess/OnboardingSuccessEndAnimation/index.styles.ts deleted file mode 100644 index cb18a7dba25..00000000000 --- a/app/components/Views/OnboardingSuccess/OnboardingSuccessEndAnimation/index.styles.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { StyleSheet } from 'react-native'; -import { getScreenDimensions } from '../../../../util/onboarding'; - -const createStyles = (dimensions: ReturnType) => - StyleSheet.create({ - animationContainer: { - height: dimensions.screenHeight * 0.5, - justifyContent: 'center', - alignItems: 'center', - }, - animationWrapper: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - }, - riveAnimation: { - width: dimensions.screenWidth, - height: dimensions.animationHeight, - alignSelf: 'center', - }, - }); - -export default createStyles; diff --git a/app/components/Views/OnboardingSuccess/OnboardingSuccessEndAnimation/index.tsx b/app/components/Views/OnboardingSuccess/OnboardingSuccessEndAnimation/index.tsx index 4591e198fb3..e3a82b983f8 100644 --- a/app/components/Views/OnboardingSuccess/OnboardingSuccessEndAnimation/index.tsx +++ b/app/components/Views/OnboardingSuccess/OnboardingSuccessEndAnimation/index.tsx @@ -1,10 +1,14 @@ -import React, { useEffect, useMemo, useRef } from 'react'; -import { View } from 'react-native'; +import React, { useEffect, useRef } from 'react'; import Rive, { Fit, Alignment, RiveRef } from 'rive-react-native'; import { useTheme } from '../../../../util/theme'; -import createStyles from './index.styles'; import { getScreenDimensions } from '../../../../util/onboarding'; import { isE2E } from '../../../../util/test/utils'; +import { + Box, + BoxAlignItems, + BoxJustifyContent, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; import onboardingLoaderEndAnimation from '../../../../animations/onboarding_loader.riv'; @@ -18,26 +22,20 @@ const OnboardingSuccessEndAnimation: React.FC< const riveRef = useRef(null); const { themeAppearance } = useTheme(); const isDarkMode = themeAppearance === 'dark'; + const tw = useTailwind(); - const screenDimensions = getScreenDimensions(); - - const styles = useMemo( - () => createStyles(screenDimensions), - [screenDimensions], - ); + const { screenWidth, screenHeight, animationHeight } = getScreenDimensions(); useEffect(() => { if (isE2E) return; const timeoutId = setTimeout(() => { if (riveRef.current) { try { - // Set dark mode input riveRef.current.setInputState( 'OnboardingLoader', 'Dark mode', isDarkMode, ); - // Fire the animation trigger riveRef.current.fireState('OnboardingLoader', 'Only_End'); } catch (error) { console.error('Error with Rive animation:', error); @@ -49,23 +47,32 @@ const OnboardingSuccessEndAnimation: React.FC< }, [isDarkMode]); return ( - - + {!isE2E && ( )} - - + + ); }; diff --git a/app/components/Views/OnboardingSuccess/__snapshots__/index.test.tsx.snap b/app/components/Views/OnboardingSuccess/__snapshots__/index.test.tsx.snap index e63ed5a0449..956ca196e32 100644 --- a/app/components/Views/OnboardingSuccess/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/OnboardingSuccess/__snapshots__/index.test.tsx.snap @@ -13,45 +13,71 @@ exports[`OnboardingSuccess route params handling uses default successFlow when r style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, } } > Your wallet is ready! @@ -91,56 +124,109 @@ exports[`OnboardingSuccess route params handling uses default successFlow when r - Done - + Manage default settings @@ -178,45 +268,71 @@ exports[`OnboardingSuccess route params handling uses default successFlow when s style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, } } > Your wallet is ready! @@ -256,56 +379,109 @@ exports[`OnboardingSuccess route params handling uses default successFlow when s - Done - + Manage default settings @@ -343,45 +523,71 @@ exports[`OnboardingSuccess route params successFlow is BACKED_UP_SRP renders mat style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, } } > Your wallet is ready! @@ -421,56 +634,109 @@ exports[`OnboardingSuccess route params successFlow is BACKED_UP_SRP renders mat - Done - + Manage default settings @@ -508,45 +778,71 @@ exports[`OnboardingSuccess route params successFlow is IMPORT_FROM_SEED_PHRASE f style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, } } > Your wallet is ready! @@ -586,56 +889,109 @@ exports[`OnboardingSuccess route params successFlow is IMPORT_FROM_SEED_PHRASE f - Done - + Manage default settings @@ -673,45 +1033,71 @@ exports[`OnboardingSuccess route params successFlow is IMPORT_FROM_SEED_PHRASE r style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, } } > Your wallet is ready! @@ -751,56 +1144,109 @@ exports[`OnboardingSuccess route params successFlow is IMPORT_FROM_SEED_PHRASE r - Done - + Manage default settings @@ -838,45 +1288,71 @@ exports[`OnboardingSuccess route params successFlow is NO_BACKED_UP_SRP renders style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, } } > Your wallet is ready! @@ -916,56 +1399,109 @@ exports[`OnboardingSuccess route params successFlow is NO_BACKED_UP_SRP renders - Done - + Manage default settings @@ -1003,45 +1543,71 @@ exports[`OnboardingSuccessComponent renders matching snapshot when successFlow i style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, } } > Your wallet is ready! @@ -1081,56 +1654,109 @@ exports[`OnboardingSuccessComponent renders matching snapshot when successFlow i - Done - + Manage default settings @@ -1168,45 +1798,71 @@ exports[`OnboardingSuccessComponent renders matching snapshot when successFlow i style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, } } > Your wallet is ready! @@ -1246,56 +1909,109 @@ exports[`OnboardingSuccessComponent renders matching snapshot when successFlow i - Done - + Manage default settings @@ -1333,45 +2053,71 @@ exports[`OnboardingSuccessComponent renders matching snapshot when successFlow i style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, } } > Your wallet is ready! @@ -1411,56 +2164,109 @@ exports[`OnboardingSuccessComponent renders matching snapshot when successFlow i - Done - + Manage default settings diff --git a/app/components/Views/OnboardingSuccess/index.styles.ts b/app/components/Views/OnboardingSuccess/index.styles.ts deleted file mode 100644 index 7445e3b02a9..00000000000 --- a/app/components/Views/OnboardingSuccess/index.styles.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { StyleSheet } from 'react-native'; -import { ThemeColors } from '@metamask/design-tokens'; - -const createStyles = (colors: ThemeColors) => - StyleSheet.create({ - root: { - flex: 1, - backgroundColor: colors.background.default, - }, - container: { - flex: 1, - paddingHorizontal: 16, - }, - animationSection: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - }, - buttonSection: { - paddingBottom: 4, - alignItems: 'center', - rowGap: 12, - }, - textTitle: { - marginTop: 25, - marginBottom: 16, - marginHorizontal: 16, - textAlign: 'center', - fontFamily: 'MMSans-Regular', - }, - footerLink: { - paddingVertical: 8, - alignItems: 'center', - }, - }); - -export default createStyles; diff --git a/app/components/Views/OnboardingSuccess/index.test.tsx b/app/components/Views/OnboardingSuccess/index.test.tsx index 4ffa6fd7d1f..93e4e119ff2 100644 --- a/app/components/Views/OnboardingSuccess/index.test.tsx +++ b/app/components/Views/OnboardingSuccess/index.test.tsx @@ -17,7 +17,8 @@ import { useSelector } from 'react-redux'; import { TextColor, TextVariant, -} from '../../../component-library/components/Texts/Text/Text.types'; + FontWeight, +} from '@metamask/design-system-react-native'; import { ReactTestInstance } from 'react-test-renderer'; jest.mock('../../../core/Engine/Engine', () => ({ @@ -144,7 +145,7 @@ describe('OnboardingSuccessComponent', () => { />, ); const button = getByTestId(OnboardingSuccessSelectorIDs.DONE_BUTTON); - button.props.onPress(); + fireEvent.press(button); expect(mockDiscoverAccounts).toHaveBeenCalled(); }); @@ -213,8 +214,9 @@ describe('OnboardingSuccessComponent', () => { ); const footerText = footerButton.children[0] as ReactTestInstance; - expect(footerText.props.color).toBe(TextColor.Info); - expect(footerText.props.variant).toBe(TextVariant.BodyMDMedium); + expect(footerText.props.color).toBe(TextColor.InfoDefault); + expect(footerText.props.variant).toBe(TextVariant.BodyMd); + expect(footerText.props.fontWeight).toBe(FontWeight.Medium); }); it('hides manage default settings button for SETTINGS_BACKUP flow', () => { @@ -321,6 +323,14 @@ describe('OnboardingSuccess', () => { }); describe('route params handling', () => { + it('uses default successFlow when route is undefined', () => { + const { getByText } = renderWithProvider(); + + expect( + getByText(strings('onboarding_success.wallet_ready')), + ).toBeOnTheScreen(); + }); + it('uses default successFlow when route params are undefined', () => { const routeWithNoParams = { params: undefined, diff --git a/app/components/Views/OnboardingSuccess/index.tsx b/app/components/Views/OnboardingSuccess/index.tsx index 51f35b008e3..7bc4db23f20 100644 --- a/app/components/Views/OnboardingSuccess/index.tsx +++ b/app/components/Views/OnboardingSuccess/index.tsx @@ -1,32 +1,34 @@ -import React, { useCallback, useLayoutEffect, useMemo } from 'react'; -import { View, TouchableOpacity } from 'react-native'; +import React, { useCallback, useLayoutEffect } from 'react'; +import { TouchableOpacity } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../component-library/components/Buttons/Button'; -import Text from '../../../component-library/components/Texts/Text'; -import { - TextColor, - TextVariant, -} from '../../../component-library/components/Texts/Text/Text.types'; import { CommonActions, - useNavigation, RouteProp, + useNavigation, } from '@react-navigation/native'; import { strings } from '../../../../locales/i18n'; import Routes from '../../../constants/navigation/Routes'; -import { useTheme } from '../../../util/theme'; import { OnboardingSuccessSelectorIDs } from './OnboardingSuccess.testIds'; -import createStyles from './index.styles'; import OnboardingSuccessEndAnimation from './OnboardingSuccessEndAnimation/index'; import { ONBOARDING_SUCCESS_FLOW } from '../../../constants/onboarding'; import Engine from '../../../core/Engine/Engine'; import { discoverAccounts } from '../../../multichain-accounts/discovery'; +import { + Box, + BoxAlignItems, + BoxJustifyContent, + Button, + ButtonSize, + ButtonVariant, + FontFamily, + FontWeight, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; export const ResetNavigationToHome = CommonActions.reset({ index: 0, @@ -43,7 +45,7 @@ interface OnboardingSuccessParamList { } interface OnboardingSuccessScreenProps { - route: RouteProp; + route?: RouteProp; } interface OnboardingSuccessProps { @@ -57,8 +59,7 @@ export const OnboardingSuccessComponent: React.FC = ({ }) => { const navigation = useNavigation(); - const { colors } = useTheme(); - const styles = useMemo(() => createStyles(colors), [colors]); + const tw = useTailwind(); useLayoutEffect(() => { navigation.setOptions({ @@ -95,14 +96,20 @@ export const OnboardingSuccessComponent: React.FC = ({ // No-op: Animation completion not needed in success mode }} /> - + {getTitleString()} ); const renderFooter = () => { - // Hide default settings for settings backup flow if (successFlow === ONBOARDING_SUCCESS_FLOW.SETTINGS_BACKUP) { return null; } @@ -111,9 +118,13 @@ export const OnboardingSuccessComponent: React.FC = ({ - + {strings('onboarding_success.manage_default_settings')} @@ -121,25 +132,35 @@ export const OnboardingSuccessComponent: React.FC = ({ }; return ( - - + - {renderContent()} - - + + {renderContent()} + + + {renderFooter()} - - + + ); }; From 516973c4de91267cb7f4fe61fea319dbcd1687d3 Mon Sep 17 00:00:00 2001 From: Olivier-BB Date: Mon, 16 Mar 2026 11:35:47 +0100 Subject: [PATCH 019/206] =?UTF-8?q?fix(25760):=20prevent=20Ledger=20connec?= =?UTF-8?q?t=20image=20from=20being=20cut=20off=20on=20iOS=20af=E2=80=A6?= =?UTF-8?q?=20(#26783)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** On iOS, the Ledger connect illustration was cut off when opening the Ledger flow after the keyboard had been shown (e.g. search in Explore, then back). Removed `overflow: 'visible'` from the cover image style so the layout no longer gets clipped. Also fixed a typo in LedgerSelectAccount: `style-=` → `style=` so the selector container gets its styles. ## **Changelog** CHANGELOG entry: Fixed Ledger connect screen image being cut off on iOS after using the keyboard. ## **Related issues** Fixes: #25760 ## **Manual testing steps** Feature: Ledger connect screen Scenario: user opens Ledger connect after using keyboard Given the app is open and user has been to Explore and opened the search keyboard then navigated back When user starts connecting a Ledger device Then the Ledger connect illustration is fully visible (not cut off) --- > [!NOTE] > **Low Risk** > Low risk, UI-only style tweaks plus a snapshot update; potential impact is limited to Ledger connect/select-account screen layout across devices. > > **Overview** > Prevents the Ledger connect illustration from being clipped (notably on iOS after keyboard interactions) by removing `overflow: 'visible'` from the `LedgerConnect` cover image styling, and updates the Jest snapshot accordingly. > > Adjusts `LedgerSelectAccount` layout by removing `flex: 1` from `selectorContainer` to ensure the selector section sizes/positions correctly. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit d6b020341839c225e952c671d28235df3c122b08. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: metamaskbot --- .../Views/LedgerConnect/__snapshots__/index.test.tsx.snap | 1 - app/components/Views/LedgerConnect/index.styles.ts | 1 - app/components/Views/LedgerSelectAccount/index.styles.ts | 1 - 3 files changed, 3 deletions(-) diff --git a/app/components/Views/LedgerConnect/__snapshots__/index.test.tsx.snap b/app/components/Views/LedgerConnect/__snapshots__/index.test.tsx.snap index ff433d1153a..2d759fcc4ea 100644 --- a/app/components/Views/LedgerConnect/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/LedgerConnect/__snapshots__/index.test.tsx.snap @@ -132,7 +132,6 @@ exports[`LedgerConnect render matches latest snapshot 1`] = ` style={ { "height": 64, - "overflow": "visible", "resizeMode": "contain", "width": 30, } diff --git a/app/components/Views/LedgerConnect/index.styles.ts b/app/components/Views/LedgerConnect/index.styles.ts index babe5389722..ccde696b793 100644 --- a/app/components/Views/LedgerConnect/index.styles.ts +++ b/app/components/Views/LedgerConnect/index.styles.ts @@ -41,7 +41,6 @@ const createStyles = (colors: Colors, insets: EdgeInsets) => resizeMode: 'contain', width: Device.getDeviceWidth() * 0.6, height: 64, - overflow: 'visible', }, connectLedgerText: { ...(fontStyles.normal as TextStyle), diff --git a/app/components/Views/LedgerSelectAccount/index.styles.ts b/app/components/Views/LedgerSelectAccount/index.styles.ts index 9e1b0a3730b..94bd1a039f5 100644 --- a/app/components/Views/LedgerSelectAccount/index.styles.ts +++ b/app/components/Views/LedgerSelectAccount/index.styles.ts @@ -22,7 +22,6 @@ const createStyles = (colors: Colors) => alignItems: 'center', }, selectorContainer: { - flex: 1, flexDirection: 'column', }, mainTitle: { From d5b137a0c84acbdee4e3b156389143525a57c55c Mon Sep 17 00:00:00 2001 From: jvbriones <1674192+jvbriones@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:11:59 +0100 Subject: [PATCH 020/206] ci: add default look back days for post merge validation workflow (#27478) ## **Description** Updates a GitHub Actions workflow to accept an optional dispatch input and pass it through with a safe default ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [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. ## **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** > Low risk: only updates a GitHub Actions workflow to accept an optional dispatch input and pass it through with a safe default, without touching application code. > > **Overview** > Adds a `workflow_dispatch` input (`lookback-days`, default `1`) to the Post Merge Validation GitHub Actions workflow. > > Passes this value through to the `MetaMask/github-tools` `post-merge-validation@v1` action (falling back to `1` when not provided) to control how far back the workflow searches for PRs. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit a2145129c3691352271bb93bbded4bbc042e7347. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .github/workflows/post-merge-validation.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/post-merge-validation.yml b/.github/workflows/post-merge-validation.yml index 31f1ef1f81d..6c146cfc008 100644 --- a/.github/workflows/post-merge-validation.yml +++ b/.github/workflows/post-merge-validation.yml @@ -4,6 +4,11 @@ on: schedule: - cron: '0 7 * * *' workflow_dispatch: + inputs: + lookback-days: + description: Number of days to look back for PRs + required: false + default: '1' jobs: post-merge-validation-tracker: @@ -14,5 +19,6 @@ jobs: with: repo: ${{ github.repository }} start-hour-utc: '7' + lookback-days: ${{ inputs.lookback-days || '1' }} github-token: ${{ secrets.GITHUB_TOKEN }} google-application-creds-base64: ${{ secrets.GCP_RLS_SHEET_ACCOUNT_BASE64 }} From 91c89e5f2d732c63984c606f2720879426c27a1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20=C5=81ucka?= <5708018+PatrykLucka@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:14:58 +0100 Subject: [PATCH 021/206] refactor(rewards): simplify claimable reward calculation and update tests (#27473) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** **Reason for change:** In the Cash section (homepage), the "Claim bonus" CTA was shown even when the claimable mUSD bonus was less than $0.01 (e.g. dust after claiming). Small amounts like 0.007401 were also displayed as "0.01" because the formatting logic rounded to 2 decimals instead of showing "< 0.01". **Improvement / solution:** 1. **MusdAggregatedRow (Cash section):** Only show the "Claim bonus" button when the claimable reward is at least $0.01. Introduced `MIN_CLAIMABLE_BONUS_USD` and `isClaimableBonusAboveThreshold(reward)`; below that threshold the row shows the static "3% bonus" text instead of the CTA. 2. **useMerklRewards:** Removed use of `renderFromTokenMinimalUnit`, which only treated values below 0.00001 as "< 0.00001" and rounded everything else (e.g. 0.007401 → "0.01"). The hook now computes the decimal value as `unclaimedBaseUnits / 10^tokenDecimals` and formats it as `"< 0.01"` when < 0.01, otherwise `toFixed(2)`. This ensures amounts like 0.007401 display as "< 0.01" and the Cash section threshold logic works correctly. 3. **Tests:** MusdAggregatedRow tests for the claimable-bonus threshold (hide CTA for "< 0.01", "0.01", "0.005"; show for "0.02"). useMerklRewards tests updated to assert outcomes instead of mocking `renderFromTokenMinimalUnit`; added case for 7401 with 6 decimals → "< 0.01". ## **Changelog** CHANGELOG entry: Fixed Cash section showing "Claim bonus" for amounts under $0.01 and corrected display of small claimable amounts as "< 0.01" ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/MUSD-513 ## **Manual testing steps** ```gherkin Feature: Cash section claim bonus threshold and small-amount display Scenario: Claim bonus CTA hidden when claimable amount is below $0.01 Given I am on the Wallet home screen with the Cash section visible And my claimable mUSD bonus is less than $0.01 (e.g. dust after claiming) When I view the mUSD row in the Cash section Then I should see "3% bonus" (green text) instead of "Claim bonus" And I should not see a tappable "Claim bonus" link Scenario: Claim bonus CTA shown when claimable amount is at least $0.01 Given I am on the Wallet home screen with the Cash section visible And my claimable mUSD bonus is at least $0.01 When I view the mUSD row in the Cash section Then I should see the "Claim bonus" link And tapping it should start the claim flow ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/ec20c986-1694-4f45-9e8b-4bb9eaed36d0 ### **After** Screenshot 2026-03-16 at 09 07 21 ## **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. ## **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 reward amount formatting and CTA gating logic in the Cash section; incorrect numeric conversion/rounding (notably `BigInt`→`Number`) could mis-display large rewards or edge-case decimals. > > **Overview** > **Fixes dust claim UX in the Cash section.** `MusdAggregatedRow` now only shows the **"Claim bonus"** CTA when the claimable bonus is at least `$0.01`; otherwise it shows the static **"3% bonus"** label (including when the hook returns `"< 0.01"`). > > **Simplifies Merkl claimable reward formatting.** `useMerklRewards` drops `renderFromTokenMinimalUnit` and instead computes the decimal amount directly from base units, returning `"< 0.01"` for anything below `$0.01` and `toFixed(2)` otherwise. > > **Tests updated/added.** Merkl reward tests no longer mock the formatter and add coverage for sub-$0.01 amounts (including cases that previously rounded up), and Cash row tests cover the new $0.01 CTA threshold behavior. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 1054f6f4853049bfb65d69dd7bcd3a8a6eca0461. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../hooks/useMerklRewards.test.ts | 152 +++++------------- .../MerklRewards/hooks/useMerklRewards.ts | 31 +--- .../Sections/Cash/MusdAggregatedRow.test.tsx | 62 ++++++- .../Sections/Cash/MusdAggregatedRow.tsx | 18 ++- 4 files changed, 124 insertions(+), 139 deletions(-) diff --git a/app/components/UI/Earn/components/MerklRewards/hooks/useMerklRewards.test.ts b/app/components/UI/Earn/components/MerklRewards/hooks/useMerklRewards.test.ts index 0191d2045c8..6c392d441e8 100644 --- a/app/components/UI/Earn/components/MerklRewards/hooks/useMerklRewards.test.ts +++ b/app/components/UI/Earn/components/MerklRewards/hooks/useMerklRewards.test.ts @@ -6,7 +6,6 @@ import { useMerklRewards, } from './useMerklRewards'; import { selectSelectedInternalAccountFormattedAddress } from '../../../../../../selectors/accountsController'; -import { renderFromTokenMinimalUnit } from '../../../../../../util/number'; import { TokenI } from '../../../../Tokens/types'; import { CHAIN_IDS } from '@metamask/transaction-controller'; import { @@ -19,10 +18,6 @@ jest.mock('react-redux', () => ({ useSelector: jest.fn(), })); -jest.mock('../../../../../../util/number', () => ({ - renderFromTokenMinimalUnit: jest.fn(), -})); - jest.mock('../merkl-client', () => ({ fetchMerklRewardsForAsset: jest.fn(), getClaimedAmountFromContract: jest.fn(), @@ -59,10 +54,6 @@ jest.mock('../../../../../../util/Logger', () => ({ global.fetch = jest.fn(); const mockUseSelector = useSelector as jest.MockedFunction; -const mockRenderFromTokenMinimalUnit = - renderFromTokenMinimalUnit as jest.MockedFunction< - typeof renderFromTokenMinimalUnit - >; const mockFetchMerklRewardsForAsset = fetchMerklRewardsForAsset as jest.MockedFunction< typeof fetchMerklRewardsForAsset @@ -158,7 +149,6 @@ describe('useMerklRewards', () => { mockGetClaimedAmountFromContract.mockReset(); // Default: return null to fall back to API's claimed value mockGetClaimedAmountFromContract.mockResolvedValue(null); - mockRenderFromTokenMinimalUnit.mockReset(); (global.fetch as jest.Mock).mockClear(); mockUseSelector.mockImplementation((selector: unknown) => { @@ -167,26 +157,6 @@ describe('useMerklRewards', () => { } return undefined; }); - - // Default implementation for renderFromTokenMinimalUnit - // This calculates the actual value from the input, which is what most tests need - mockRenderFromTokenMinimalUnit.mockImplementation( - (value: string | number | unknown, decimals: number) => { - let stringValue: string; - if (typeof value === 'string') { - stringValue = value; - } else if (typeof value === 'number') { - stringValue = value.toString(); - } else { - // Handle BN or other types - stringValue = String(value); - } - const bigIntValue = BigInt(stringValue); - const divisor = BigInt(10 ** decimals); - const result = Number(bigIntValue) / Number(divisor); - return result.toFixed(2); - }, - ); }); it('initializes with null claimableReward', () => { @@ -278,12 +248,6 @@ describe('useMerklRewards', () => { mockAsset.address, '0xe708', // CHAIN_IDS.LINEA_MAINNET ); - - expect(mockRenderFromTokenMinimalUnit).toHaveBeenCalledWith( - '1500000000000000000', - 18, - 2, - ); }); it('adds test=true parameter with different case address (case-insensitive)', async () => { @@ -411,13 +375,6 @@ describe('useMerklRewards', () => { }, { timeout: 3000 }, ); - - // Verify it found the reward - expect(mockRenderFromTokenMinimalUnit).toHaveBeenCalledWith( - '2500000000000000000', - 18, - 2, - ); }); it('returns null claimableReward when unclaimed amount is zero', async () => { @@ -505,13 +462,11 @@ describe('useMerklRewards', () => { mockFetchMerklRewardsForAsset.mockResolvedValueOnce(mockRewardData); mockGetClaimedAmountFromContract.mockResolvedValueOnce('0'); - // Simulate renderFromTokenMinimalUnit returning a value without trailing zero - mockRenderFromTokenMinimalUnit.mockReturnValueOnce('0.9'); const { result } = renderHook(() => useMerklRewards({ asset: mockAsset })); await waitFor(() => { - // Should format to 2 decimal places + // Should format to 2 decimal places (0.9 * 10^18 base units → "0.90") expect(result.current.claimableReward).toBe('0.90'); }); }); @@ -536,13 +491,11 @@ describe('useMerklRewards', () => { mockFetchMerklRewardsForAsset.mockResolvedValueOnce(mockRewardData); mockGetClaimedAmountFromContract.mockResolvedValueOnce('0'); - // Simulate renderFromTokenMinimalUnit returning a whole number - mockRenderFromTokenMinimalUnit.mockReturnValueOnce('1'); const { result } = renderHook(() => useMerklRewards({ asset: mockAsset })); await waitFor(() => { - // Should format to 2 decimal places + // Should format to 2 decimal places (1 * 10^18 base units → "1.00") expect(result.current.claimableReward).toBe('1.00'); }); }); @@ -567,13 +520,11 @@ describe('useMerklRewards', () => { mockFetchMerklRewardsForAsset.mockResolvedValueOnce(mockRewardData); mockGetClaimedAmountFromContract.mockResolvedValueOnce('0'); - // Simulate renderFromTokenMinimalUnit returning single decimal - mockRenderFromTokenMinimalUnit.mockReturnValueOnce('12.5'); const { result } = renderHook(() => useMerklRewards({ asset: mockAsset })); await waitFor(() => { - // Should format to 2 decimal places + // Should format to 2 decimal places (12.5 * 10^18 base units → "12.50") expect(result.current.claimableReward).toBe('12.50'); }); }); @@ -599,13 +550,40 @@ describe('useMerklRewards', () => { mockFetchMerklRewardsForAsset.mockResolvedValueOnce(mockRewardData); mockGetClaimedAmountFromContract.mockResolvedValueOnce('0'); - // renderFromTokenMinimalUnit returns "< 0.00001" for very small amounts - mockRenderFromTokenMinimalUnit.mockReturnValue('< 0.00001'); + const { result } = renderHook(() => useMerklRewards({ asset: mockAsset })); + + await waitFor(() => { + // 100 base units with 18 decimals = 1e-16, below 0.01 → "< 0.01" + expect(result.current.claimableReward).toBe('< 0.01'); + }); + }); + + it('shows "< 0.01" when actual amount is below 0.01 but would round to 0.01', async () => { + // 7401 with 6 decimals = 0.007401; renderFromTokenMinimalUnit(7401, 6, 2) returns "0.01" (rounds) + const mockRewardData = { + token: { + address: AGLAMERKL_ADDRESS_MAINNET, + chainId: 1, + symbol: 'aglaMerkl', + decimals: 6, + price: null, + }, + accumulated: '0', + unclaimed: '7401', + pending: '0', + proofs: [], + amount: '7401', + claimed: '0', + recipient: mockSelectedAddress, + }; + + mockFetchMerklRewardsForAsset.mockResolvedValueOnce(mockRewardData); + mockGetClaimedAmountFromContract.mockResolvedValueOnce('0'); const { result } = renderHook(() => useMerklRewards({ asset: mockAsset })); await waitFor(() => { - // Should convert to "< 0.01" for consistency with 2 decimal places + // 7401 with 6 decimals = 0.007401, below 0.01 → "< 0.01" expect(result.current.claimableReward).toBe('< 0.01'); }); }); @@ -726,13 +704,6 @@ describe('useMerklRewards', () => { }, { timeout: 3000 }, ); - - // Should use token decimals from API (18) not asset decimals (6) - expect(mockRenderFromTokenMinimalUnit).toHaveBeenCalledWith( - '1500000000000000000', - 18, - 2, - ); }); it('defaults to 18 decimals when token and asset decimals are undefined', async () => { @@ -769,12 +740,6 @@ describe('useMerklRewards', () => { await waitFor(() => { expect(result.current.claimableReward).toBe('1.50'); }); - - expect(mockRenderFromTokenMinimalUnit).toHaveBeenCalledWith( - '1500000000000000000', - 18, - 2, - ); }); it('falls back to API claimed value when contract call fails', async () => { @@ -807,14 +772,6 @@ describe('useMerklRewards', () => { }, { timeout: 3000 }, ); - - // Should use API's claimed value (1.0) instead of contract value - // unclaimed = amount - claimed = 1.5 - 1.0 = 0.5 - expect(mockRenderFromTokenMinimalUnit).toHaveBeenCalledWith( - '500000000000000000', - 18, - 2, - ); }); it('uses contract value when available, even if API has different claimed value', async () => { @@ -849,17 +806,10 @@ describe('useMerklRewards', () => { }, { timeout: 3000 }, ); - - // Should use contract value (1.2) not API value (1.0) - // unclaimed = amount - claimed = 1.5 - 1.2 = 0.3 - expect(mockRenderFromTokenMinimalUnit).toHaveBeenCalledWith( - '300000000000000000', - 18, - 2, - ); }); - it('returns null claimableReward when renderFromTokenMinimalUnit returns empty string', async () => { + it('returns "< 0.01" when amount is below 0.01 (e.g. 0.001 tokens)', async () => { + // 1e15 base units with 18 decimals = 0.001 → below 0.01 → displayAmount = '< 0.01' const mockRewardData = { token: { address: AGLAMERKL_ADDRESS_MAINNET, @@ -869,30 +819,26 @@ describe('useMerklRewards', () => { price: null, }, accumulated: '0', - unclaimed: '1000000000000000000', + unclaimed: '1000000000000000', // 0.001 tokens (1e15 base units) pending: '0', proofs: [], - amount: '1000000000000000000', + amount: '1000000000000000', claimed: '0', recipient: mockSelectedAddress, }; mockFetchMerklRewardsForAsset.mockResolvedValueOnce(mockRewardData); mockGetClaimedAmountFromContract.mockResolvedValueOnce('0'); - // Return empty string to test the falsy check - mockRenderFromTokenMinimalUnit.mockReturnValueOnce(''); const { result } = renderHook(() => useMerklRewards({ asset: mockAsset })); await waitFor(() => { - expect(mockFetchMerklRewardsForAsset).toHaveBeenCalled(); + expect(result.current.claimableReward).toBe('< 0.01'); }); - - // Should remain null when rendered amount is empty string - expect(result.current.claimableReward).toBe(null); }); - it('returns null claimableReward when renderFromTokenMinimalUnit returns "0"', async () => { + it('returns "< 0.01" when amount is tiny (e.g. 1 base unit)', async () => { + // 1 base unit with 18 decimals = 1e-18 → below 0.01 → displayAmount = '< 0.01' const mockRewardData = { token: { address: AGLAMERKL_ADDRESS_MAINNET, @@ -902,27 +848,22 @@ describe('useMerklRewards', () => { price: null, }, accumulated: '0', - unclaimed: '1000000000000000000', + unclaimed: '1', pending: '0', proofs: [], - amount: '1000000000000000000', + amount: '1', claimed: '0', recipient: mockSelectedAddress, }; mockFetchMerklRewardsForAsset.mockResolvedValueOnce(mockRewardData); mockGetClaimedAmountFromContract.mockResolvedValueOnce('0'); - // Return '0' to test the exact zero check - mockRenderFromTokenMinimalUnit.mockReturnValueOnce('0'); const { result } = renderHook(() => useMerklRewards({ asset: mockAsset })); await waitFor(() => { - expect(mockFetchMerklRewardsForAsset).toHaveBeenCalled(); + expect(result.current.claimableReward).toBe('< 0.01'); }); - - // Should remain null when rendered amount is exactly '0' - expect(result.current.claimableReward).toBe(null); }); it('ignores AbortError when fetch is cancelled', async () => { @@ -977,13 +918,6 @@ describe('useMerklRewards', () => { }, { timeout: 3000 }, ); - - // Should fall back to asset decimals (6) when token decimals is null - expect(mockRenderFromTokenMinimalUnit).toHaveBeenCalledWith( - '1500000', - 6, - 2, - ); }); it('exposes refetch function that triggers data refresh', async () => { diff --git a/app/components/UI/Earn/components/MerklRewards/hooks/useMerklRewards.ts b/app/components/UI/Earn/components/MerklRewards/hooks/useMerklRewards.ts index 0e401dd75a7..35e5aceb9f0 100644 --- a/app/components/UI/Earn/components/MerklRewards/hooks/useMerklRewards.ts +++ b/app/components/UI/Earn/components/MerklRewards/hooks/useMerklRewards.ts @@ -2,7 +2,6 @@ import { useState, useEffect, useCallback } from 'react'; import { useSelector } from 'react-redux'; import { Hex } from '@metamask/utils'; import { selectSelectedInternalAccountFormattedAddress } from '../../../../../../selectors/accountsController'; -import { renderFromTokenMinimalUnit } from '../../../../../../util/number'; import { TokenI } from '../../../../Tokens/types'; import { CHAIN_IDS } from '@metamask/transaction-controller'; import { MUSD_TOKEN_ADDRESS_BY_CHAIN } from '../../../constants/musd'; @@ -138,31 +137,11 @@ export const useMerklRewards = ({ matchingReward.token.decimals ?? asset.decimals ?? 18; if (unclaimedBaseUnits > 0n) { - // Convert from wei to token amount - const unclaimedAmount = renderFromTokenMinimalUnit( - unclaimedBaseUnits.toString(), - tokenDecimals, - 2, // Show 2 decimal places - ); - // Handle the "< 0.00001" case from renderFromTokenMinimalUnit - // by showing "< 0.01" for consistency with 2 decimal places - // Also ensure we always show exactly 2 decimal places for currency display - let displayAmount: string; - if (unclaimedAmount.startsWith('<')) { - displayAmount = '< 0.01'; - } else { - // Ensure exactly 2 decimal places (e.g., "0.9" -> "0.90") - const numValue = parseFloat(unclaimedAmount); - displayAmount = numValue.toFixed(2); - } - // Double-check that the rendered amount is not '0' or '0.00' - // This handles edge cases where very small amounts round to zero - if ( - displayAmount && - displayAmount !== '0' && - displayAmount !== '0.00' - ) { - // Final check before setting state to ensure effect is still active + const unclaimedDecimal = + Number(unclaimedBaseUnits) / Math.pow(10, tokenDecimals); + const displayAmount = + unclaimedDecimal < 0.01 ? '< 0.01' : unclaimedDecimal.toFixed(2); + if (displayAmount !== '0' && displayAmount !== '0.00') { if (!controller.signal.aborted) { setClaimableReward(displayAmount); } diff --git a/app/components/Views/Homepage/Sections/Cash/MusdAggregatedRow.test.tsx b/app/components/Views/Homepage/Sections/Cash/MusdAggregatedRow.test.tsx index aead65d138c..7e8e3faedb1 100644 --- a/app/components/Views/Homepage/Sections/Cash/MusdAggregatedRow.test.tsx +++ b/app/components/Views/Homepage/Sections/Cash/MusdAggregatedRow.test.tsx @@ -18,7 +18,7 @@ jest.mock('../../../../UI/Earn/hooks/useMusdBalance', () => ({ })); const mockUseMerklBonusClaim = jest.fn(() => ({ - claimableReward: { amount: '10' } as { amount: string } | null, + claimableReward: '10' as string | null, hasPendingClaim: false, claimRewards: mockClaimRewards, isClaiming: false, @@ -49,7 +49,7 @@ describe('MusdAggregatedRow', () => { beforeEach(() => { jest.clearAllMocks(); mockUseMerklBonusClaim.mockReturnValue({ - claimableReward: { amount: '10' }, + claimableReward: '10', hasPendingClaim: false, claimRewards: mockClaimRewards, isClaiming: false, @@ -84,7 +84,7 @@ describe('MusdAggregatedRow', () => { it('shows Spinner when isClaiming is true', () => { mockUseMerklBonusClaim.mockReturnValue({ - claimableReward: { amount: '10' }, + claimableReward: '10', hasPendingClaim: false, claimRewards: mockClaimRewards, isClaiming: true, @@ -109,4 +109,60 @@ describe('MusdAggregatedRow', () => { expect(screen.queryByText('Claim bonus')).toBeNull(); expect(screen.getByText('3% bonus')).toBeOnTheScreen(); }); + + describe('claimable bonus threshold (min $0.01)', () => { + it('hides Claim bonus when claimable reward is "< 0.01"', () => { + mockUseMerklBonusClaim.mockReturnValue({ + claimableReward: '< 0.01', + hasPendingClaim: false, + claimRewards: mockClaimRewards, + isClaiming: false, + }); + + renderWithProvider(); + + expect(screen.queryByText('Claim bonus')).toBeNull(); + expect(screen.getByText('3% bonus')).toBeOnTheScreen(); + }); + + it('shows Claim bonus when claimable reward is exactly 0.01', () => { + mockUseMerklBonusClaim.mockReturnValue({ + claimableReward: '0.01', + hasPendingClaim: false, + claimRewards: mockClaimRewards, + isClaiming: false, + }); + + renderWithProvider(); + + expect(screen.getByText('Claim bonus')).toBeOnTheScreen(); + }); + + it('hides Claim bonus when claimable reward is below 0.01', () => { + mockUseMerklBonusClaim.mockReturnValue({ + claimableReward: '0.005', + hasPendingClaim: false, + claimRewards: mockClaimRewards, + isClaiming: false, + }); + + renderWithProvider(); + + expect(screen.queryByText('Claim bonus')).toBeNull(); + expect(screen.getByText('3% bonus')).toBeOnTheScreen(); + }); + + it('shows Claim bonus when claimable reward is above 0.01', () => { + mockUseMerklBonusClaim.mockReturnValue({ + claimableReward: '0.02', + hasPendingClaim: false, + claimRewards: mockClaimRewards, + isClaiming: false, + }); + + renderWithProvider(); + + expect(screen.getByText('Claim bonus')).toBeOnTheScreen(); + }); + }); }); diff --git a/app/components/Views/Homepage/Sections/Cash/MusdAggregatedRow.tsx b/app/components/Views/Homepage/Sections/Cash/MusdAggregatedRow.tsx index 2fc9e5ac0de..60b7389c41a 100644 --- a/app/components/Views/Homepage/Sections/Cash/MusdAggregatedRow.tsx +++ b/app/components/Views/Homepage/Sections/Cash/MusdAggregatedRow.tsx @@ -43,6 +43,21 @@ import { MUSD_MAINNET_ASSET_FOR_DETAILS } from './CashGetMusdEmptyState.constant import NavigationService from '../../../../../core/NavigationService'; import { TokenDetailsSource } from '../../../../UI/TokenDetails/constants/constants'; +/** Minimum claimable bonus (USD) to show the "Claim bonus" CTA; below this we show "3% bonus" instead. */ +const MIN_CLAIMABLE_BONUS_USD = 0.01; + +/** + * Returns true when the claimable reward string represents an amount >= MIN_CLAIMABLE_BONUS_USD. + * useMerklRewards returns "< 0.01" for very small amounts; we do not show "Claim bonus" for those. + */ +const isClaimableBonusAboveThreshold = (reward: string | null): boolean => { + if (!reward || typeof reward !== 'string') return false; + if (reward.startsWith('<')) return false; + const value = parseFloat(reward); + if (Number.isNaN(value)) return false; + return value >= MIN_CLAIMABLE_BONUS_USD; +}; + /** * Minimal mUSD asset for useMerklBonusClaim (claim runs on Linea). * Only chainId and address are required for the claim flow. @@ -69,7 +84,8 @@ const MusdAggregatedRow = () => { const { trackEvent, createEventBuilder } = useAnalytics(); const networkName = useNetworkName(LINEA_MUSD_ASSET.chainId as Hex); - const hasClaimableBonus = Boolean(claimableReward) && !hasPendingClaim; + const hasClaimableBonus = + isClaimableBonusAboveThreshold(claimableReward) && !hasPendingClaim; const handleClaimBonus = useCallback(() => { trackEvent( From b5124ce9f6523b2358c90f401467ea30c66faffb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20=C5=81ucka?= <5708018+PatrykLucka@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:15:11 +0100 Subject: [PATCH 022/206] refactor(MusdConversionAssetListCta): enhance CTA component with full view support and update event tracking locations (#27415) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The `mUSD Conversion CTA Clicked` event in `CashGetMusdEmptyState` always sent `location: 'home_cash_section'`, even when the CTA was tapped from the full Cash token list page. This PR adds a new `MOBILE_TOKEN_LIST_PAGE` event location and an `isFullView` prop to `CashGetMusdEmptyState` so the full-page context is reported correctly, while the homepage default (`home_cash_section`) remains unchanged: - **`CashGetMusdEmptyState`** — accepts `isFullView`; sends `mobile-token-list-page` when true, keeps `home_cash_section` otherwise. - **`CashTokensFullView`** — passes `isFullView` to `CashGetMusdEmptyState`. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TMCU-560 ## **Manual testing steps** ```gherkin Feature: mUSD Conversion CTA location tracking in Cash section Scenario: CTA clicked on homepage Cash section keeps home_cash_section location Given the user is on the wallet homepage with mUSD conversion enabled And the user has no mUSD balance (Cash empty state is shown) When user taps the "Get mUSD" button in the Cash section Then the mUSD Conversion CTA Clicked event fires with location "home_cash_section" Scenario: CTA clicked on full Cash token list page sends mobile-token-list-page location Given the user navigates to the full Cash token list page (via ">" from homepage Cash section) And the user has no mUSD balance (Cash empty state is shown) When user taps the "Get mUSD" button Then the mUSD Conversion CTA Clicked event fires with location "mobile-token-list-page" ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Low risk: limited to analytics metadata and a new optional prop wiring for `CashGetMusdEmptyState`, with updated unit coverage. > > **Overview** > Updates mUSD conversion CTA analytics so the `MUSD_CONVERSION_CTA_CLICKED` event reports the correct `location` when triggered from the full Cash token list page. > > Adds `EVENT_LOCATIONS.MOBILE_TOKEN_LIST_PAGE` and an `isFullView` prop to `CashGetMusdEmptyState`, passes it from `CashTokensFullView`, and extends tests to cover both homepage (`home_cash_section`) and full-view tracking. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 720ef20aecfa6c10b3c7c35448810504567e1c86. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../UI/Earn/constants/events/musdEvents.ts | 2 ++ .../CashTokensFullView/CashTokensFullView.tsx | 2 +- .../Cash/CashGetMusdEmptyState.test.tsx | 20 ++++++++++++++++++- .../Sections/Cash/CashGetMusdEmptyState.tsx | 13 ++++++++++-- 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/app/components/UI/Earn/constants/events/musdEvents.ts b/app/components/UI/Earn/constants/events/musdEvents.ts index 0abf0ed12c0..b6acbe1a1b0 100644 --- a/app/components/UI/Earn/constants/events/musdEvents.ts +++ b/app/components/UI/Earn/constants/events/musdEvents.ts @@ -16,6 +16,8 @@ const EVENT_LOCATIONS = { 'quick_convert_max_bottom_sheet_confirmation_screen', CUSTOM_AMOUNT_NAVBAR: 'custom_amount_navbar', PERCENTAGE_ROW: 'percentage_row', + /** CTA on full page Cash token list */ + MOBILE_TOKEN_LIST_PAGE: 'mobile-token-list-page', }; const MUSD_CTA_TYPES = { diff --git a/app/components/Views/CashTokensFullView/CashTokensFullView.tsx b/app/components/Views/CashTokensFullView/CashTokensFullView.tsx index 32d523ac65c..6ae12e618bd 100644 --- a/app/components/Views/CashTokensFullView/CashTokensFullView.tsx +++ b/app/components/Views/CashTokensFullView/CashTokensFullView.tsx @@ -48,7 +48,7 @@ const CashTokensFullView = () => { {!hasMusdBalanceOnAnyChain ? ( - + ) : ( diff --git a/app/components/Views/Homepage/Sections/Cash/CashGetMusdEmptyState.test.tsx b/app/components/Views/Homepage/Sections/Cash/CashGetMusdEmptyState.test.tsx index c0cb2cdc104..15f8446f461 100644 --- a/app/components/Views/Homepage/Sections/Cash/CashGetMusdEmptyState.test.tsx +++ b/app/components/Views/Homepage/Sections/Cash/CashGetMusdEmptyState.test.tsx @@ -126,7 +126,7 @@ describe('CashGetMusdEmptyState', () => { expect(mockInitiateCustomConversion).not.toHaveBeenCalled(); }); - it('tracks MUSD_CONVERSION_CTA_CLICKED with home_cash_section when Get mUSD is pressed', () => { + it('tracks MUSD_CONVERSION_CTA_CLICKED with home_cash_section when Get mUSD is pressed on homepage', () => { renderWithProvider(); fireEvent.press(screen.getByTestId(CashGetMusdEmptyStateSelectors.BUTTON)); @@ -144,6 +144,24 @@ describe('CashGetMusdEmptyState', () => { expect(mockTrackEvent).toHaveBeenCalled(); }); + it('tracks MUSD_CONVERSION_CTA_CLICKED with mobile-token-list-page when Get mUSD is pressed on full view', () => { + renderWithProvider(); + + fireEvent.press(screen.getByTestId(CashGetMusdEmptyStateSelectors.BUTTON)); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.MUSD_CONVERSION_CTA_CLICKED, + ); + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + location: MUSD_EVENTS_CONSTANTS.EVENT_LOCATIONS.MOBILE_TOKEN_LIST_PAGE, + cta_type: MUSD_EVENTS_CONSTANTS.MUSD_CTA_TYPES.PRIMARY, + cta_text: 'Get mUSD', + }), + ); + expect(mockTrackEvent).toHaveBeenCalled(); + }); + it('hides Get mUSD button when no convertible tokens and mUSD is not buyable', () => { mockUseMusdConversionFlowData.hasConvertibleTokens = false; mockUseMusdConversionFlowData.isMusdBuyableOnAnyChain = false; diff --git a/app/components/Views/Homepage/Sections/Cash/CashGetMusdEmptyState.tsx b/app/components/Views/Homepage/Sections/Cash/CashGetMusdEmptyState.tsx index 7446c925067..bb8f9b75e48 100644 --- a/app/components/Views/Homepage/Sections/Cash/CashGetMusdEmptyState.tsx +++ b/app/components/Views/Homepage/Sections/Cash/CashGetMusdEmptyState.tsx @@ -44,12 +44,18 @@ import { getIntlNumberFormatter } from '../../../../../util/intl'; import { CashGetMusdEmptyStateSelectors } from './CashGetMusdEmptyState.testIds'; import { MUSD_MAINNET_ASSET_FOR_DETAILS } from './CashGetMusdEmptyState.constants'; +interface CashGetMusdEmptyStateProps { + isFullView?: boolean; +} + /** * Empty state for the Cash (mUSD) full view when the user has no mUSD. * Shows a "Get mUSD" card: token row (navigates to Mainnet mUSD Asset Details) + Get mUSD button. * Button routes to Buy flow (empty wallet + mUSD buyable) or Convert flow (non-empty + has convertible tokens). */ -const CashGetMusdEmptyState = () => { +const CashGetMusdEmptyState = ({ + isFullView = false, +}: CashGetMusdEmptyStateProps) => { const tw = useTailwind(); const { goToBuy } = useRampNavigation(); const { @@ -117,7 +123,9 @@ const CashGetMusdEmptyState = () => { trackEvent( createEventBuilder(MetaMetricsEvents.MUSD_CONVERSION_CTA_CLICKED) .addProperties({ - location: EVENT_LOCATIONS.HOME_CASH_SECTION, + location: isFullView + ? EVENT_LOCATIONS.MOBILE_TOKEN_LIST_PAGE + : EVENT_LOCATIONS.HOME_CASH_SECTION, redirects_to: getRedirectLocation(), cta_type: MUSD_CTA_TYPES.PRIMARY, cta_text: strings('earn.musd_conversion.get_musd'), @@ -166,6 +174,7 @@ const CashGetMusdEmptyState = () => { isMusdBuyableOnAnyChain, hasConvertibleTokens, hasSeenConversionEducationScreen, + isFullView, isQuickConvertEnabled, getPaymentTokenForSelectedNetwork, goToBuy, From c1e2accb0ccb747c2d04b808c793fe0640a3a4e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20=C5=81ucka?= <5708018+PatrykLucka@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:47:35 +0100 Subject: [PATCH 023/206] refactor: simplify button styles in AdditionalNetworkItem component (#27475) ## **Description** Removes background on the "+" icon in the new Network management view. ## **Changelog** CHANGELOG entry: Remove background from Additional Networks in Network Management ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TMCU-521 ## **Manual testing steps** ```gherkin Feature: Network management Scenario: user can access network management and sees updated additional network icons Given the app is opened and the user is logged in When the user goes to Account Menu and opens Networks Then the Networks management screen is shown And each additional network item shows its icon without a rounded muted background ``` ## **Screenshots/Recordings** ### **Before** Screenshot 2026-03-16 at 11 27 32 ### **After** Screenshot 2026-03-16 at 11 27 41 ## **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. ## **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** > Low risk UI-only change that adjusts styling for the add (+) button with no logic or data flow changes. > > **Overview** > Updates `AdditionalNetworkItem` so the add (+) `Pressable` no longer renders with a muted rounded background, leaving only sizing/alignment and pressed opacity styling. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit c27272cdfdec179344ae8ddb0ff80e134a2cc2e4. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../NetworksManagement/components/AdditionalNetworkItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/Views/NetworksManagement/components/AdditionalNetworkItem.tsx b/app/components/Views/NetworksManagement/components/AdditionalNetworkItem.tsx index 09b9cda210d..c50e910c6a7 100644 --- a/app/components/Views/NetworksManagement/components/AdditionalNetworkItem.tsx +++ b/app/components/Views/NetworksManagement/components/AdditionalNetworkItem.tsx @@ -56,7 +56,7 @@ const AdditionalNetworkItem = ({ item, onAdd }: AdditionalNetworkItemProps) => { hitSlop={HIT_SLOP} style={({ pressed }) => tw.style( - 'w-7 h-7 items-center justify-center rounded-lg bg-background-muted mr-2.5', + 'w-7 h-7 items-center justify-center mr-2.5', pressed && 'opacity-70', ) } From 8024253ef783d609aa799d384fac490e7be909e1 Mon Sep 17 00:00:00 2001 From: Juanmi <95381763+juanmigdr@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:30:00 +0100 Subject: [PATCH 024/206] feat: show skeleton when loading NFT images (#27413) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** NFT images in the grid had no loading indicator — the cell would appear empty until the image finished loading. This PR adds a skeleton overlay that shows immediately when an NFT item has an image URL, then disappears once the image loads or fails. **Changes:** - `NftGridItem`: Tracks a local `isImageLoading` state (true when `item.image` or `item.imageOriginal` is set). Renders a `Skeleton` overlay on the image cell until `onLoad` fires. Resets correctly when the item changes. - `CollectibleMedia` + `CollectibleMedia.types`: Adds an `onLoad` prop wired through to `RemoteImage`. Also calls it in the error fallback so the skeleton always clears even when an image fails to load. - `RemoteImage`: Adds an `onLoad` prop called from the internal `onImageLoad` handler. Adds `recyclingKey={uri}` to expo-image `Image` components so cached images are reused correctly when URIs change. - `NftGrid`: Improves `keyExtractor` from an array-index key to a stable `${chainId}-${address}-${tokenId}` key, preventing unnecessary re-renders when the list updates. ## **Changelog** CHANGELOG entry: Added skeleton loading indicator to NFT grid items while images are loading ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-2903 ## **Manual testing steps** ```gherkin Feature: NFT grid skeleton loading Scenario: user views the NFT grid while images load Given the user has NFTs in their wallet When the user navigates to the NFT grid Then a skeleton placeholder is visible on each NFT cell while the image loads And the skeleton disappears once the image finishes loading Scenario: user views an NFT whose image fails to load Given the user has an NFT with an unreachable image URL When the NFT grid renders that item Then the skeleton disappears even though no image is shown ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/00107c9f-5d61-4a2c-89fb-5e46bf442756 ### **After** https://github.com/user-attachments/assets/9d690211-1ebd-40a8-b7a8-4b8782bbeb93 ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Touches NFT grid rendering by adding loading state/skeleton overlays and changing list keys, which can affect perceived UI behavior and item reuse. Also propagates new `onLoad` callbacks through image components, so regressions would mainly be around image lifecycle events and re-rendering. > > **Overview** > Adds a **skeleton loading overlay** to `NftGridItem` that shows when an NFT has an image (and media display is enabled) and hides when the underlying media reports `onLoad`. > > Threads a new `onLoad` callback through `CollectibleMedia` → `RemoteImage`, and ensures the callback also fires on image error fallback so the skeleton clears even when an image fails. `RemoteImage` now sets `recyclingKey={uri}` on `expo-image` instances and invokes the new `onLoad` prop from its internal load handler. > > Updates `NftGrid` to use a stable `keyExtractor` (`chainId-address-tokenId`) instead of index keys, and adds/updates unit tests covering the new `onLoad` behavior and skeleton visibility. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit e382cc83f11ccb9be658a8df6fb2e6dfc6f0f963. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../Base/RemoteImage/index.test.tsx | 22 ++ app/components/Base/RemoteImage/index.tsx | 15 +- .../CollectibleMedia.test.tsx | 71 ++++++- .../UI/CollectibleMedia/CollectibleMedia.tsx | 8 +- .../CollectibleMedia.types.ts | 1 + app/components/UI/NftGrid/NftGrid.test.tsx | 8 + app/components/UI/NftGrid/NftGrid.tsx | 5 +- .../UI/NftGrid/NftGridItem.test.tsx | 194 ++++++++++++++++++ app/components/UI/NftGrid/NftGridItem.tsx | 25 ++- 9 files changed, 342 insertions(+), 7 deletions(-) create mode 100644 app/components/UI/NftGrid/NftGridItem.test.tsx diff --git a/app/components/Base/RemoteImage/index.test.tsx b/app/components/Base/RemoteImage/index.test.tsx index 19bf70b8194..c827f270b39 100644 --- a/app/components/Base/RemoteImage/index.test.tsx +++ b/app/components/Base/RemoteImage/index.test.tsx @@ -227,6 +227,28 @@ describe('RemoteImage', () => { }); }); + describe('onLoad callback', () => { + it('calls onLoad prop when image loads successfully', async () => { + const mockOnLoad = jest.fn(); + + const { UNSAFE_getByType } = render( + , + ); + + await act(async () => { + const image = UNSAFE_getByType(Image); + image.props.onLoad({ source: { width: 100, height: 100 } }); + }); + + await waitFor(() => { + expect(mockOnLoad).toHaveBeenCalledTimes(1); + }); + }); + }); + describe('Error Handling', () => { it('renders Identicon when image fails to load and address is provided', async () => { const { UNSAFE_getByType, findByTestId } = render( diff --git a/app/components/Base/RemoteImage/index.tsx b/app/components/Base/RemoteImage/index.tsx index 1ec20a2d68f..a9e74450ac0 100644 --- a/app/components/Base/RemoteImage/index.tsx +++ b/app/components/Base/RemoteImage/index.tsx @@ -27,6 +27,7 @@ interface RemoteImageProps { style?: StyleProp; placeholderStyle?: StyleProp; onError?: () => void; + onLoad?: () => void; isUrl?: boolean; address?: string; isTokenImage?: boolean; @@ -52,6 +53,7 @@ const RemoteImage: React.FC = (props) => { const source = resolveAssetSource(props.source); const ipfsGateway = useIpfsGateway(); const [resolvedIpfsUrl, setResolvedIpfsUrl] = useState(); + const { onLoad: onLoadProp } = props; const uri = resolvedIpfsUrl || @@ -134,8 +136,9 @@ const RemoteImage: React.FC = (props) => { return { width: calculatedWidth, height: calculatedHeight }; }); } + onLoadProp?.(); }, - [calculateImageDimensions], + [calculateImageDimensions, onLoadProp], ); if (error && props.address) { @@ -147,7 +150,13 @@ const RemoteImage: React.FC = (props) => { } const defaultImage = ( - + ); if (props.fadeIn) { @@ -164,6 +173,7 @@ const RemoteImage: React.FC = (props) => { {showFullRatioImage ? ( = (props) => { style={styles.imageStyle} {...restProps} source={{ uri }} + recyclingKey={uri} onLoad={onImageLoad} onError={onError} /> diff --git a/app/components/UI/CollectibleMedia/CollectibleMedia.test.tsx b/app/components/UI/CollectibleMedia/CollectibleMedia.test.tsx index 76415fccb58..3bbbf18189d 100644 --- a/app/components/UI/CollectibleMedia/CollectibleMedia.test.tsx +++ b/app/components/UI/CollectibleMedia/CollectibleMedia.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; -import { waitFor } from '@testing-library/react-native'; +import { waitFor, act } from '@testing-library/react-native'; import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { Image } from 'expo-image'; import CollectibleMedia from './CollectibleMedia'; @@ -87,6 +88,74 @@ describe('CollectibleMedia', () => { expect(fallbackCollectible).toBeDefined(); }); + it('should call onLoad when the image loads successfully', async () => { + const mockOnLoad = jest.fn(); + + renderWithProvider( + , + { state: mockInitialState }, + ); + + // The expo-image mock auto-fires onLoad via setTimeout(0) + await waitFor(() => { + expect(mockOnLoad).toHaveBeenCalled(); + }); + }); + + it('should call onLoad when the image fails to load (fallback)', async () => { + const mockOnLoad = jest.fn(); + + const { UNSAFE_getAllByType } = renderWithProvider( + , + { state: mockInitialState }, + ); + + // Wait for the image to render and let auto-load settle + await waitFor(() => { + const images = UNSAFE_getAllByType(Image); + expect(images.some((img) => img.props.testID === 'nft-image')).toBe(true); + }); + + mockOnLoad.mockClear(); + + // Simulate image error - should call onLoad via the fallback + await act(async () => { + const images = UNSAFE_getAllByType(Image); + const nftImage = images.find((img) => img.props.testID === 'nft-image'); + nftImage?.props.onError?.({ error: 'Failed to load' }); + }); + + await waitFor(() => { + expect(mockOnLoad).toHaveBeenCalledTimes(1); + }); + }); + it('should handle an nft with multiple images and render the first image', async () => { const images = [ 'ipfs://bafybeidgklvljyifilhtrxzh77brgnhcy6s2wxoxqc2l73zr2nxlwuxfcy', diff --git a/app/components/UI/CollectibleMedia/CollectibleMedia.tsx b/app/components/UI/CollectibleMedia/CollectibleMedia.tsx index d5f2754b9f8..0082bb6af67 100644 --- a/app/components/UI/CollectibleMedia/CollectibleMedia.tsx +++ b/app/components/UI/CollectibleMedia/CollectibleMedia.tsx @@ -37,6 +37,7 @@ const CollectibleMedia: React.FC = ({ onPressColectible, isTokenImage, isFullRatio, + onLoad, }) => { const [sourceUri, setSourceUri] = useState(null); const isIpfsGatewayEnabled = useSelector(selectIsIpfsGatewayEnabled); @@ -47,7 +48,10 @@ const CollectibleMedia: React.FC = ({ backgroundColor: collectible.backgroundColor, }); - const fallback = useCallback(() => setSourceUri(null), []); + const fallback = useCallback(() => { + setSourceUri(null); + onLoad?.(); + }, [onLoad]); useEffect(() => { const { image, imageOriginal, imagePreview, address } = collectible; @@ -204,6 +208,7 @@ const CollectibleMedia: React.FC = ({ ]} chainId={collectible.chainId} onError={fallback} + onLoad={onLoad} testID="nft-image" isTokenImage={isTokenImage} isFullRatio={isFullRatio} @@ -240,6 +245,7 @@ const CollectibleMedia: React.FC = ({ isTokenImage, isFullRatio, collectible.chainId, + onLoad, ]); return {renderMedia()}; diff --git a/app/components/UI/CollectibleMedia/CollectibleMedia.types.ts b/app/components/UI/CollectibleMedia/CollectibleMedia.types.ts index 3c7cc47a6d4..eb1e94590f0 100644 --- a/app/components/UI/CollectibleMedia/CollectibleMedia.types.ts +++ b/app/components/UI/CollectibleMedia/CollectibleMedia.types.ts @@ -36,4 +36,5 @@ export interface CollectibleMediaProps { onPressColectible?: () => void; isTokenImage?: boolean; isFullRatio?: boolean; + onLoad?: () => void; } diff --git a/app/components/UI/NftGrid/NftGrid.test.tsx b/app/components/UI/NftGrid/NftGrid.test.tsx index 8e7c234eb57..4f1fbaa74dc 100644 --- a/app/components/UI/NftGrid/NftGrid.test.tsx +++ b/app/components/UI/NftGrid/NftGrid.test.tsx @@ -140,6 +140,14 @@ jest.mock('./NftGridSkeleton', () => { return () => ; }); +// Mock Skeleton to avoid animation/design-system dependencies +jest.mock('../../../component-library/components-temp/Skeleton', () => ({ + Skeleton: ({ testID }: { testID?: string }) => { + const { View } = jest.requireActual('react-native'); + return ; + }, +})); + // Mock CollectiblesEmptyState - has complex dependencies jest.mock('../CollectiblesEmptyState', () => ({ CollectiblesEmptyState: ({ diff --git a/app/components/UI/NftGrid/NftGrid.tsx b/app/components/UI/NftGrid/NftGrid.tsx index 5ab8946d327..460f674e5a4 100644 --- a/app/components/UI/NftGrid/NftGrid.tsx +++ b/app/components/UI/NftGrid/NftGrid.tsx @@ -249,9 +249,10 @@ const NftGrid = forwardRef( /> )} - keyExtractor={(_, index) => `nft-row-${index}`} + keyExtractor={(item) => + `${item.chainId}-${item.address}-${item.tokenId}` + } testID={RefreshTestId} - decelerationRate="fast" refreshControl={ ({ + useSelector: () => mockDisplayNftMedia, +})); + +const mockNavigate = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ + navigate: mockNavigate, + }), +})); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => { + const styleFunc = (className: string | string[]) => { + if (Array.isArray(className)) { + return className.reduce((acc, cls) => ({ ...acc, [cls]: true }), {}); + } + return { [className]: true }; + }; + styleFunc.style = styleFunc; + return styleFunc; + }, +})); + +jest.mock('@metamask/design-system-react-native', () => ({ + Text: ({ + children, + ...props + }: { + children: React.ReactNode; + [key: string]: unknown; + }) => { + const { Text: RNText } = jest.requireActual('react-native'); + return {children}; + }, + TextVariant: { BodyMd: 'BodyMd', BodySm: 'BodySm' }, + FontWeight: { Medium: 'Medium' }, + Box: ({ + children, + testID, + }: { + children: React.ReactNode; + testID?: string; + }) => { + const { View } = jest.requireActual('react-native'); + return {children}; + }, +})); + +jest.mock('lodash', () => ({ + ...jest.requireActual('lodash'), + debounce: (fn: (...args: unknown[]) => unknown) => fn, +})); + +let mockOnLoad: (() => void) | undefined; +jest.mock('../CollectibleMedia', () => ({ + __esModule: true, + default: ({ onLoad }: { onLoad?: () => void }) => { + mockOnLoad = onLoad; + const { View } = jest.requireActual('react-native'); + return ; + }, +})); + +jest.mock('../../../component-library/components-temp/Skeleton', () => ({ + Skeleton: () => { + const { View } = jest.requireActual('react-native'); + return ; + }, +})); + +describe('NftGridItem', () => { + const mockNft: Nft = { + address: '0x123', + tokenId: '456', + name: 'Test NFT', + image: 'https://example.com/nft.png', + collection: { name: 'Test Collection' }, + chainId: 1, + isCurrentlyOwned: true, + standard: 'ERC721', + } as Nft; + + const mockOnLongPress = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockOnLoad = undefined; + mockDisplayNftMedia = true; + }); + + it('shows skeleton while image is loading when NFT has an image', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('nft-skeleton')).toBeOnTheScreen(); + }); + + it('hides skeleton after image loads', async () => { + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId('nft-skeleton')).toBeOnTheScreen(); + + await act(async () => { + mockOnLoad?.(); + }); + + await waitFor(() => { + expect(queryByTestId('nft-skeleton')).toBeNull(); + }); + }); + + it('does not show skeleton when NFT has no image', () => { + const nftWithoutImage: Nft = { + ...mockNft, + image: null, + imageOriginal: undefined, + }; + + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('nft-skeleton')).toBeNull(); + }); + + it('does not show skeleton when NFT media display is disabled, even if NFT has an image', () => { + mockDisplayNftMedia = false; + + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('nft-skeleton')).toBeNull(); + }); + + it('resets skeleton loading state when NFT item changes to one with an image', async () => { + const nftWithoutImage: Nft = { + ...mockNft, + image: null, + imageOriginal: undefined, + }; + + const { queryByTestId, rerender } = render( + , + ); + + expect(queryByTestId('nft-skeleton')).toBeNull(); + + await act(async () => { + rerender( + , + ); + }); + + await waitFor(() => { + expect(queryByTestId('nft-skeleton')).not.toBeNull(); + }); + }); +}); diff --git a/app/components/UI/NftGrid/NftGridItem.tsx b/app/components/UI/NftGrid/NftGridItem.tsx index 1e3a8605b05..02e4834e94d 100644 --- a/app/components/UI/NftGrid/NftGridItem.tsx +++ b/app/components/UI/NftGrid/NftGridItem.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Nft } from '@metamask/assets-controllers'; import { debounce } from 'lodash'; import { useNavigation } from '@react-navigation/native'; @@ -10,7 +10,10 @@ import { FontWeight, } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { useSelector } from 'react-redux'; +import { selectDisplayNftMedia } from '../../../selectors/preferencesController'; import CollectibleMedia from '../CollectibleMedia'; +import { Skeleton } from '../../../component-library/components-temp/Skeleton'; const debouncedNavigation = debounce((navigation, collectible, source) => { navigation.navigate('NftDetails', { collectible, source }); @@ -27,11 +30,27 @@ const NftGridItem = ({ }) => { const navigation = useNavigation(); const tw = useTailwind(); + const displayNftMedia = useSelector(selectDisplayNftMedia); + const [isImageLoading, setIsImageLoading] = useState( + () => displayNftMedia && !!(item.image || item.imageOriginal), + ); + + useEffect(() => { + setIsImageLoading(displayNftMedia && !!(item.image || item.imageOriginal)); + }, [ + item.address, + item.tokenId, + item.image, + item.imageOriginal, + displayNftMedia, + ]); const onPress = useCallback(() => { debouncedNavigation(navigation, item, source); }, [navigation, item, source]); + const handleImageLoad = useCallback(() => setIsImageLoading(false), []); + return ( + {isImageLoading && ( + + )} Date: Mon, 16 Mar 2026 13:44:32 +0000 Subject: [PATCH 025/206] test: CV - add `nock` and cleanup Trending CV services mocks (#27412) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** We have a few areas in the app that perform fetch requests outside of the engine, by using `core` services. Some examples: - Perps - Predict - Explore This PR adds the `nock` mock API requests library that we use for unit tests in `MetaMask/core` and `MetaMask/metamask-extension`. - It allows us to strictly mock API calls and not the whole core service, for more realistic integration tests. ## **Changelog** CHANGELOG entry: test: CV - add `nock` and cleanup Trending CV services mocks ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/26270 https://consensyssoftware.atlassian.net/browse/ASSETS-2734 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Test-only changes that swap service-function mocking for HTTP interception; risk is limited to potential flakiness/over-blocking of network calls in view tests if nock isn’t cleaned up correctly. > > **Overview** > Switches Trending component-view tests from mocking `@metamask/assets-controllers` (`getTrendingTokens`) to mocking the actual trending HTTP endpoint via **`nock`**, making the tests exercise the real fetch path. > > Updates `tests/component-view/mocks/trendingApiMocks.ts` to define nock-based helpers (including optional per-request reply logic for chain filtering), adds `nock` to `devDependencies`, and simplifies the Trending view tests accordingly (removing `itForPlatforms`/controller mocks and using request-URL-based responses for the BNB filter case). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit fc3106deb3045bf5ab5b1eaffa58e2233ca52ac5. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../TrendingView/TrendingView.view.test.tsx | 244 ++++++++---------- package.json | 1 + .../component-view/mocks/trendingApiMocks.ts | 104 +++----- yarn.lock | 78 ++++++ 4 files changed, 227 insertions(+), 200 deletions(-) diff --git a/app/components/Views/TrendingView/TrendingView.view.test.tsx b/app/components/Views/TrendingView/TrendingView.view.test.tsx index f3f0041fbe3..e48de30d48f 100644 --- a/app/components/Views/TrendingView/TrendingView.view.test.tsx +++ b/app/components/Views/TrendingView/TrendingView.view.test.tsx @@ -1,8 +1,5 @@ import '../../../../tests/component-view/mocks'; -import { - describeForPlatforms, - itForPlatforms, -} from '../../../util/test/platform'; +import { describeForPlatforms } from '../../../util/test/platform'; import { renderTrendingViewWithRoutes } from '../../../../tests/component-view/renderers/trending'; import { TrendingViewSelectorsIDs } from './TrendingView.testIds'; import { @@ -10,7 +7,6 @@ import { clearTrendingApiMocks, mockTrendingTokensData, mockBnbChainToken, - getTrendingTokensMock, } from '../../../../tests/component-view/mocks/trendingApiMocks'; import { fireEvent, @@ -22,19 +18,6 @@ import { } from '@testing-library/react-native'; import { ReactTestInstance } from 'react-test-renderer'; -// TODO: Anti-pattern — only Engine and native modules should be mocked here. -// getTrendingTokens is a standalone service function called directly from -// components, not through a controller on Engine. -// https://github.com/MetaMask/metamask-mobile/issues/26270 -// eslint-disable-next-line no-restricted-syntax -jest.mock('@metamask/assets-controllers', () => { - const actual = jest.requireActual('@metamask/assets-controllers'); - return { - ...actual, - getTrendingTokens: jest.fn().mockResolvedValue([]), - }; -}); - const TRENDING_ETHEREUM_ID = 'trending-token-row-item-eip155:1/erc20:0x0000000000000000000000000000000000000000'; const TRENDING_BITCOIN_ID = @@ -96,7 +79,7 @@ describeForPlatforms('ExploreFeed - Component Tests', () => { clearTrendingApiMocks(); }); - itForPlatforms('renders Explore screen wrapped in SafeAreaView', async () => { + it('renders Explore screen wrapped in SafeAreaView', async () => { const { getByTestId } = renderTrendingViewWithRoutes(); await waitFor(() => { @@ -106,7 +89,7 @@ describeForPlatforms('ExploreFeed - Component Tests', () => { }); }); - itForPlatforms('renders HeaderRoot on Explore screen', async () => { + it('renders HeaderRoot on Explore screen', async () => { const { getByTestId } = renderTrendingViewWithRoutes(); await waitFor(() => { @@ -116,7 +99,7 @@ describeForPlatforms('ExploreFeed - Component Tests', () => { }); }); - itForPlatforms('renders Explore title on Explore screen', async () => { + it('renders Explore title on Explore screen', async () => { const { getByText } = renderTrendingViewWithRoutes(); await waitFor(() => { @@ -124,39 +107,36 @@ describeForPlatforms('ExploreFeed - Component Tests', () => { }); }); - itForPlatforms( - 'user sees trending tokens section with mocked data', - async () => { - const { findByText, queryByTestId } = renderTrendingViewWithRoutes(); + it('user sees trending tokens section with mocked data', async () => { + const { findByText, queryByTestId } = renderTrendingViewWithRoutes(); - await waitFor(async () => { - expect(await findByText('Ethereum')).toBeOnTheScreen(); - }); + await waitFor(async () => { + expect(await findByText('Ethereum')).toBeOnTheScreen(); + }); - await assertTrendingTokenRowsVisibility({ - queryByTestId, - visible: [ - { - id: TRENDING_ETHEREUM_ID, - name: 'Ethereum', - pricePercentageChange: '+5.20%', - }, - { - id: TRENDING_BITCOIN_ID, - name: 'Bitcoin', - pricePercentageChange: '-2.50%', - }, - { - id: TRENDING_UNISWAP_ID, - name: 'Uniswap', - pricePercentageChange: '+12.80%', - }, - ], - }); - }, - ); + await assertTrendingTokenRowsVisibility({ + queryByTestId, + visible: [ + { + id: TRENDING_ETHEREUM_ID, + name: 'Ethereum', + pricePercentageChange: '+5.20%', + }, + { + id: TRENDING_BITCOIN_ID, + name: 'Bitcoin', + pricePercentageChange: '-2.50%', + }, + { + id: TRENDING_UNISWAP_ID, + name: 'Uniswap', + pricePercentageChange: '+12.80%', + }, + ], + }); + }); - itForPlatforms('user navigates to trending tokens full view', async () => { + it('user navigates to trending tokens full view', async () => { const { getByTestId, queryByTestId } = renderTrendingViewWithRoutes(); await waitFor(() => { @@ -195,41 +175,38 @@ describeForPlatforms('ExploreFeed - Component Tests', () => { }); }); - itForPlatforms( - 'user can search for a trending token from the explore feed', - async () => { - const { findByTestId, getByTestId } = renderTrendingViewWithRoutes(); + it('user can search for a trending token from the explore feed', async () => { + const { findByTestId, getByTestId } = renderTrendingViewWithRoutes(); - await waitFor(() => { - expect( - getByTestId(TrendingViewSelectorsIDs.TRENDING_FEED_SCROLL_VIEW), - ).toBeOnTheScreen(); - }); + await waitFor(() => { + expect( + getByTestId(TrendingViewSelectorsIDs.TRENDING_FEED_SCROLL_VIEW), + ).toBeOnTheScreen(); + }); - const searchButton = getByTestId('explore-view-search-button'); - await actButtonPress(searchButton); + const searchButton = getByTestId('explore-view-search-button'); + await actButtonPress(searchButton); - const searchInput = await findByTestId('explore-view-search-input'); - expect(searchInput).toBeOnTheScreen(); + const searchInput = await findByTestId('explore-view-search-input'); + expect(searchInput).toBeOnTheScreen(); - await userEvent.type(searchInput, 'ethereum'); + await userEvent.type(searchInput, 'ethereum'); - const searchResultsList = await findByTestId( - 'trending-search-results-list', - ); + const searchResultsList = await findByTestId( + 'trending-search-results-list', + ); - await assertTrendingTokenRowsVisibility({ - queryByTestId: within(searchResultsList).queryByTestId, - visible: [ - { - id: TRENDING_ETHEREUM_ID, - name: 'Ethereum', - pricePercentageChange: '+5.20%', - }, - ], - }); - }, - ); + await assertTrendingTokenRowsVisibility({ + queryByTestId: within(searchResultsList).queryByTestId, + visible: [ + { + id: TRENDING_ETHEREUM_ID, + name: 'Ethereum', + pricePercentageChange: '+5.20%', + }, + ], + }); + }); }); describeForPlatforms('TrendingTokensFullView - Component Tests', () => { @@ -241,74 +218,67 @@ describeForPlatforms('TrendingTokensFullView - Component Tests', () => { clearTrendingApiMocks(); }); - itForPlatforms( - 'displays only BNB tokens when BNB Chain network filter is selected', - async () => { - getTrendingTokensMock.mockImplementation( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async (params: any) => { - if ( - params?.chainIds && - params.chainIds.length === 1 && - params.chainIds[0] === 'eip155:56' - ) { - return mockBnbChainToken; - } - return mockTrendingTokensData; - }, - ); + it('displays only BNB tokens when BNB Chain network filter is selected', async () => { + setupTrendingApiFetchMock(mockTrendingTokensData, (uri) => { + const url = new URL(uri, 'https://token.api.cx.metamask.io'); + const chainIdsParam = url.searchParams.get('chainIds') ?? ''; + const chainIds = chainIdsParam.split(',').map((s) => s.trim()); + if (chainIds.length === 1 && chainIds[0] === 'eip155:56') { + return mockBnbChainToken; + } + return mockTrendingTokensData; + }); - const { findByText, getByTestId, queryByTestId } = - renderTrendingViewWithRoutes(); + const { findByText, getByTestId, queryByTestId } = + renderTrendingViewWithRoutes(); - await waitFor(() => { - expect( - getByTestId(TrendingViewSelectorsIDs.TRENDING_FEED_SCROLL_VIEW), - ).toBeOnTheScreen(); - }); + await waitFor(() => { + expect( + getByTestId(TrendingViewSelectorsIDs.TRENDING_FEED_SCROLL_VIEW), + ).toBeOnTheScreen(); + }); - const viewAllButton = getByTestId('section-header-view-all-tokens'); - await actButtonPress(viewAllButton); + const viewAllButton = getByTestId('section-header-view-all-tokens'); + await actButtonPress(viewAllButton); - await waitFor(() => { - expect(getByTestId('trending-tokens-header')).toBeOnTheScreen(); - }); + await waitFor(() => { + expect(getByTestId('trending-tokens-header')).toBeOnTheScreen(); + }); - await assertTrendingTokenRowsVisibility({ - queryByTestId, - visible: [ - { id: TRENDING_ETHEREUM_ID }, - { id: TRENDING_BITCOIN_ID }, - { id: TRENDING_UNISWAP_ID }, - ], - missing: [{ id: TRENDING_BNB_ID }], - }); + await assertTrendingTokenRowsVisibility({ + queryByTestId, + visible: [ + { id: TRENDING_ETHEREUM_ID }, + { id: TRENDING_BITCOIN_ID }, + { id: TRENDING_UNISWAP_ID }, + ], + missing: [{ id: TRENDING_BNB_ID }], + }); - const networkButton = getByTestId('all-networks-button'); - await actButtonPress(networkButton); + const networkButton = getByTestId('all-networks-button'); + await actButtonPress(networkButton); - await waitFor(() => { - expect(getByTestId('close-button')).toBeOnTheScreen(); - }); + await waitFor(() => { + expect(getByTestId('close-button')).toBeOnTheScreen(); + }); - const bnbNetworkOption = await findByText('BNB Chain'); - expect(bnbNetworkOption).toBeOnTheScreen(); + const bnbNetworkOption = await findByText('BNB Chain'); + expect(bnbNetworkOption).toBeOnTheScreen(); - await actButtonPress(bnbNetworkOption); + await actButtonPress(bnbNetworkOption); - await assertTrendingTokenRowsVisibility({ - queryByTestId, - visible: [{ id: TRENDING_BNB_ID }], - missing: [ - { id: TRENDING_ETHEREUM_ID }, - { id: TRENDING_BITCOIN_ID }, - { id: TRENDING_UNISWAP_ID }, - ], - }); - }, - ); + await assertTrendingTokenRowsVisibility({ + queryByTestId, + visible: [{ id: TRENDING_BNB_ID }], + missing: [ + { id: TRENDING_ETHEREUM_ID }, + { id: TRENDING_BITCOIN_ID }, + { id: TRENDING_UNISWAP_ID }, + ], + }); + }); - itForPlatforms('user can search on trending tokens full view', async () => { + it('user can search on trending tokens full view', async () => { const { findByTestId, getByTestId, queryByTestId } = renderTrendingViewWithRoutes(); diff --git a/package.json b/package.json index 90899b6424b..2092ec7fdb3 100644 --- a/package.json +++ b/package.json @@ -630,6 +630,7 @@ "listr2": "^8.0.2", "metro-react-native-babel-preset": "~0.76.9", "metro-react-native-babel-transformer": "~0.76.9", + "nock": "^14.0.11", "nyc": "^15.1.0", "openai": "^6.25.0", "patch-package": "^6.2.2", diff --git a/tests/component-view/mocks/trendingApiMocks.ts b/tests/component-view/mocks/trendingApiMocks.ts index 20d3e84a8db..1f95da8c50e 100644 --- a/tests/component-view/mocks/trendingApiMocks.ts +++ b/tests/component-view/mocks/trendingApiMocks.ts @@ -1,25 +1,11 @@ -/** - * Mock API responses for trending tokens integration tests - */ - -import { getTrendingTokens } from '@metamask/assets-controllers'; - -export const getTrendingTokensMock = getTrendingTokens as jest.Mock; - +// eslint-disable-next-line import/no-extraneous-dependencies +import nock from 'nock'; export interface MockTrendingToken { assetId: string; name: string; symbol: string; - address?: string; - chainId: string; - price?: string; - priceChange24h?: number; - volume24h?: number; - liquidity?: number; - imageUrl?: string; decimals?: number; - aggregatedUsdVolume?: number; - marketCap?: number; + price?: string; priceChangePct?: { m5?: string; m15?: string; @@ -28,6 +14,8 @@ export interface MockTrendingToken { h6?: string; h24?: string; }; + aggregatedUsdVolume?: number; + marketCap?: number; } export const mockTrendingTokensData: MockTrendingToken[] = [ @@ -35,55 +23,37 @@ export const mockTrendingTokensData: MockTrendingToken[] = [ assetId: 'eip155:1/erc20:0x0000000000000000000000000000000000000000', name: 'Ethereum', symbol: 'ETH', - address: '0x0000000000000000000000000000000000000000', - chainId: 'eip155:1', - price: '2000.00', - priceChange24h: 5.2, - volume24h: 15000000000, - liquidity: 5000000000, - imageUrl: 'https://example.com/eth.png', decimals: 18, - aggregatedUsdVolume: 15000000000, - marketCap: 500000000000, + price: '2000.00', priceChangePct: { h24: '5.2', }, + aggregatedUsdVolume: 15000000000, + marketCap: 500000000000, }, { assetId: 'eip155:1/erc20:0x1234567890123456789012345678901234567890', name: 'Bitcoin', symbol: 'BTC', - address: '0x1234567890123456789012345678901234567890', - chainId: 'eip155:1', - price: '45000.00', - priceChange24h: -2.5, - volume24h: 25000000000, - liquidity: 8000000000, - imageUrl: 'https://example.com/btc.png', decimals: 8, - aggregatedUsdVolume: 25000000000, - marketCap: 800000000000, + price: '45000.00', priceChangePct: { h24: '-2.5', }, + aggregatedUsdVolume: 25000000000, + marketCap: 800000000000, }, { assetId: 'eip155:1/erc20:0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', name: 'Uniswap', symbol: 'UNI', - address: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', - chainId: 'eip155:1', - price: '8.50', - priceChange24h: 12.8, - volume24h: 500000000, - liquidity: 300000000, - imageUrl: 'https://example.com/uni.png', decimals: 18, - aggregatedUsdVolume: 500000000, - marketCap: 5000000000, + price: '8.50', priceChangePct: { h24: '12.8', }, + aggregatedUsdVolume: 500000000, + marketCap: 5000000000, }, ]; @@ -92,16 +62,8 @@ export const mockBnbChainToken: MockTrendingToken[] = [ assetId: 'eip155:56/erc20:0xBTC0000000000000000000000000000000000000', name: 'Bitcoin BNB', symbol: 'BTCB', - address: '0xBTC0000000000000000000000000000000000000', - chainId: 'eip155:56', - price: '44500.00', - priceChange24h: -1.8, - volume24h: 18000000000, - liquidity: 7000000000, - imageUrl: 'https://example.com/btcb.png', decimals: 18, - aggregatedUsdVolume: 18000000000, - marketCap: 750000000000, + price: '44500.00', priceChangePct: { h24: '-1.8', }, @@ -109,19 +71,35 @@ export const mockBnbChainToken: MockTrendingToken[] = [ ]; /** - * Setup mock for getTrendingTokens from @metamask/assets-controllers - * Call this in beforeEach of your tests + * Setup mock for the trending tokens API using nock. + * Intercepts GET requests to the trending URL (any query params) and replies with the given data. + * Optional customReply(uri) can return different data based on the request URL (e.g. for BNB-only requests). + * Call in beforeEach: setupTrendingApiFetchMock(...) or await setupTrendingApiFetchMock(...) */ -export const setupTrendingApiFetchMock = ( +export function setupTrendingApiFetchMock( responseData: MockTrendingToken[] = mockTrendingTokensData, -) => { - (getTrendingTokens as jest.Mock).mockImplementation(async () => responseData); -}; + customReply?: (uri: string) => MockTrendingToken[], +): void { + nock.cleanAll(); + nock.disableNetConnect(); + + const replyBody = + customReply !== undefined + ? (uri: string) => customReply(uri) + : () => responseData; + + nock('https://token.api.cx.metamask.io') + .get('/v3/tokens/trending') + .query(true) + .reply(200, (uri) => replyBody(uri)) + .persist(); +} /** - * Clear all mocks - * Call this in afterEach of your tests + * Clear nock interceptors and Jest mocks. + * Call this in afterEach of your tests. */ -export const clearTrendingApiMocks = () => { +export function clearTrendingApiMocks(): void { jest.clearAllMocks(); -}; + nock.cleanAll(); +} diff --git a/yarn.lock b/yarn.lock index ff910d2345c..e175acb921c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10311,6 +10311,20 @@ __metadata: languageName: node linkType: hard +"@mswjs/interceptors@npm:^0.41.0": + version: 0.41.3 + resolution: "@mswjs/interceptors@npm:0.41.3" + dependencies: + "@open-draft/deferred-promise": "npm:^2.2.0" + "@open-draft/logger": "npm:^0.3.0" + "@open-draft/until": "npm:^2.0.0" + is-node-process: "npm:^1.2.0" + outvariant: "npm:^1.4.3" + strict-event-emitter: "npm:^0.5.1" + checksum: 10/96b6c535fd27c8aed57f2dad380ea31e09026bf6ef960420bc0e30a2ccff269c8121f21f20423f4edd2ef1ed7db6173295950a3c4529693e6bca12eca1be4347 + languageName: node + linkType: hard + "@myx-trade/sdk@npm:^0.1.265": version: 0.1.265 resolution: "@myx-trade/sdk@npm:0.1.265" @@ -11032,6 +11046,30 @@ __metadata: languageName: node linkType: hard +"@open-draft/deferred-promise@npm:^2.2.0": + version: 2.2.0 + resolution: "@open-draft/deferred-promise@npm:2.2.0" + checksum: 10/bc3bb1668a555bb87b33383cafcf207d9561e17d2ca0d9e61b7ce88e82b66e36a333d3676c1d39eb5848022c03c8145331fcdc828ba297f88cb1de9c5cef6c19 + languageName: node + linkType: hard + +"@open-draft/logger@npm:^0.3.0": + version: 0.3.0 + resolution: "@open-draft/logger@npm:0.3.0" + dependencies: + is-node-process: "npm:^1.2.0" + outvariant: "npm:^1.4.0" + checksum: 10/7a280f170bcd4e91d3eedbefe628efd10c3bd06dd2461d06a7fdbced89ef457a38785847f88cc630fb4fd7dfa176d6f77aed17e5a9b08000baff647433b5ff78 + languageName: node + linkType: hard + +"@open-draft/until@npm:^2.0.0": + version: 2.1.0 + resolution: "@open-draft/until@npm:2.1.0" + checksum: 10/622be42950afc8e89715d0fd6d56cbdcd13e36625e23b174bd3d9f06f80e25f9adf75d6698af93bca1e1bf465b9ce00ec05214a12189b671fb9da0f58215b6f4 + languageName: node + linkType: hard + "@open-rpc/examples@npm:^1.6.1": version: 1.7.0 resolution: "@open-rpc/examples@npm:1.7.0" @@ -32432,6 +32470,13 @@ __metadata: languageName: node linkType: hard +"is-node-process@npm:^1.2.0": + version: 1.2.0 + resolution: "is-node-process@npm:1.2.0" + checksum: 10/930765cdc6d81ab8f1bbecbea4a8d35c7c6d88a3ff61f3630e0fc7f22d624d7661c1df05c58547d0eb6a639dfa9304682c8e342c4113a6ed51472b704cee2928 + languageName: node + linkType: hard + "is-number-like@npm:^1.0.3": version: 1.0.8 resolution: "is-number-like@npm:1.0.8" @@ -35728,6 +35773,7 @@ __metadata: metro-react-native-babel-transformer: "npm:~0.76.9" mockttp: "npm:^3.15.2" multihashes: "npm:0.4.14" + nock: "npm:^14.0.11" number-to-bn: "npm:1.7.0" nyc: "npm:^15.1.0" openai: "npm:^6.25.0" @@ -37083,6 +37129,17 @@ __metadata: languageName: node linkType: hard +"nock@npm:^14.0.11": + version: 14.0.11 + resolution: "nock@npm:14.0.11" + dependencies: + "@mswjs/interceptors": "npm:^0.41.0" + json-stringify-safe: "npm:^5.0.1" + propagate: "npm:^2.0.0" + checksum: 10/2fc579f3bee5148ebfacdfc7588a1f45205b139dcc6e32a5bef436f74f479383c7445a9812f433908600f62a0e142ff4bbbe7da7d5e9ed1781bad278b792cf98 + languageName: node + linkType: hard + "node-abi@npm:^3.3.0": version: 3.22.0 resolution: "node-abi@npm:3.22.0" @@ -38122,6 +38179,13 @@ __metadata: languageName: node linkType: hard +"outvariant@npm:^1.4.0, outvariant@npm:^1.4.3": + version: 1.4.3 + resolution: "outvariant@npm:1.4.3" + checksum: 10/3a7582745850cb344d49641867a4c080858c54f4091afd91b9c0765ba6e471c2bc841348f0fff344845ddd0a4db42fd5d68c6f7ebaf32d4b676a3a9987b2488a + languageName: node + linkType: hard + "own-keys@npm:^1.0.1": version: 1.0.1 resolution: "own-keys@npm:1.0.1" @@ -39483,6 +39547,13 @@ __metadata: languageName: node linkType: hard +"propagate@npm:^2.0.0": + version: 2.0.1 + resolution: "propagate@npm:2.0.1" + checksum: 10/8c761c16e8232f82f6d015d3e01e8bd4109f47ad804f904d950f6fe319813b448ca112246b6bfdc182b400424b155b0b7c4525a9bb009e6fa950200157569c14 + languageName: node + linkType: hard + "proper-lockfile@npm:^3.0.2": version: 3.2.0 resolution: "proper-lockfile@npm:3.2.0" @@ -43976,6 +44047,13 @@ __metadata: languageName: node linkType: hard +"strict-event-emitter@npm:^0.5.1": + version: 0.5.1 + resolution: "strict-event-emitter@npm:0.5.1" + checksum: 10/25c84d88be85940d3547db665b871bfecea4ea0bedfeb22aae8db48126820cfb2b0bc2fba695392592a09b1aa36b686d6eede499e1ecd151593c03fe5a50d512 + languageName: node + linkType: hard + "strict-uri-encode@npm:^2.0.0": version: 2.0.0 resolution: "strict-uri-encode@npm:2.0.0" From 2d1ed074c5dfc68f4f4e768e19749887014ccc92 Mon Sep 17 00:00:00 2001 From: Brian August Nguyen Date: Mon, 16 Mar 2026 07:42:25 -0700 Subject: [PATCH 026/206] refactor: Reverted selected state for explore tab icon cp-7.69.1 cp-7.70.0 (#27459) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Updates the Explore (Trending) tab icon in the main tab bar from the filled variant to the outline variant. ## **Changelog** CHANGELOG entry: Updated Explore tab icon in the main tab bar to use the outline style. ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/DSYS-578 ## **Manual testing steps** ```gherkin Feature: Explore tab icon in tab bar Scenario: Explore tab shows outline search icon Given the app is open and the user is on any main tab When the user views the tab bar Then the Explore (Trending) tab shows the outline search icon (not the filled variant) And the icon is visible and correctly aligned with other tab icons ``` ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/e9d96e13-1420-4db6-8353-3de9fd0b06a9 ## **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. ## **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** > Low risk visual-only change that swaps the selected-state icon for the Explore/Trending tab; no navigation, analytics, or data flows are affected. > > **Overview** > Updates the TabBar selected-state icon mapping for the Explore/Trending tab to use the outline `IconName.Search` instead of the filled `IconName.SearchFilled`, reverting its “active” appearance to match the outline style. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 829d01899942273c18ba161518fa4adeabde52ce. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/component-library/components/Navigation/TabBar/TabBar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/component-library/components/Navigation/TabBar/TabBar.tsx b/app/component-library/components/Navigation/TabBar/TabBar.tsx index 07dbb7587e4..be91fde7a8f 100644 --- a/app/component-library/components/Navigation/TabBar/TabBar.tsx +++ b/app/component-library/components/Navigation/TabBar/TabBar.tsx @@ -33,7 +33,7 @@ import { useAccountMenuEnabled } from '../../../../selectors/featureFlagControll const FILLED_ICONS: Partial> = { [TabBarIconKey.Wallet]: IconName.HomeFilled, [TabBarIconKey.Activity]: IconName.ClockFilled, - [TabBarIconKey.Trending]: IconName.SearchFilled, + [TabBarIconKey.Trending]: IconName.Search, [TabBarIconKey.Rewards]: IconName.MetamaskFoxFilled, }; From df1eeeb5986a99ddf09edadda5916d54e451d613 Mon Sep 17 00:00:00 2001 From: Amanda Yeoh <147617420+amandaye0h@users.noreply.github.com> Date: Mon, 16 Mar 2026 22:44:11 +0800 Subject: [PATCH 027/206] chore: Polish MainActionButton to match designs (#27342) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR refines the main action buttons to match the design specs. Update the vertical padding from `16px` > `12px` to improve the spacing. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** Screenshot 2026-03-11 at 7 43 58 PM ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Low risk visual-only spacing change that updates styles and corresponding Jest snapshots without altering behavior or data flow. > > **Overview** > Polishes `MainActionButton` spacing by reducing `paddingVertical` from `16` to `12` in `MainActionButton.styles.ts` to better match design specs. > > Updates Jest snapshots for `MainActionButton` and screens that render it (e.g., `AccountsMenu` scan button and `AssetDetailsActions` buttons) to reflect the new padding. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 0950cef5c3fa368da62bdf496dbb4e7eda30970b. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../MainActionButton/MainActionButton.styles.ts | 2 +- .../__snapshots__/MainActionButton.test.tsx.snap | 2 +- .../AccountsMenu/__snapshots__/AccountsMenu.test.tsx.snap | 4 ++-- .../__snapshots__/AssetDetailsActions.test.tsx.snap | 8 ++++---- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/component-library/components-temp/MainActionButton/MainActionButton.styles.ts b/app/component-library/components-temp/MainActionButton/MainActionButton.styles.ts index da96ca51c5c..c3fad497e10 100644 --- a/app/component-library/components-temp/MainActionButton/MainActionButton.styles.ts +++ b/app/component-library/components-temp/MainActionButton/MainActionButton.styles.ts @@ -34,7 +34,7 @@ const styleSheet = (params: { backgroundColor, borderRadius: 12, paddingHorizontal: 4, - paddingVertical: 16, + paddingVertical: 12, justifyContent: 'center', alignItems: 'center', opacity: isDisabled ? 0.5 : 1, diff --git a/app/component-library/components-temp/MainActionButton/__snapshots__/MainActionButton.test.tsx.snap b/app/component-library/components-temp/MainActionButton/__snapshots__/MainActionButton.test.tsx.snap index d67b7c1d51b..33f3679622b 100644 --- a/app/component-library/components-temp/MainActionButton/__snapshots__/MainActionButton.test.tsx.snap +++ b/app/component-library/components-temp/MainActionButton/__snapshots__/MainActionButton.test.tsx.snap @@ -52,7 +52,7 @@ exports[`MainActionButton should render correctly 1`] = ` "justifyContent": "center", "opacity": 1, "paddingHorizontal": 4, - "paddingVertical": 16, + "paddingVertical": 12, }, false, ] diff --git a/app/components/Views/AccountsMenu/__snapshots__/AccountsMenu.test.tsx.snap b/app/components/Views/AccountsMenu/__snapshots__/AccountsMenu.test.tsx.snap index 1b04a99b047..db0ee9055a4 100644 --- a/app/components/Views/AccountsMenu/__snapshots__/AccountsMenu.test.tsx.snap +++ b/app/components/Views/AccountsMenu/__snapshots__/AccountsMenu.test.tsx.snap @@ -228,7 +228,7 @@ exports[`AccountsMenu Snapshots match snapshot when MetaMask Card is hidden 1`] "justifyContent": "center", "opacity": 1, "paddingHorizontal": 4, - "paddingVertical": 16, + "paddingVertical": 12, }, false, ] @@ -1578,7 +1578,7 @@ exports[`AccountsMenu Snapshots match snapshot when MetaMask Card is visible 1`] "justifyContent": "center", "opacity": 1, "paddingHorizontal": 4, - "paddingVertical": 16, + "paddingVertical": 12, }, false, ] diff --git a/app/components/Views/AssetDetails/AssetDetailsActions/__snapshots__/AssetDetailsActions.test.tsx.snap b/app/components/Views/AssetDetails/AssetDetailsActions/__snapshots__/AssetDetailsActions.test.tsx.snap index e94f41fa81a..8bc4691a3c8 100644 --- a/app/components/Views/AssetDetails/AssetDetailsActions/__snapshots__/AssetDetailsActions.test.tsx.snap +++ b/app/components/Views/AssetDetails/AssetDetailsActions/__snapshots__/AssetDetailsActions.test.tsx.snap @@ -70,7 +70,7 @@ exports[`AssetDetailsActions should render correctly 1`] = ` "justifyContent": "center", "opacity": 1, "paddingHorizontal": 4, - "paddingVertical": 16, + "paddingVertical": 12, }, false, ] @@ -183,7 +183,7 @@ exports[`AssetDetailsActions should render correctly 1`] = ` "justifyContent": "center", "opacity": 0.5, "paddingHorizontal": 4, - "paddingVertical": 16, + "paddingVertical": 12, }, false, ] @@ -296,7 +296,7 @@ exports[`AssetDetailsActions should render correctly 1`] = ` "justifyContent": "center", "opacity": 0.5, "paddingHorizontal": 4, - "paddingVertical": 16, + "paddingVertical": 12, }, false, ] @@ -409,7 +409,7 @@ exports[`AssetDetailsActions should render correctly 1`] = ` "justifyContent": "center", "opacity": 1, "paddingHorizontal": 4, - "paddingVertical": 16, + "paddingVertical": 12, }, false, ] From 3c1ee4809d3a6c1a39e091c7240880234902a120 Mon Sep 17 00:00:00 2001 From: Brian August Nguyen Date: Mon, 16 Mar 2026 08:02:35 -0700 Subject: [PATCH 028/206] fix: Updated root pages scrollable behavior with safeareaview cp-7.69.1 cp-7.70.0 (#27446) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Standardizes safe area and header inset behavior across the main tab views (Wallet, Explore, Activity, Rewards). 1. **Reason for the change:** These views used `edges={{ bottom: 'additive' }}` on `SafeAreaView` and `includesTopInset` on headers, which was inconsistent with the desired layout and could cause double insets or incorrect safe area handling. 2. **Improvement:** Switched to `edges={{ top: 'additive' }}` on `SafeAreaView` and removed `includesTopInset` from header components so the top safe area is handled by the screen container and headers align consistently. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/27443 ## **Manual testing steps** ```gherkin Feature: Safe area and header insets on main tabs Scenario: Wallet, Explore, Activity, and Rewards use consistent safe area Given the app is open on a device or simulator with a notch/safe area When the user switches to each main tab (Wallet, Explore, Activity, Rewards) Then the top safe area is applied by the screen (no double inset) And the header (title + accessories) aligns correctly below the safe area And the bottom of each view respects the tab bar / device safe area as before ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/0eda5978-7bd5-4428-86ea-f62637fb06ed ### **After** https://github.com/user-attachments/assets/a8ff8e19-a49e-41f4-9056-7c2271da8c66 ## **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. ## **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** > Layout-only changes to core tab screens (Wallet/Explore/Activity/Rewards) that can affect safe-area padding and scroll behavior across devices, plus removal of Wallet’s bottom fade/scroll tracking logic which could subtly change UX on the homepage. > > **Overview** > Standardizes safe-area handling across the main tab views by switching root `SafeAreaView` usage from `edges={{ bottom: 'additive' }}` to `edges={{ top: 'additive' }}` and removing header `includesTopInset` so the top inset is applied consistently by the screen container. > > In `Wallet`, removes the bottom fade `LinearGradient` overlay and its associated scroll/size tracking state, simplifying scroll handling to just notify homepage section subscribers via `handleHomepageScroll`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit c4b0e412d39fdeac5095c0e006c37a258fa3ced9. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../UI/Rewards/Views/RewardsDashboard.tsx | 3 +- .../__snapshots__/index.test.tsx.snap | 8 +- app/components/Views/ActivityView/index.js | 4 +- .../Views/TrendingView/TrendingView.tsx | 3 +- app/components/Views/Wallet/index.tsx | 74 +------------------ 5 files changed, 8 insertions(+), 84 deletions(-) diff --git a/app/components/UI/Rewards/Views/RewardsDashboard.tsx b/app/components/UI/Rewards/Views/RewardsDashboard.tsx index 0cc333cb8a4..e3763fc333a 100644 --- a/app/components/UI/Rewards/Views/RewardsDashboard.tsx +++ b/app/components/UI/Rewards/Views/RewardsDashboard.tsx @@ -337,13 +337,12 @@ const RewardsDashboard: React.FC = () => { return ( { return ( { ) : ( )} diff --git a/app/components/Views/TrendingView/TrendingView.tsx b/app/components/Views/TrendingView/TrendingView.tsx index 33ff8f25b4b..c8a45588efb 100644 --- a/app/components/Views/TrendingView/TrendingView.tsx +++ b/app/components/Views/TrendingView/TrendingView.tsx @@ -185,13 +185,12 @@ export const ExploreFeed: React.FC = () => { return ( diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx index e3d2d4c2ab4..bcc11dd199d 100644 --- a/app/components/Views/Wallet/index.tsx +++ b/app/components/Views/Wallet/index.tsx @@ -24,7 +24,6 @@ import { View, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import LinearGradient from 'react-native-linear-gradient'; import { connect, useDispatch, useSelector } from 'react-redux'; import { strings } from '../../../../locales/i18n'; import { @@ -149,7 +148,6 @@ import { useMultichainAccountsIntroModal } from '../../hooks/useMultichainAccoun import { useAccountsWithNetworkActivitySync } from '../../hooks/useAccountsWithNetworkActivitySync'; import { selectUseTokenDetection } from '../../../selectors/preferencesController'; import Logger from '../../../util/Logger'; -import { colorWithOpacity } from '../../../util/colors'; import { useNftDetection } from '../../hooks/useNftDetection'; import { Carousel } from '../../UI/Carousel'; import { TokenI } from '../../UI/Tokens/types'; @@ -228,13 +226,6 @@ const createStyles = ({ colors }: Theme) => carousel: { overflow: 'hidden', // Allow for smooth height animations }, - bottomFadeOverlay: { - position: 'absolute', - left: 0, - right: 0, - bottom: 0, - height: 40, - }, headerEndAccessoryContainer: { alignItems: 'flex-end', }, @@ -642,10 +633,6 @@ const Wallet = ({ const scrollSubscribersRef = useRef void>>(new Set()); // Tracks which sections have been viewed this visit (reset on each focus). const viewedSectionsRef = useRef>(new Set()); - const scrollContentHeightRef = useRef(0); - const scrollYRef = useRef(0); - const lastBottomFadeOpacityRef = useRef(0); - const [bottomFadeOpacity, setBottomFadeOpacity] = useState(0); // ───────────────────────────────────────────────────────────────────────── const isPerpsFlagEnabled = useSelector(selectPerpsEnabledFlag); @@ -1083,44 +1070,6 @@ const Wallet = ({ } }, [isHomepageSectionsV1Enabled]); - const handleScrollWithFade = useCallback( - (e: { - nativeEvent: { - contentOffset: { y: number }; - contentSize: { height: number }; - layoutMeasurement: { height: number }; - }; - }) => { - const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent; - scrollContentHeightRef.current = contentSize.height; - scrollYRef.current = contentOffset.y; - handleHomepageScroll(); - if (!isHomepageSectionsV1Enabled) return; - const remaining = - contentSize.height - contentOffset.y - layoutMeasurement.height; - const nextOpacity = remaining > 20 ? Math.min(1, remaining / 80) : 0; - if (Math.abs(nextOpacity - lastBottomFadeOpacityRef.current) > 0.05) { - lastBottomFadeOpacityRef.current = nextOpacity; - setBottomFadeOpacity(nextOpacity); - } - }, - [handleHomepageScroll, isHomepageSectionsV1Enabled], - ); - - const handleScrollContentSizeChange = useCallback( - (_w: number, contentHeight: number) => { - scrollContentHeightRef.current = contentHeight; - if (!isHomepageSectionsV1Enabled || viewportHeight <= 0) return; - const remaining = contentHeight - scrollYRef.current - viewportHeight; - const nextOpacity = remaining > 20 ? Math.min(1, remaining / 80) : 0; - if (Math.abs(nextOpacity - lastBottomFadeOpacityRef.current) > 0.05) { - lastBottomFadeOpacityRef.current = nextOpacity; - setBottomFadeOpacity(nextOpacity); - } - }, - [isHomepageSectionsV1Enabled, viewportHeight], - ); - const touchAreaSlop = useMemo( () => ({ top: 12, bottom: 12, left: 12, right: 12 }), [], @@ -1539,13 +1488,12 @@ const Wallet = ({ baseStyles.flexGrow, { backgroundColor: colors.background.default }, ]} - edges={{ bottom: 'additive' }} + edges={{ top: 'additive' }} testID={WalletViewSelectorsIDs.WALLET_SAFE_AREA} > {selectedInternalAccount ? ( <> @@ -1684,10 +1632,7 @@ const Wallet = ({ contentContainerStyle: scrollViewContentStyle, showsVerticalScrollIndicator: false, onScroll: isHomepageSectionsV1Enabled - ? handleScrollWithFade - : undefined, - onContentSizeChange: isHomepageSectionsV1Enabled - ? handleScrollContentSizeChange + ? handleHomepageScroll : undefined, scrollEventThrottle: 16, refreshControl: shouldEnableParentScroll ? ( @@ -1702,21 +1647,6 @@ const Wallet = ({ > {content} - {isHomepageSectionsV1Enabled && bottomFadeOpacity > 0 && ( - - )} ) : ( From e04b592fe449612485691ac8ade81375f346f21c Mon Sep 17 00:00:00 2001 From: Remi ARQUEVAUX Date: Mon, 16 Mar 2026 09:34:36 -0700 Subject: [PATCH 029/206] feat(card): add MMM_CARD origin for Card delegation transactions (#27437) ## **Description** Card delegation transactions currently use the generic `TransactionTypes.MMM` (`'MetaMask Mobile'`) origin, making them indistinguishable from other internal transactions in analytics and debugging. This PR introduces a dedicated `MMM_CARD` origin (`'MetaMask Mobile Card'`) and threads it through all internal-origin checks so Card delegation transactions are identifiable while preserving identical runtime behavior: - Added `MMM_CARD: 'MetaMask Mobile Card'` to `TransactionTypes` - Added `MMM_CARD` to both `INTERNAL_ORIGINS` arrays (`constants/transaction.ts` and `Permissions/index.ts`) - Extended the `onUnapprovedTransaction` guard to also early-return for `MMM_CARD` - Updated `useCardDelegation` to emit `MMM_CARD` instead of `MMM` ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ## **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. ## **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** > Low risk: this is a new origin constant threaded through internal-origin allowlists and a guard in `onUnapprovedTransaction`, with tests updated; transaction execution logic is unchanged aside from labeling. > > **Overview** > Introduces a dedicated internal transaction origin `TransactionTypes.MMM_CARD` (`"MetaMask Mobile Card"`) so Card delegation approval transactions are distinguishable from generic `MMM` transactions. > > Threads `MMM_CARD` through internal-origin handling by (1) emitting it from `useCardDelegation` when submitting the approval transaction, (2) treating it as an internal origin in `constants/transaction.ts` and `Permissions/index.ts`, and (3) extending the `onUnapprovedTransaction` early-return guard to skip auto-sign processing for this origin, with matching test updates. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 558aba5e2f397caf75da2964fc84c6bd826bcae6. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/components/Nav/Main/onUnapprovedTransaction.test.ts | 9 +++++++++ app/components/Nav/Main/onUnapprovedTransaction.ts | 6 +++++- app/components/UI/Card/hooks/useCardDelegation.test.ts | 2 +- app/components/UI/Card/hooks/useCardDelegation.ts | 2 +- app/constants/transaction.ts | 1 + app/core/Permissions/index.ts | 6 +++++- app/core/TransactionTypes.js | 1 + 7 files changed, 23 insertions(+), 4 deletions(-) diff --git a/app/components/Nav/Main/onUnapprovedTransaction.test.ts b/app/components/Nav/Main/onUnapprovedTransaction.test.ts index 0e7984408eb..5caa7c44ae2 100644 --- a/app/components/Nav/Main/onUnapprovedTransaction.test.ts +++ b/app/components/Nav/Main/onUnapprovedTransaction.test.ts @@ -76,6 +76,15 @@ describe('onUnapprovedTransaction', () => { expect(callbacks.autoSign).not.toHaveBeenCalled(); }); + it('skips processing when origin is MetaMask Mobile Card (MMM_CARD)', () => { + const callbacks = mockCallbacks(); + const txMeta = buildSwapTxMeta({ origin: 'MetaMask Mobile Card' }); + + onUnapprovedTransaction(txMeta, callbacks); + + expect(callbacks.autoSign).not.toHaveBeenCalled(); + }); + it('calls autoSign for hardware wallet swap', () => { isHardwareAccountMock.mockReturnValue(true); const callbacks = mockCallbacks(); diff --git a/app/components/Nav/Main/onUnapprovedTransaction.ts b/app/components/Nav/Main/onUnapprovedTransaction.ts index c7d93d4be1f..a1e4f5558df 100644 --- a/app/components/Nav/Main/onUnapprovedTransaction.ts +++ b/app/components/Nav/Main/onUnapprovedTransaction.ts @@ -19,7 +19,11 @@ export function onUnapprovedTransaction( ) { const transactionMeta = cloneDeep(transactionMetaOriginal); - if (transactionMeta.origin === TransactionTypes.MMM) return; + if ( + transactionMeta.origin === TransactionTypes.MMM || + transactionMeta.origin === TransactionTypes.MMM_CARD + ) + return; const to = transactionMeta.txParams.to?.toLowerCase(); const data = transactionMeta.txParams.data as string; diff --git a/app/components/UI/Card/hooks/useCardDelegation.test.ts b/app/components/UI/Card/hooks/useCardDelegation.test.ts index dbd83c7bdd2..f147b724b66 100644 --- a/app/components/UI/Card/hooks/useCardDelegation.test.ts +++ b/app/components/UI/Card/hooks/useCardDelegation.test.ts @@ -960,7 +960,7 @@ describe('useCardDelegation', () => { }, { networkClientId: mockNetworkClientId, - origin: TransactionTypes.MMM, + origin: TransactionTypes.MMM_CARD, type: TransactionType.tokenMethodApprove, deviceConfirmedOn: WalletDevice.MM_MOBILE, requireApproval: true, diff --git a/app/components/UI/Card/hooks/useCardDelegation.ts b/app/components/UI/Card/hooks/useCardDelegation.ts index d9b49f37055..1deb0d20d7e 100644 --- a/app/components/UI/Card/hooks/useCardDelegation.ts +++ b/app/components/UI/Card/hooks/useCardDelegation.ts @@ -141,7 +141,7 @@ export const useCardDelegation = (token?: CardTokenAllowance | null) => { }, { networkClientId, - origin: TransactionTypes.MMM, + origin: TransactionTypes.MMM_CARD, type: TransactionType.tokenMethodApprove, deviceConfirmedOn: WalletDevice.MM_MOBILE, requireApproval: true, diff --git a/app/constants/transaction.ts b/app/constants/transaction.ts index 0bbeb31c605..b691c760456 100644 --- a/app/constants/transaction.ts +++ b/app/constants/transaction.ts @@ -25,6 +25,7 @@ export const PREFIX_HEX_STRING = '0x'; export const INTERNAL_ORIGINS = [ process.env.MM_FOX_CODE, TransactionTypes.MMM, + TransactionTypes.MMM_CARD, ORIGIN_METAMASK, ]; diff --git a/app/core/Permissions/index.ts b/app/core/Permissions/index.ts index 34ac857783c..3ef4b225818 100644 --- a/app/core/Permissions/index.ts +++ b/app/core/Permissions/index.ts @@ -30,7 +30,11 @@ import { getNetworkConfigurationsByCaipChainId } from '../../selectors/networkCo import { areAddressesEqual } from '../../util/address'; import Logger from '../../util/Logger'; -const INTERNAL_ORIGINS = [process.env.MM_FOX_CODE, TransactionTypes.MMM]; +const INTERNAL_ORIGINS = [ + process.env.MM_FOX_CODE, + TransactionTypes.MMM, + TransactionTypes.MMM_CARD, +]; // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/app/core/TransactionTypes.js b/app/core/TransactionTypes.js index 0aa9bcd753d..4852e8a3abb 100644 --- a/app/core/TransactionTypes.js +++ b/app/core/TransactionTypes.js @@ -12,5 +12,6 @@ export default { ERC1155: 'ERC1155', }, MMM: 'MetaMask Mobile', + MMM_CARD: 'MetaMask Mobile Card', MM: 'metamask', }; From 5beada8d356e26ceb09d7b4913285af7360830c3 Mon Sep 17 00:00:00 2001 From: tommasini <46944231+tommasini@users.noreply.github.com> Date: Mon, 16 Mar 2026 17:20:33 +0000 Subject: [PATCH 030/206] ci: test to try new keystore (#27301) ## **Description** Pointing to the new aws secret that includes the new Android keystore. ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Updates Android signing to new AWS Secrets Manager entries for production builds; a misconfigured secret/keystore could break CI builds or produce improperly signed release artifacts. > > **Overview** > Updates `builds.yml` signing anchors to use new AWS Secrets Manager secret names for production signing: `metamask-mobile-main-prod-signer-v2` and `metamask-mobile-flask-prod-signer-v2`. > > No other build environments or settings are changed. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 3dc31037561babb35f5c8502c85f841fabfc50f0. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: metamaskbot --- builds.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/builds.yml b/builds.yml index 653e89b64b3..afc7068debe 100644 --- a/builds.yml +++ b/builds.yml @@ -98,7 +98,7 @@ _secrets: &secrets # Infrastructure # GH Environments vs AWS: build-production=prod signer; build-rc & build-beta=rc signer; build-uat/build-e2e/build-exp=uat signer; build-flask-prod=flask prod; build-flask-uat/build-flask-e2e=flask uat _signing_prod: &signing_prod aws_role: 'arn:aws:iam::363762752069:role/metamask-mobile-prod-signer' - aws_secret: 'metamask-mobile-main-prod-signer' + aws_secret: 'metamask-mobile-main-prod-signer-v2' android_keystore_path: 'release.keystore' _signing_rc: &signing_rc aws_role: 'arn:aws:iam::363762752069:role/metamask-mobile-rc-signer' @@ -110,7 +110,7 @@ _signing_uat: &signing_uat android_keystore_path: 'internalRelease.keystore' _signing_flask_prod: &signing_flask_prod aws_role: 'arn:aws:iam::363762752069:role/metamask-mobile-prod-signer' - aws_secret: 'metamask-mobile-flask-prod-signer' + aws_secret: 'metamask-mobile-flask-prod-signer-v2' android_keystore_path: 'flaskRelease.keystore' _signing_flask_uat: &signing_flask_uat aws_role: 'arn:aws:iam::363762752069:role/metamask-mobile-uat-signer' From 005074257afbac96aac5e8a1c4ec58ad7fa86885 Mon Sep 17 00:00:00 2001 From: MetaMask Bot <37885440+metamaskbot@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:06:39 -0230 Subject: [PATCH 031/206] chore: New Crowdin translations by Github Action cp-7.70.0 (#27390) Co-authored-by: metamaskbot --- locales/languages/de.json | 54 +++++++++++++++++++++++++++++++-------- locales/languages/el.json | 48 ++++++++++++++++++++++++++++------ locales/languages/es.json | 48 ++++++++++++++++++++++++++++------ locales/languages/fr.json | 48 ++++++++++++++++++++++++++++------ locales/languages/hi.json | 48 ++++++++++++++++++++++++++++------ locales/languages/id.json | 48 ++++++++++++++++++++++++++++------ locales/languages/ja.json | 48 ++++++++++++++++++++++++++++------ locales/languages/ko.json | 48 ++++++++++++++++++++++++++++------ locales/languages/pt.json | 48 ++++++++++++++++++++++++++++------ locales/languages/ru.json | 48 ++++++++++++++++++++++++++++------ locales/languages/tl.json | 48 ++++++++++++++++++++++++++++------ locales/languages/tr.json | 48 ++++++++++++++++++++++++++++------ locales/languages/vi.json | 48 ++++++++++++++++++++++++++++------ locales/languages/zh.json | 48 ++++++++++++++++++++++++++++------ 14 files changed, 563 insertions(+), 115 deletions(-) diff --git a/locales/languages/de.json b/locales/languages/de.json index d3c201a77e7..154b701bb61 100644 --- a/locales/languages/de.json +++ b/locales/languages/de.json @@ -119,6 +119,10 @@ "message": "Sie senden Ihre Vermögenswerte an eine Burn-Adresse. Wenn Sie fortfahren, verlieren Sie Ihre Vermögenswerte.", "title": "Senden von Assets an die Burn-Adresse" }, + "token_contract_warning": { + "title": "Token contract warning", + "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + }, "gas_sponsorship_reserve_balance": { "message": "Gas-Sponsoring ist für diese Transaktion nicht verfügbar. Sie benötigen mindestens %{minBalance} %{nativeTokenSymbol} auf Ihrem Konto.", "title": "Gas-Sponsoring nicht verfügbar" @@ -689,7 +693,11 @@ "invisible_character_error": "Wir haben ein unsichtbares Zeichen im ENS-Namen festgestellt. Überprüfen Sie den ENS-Namen, um möglichen Betrug zu vermeiden.", "could_not_resolve_name": "Name konnte nicht aufgelöst werden", "invalid_address": "Ungültige Adresse", - "contractAddressError": "Sie senden Tokens an die Kontraktadresse des Tokens. Dies kann zum Verlust dieser Tokens führen." + "contractAddressError": "Sie senden Tokens an die Kontraktadresse des Tokens. Dies kann zum Verlust dieser Tokens führen.", + "smart_contract_address": "Smart contract address", + "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "i_understand": "Ich verstehe", + "cancel": "Stornieren" }, "unified_ramp": { "networks_filter_bar": { @@ -2154,6 +2162,7 @@ "sell_position": "Position verkaufen", "cash_out": "Auszahlung", "cash_out_info": "Gelder werden Ihrem verfügbaren Guthaben hinzugefügt", + "buy_preview_outcome_at_price": "{{outcome}} at {{price}}", "at_price_per_share": "Verkauf von {{size}}-Aktien zu {{price}}", "cashout_info": "{{amount}} bei {{outcome}} zu {{initialPrice}}", "cashout_info_multiple": "{{amount}} bei {{outcomeGroupTitle}} • {{outcome}} zu {{initialPrice}}", @@ -2338,7 +2347,9 @@ "exchange_fee_description": "An die Börse oder den Markt entrichtete Gebühr", "total_incl_fees": "inkl. Gebühren", "close": "Schließen", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled." + "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", + "deposit_fee": "Deposit fee", + "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" }, "error": { "title": "Verbindung zu Prognosen nicht möglich", @@ -5921,6 +5932,7 @@ "earn": { "claimable_bonus_tooltip": "Sie haben den jährlichen Bonus für das Halten von mUSD verdient. Ihr Bonus kann täglich auf Linea eingefordert werden.", "earn_a_percentage_bonus": "Verdienen Sie einen Bonus von {{percentage}} %", + "percentage_bonus": "Bonus von {{percentage}} %", "claimable_bonus": "Anspruchsberechtigter Bonus", "claim_bonus": "Bonus einfordern", "claim_bonus_subtitle": "Der Bonus wird auf {{networkName}} ausgezahlt.", @@ -6930,7 +6942,7 @@ "upgrade_title": "Upgrade auf Metall", "continue_button": "Fortfahren", "virtual_card": { - "name": "Orange virtuelle Karte", + "name": "Virtual Card", "price": "Kostenlos", "feature_1": "Virtuelle Karte für Apple Pay und Google Pay", "feature_2": "Bezahlen Sie mit Kryptowährung (USDC, USDT, WETH und mehr)", @@ -6939,10 +6951,13 @@ "metal_card": { "name": "Metallkarte", "price": "199 $/Jahr", - "feature_1": "Gravierte Metallkarte und virtuelle Karte für Apple Pay und Google Pay", - "feature_2": "3 % Cashback auf die ersten 10.000 $, die jedes Jahr ausgegeben werden, danach 1 %.", + "everything_in_virtual": "Everything in virtual, plus:", + "feature_1": "Premium engraved metal card", + "feature_2": "3% cashback on first $10,000/year", "feature_3": "Keine Auslandstransaktionsgebühren" - } + }, + "earn_up_to_badge": "Earn up to $300 in cashback annually", + "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" }, "review_order": { "title": "Prüfen Sie Ihre Bestellung", @@ -6987,10 +7002,10 @@ "not_now_button": "Nicht jetzt", "sign_up": { "title": "Lassen Sie uns beginnen", - "description": "Erstellen Sie ein Konto bei Crypto Life (CL), um Ihre MetaMask-Karte einzurichten. MetaMask sieht und speichert Ihre personenbezogenen Daten nicht.", - "i_already_have_an_account": "Ich habe bereits eine MetaMask-Karte", + "description": "Erstellen Sie ein Konto bei Crypto Life (CL), um Ihre MetaMask Card einzurichten. MetaMask sieht und speichert Ihre personenbezogenen Daten nicht.", + "i_already_have_an_account": "Ich habe bereits eine MetaMask Card", "email_label": "E-Mail-Adresse", - "password_label": "Neues Passwort für die MetaMask-Karte", + "password_label": "Neues Passwort für die MetaMask Card", "password_description": "Unterscheidet sich von Ihrem Passwort zum Entsperren der MetaMask-App. Muss mindestens 15 Zeichen lang sein.", "country_label": "Wohnsitzland", "country_placeholder": "Wählen Sie Ihr Land", @@ -7090,7 +7105,8 @@ "ssn_label": "Sozialversicherungsnummer", "ssn_description": "Vom Kartenaussteller gefordert. Es wird keine Bonitätsprüfung durchgeführt.", "invalid_ssn": "Ungültige SSN", - "invalid_date_of_birth": "Ungültiges Geburtsdatum. Sie müssen mindestens 18 Jahre alt sein." + "invalid_date_of_birth": "Ungültiges Geburtsdatum. Sie müssen mindestens 18 Jahre alt sein.", + "name_mismatch_error": "First and last name must match your verified identity" }, "physical_address": { "title": "Fügen Sie Ihre Adresse hinzu", @@ -7174,6 +7190,7 @@ "enable_assets_button_label": "Assets aktivieren", "spending_limit_warning": "Sie haben Ihr Ausgabenlimit fast erreicht. Aktualisieren Sie Ihr Konto, um Ablehnungen zu vermeiden.", "logout": "Abmelden", + "contact_support": "Support kontaktieren", "logout_confirmation_title": "Von Karte abmelden?", "logout_confirmation_message": "Möchten Sie sich wirklich von Ihrem MetaMask-Karte-Konto abmelden?", "logout_confirmation_cancel": "Stornieren", @@ -7727,6 +7744,18 @@ }, "active_boosts_title": "Aktive Boosts", "season_1": "Saison 1", + "musd": { + "title": "mUSD bonus calculator", + "description": "See how much you could earn by converting your stablecoins to mUSD.", + "amount_label": "Amount converted", + "estimated_bonus": "Estimated annualized bonus: up to 3%", + "initial_amount": "Initial amount", + "daily_bonus": "Daily claimable bonus", + "annualized_bonus": "Annualized bonus", + "disclaimer": "This is only an estimate. The bonus is subject to change.", + "buy_button": "mUSD kaufen", + "swap_button": "Swap to mUSD" + }, "upcoming_rewards": { "title": "Gesperrte Belohnungen", "points_needed": "{{points}} Punkte", @@ -8006,6 +8035,9 @@ }, "homepage": { "sections": { + "cash": "Cash", + "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", + "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", "tokens": "Token", "perpetuals": "Perpetuals", "predictions": "Prognosen", @@ -8023,4 +8055,4 @@ "retry": "Erneut versuchen" } } -} \ No newline at end of file +} diff --git a/locales/languages/el.json b/locales/languages/el.json index e96d7529c90..bec873fa2cb 100644 --- a/locales/languages/el.json +++ b/locales/languages/el.json @@ -119,6 +119,10 @@ "message": "Στέλνετε τα περιουσιακά σας στοιχεία σε διεύθυνση καύσης. Αν συνεχίσετε, θα τα χάσετε.", "title": "Αποστολή περιουσιακών στοιχείων σε διεύθυνση καύσης" }, + "token_contract_warning": { + "title": "Token contract warning", + "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + }, "gas_sponsorship_reserve_balance": { "message": "Η κάλυψη των τελών δεν είναι διαθέσιμη για αυτή τη συναλλαγή. Θα χρειαστεί να διατηρείτε τουλάχιστον %{minBalance} %{nativeTokenSymbol} στον λογαριασμό σας.", "title": "Η κάλυψη των τελών δεν είναι διαθέσιμη" @@ -689,7 +693,11 @@ "invisible_character_error": "Εντοπίσαμε έναν αόρατο χαρακτήρα στο όνομα ENS. Ελέγξτε το όνομα ENS για να αποφύγετε πιθανή απάτη.", "could_not_resolve_name": "Δεν ήταν δυνατή η επίλυση του ονόματος", "invalid_address": "Μη έγκυρη διεύθυνση", - "contractAddressError": "Πρόκειται να στείλετε tokens στη διεύθυνση συμβολαίου του token. Αυτό μπορεί να οδηγήσει σε απώλεια των token." + "contractAddressError": "Πρόκειται να στείλετε tokens στη διεύθυνση συμβολαίου του token. Αυτό μπορεί να οδηγήσει σε απώλεια των token.", + "smart_contract_address": "Smart contract address", + "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "i_understand": "Κατανοώ", + "cancel": "Άκυρο" }, "unified_ramp": { "networks_filter_bar": { @@ -2154,6 +2162,7 @@ "sell_position": "Πώληση θέσης", "cash_out": "Εξαργύρωση", "cash_out_info": "Τα χρήματα θα προστεθούν στο διαθέσιμο υπόλοιπό σας", + "buy_preview_outcome_at_price": "{{outcome}} με {{price}}", "at_price_per_share": "Πώληση {{size}} μετοχών για {{price}}", "cashout_info": "{{amount}} στο {{outcome}} με τιμή {{initialPrice}}", "cashout_info_multiple": "{{amount}} στο {{outcomeGroupTitle}} • {{outcome}} για {{initialPrice}}", @@ -2338,7 +2347,9 @@ "exchange_fee_description": "Τέλη που καταβάλλονται στην πλατφόρμα συναλλαγών ή στην αγορά", "total_incl_fees": "συμπερ. τέλη", "close": "Κλείσιμο", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled." + "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", + "deposit_fee": "Deposit fee", + "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" }, "error": { "title": "Δεν ήταν δυνατή η σύνδεση με την πλατφόρμα προβλέψεων", @@ -5921,6 +5932,7 @@ "earn": { "claimable_bonus_tooltip": "Το ετήσιο μπόνους που έχετε κερδίσει επειδή διατηρήσατε τα mUSD. Το μπόνους σας μπορείτε να το εξαργυρώσετε καθημερινά στο δίκτυο Linea.", "earn_a_percentage_bonus": "Κερδίστε ένα μπόνους {{percentage}}%", + "percentage_bonus": "{{percentage}}% μπόνους", "claimable_bonus": "Μπόνους προς εξαργύρωση", "claim_bonus": "Εξαργύρωση του μπόνους", "claim_bonus_subtitle": "Το μπόνους θα καταβληθεί στο δίκτυο {{networkName}}.", @@ -6930,7 +6942,7 @@ "upgrade_title": "Αναβάθμιση σε Metal", "continue_button": "Συνεχίστε", "virtual_card": { - "name": "Orange Virtual Card", + "name": "Virtual Card", "price": "Δωρεάν", "feature_1": "Εικονική κάρτα για Apple Pay και Google Pay", "feature_2": "Πληρώστε με κρυπτονομίσματα (USDC, USDT, WETH και άλλα)", @@ -6939,10 +6951,13 @@ "metal_card": { "name": "Metal Card", "price": "$199/έτος", - "feature_1": "Μεταλλική κάρτα με χάραξη και εικονική κάρτα για Apple Pay και Google Pay", - "feature_2": "3% επιστροφή στα πρώτα $10.000 που ξοδεύετε κάθε χρόνο και 1% μετά από αυτό", + "everything_in_virtual": "Everything in virtual, plus:", + "feature_1": "Premium engraved metal card", + "feature_2": "3% cashback on first $10,000/year", "feature_3": "Χωρίς χρεώσεις για διεθνείς συναλλαγές" - } + }, + "earn_up_to_badge": "Earn up to $300 in cashback annually", + "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" }, "review_order": { "title": "Έλεγχος εντολής", @@ -7090,7 +7105,8 @@ "ssn_label": "Αριθμός Μητρώου Κοινωνικής Ασφάλισης (ΑΜΚΑ)", "ssn_description": "Απαιτείται από τον εκδότη της κάρτας. Δεν θα πραγματοποιηθεί έλεγχος πιστοληπτικής ικανότητας.", "invalid_ssn": "Μη έγκυρος αριθμός κοινωνικής ασφάλισης (ΑΜΚΑ)", - "invalid_date_of_birth": "Μη έγκυρη ημερομηνία γέννησης. Πρέπει να είστε τουλάχιστον 18 ετών" + "invalid_date_of_birth": "Μη έγκυρη ημερομηνία γέννησης. Πρέπει να είστε τουλάχιστον 18 ετών", + "name_mismatch_error": "First and last name must match your verified identity" }, "physical_address": { "title": "Προσθέστε τη διεύθυνσή σας", @@ -7174,6 +7190,7 @@ "enable_assets_button_label": "Ενεργοποιήστε τα περιουσιακά στοιχεία", "spending_limit_warning": "Έχετε σχεδόν φτάσει το όριο δαπανών σας. Ενημερώστε για να αποφύγετε απορρίψεις.", "logout": "Αποσύνδεση", + "contact_support": "Επικοινωνία με την υποστήριξη", "logout_confirmation_title": "Αποσύνδεση από την κάρτα;", "logout_confirmation_message": "Είστε σίγουροι ότι θέλετε να αποσυνδεθείτε από τον λογαριασμό σας στην MetaMask Card;", "logout_confirmation_cancel": "Άκυρο", @@ -7727,6 +7744,18 @@ }, "active_boosts_title": "Ενεργές ενισχύσεις", "season_1": "Περίοδος 1", + "musd": { + "title": "mUSD bonus calculator", + "description": "See how much you could earn by converting your stablecoins to mUSD.", + "amount_label": "Amount converted", + "estimated_bonus": "Estimated annualized bonus: up to 3%", + "initial_amount": "Initial amount", + "daily_bonus": "Daily claimable bonus", + "annualized_bonus": "Annualized bonus", + "disclaimer": "This is only an estimate. The bonus is subject to change.", + "buy_button": "Αγοράστε mUSD", + "swap_button": "Swap to mUSD" + }, "upcoming_rewards": { "title": "Κλειδωμένες ανταμοιβές", "points_needed": "{{points}} πόντοι", @@ -8006,6 +8035,9 @@ }, "homepage": { "sections": { + "cash": "Cash", + "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", + "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", "tokens": "Token", "perpetuals": "Συμβόλαια αορίστου διάρκειας", "predictions": "Προβλέψεις", @@ -8023,4 +8055,4 @@ "retry": "Επανάληψη" } } -} \ No newline at end of file +} diff --git a/locales/languages/es.json b/locales/languages/es.json index 0f466a52b69..a047058447b 100644 --- a/locales/languages/es.json +++ b/locales/languages/es.json @@ -119,6 +119,10 @@ "message": "Estás enviando tus activos a una dirección de quema. Si continúas, perderás tus activos.", "title": "Envío de activos a la dirección de quema" }, + "token_contract_warning": { + "title": "Token contract warning", + "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + }, "gas_sponsorship_reserve_balance": { "message": "El patrocinio de gas no está disponible para esta transacción. Deberás mantener al menos %{minBalance} %{nativeTokenSymbol} en tu cuenta.", "title": "Patrocinio de gas no disponible" @@ -689,7 +693,11 @@ "invisible_character_error": "Detectamos un carácter invisible en el nombre de ENS. Verifica el nombre de ENS para evitar una posible estafa.", "could_not_resolve_name": "No se pudo resolver el nombre", "invalid_address": "Dirección no válida", - "contractAddressError": "Estás enviando tokens a la dirección del contrato del token. Esto podría resultar en la pérdida de estos tokens." + "contractAddressError": "Estás enviando tokens a la dirección del contrato del token. Esto podría resultar en la pérdida de estos tokens.", + "smart_contract_address": "Smart contract address", + "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "i_understand": "Comprendo", + "cancel": "Cancelar" }, "unified_ramp": { "networks_filter_bar": { @@ -2154,6 +2162,7 @@ "sell_position": "Posición de venta", "cash_out": "Retiro de efectivo", "cash_out_info": "Los fondos se agregarán a tu saldo disponible", + "buy_preview_outcome_at_price": "{{outcome}} a {{price}}", "at_price_per_share": "Vendiendo {{size}} acciones a {{price}}", "cashout_info": "{{amount}} por {{outcome}} a {{initialPrice}}", "cashout_info_multiple": "{{amount}} en {{outcomeGroupTitle}} • {{outcome}} a {{initialPrice}}", @@ -2338,7 +2347,9 @@ "exchange_fee_description": "Tarifa pagada al cambio o al mercado", "total_incl_fees": "tarifas incl.", "close": "Cerrar", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled." + "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", + "deposit_fee": "Deposit fee", + "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" }, "error": { "title": "No se puede conectar a las predicciones", @@ -5921,6 +5932,7 @@ "earn": { "claimable_bonus_tooltip": "El bono anualizado que has ganado por mantener mUSD. Puedes reclamar tu bono diariamente en Linea.", "earn_a_percentage_bonus": "Gana un bono del {{percentage}} %", + "percentage_bonus": "Bonificación del {{percentage}} %", "claimable_bonus": "Bonificación reclamable", "claim_bonus": "Reclamar bono", "claim_bonus_subtitle": "El bono se pagará en {{networkName}}.", @@ -6930,7 +6942,7 @@ "upgrade_title": "Actualiza a Metal", "continue_button": "Continuar", "virtual_card": { - "name": "Tarjeta Virtual Orange", + "name": "Virtual Card", "price": "Gratis", "feature_1": "Tarjeta virtual para Apple Pay y Google Pay", "feature_2": "Paga con criptomonedas (USDC, USDT, WETH y más)", @@ -6939,10 +6951,13 @@ "metal_card": { "name": "Tarjeta Metal", "price": "$199/año", - "feature_1": "Tarjeta metálica grabada y tarjeta virtual para Apple Pay y Google Pay", - "feature_2": "3 % de cashback en los primeros $10.000 gastados cada año, y 1 % a partir de entonces", + "everything_in_virtual": "Everything in virtual, plus:", + "feature_1": "Premium engraved metal card", + "feature_2": "3% cashback on first $10,000/year", "feature_3": "Sin tarifas por transacciones internacionales" - } + }, + "earn_up_to_badge": "Earn up to $300 in cashback annually", + "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" }, "review_order": { "title": "Revisa tu orden", @@ -7090,7 +7105,8 @@ "ssn_label": "Número de seguro social", "ssn_description": "Requerido por el emisor de la tarjeta. No se realizará ninguna verificación de crédito.", "invalid_ssn": "SSN no válido", - "invalid_date_of_birth": "Fecha de nacimiento no válida. Debes tener al menos 18 años" + "invalid_date_of_birth": "Fecha de nacimiento no válida. Debes tener al menos 18 años", + "name_mismatch_error": "First and last name must match your verified identity" }, "physical_address": { "title": "Agrega tu dirección", @@ -7174,6 +7190,7 @@ "enable_assets_button_label": "Habilitar activos", "spending_limit_warning": "Estás cerca de tu límite de gasto. Actualiza tu cuenta para evitar rechazos.", "logout": "Cerrar sesión", + "contact_support": "Contactar a soporte", "logout_confirmation_title": "¿Cerrar sesión en Card?", "logout_confirmation_message": "¿Seguro que quieres cerrar sesión en tu cuenta de MetaMask Card?", "logout_confirmation_cancel": "Cancelar", @@ -7727,6 +7744,18 @@ }, "active_boosts_title": "Potenciadores activos", "season_1": "Temporada 1", + "musd": { + "title": "mUSD bonus calculator", + "description": "See how much you could earn by converting your stablecoins to mUSD.", + "amount_label": "Amount converted", + "estimated_bonus": "Estimated annualized bonus: up to 3%", + "initial_amount": "Initial amount", + "daily_bonus": "Daily claimable bonus", + "annualized_bonus": "Annualized bonus", + "disclaimer": "This is only an estimate. The bonus is subject to change.", + "buy_button": "Comprar mUSD", + "swap_button": "Swap to mUSD" + }, "upcoming_rewards": { "title": "Recompensas bloqueadas", "points_needed": "{{points}} puntos", @@ -8006,6 +8035,9 @@ }, "homepage": { "sections": { + "cash": "Cash", + "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", + "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", "tokens": "Tokens", "perpetuals": "Contratos perpetuos", "predictions": "Predicciones", @@ -8023,4 +8055,4 @@ "retry": "Reintentar" } } -} \ No newline at end of file +} diff --git a/locales/languages/fr.json b/locales/languages/fr.json index 976eb3eeebb..b5db10d6f94 100644 --- a/locales/languages/fr.json +++ b/locales/languages/fr.json @@ -119,6 +119,10 @@ "message": "Vous envoyez vos actifs vers une adresse de destruction. Si vous continuez, vous perdrez vos actifs.", "title": "Envoi d’actifs vers une adresse de destruction" }, + "token_contract_warning": { + "title": "Token contract warning", + "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + }, "gas_sponsorship_reserve_balance": { "message": "Le parrainage de gaz n’est pas disponible pour cette transaction. Vous devrez conserver au moins %{minBalance} %{nativeTokenSymbol} sur votre compte.", "title": "Parrainage de gaz non disponible" @@ -689,7 +693,11 @@ "invisible_character_error": "Nous avons détecté un caractère invisible dans le nom ENS. Vérifiez le nom ENS pour éviter toute tentative d’escroquerie.", "could_not_resolve_name": "Impossible de résoudre le nom", "invalid_address": "Adresse non valide", - "contractAddressError": "Vous envoyez des jetons à l’adresse du contrat du jeton. Cela peut entraîner la perte de ces jetons." + "contractAddressError": "Vous envoyez des jetons à l’adresse du contrat du jeton. Cela peut entraîner la perte de ces jetons.", + "smart_contract_address": "Smart contract address", + "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "i_understand": "Je comprends", + "cancel": "Annuler" }, "unified_ramp": { "networks_filter_bar": { @@ -2154,6 +2162,7 @@ "sell_position": "Position de vente", "cash_out": "Encaisser", "cash_out_info": "Les fonds seront ajoutés à votre solde disponible", + "buy_preview_outcome_at_price": "{{outcome}} à {{price}} ", "at_price_per_share": "Vente de {{size}} actions au prix de {{price}}", "cashout_info": "{{amount}} sur {{outcome}} à {{initialPrice}} ", "cashout_info_multiple": "{{amount}} sur {{outcomeGroupTitle}} • {{outcome}} à {{initialPrice}}", @@ -2338,7 +2347,9 @@ "exchange_fee_description": "Frais payés à la bourse ou au marché", "total_incl_fees": "frais inclus", "close": "Fermer", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled." + "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", + "deposit_fee": "Deposit fee", + "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" }, "error": { "title": "Impossible de se connecter aux marchés de prédiction", @@ -5921,6 +5932,7 @@ "earn": { "claimable_bonus_tooltip": "Le bonus annualisé que vous avez gagné pour avoir détenu des mUSD. Vous pouvez réclamer votre bonus quotidiennement sur Linea.", "earn_a_percentage_bonus": "Obtenez un bonus de {{percentage}} %", + "percentage_bonus": "Bonus de {{percentage}} %", "claimable_bonus": "Bonus réclamable", "claim_bonus": "Réclamer le bonus", "claim_bonus_subtitle": "Le bonus sera versé sur {{networkName}}.", @@ -6930,7 +6942,7 @@ "upgrade_title": "Passer à Metal", "continue_button": "Continuer", "virtual_card": { - "name": "Carte virtuelle Orange", + "name": "Virtual Card", "price": "Gratuit", "feature_1": "Carte virtuelle pour Apple Pay et Google Pay", "feature_2": "Payer avec des cryptomonnaies (USDC, USDT, WETH, etc.)", @@ -6939,10 +6951,13 @@ "metal_card": { "name": "Carte Metal", "price": "199 $/an", - "feature_1": "Carte métallique gravée et carte virtuelle pour Apple Pay et Google Pay", - "feature_2": "3 % de cashback sur les 10 000 premiers dollars dépensés chaque année, puis 1 % sur les sommes suivantes", + "everything_in_virtual": "Everything in virtual, plus:", + "feature_1": "Premium engraved metal card", + "feature_2": "3% cashback on first $10,000/year", "feature_3": "Pas de frais de transaction à l’étranger" - } + }, + "earn_up_to_badge": "Earn up to $300 in cashback annually", + "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" }, "review_order": { "title": "Vérifiez votre commande", @@ -7090,7 +7105,8 @@ "ssn_label": "Numéro de sécurité sociale", "ssn_description": "Requis par l’émetteur de la carte. Aucune vérification de solvabilité ne sera effectuée.", "invalid_ssn": "Numéro de sécurité sociale non valide", - "invalid_date_of_birth": "Date de naissance non valide. Vous devez être âgé d’au moins 18 ans" + "invalid_date_of_birth": "Date de naissance non valide. Vous devez être âgé d’au moins 18 ans", + "name_mismatch_error": "First and last name must match your verified identity" }, "physical_address": { "title": "Ajoutez votre adresse", @@ -7174,6 +7190,7 @@ "enable_assets_button_label": "Activer les actifs", "spending_limit_warning": "Vous avez presque atteint votre limite de dépenses. Modifiez votre limite de dépenses pour éviter tout refus de paiement.", "logout": "Déconnexion", + "contact_support": "Contacter le service d’assistance", "logout_confirmation_title": "Voulez-vous vous déconnecter de votre compte MetaMask Card ?", "logout_confirmation_message": "Êtes-vous sûr(e) de vouloir vous déconnecter de votre compte MetaMask Card ?", "logout_confirmation_cancel": "Annuler", @@ -7727,6 +7744,18 @@ }, "active_boosts_title": "Boosts actifs", "season_1": "Saison 1", + "musd": { + "title": "mUSD bonus calculator", + "description": "See how much you could earn by converting your stablecoins to mUSD.", + "amount_label": "Amount converted", + "estimated_bonus": "Estimated annualized bonus: up to 3%", + "initial_amount": "Initial amount", + "daily_bonus": "Daily claimable bonus", + "annualized_bonus": "Annualized bonus", + "disclaimer": "This is only an estimate. The bonus is subject to change.", + "buy_button": "Acheter des mUSD", + "swap_button": "Swap to mUSD" + }, "upcoming_rewards": { "title": "Récompenses verrouillées", "points_needed": "{{points}} points", @@ -8006,6 +8035,9 @@ }, "homepage": { "sections": { + "cash": "Cash", + "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", + "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", "tokens": "Jetons", "perpetuals": "Contrats perpétuels", "predictions": "Prédictions", @@ -8023,4 +8055,4 @@ "retry": "Réessayer" } } -} \ No newline at end of file +} diff --git a/locales/languages/hi.json b/locales/languages/hi.json index cf29c49855f..30e9fef41b4 100644 --- a/locales/languages/hi.json +++ b/locales/languages/hi.json @@ -119,6 +119,10 @@ "message": "आप अपनी एसेट्स एक बर्न एड्रेस पर भेज रहे हैं। यदि आप जारी रखते हैं, तो आप अपनी एसेट्स खो देंगे।", "title": "एसेट्स को बर्न एड्रेस पर भेजना" }, + "token_contract_warning": { + "title": "Token contract warning", + "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + }, "gas_sponsorship_reserve_balance": { "message": "इस ट्रांसेक्शन के लिए गैस स्पॉन्सरशिप उपलब्ध नहीं है। आपको अपने अकाउंट में कम से कम %{minBalance} %{nativeTokenSymbol} रखना होगा।", "title": "गैस स्पॉन्सरशिप उपलब्ध नहीं है" @@ -689,7 +693,11 @@ "invisible_character_error": "हमने ENS नाम में एक अदृश्य कैरेक्टर का पता लगाया है। संभावित स्कैम से बचने के लिए ENS नाम की जाँच करें।", "could_not_resolve_name": "नाम हल नहीं किया जा सका", "invalid_address": "एड्रेस ग़लत है", - "contractAddressError": "आप टोकन को टोकन के कॉन्ट्रैक्ट एड्रेस पर भेज रहे हैं। इससे इन टोकन को खोने की संभावना है।" + "contractAddressError": "आप टोकन को टोकन के कॉन्ट्रैक्ट एड्रेस पर भेज रहे हैं। इससे इन टोकन को खोने की संभावना है।", + "smart_contract_address": "Smart contract address", + "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "i_understand": "मैं समझता हूं", + "cancel": "कैंसिल करें" }, "unified_ramp": { "networks_filter_bar": { @@ -2154,6 +2162,7 @@ "sell_position": "सैल पोज़िशन", "cash_out": "कैश आउट करें", "cash_out_info": "फ़ंड आपके उपलब्ध बैलेंस में जोड़े जाएंगे", + "buy_preview_outcome_at_price": "{{outcome}} at {{price}}", "at_price_per_share": "{{size}} शेयरों को {{price}} पर बेचा जा रहा है", "cashout_info": "{{initialPrice}} पर {{outcome}} पर {{amount}}", "cashout_info_multiple": "{{outcomeGroupTitle}} पर {{amount}} • {{initialPrice}} पर {{outcome}}", @@ -2338,7 +2347,9 @@ "exchange_fee_description": "एक्सचेंज या मार्केट को दिया गया शुल्क", "total_incl_fees": "शुल्क सहित", "close": "बंद करें", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled." + "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", + "deposit_fee": "Deposit fee", + "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" }, "error": { "title": "प्रीडिक्शंस से कनेक्ट नहीं हो पाया", @@ -5921,6 +5932,7 @@ "earn": { "claimable_bonus_tooltip": "mUSD रखने पर आपको मिलने वाला वार्षिक बोनस। आपका बोनस Linea पर रोज़ाना क्लेम किया जा सकता है।", "earn_a_percentage_bonus": "{{percentage}}% बोनस कमाएं", + "percentage_bonus": "{{percentage}}% बोनस", "claimable_bonus": "क्लेम करने योग्य बोनस", "claim_bonus": "बोनस क्लेम करें", "claim_bonus_subtitle": "बोनस {{networkName}} पर दिया जाएगा।", @@ -6930,7 +6942,7 @@ "upgrade_title": "मेटल में अपग्रेड करें", "continue_button": "जारी रखें", "virtual_card": { - "name": "ऑरेंज वर्चुअल कार्ड", + "name": "Virtual Card", "price": "मुफ्त", "feature_1": "Apple Pay और Google Pay के लिए वर्चुअल कार्ड", "feature_2": "क्रिप्टो से भुगतान करें (USDC, USDT, WETH और अन्य)", @@ -6939,10 +6951,13 @@ "metal_card": { "name": "मेटल कार्ड", "price": "$199/साल", - "feature_1": "Apple Pay व Google Pay के लिए उकेरा हुआ मेटल कार्ड और वर्चुअल कार्ड", - "feature_2": "हर वर्ष पहले $10,000 की खर्च राशि पर 3% कैशबैक, उसके बाद 1%", + "everything_in_virtual": "Everything in virtual, plus:", + "feature_1": "Premium engraved metal card", + "feature_2": "3% cashback on first $10,000/year", "feature_3": "कोई विदेशी ट्रांसेक्शन फीस नहीं" - } + }, + "earn_up_to_badge": "Earn up to $300 in cashback annually", + "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" }, "review_order": { "title": "अपना ऑर्डर समीक्षा करें", @@ -7090,7 +7105,8 @@ "ssn_label": "सोशल सिक्योरिटी नंबर", "ssn_description": "कार्ड जारी करने वाले के लिए ज़रूरी है। कोई क्रेडिट चेक नहीं किया जाएगा।", "invalid_ssn": "SSN ग़लत है", - "invalid_date_of_birth": "जन्मतिथि ग़लत है। आपकी आयु कम से कम 18 वर्ष होनी चाहिए" + "invalid_date_of_birth": "जन्मतिथि ग़लत है। आपकी आयु कम से कम 18 वर्ष होनी चाहिए", + "name_mismatch_error": "First and last name must match your verified identity" }, "physical_address": { "title": "अपना एड्रेस जोड़ें", @@ -7174,6 +7190,7 @@ "enable_assets_button_label": "एसेट्स चालू करें", "spending_limit_warning": "आप अपनी खर्च सीमा के करीब हैं। अस्वीकृतियों से बचने के लिए अपडेट करें।", "logout": "लॉग आउट करें", + "contact_support": "सपोर्ट टीम से कॉन्टेक्ट करें", "logout_confirmation_title": "कार्ड से लॉग आउट करें?", "logout_confirmation_message": "क्या आप वाकई अपने MetaMask कार्ड अकाउंट से लॉग आउट करना चाहते हैं?", "logout_confirmation_cancel": "कैंसिल करें", @@ -7727,6 +7744,18 @@ }, "active_boosts_title": "सक्रिय बूस्ट्स", "season_1": "सीज़न 1", + "musd": { + "title": "mUSD bonus calculator", + "description": "See how much you could earn by converting your stablecoins to mUSD.", + "amount_label": "Amount converted", + "estimated_bonus": "Estimated annualized bonus: up to 3%", + "initial_amount": "Initial amount", + "daily_bonus": "Daily claimable bonus", + "annualized_bonus": "Annualized bonus", + "disclaimer": "This is only an estimate. The bonus is subject to change.", + "buy_button": "mUSD खरीदें", + "swap_button": "Swap to mUSD" + }, "upcoming_rewards": { "title": "लॉक किए हुए रिवॉर्ड्स", "points_needed": "{{points}} पॉइंट्स", @@ -8006,6 +8035,9 @@ }, "homepage": { "sections": { + "cash": "Cash", + "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", + "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", "tokens": "टोकन", "perpetuals": "परपेचुअल्स", "predictions": "प्रेडिक्शंस", @@ -8023,4 +8055,4 @@ "retry": "फिर से प्रयास करें" } } -} \ No newline at end of file +} diff --git a/locales/languages/id.json b/locales/languages/id.json index fd8e7d2060d..9b73be1472a 100644 --- a/locales/languages/id.json +++ b/locales/languages/id.json @@ -119,6 +119,10 @@ "message": "Anda mengirimkan aset ke alamat burn. Jika dilanjutkan, aset Anda akan hilang.", "title": "Mengirim aset ke alamat burn" }, + "token_contract_warning": { + "title": "Token contract warning", + "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + }, "gas_sponsorship_reserve_balance": { "message": "Sponsor gas tidak tersedia untuk transaksi ini. Anda perlu mempertahankan saldo minimal %{minBalance} %{nativeTokenSymbol} di akun Anda.", "title": "Sponsor gas tidak tersedia" @@ -689,7 +693,11 @@ "invisible_character_error": "Kami mendeteksi karakter tak terlihat pada nama ENS. Periksa nama ENS untuk menghindari potensi penipuan.", "could_not_resolve_name": "Tidak dapat menyelesaikan nama", "invalid_address": "Alamat tidak valid", - "contractAddressError": "Anda mengirimkan token ke alamat kontrak token. Hal ini dapat mengakibatkan hilangnya token tersebut." + "contractAddressError": "Anda mengirimkan token ke alamat kontrak token. Hal ini dapat mengakibatkan hilangnya token tersebut.", + "smart_contract_address": "Smart contract address", + "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "i_understand": "Saya mengerti", + "cancel": "Batal" }, "unified_ramp": { "networks_filter_bar": { @@ -2154,6 +2162,7 @@ "sell_position": "Posisi jual", "cash_out": "Cairkan", "cash_out_info": "Dana akan ditambahkan ke saldo yang tersedia", + "buy_preview_outcome_at_price": "{{outcome}} pada {{price}}", "at_price_per_share": "Menjual {{size}} saham pada harga {{price}}", "cashout_info": "{{amount}} pada {{outcome}} dengan harga {{initialPrice}}", "cashout_info_multiple": "{{amount}} pada {{outcomeGroupTitle}} • {{outcome}} di harga {{initialPrice}}", @@ -2338,7 +2347,9 @@ "exchange_fee_description": "Biaya yang dibayarkan ke bursa atau pasar", "total_incl_fees": "termasuk biaya", "close": "Tutup", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled." + "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", + "deposit_fee": "Deposit fee", + "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" }, "error": { "title": "Tidak dapat terhubung ke prediksi", @@ -5921,6 +5932,7 @@ "earn": { "claimable_bonus_tooltip": "Bonus tahunan yang Anda peroleh karena memiliki mUSD. Bonus dapat diklaim setiap hari di Linea.", "earn_a_percentage_bonus": "Dapatkan bonus sebesar {{percentage}}%", + "percentage_bonus": "Bonus {{percentage}}%", "claimable_bonus": "Bonus yang dapat diklaim", "claim_bonus": "Klaim bonus", "claim_bonus_subtitle": "Bonus akan dibayarkan melalui {{networkName}}.", @@ -6930,7 +6942,7 @@ "upgrade_title": "Upgrade ke Logam", "continue_button": "Lanjutkan", "virtual_card": { - "name": "Kartu Virtual Orange", + "name": "Virtual Card", "price": "Gratis", "feature_1": "Kartu virtual untuk Apple Pay dan Google Pay", "feature_2": "Bayar dengan kripto (USDC, USDT, WETH, dan lainnya)", @@ -6939,10 +6951,13 @@ "metal_card": { "name": "Kartu Logam", "price": "$199/tahun", - "feature_1": "Kartu logam berukir dan kartu virtual untuk Apple Pay dan Google Pay", - "feature_2": "Cashback 3% untuk penggunaan pertama senilai $10.000 setiap tahun, lalu 1% setelahnya", + "everything_in_virtual": "Everything in virtual, plus:", + "feature_1": "Premium engraved metal card", + "feature_2": "3% cashback on first $10,000/year", "feature_3": "Tidak ada biaya transaksi luar negeri" - } + }, + "earn_up_to_badge": "Earn up to $300 in cashback annually", + "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" }, "review_order": { "title": "Tinjau order", @@ -7090,7 +7105,8 @@ "ssn_label": "Nomor Jaminan Sosial", "ssn_description": "Diperlukan oleh penerbit kartu. Tidak akan dilakukan pengecekan kredit.", "invalid_ssn": "NJS tidak valid", - "invalid_date_of_birth": "Tanggal lahir tidak valid. Anda harus berusia minimal 18 tahun" + "invalid_date_of_birth": "Tanggal lahir tidak valid. Anda harus berusia minimal 18 tahun", + "name_mismatch_error": "First and last name must match your verified identity" }, "physical_address": { "title": "Tambahkan alamat Anda", @@ -7174,6 +7190,7 @@ "enable_assets_button_label": "Aktifkan aset", "spending_limit_warning": "Batas penggunaan hampir tercapai. Perbarui untuk menghindari penolakan.", "logout": "Keluar", + "contact_support": "Hubungi dukungan", "logout_confirmation_title": "Keluar dari Kartu?", "logout_confirmation_message": "Yakin ingin keluar dari akun Kartu MetaMask?", "logout_confirmation_cancel": "Batal", @@ -7727,6 +7744,18 @@ }, "active_boosts_title": "Peningkatan aktif", "season_1": "Musim 1", + "musd": { + "title": "mUSD bonus calculator", + "description": "See how much you could earn by converting your stablecoins to mUSD.", + "amount_label": "Amount converted", + "estimated_bonus": "Estimated annualized bonus: up to 3%", + "initial_amount": "Initial amount", + "daily_bonus": "Daily claimable bonus", + "annualized_bonus": "Annualized bonus", + "disclaimer": "This is only an estimate. The bonus is subject to change.", + "buy_button": "Beli mUSD", + "swap_button": "Swap to mUSD" + }, "upcoming_rewards": { "title": "Reward terkunci", "points_needed": "{{points}} poin", @@ -8006,6 +8035,9 @@ }, "homepage": { "sections": { + "cash": "Cash", + "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", + "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", "tokens": "Token", "perpetuals": "Abadi", "predictions": "Prediksi", @@ -8023,4 +8055,4 @@ "retry": "Coba lagi" } } -} \ No newline at end of file +} diff --git a/locales/languages/ja.json b/locales/languages/ja.json index 9de73a0d602..6f2eaff2a50 100644 --- a/locales/languages/ja.json +++ b/locales/languages/ja.json @@ -119,6 +119,10 @@ "message": "資産をバーンアドレスに送金しようとしています。続行すると、資産が失われます。", "title": "バーンアドレスに資産を送ろうとしています" }, + "token_contract_warning": { + "title": "Token contract warning", + "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + }, "gas_sponsorship_reserve_balance": { "message": "この取引でガススポンサーシップはご利用いただけません。アカウントに%{minBalance} %{nativeTokenSymbol}以上の残高が必要です。", "title": "ガススポンサーシップは利用できません" @@ -689,7 +693,11 @@ "invisible_character_error": "ENS名に表示されない文字が検出されました。詐欺防止のため、ENS名を確認してください。", "could_not_resolve_name": "名前の解決ができませんでした", "invalid_address": "無効なアドレス", - "contractAddressError": "トークンのコントラクトアドレスにトークンを送金しようとしています。これにより、当該トークンが失われる可能性があります。" + "contractAddressError": "トークンのコントラクトアドレスにトークンを送金しようとしています。これにより、当該トークンが失われる可能性があります。", + "smart_contract_address": "Smart contract address", + "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "i_understand": "理解しています", + "cancel": "キャンセル" }, "unified_ramp": { "networks_filter_bar": { @@ -2154,6 +2162,7 @@ "sell_position": "ポジションを売却", "cash_out": "キャッシュアウト", "cash_out_info": "資金は利用可能残高に追加されます", + "buy_preview_outcome_at_price": "{{outcome}} at {{price}}", "at_price_per_share": "{{size}}株を{{price}}で売却中", "cashout_info": "{{outcome}}を{{initialPrice}}で{{amount}}", "cashout_info_multiple": "{{outcomeGroupTitle}} • {{outcome}}を{{initialPrice}}で{{amount}}", @@ -2338,7 +2347,9 @@ "exchange_fee_description": "取引所または市場に支払われる手数料", "total_incl_fees": "手数料込", "close": "閉じる", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled." + "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", + "deposit_fee": "Deposit fee", + "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" }, "error": { "title": "予想に接続できません", @@ -5921,6 +5932,7 @@ "earn": { "claimable_bonus_tooltip": "mUSDを保有することで獲得した年換算ボーナスです。ボーナスはLineaで毎日請求できます。", "earn_a_percentage_bonus": "{{percentage}}%のボーナスを獲得", + "percentage_bonus": "{{percentage}}%のボーナス", "claimable_bonus": "獲得できるボーナス", "claim_bonus": "ボーナスを請求する", "claim_bonus_subtitle": "ボーナスは{{networkName}}上で支払われます。", @@ -6930,7 +6942,7 @@ "upgrade_title": "メタルにアップグレード", "continue_button": "続行", "virtual_card": { - "name": "オレンジバーチャルカード", + "name": "Virtual Card", "price": "無料", "feature_1": "Apple PayとGoogle Payで使えるバーチャルカード", "feature_2": "仮想通貨でお支払い (USDC、USDT、WETHなど)", @@ -6939,10 +6951,13 @@ "metal_card": { "name": "メタルカード", "price": "年間199ドル", - "feature_1": "Apple PayとGoogle Payで使える刻印入りメタルカードとバーチャルカード", - "feature_2": "毎年、支出額が10,000ドルに達した時点で3%、その後10,000ドル支出するたびに1%のキャッシュバック", + "everything_in_virtual": "Everything in virtual, plus:", + "feature_1": "Premium engraved metal card", + "feature_2": "3% cashback on first $10,000/year", "feature_3": "海外トランザクション手数料なし" - } + }, + "earn_up_to_badge": "Earn up to $300 in cashback annually", + "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" }, "review_order": { "title": "ご注文内容の確認", @@ -7090,7 +7105,8 @@ "ssn_label": "社会保障番号", "ssn_description": "カード発行者により求められています。信用調査は行われません。", "invalid_ssn": "無効な社会保障番号です", - "invalid_date_of_birth": "生年月日が無効です。18歳以上である必要があります。" + "invalid_date_of_birth": "生年月日が無効です。18歳以上である必要があります。", + "name_mismatch_error": "First and last name must match your verified identity" }, "physical_address": { "title": "住所を追加", @@ -7174,6 +7190,7 @@ "enable_assets_button_label": "アセットを有効にする", "spending_limit_warning": "使用上限に近づいています。拒否されないように更新してください。", "logout": "ログアウト", + "contact_support": "サポートへのお問い合わせ", "logout_confirmation_title": "カードからログアウトしますか?", "logout_confirmation_message": "MetaMaskカードアカウントからログアウトしてよろしいですか?", "logout_confirmation_cancel": "キャンセル", @@ -7727,6 +7744,18 @@ }, "active_boosts_title": "有効なブースト", "season_1": "シーズン1", + "musd": { + "title": "mUSD bonus calculator", + "description": "See how much you could earn by converting your stablecoins to mUSD.", + "amount_label": "Amount converted", + "estimated_bonus": "Estimated annualized bonus: up to 3%", + "initial_amount": "Initial amount", + "daily_bonus": "Daily claimable bonus", + "annualized_bonus": "Annualized bonus", + "disclaimer": "This is only an estimate. The bonus is subject to change.", + "buy_button": "mUSDを購入", + "swap_button": "Swap to mUSD" + }, "upcoming_rewards": { "title": "ロックされているリワード", "points_needed": "{{points}}ポイント", @@ -8006,6 +8035,9 @@ }, "homepage": { "sections": { + "cash": "Cash", + "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", + "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", "tokens": "トークン", "perpetuals": "パーペチュアル", "predictions": "予測", @@ -8023,4 +8055,4 @@ "retry": "再試行" } } -} \ No newline at end of file +} diff --git a/locales/languages/ko.json b/locales/languages/ko.json index 64fd07b2246..1d427a420e7 100644 --- a/locales/languages/ko.json +++ b/locales/languages/ko.json @@ -119,6 +119,10 @@ "message": "지금 자산을 소각 주소로 전송하고 있습니다. 계속 진행하면 자산을 잃게 됩니다.", "title": "소각 주소로 자산 전송" }, + "token_contract_warning": { + "title": "Token contract warning", + "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + }, "gas_sponsorship_reserve_balance": { "message": "이 트랜잭션에는 가스 후원이 제공되지 않습니다. 계정에 최소 %{minBalance}의 %{nativeTokenSymbol} 토큰을 보유해야 합니다.", "title": "가스 후원 이용 불가" @@ -689,7 +693,11 @@ "invisible_character_error": "ENS 이름에서 보이지 않는 문자가 감지되었습니다. 잠재적인 사기를 피하기 위해 ENS 이름을 확인하세요.", "could_not_resolve_name": "이름을 확인할 수 없습니다", "invalid_address": "잘못된 주소", - "contractAddressError": "토큰의 계약 주소로 토큰을 보내고 있습니다. 이로 인해 해당 토큰이 손실될 수 있습니다." + "contractAddressError": "토큰의 계약 주소로 토큰을 보내고 있습니다. 이로 인해 해당 토큰이 손실될 수 있습니다.", + "smart_contract_address": "Smart contract address", + "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "i_understand": "견적은 다음 기간 전에 만료됨을", + "cancel": "취소" }, "unified_ramp": { "networks_filter_bar": { @@ -2154,6 +2162,7 @@ "sell_position": "포지션 매도", "cash_out": "출금", "cash_out_info": "자금은 사용 가능한 잔액에 추가됩니다", + "buy_preview_outcome_at_price": "{{outcome}} at {{price}}", "at_price_per_share": "{{price}}에 {{size}}주 매도", "cashout_info": "{{outcome}}에 {{amount}}(단가: {{initialPrice}})", "cashout_info_multiple": "{{outcomeGroupTitle}} - {{outcome}}에 {{amount}}(단가:{{initialPrice}}", @@ -2338,7 +2347,9 @@ "exchange_fee_description": "거래소 또는 시장에 지불한 수수료", "total_incl_fees": "수수료 포함", "close": "닫기", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled." + "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", + "deposit_fee": "Deposit fee", + "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" }, "error": { "title": "예측에 연결할 수 없습니다", @@ -5921,6 +5932,7 @@ "earn": { "claimable_bonus_tooltip": "mUSD 보유로 얻은 연간 보너스입니다. 보너스는 Linea에서 매일 청구할 수 있습니다.", "earn_a_percentage_bonus": "{{percentage}}% 보너스 받기", + "percentage_bonus": "{{percentage}}% 보너스", "claimable_bonus": "청구 가능한 보너스", "claim_bonus": "보너스 수령", "claim_bonus_subtitle": "보너스는 {{networkName}}에서 지급됩니다.", @@ -6930,7 +6942,7 @@ "upgrade_title": "메탈 카드로 업그레이드", "continue_button": "계속", "virtual_card": { - "name": "오렌지 가상 카드", + "name": "Virtual Card", "price": "수수료", "feature_1": "Apple Pay 및 Google Pay용 가상 카드", "feature_2": "암호화폐로 결제 (USDC, USDT, WETH 등)", @@ -6939,10 +6951,13 @@ "metal_card": { "name": "메탈 카드", "price": "연 $199", - "feature_1": "각인된 메탈 카드 및 Apple Pay·Google Pay용 가상 카드", - "feature_2": "매년 $10,000까지 3% 캐시백, 이후 1%", + "everything_in_virtual": "Everything in virtual, plus:", + "feature_1": "Premium engraved metal card", + "feature_2": "3% cashback on first $10,000/year", "feature_3": "해외 결제 수수료 없음" - } + }, + "earn_up_to_badge": "Earn up to $300 in cashback annually", + "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" }, "review_order": { "title": "주문 검토", @@ -7090,7 +7105,8 @@ "ssn_label": "사회보장번호", "ssn_description": "카드 발급사의 요구 사항입니다. 신용 조회는 진행되지 않습니다.", "invalid_ssn": "잘못된 SSN입니다", - "invalid_date_of_birth": "유효하지 않은 생년월일입니다. 18세 이상이어야 합니다" + "invalid_date_of_birth": "유효하지 않은 생년월일입니다. 18세 이상이어야 합니다", + "name_mismatch_error": "First and last name must match your verified identity" }, "physical_address": { "title": "주소 추가", @@ -7174,6 +7190,7 @@ "enable_assets_button_label": "자산 활성화", "spending_limit_warning": "지출 한도가 거의 찼습니다. 결제가 거부되지 않도록 정보를 업데이트하세요.", "logout": "로그아웃", + "contact_support": "지원팀에 문의", "logout_confirmation_title": "카드에서 로그아웃하시겠습니까?", "logout_confirmation_message": "MetaMask 카드 계정에서 로그아웃하시겠습니까?", "logout_confirmation_cancel": "취소", @@ -7727,6 +7744,18 @@ }, "active_boosts_title": "적용 중인 부스트", "season_1": "시즌 1", + "musd": { + "title": "mUSD bonus calculator", + "description": "See how much you could earn by converting your stablecoins to mUSD.", + "amount_label": "Amount converted", + "estimated_bonus": "Estimated annualized bonus: up to 3%", + "initial_amount": "Initial amount", + "daily_bonus": "Daily claimable bonus", + "annualized_bonus": "Annualized bonus", + "disclaimer": "This is only an estimate. The bonus is subject to change.", + "buy_button": "mUSD 구매", + "swap_button": "Swap to mUSD" + }, "upcoming_rewards": { "title": "잠긴 리워드", "points_needed": "{{points}}포인트", @@ -8006,6 +8035,9 @@ }, "homepage": { "sections": { + "cash": "Cash", + "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", + "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", "tokens": "토큰", "perpetuals": "영구계약", "predictions": "예측", @@ -8023,4 +8055,4 @@ "retry": "다시 시도" } } -} \ No newline at end of file +} diff --git a/locales/languages/pt.json b/locales/languages/pt.json index 06a3bbf56fc..a5ee60caab7 100644 --- a/locales/languages/pt.json +++ b/locales/languages/pt.json @@ -119,6 +119,10 @@ "message": "Você está enviando seus ativos para um endereço de queima. Se continuar, você perderá seus ativos.", "title": "Enviando ativos para endereço de queima" }, + "token_contract_warning": { + "title": "Token contract warning", + "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + }, "gas_sponsorship_reserve_balance": { "message": "O patrocínio de gas não está disponível para esta transação. Você precisará manter pelo menos %{minBalance} %{nativeTokenSymbol} em sua conta.", "title": "Patrocínio de gas não disponível" @@ -689,7 +693,11 @@ "invisible_character_error": "Detectamos um caractere invisível no nome ENS. Verifique o nome ENS para evitar um possível golpe.", "could_not_resolve_name": "Não foi possível resolver o nome", "invalid_address": "Endereço inválido", - "contractAddressError": "Você está enviando tokens para o endereço do contrato do token. Isso pode levar à perda desses tokens." + "contractAddressError": "Você está enviando tokens para o endereço do contrato do token. Isso pode levar à perda desses tokens.", + "smart_contract_address": "Smart contract address", + "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "i_understand": "Eu compreendo", + "cancel": "Cancelar" }, "unified_ramp": { "networks_filter_bar": { @@ -2154,6 +2162,7 @@ "sell_position": "Posição de venda", "cash_out": "Sacar", "cash_out_info": "Os fundos serão adicionados ao seu saldo disponível", + "buy_preview_outcome_at_price": "{{outcome}} a {{price}}", "at_price_per_share": "Vendendo {{size}} ações a {{price}}", "cashout_info": "{{amount}} em {{outcome}} a {{initialPrice}}", "cashout_info_multiple": "{{amount}} em {{outcomeGroupTitle}} • {{outcome}} a {{initialPrice}}", @@ -2338,7 +2347,9 @@ "exchange_fee_description": "Taxa paga à bolsa ou ao mercado", "total_incl_fees": "incluindo taxas", "close": "Fechar", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled." + "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", + "deposit_fee": "Deposit fee", + "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" }, "error": { "title": "Não foi possível conectar-se às previsões", @@ -5921,6 +5932,7 @@ "earn": { "claimable_bonus_tooltip": "O bônus anualizado que você ganhou por manter mUSD. Seu bônus pode ser resgatado diariamente na Linea.", "earn_a_percentage_bonus": "Ganhe um bônus de {{percentage}}%", + "percentage_bonus": "{{percentage}}% de bônus", "claimable_bonus": "Bônus resgatável", "claim_bonus": "Resgatar bônus", "claim_bonus_subtitle": "O bônus será pago em {{networkName}}.", @@ -6930,7 +6942,7 @@ "upgrade_title": "Faça upgrade para Metal", "continue_button": "Continuar", "virtual_card": { - "name": "Cartão virtual Laranja", + "name": "Virtual Card", "price": "Gratuito", "feature_1": "Cartão virtual para Apple Pay e Google Pay", "feature_2": "Pague com criptomoedas (USDC, USDT, WETH e várias outras)", @@ -6939,10 +6951,13 @@ "metal_card": { "name": "Cartão Metal", "price": "US$ 199/ano", - "feature_1": "Cartão metálico entalhado e cartão virtual para Apple Pay e Google Pay", - "feature_2": "3% de cashback nos primeiros US$ 10.000 gastos a cada ano, depois 1% a partir daí", + "everything_in_virtual": "Everything in virtual, plus:", + "feature_1": "Premium engraved metal card", + "feature_2": "3% cashback on first $10,000/year", "feature_3": "Sem taxas de transação internacional" - } + }, + "earn_up_to_badge": "Earn up to $300 in cashback annually", + "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" }, "review_order": { "title": "Confira sua ordem", @@ -7090,7 +7105,8 @@ "ssn_label": "Número do seguro social (NSS)", "ssn_description": "Exigido pela emissora do cartão. Nenhuma verificação de crédito será realizada.", "invalid_ssn": "NSS inválido", - "invalid_date_of_birth": "Data de nascimento inválida. Você deve ter pelo menos 18 anos" + "invalid_date_of_birth": "Data de nascimento inválida. Você deve ter pelo menos 18 anos", + "name_mismatch_error": "First and last name must match your verified identity" }, "physical_address": { "title": "Insira seu endereço", @@ -7174,6 +7190,7 @@ "enable_assets_button_label": "Habilitar ativos", "spending_limit_warning": "Você está perto de atingir seu limite de gastos. Atualize sua conta para evitar recusas.", "logout": "Sair", + "contact_support": "Falar com o suporte", "logout_confirmation_title": "Sair do cartão?", "logout_confirmation_message": "Tem certeza de que deseja sair da sua conta do cartão MetaMask?", "logout_confirmation_cancel": "Cancelar", @@ -7727,6 +7744,18 @@ }, "active_boosts_title": "Incrementos ativos", "season_1": "Temporada 1", + "musd": { + "title": "mUSD bonus calculator", + "description": "See how much you could earn by converting your stablecoins to mUSD.", + "amount_label": "Amount converted", + "estimated_bonus": "Estimated annualized bonus: up to 3%", + "initial_amount": "Initial amount", + "daily_bonus": "Daily claimable bonus", + "annualized_bonus": "Annualized bonus", + "disclaimer": "This is only an estimate. The bonus is subject to change.", + "buy_button": "Comprar mUSD", + "swap_button": "Swap to mUSD" + }, "upcoming_rewards": { "title": "Recompensas bloqueadas", "points_needed": "{{points}} pontos", @@ -8006,6 +8035,9 @@ }, "homepage": { "sections": { + "cash": "Cash", + "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", + "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", "tokens": "Tokens", "perpetuals": "Perpétuos", "predictions": "Previsões", @@ -8023,4 +8055,4 @@ "retry": "Tentar novamente" } } -} \ No newline at end of file +} diff --git a/locales/languages/ru.json b/locales/languages/ru.json index 38b8f2a8164..6b9d12863fb 100644 --- a/locales/languages/ru.json +++ b/locales/languages/ru.json @@ -119,6 +119,10 @@ "message": "Вы отправляете свои активы на адрес для сжигания. Если вы продолжите, вы потеряете их.", "title": "Активы отправляются на адрес для сжигания" }, + "token_contract_warning": { + "title": "Token contract warning", + "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + }, "gas_sponsorship_reserve_balance": { "message": "Для этой транзакции недоступна спонсорская оплата газа. Вам необходимо постоянно иметь на счету не менее %{minBalance} %{nativeTokenSymbol}.", "title": "Спонсорская оплата газа недоступна" @@ -689,7 +693,11 @@ "invisible_character_error": "Мы обнаружили невидимый символ в имени ENS. Проверьте имя ENS, чтобы избежать возможного мошенничества.", "could_not_resolve_name": "Не удалось разрешить имя", "invalid_address": "Недействительный адрес", - "contractAddressError": "Вы отправляете токены на адрес контракта токена. Это может привести к потере этих токенов." + "contractAddressError": "Вы отправляете токены на адрес контракта токена. Это может привести к потере этих токенов.", + "smart_contract_address": "Smart contract address", + "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "i_understand": "Я понимаю", + "cancel": "Отмена" }, "unified_ramp": { "networks_filter_bar": { @@ -2154,6 +2162,7 @@ "sell_position": "Позиция на продажу", "cash_out": "Вывести деньги", "cash_out_info": "Средства будут зачислены на ваш доступный баланс.", + "buy_preview_outcome_at_price": "{{outcome}} at {{price}}", "at_price_per_share": "Продажа {{size}} акции(-ий) по цене {{price}}", "cashout_info": "{{amount}} при {{outcome}} за {{initialPrice}}", "cashout_info_multiple": "{{amount}} при {{outcomeGroupTitle}} • {{outcome}} по цене {{initialPrice}}", @@ -2338,7 +2347,9 @@ "exchange_fee_description": "Комиссия, уплачиваемая бирже или рынку", "total_incl_fees": "вкл. комиссии", "close": "Закрыть", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled." + "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", + "deposit_fee": "Deposit fee", + "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" }, "error": { "title": "Невозможно подключиться к прогнозам", @@ -5921,6 +5932,7 @@ "earn": { "claimable_bonus_tooltip": "Годовой бонус, который вы заработали за хранение mUSD. Ваш бонус можно получить ежедневно на Linea.", "earn_a_percentage_bonus": "Заработайте бонус в размере {{percentage}}%", + "percentage_bonus": "Бонус {{percentage}}%", "claimable_bonus": "Встребуемый бонус", "claim_bonus": "Получить бонус", "claim_bonus_subtitle": "Бонус будет выплачен в сети {{networkName}}.", @@ -6930,7 +6942,7 @@ "upgrade_title": "Повысить уровень до Металлической", "continue_button": "Продолжить", "virtual_card": { - "name": "Виртуальная карта Orange", + "name": "Virtual Card", "price": "Бесплатно", "feature_1": "Виртуальная карта для Apple Pay и Google Pay", "feature_2": "Оплата криптовалютой (USDC, USDT, WETH и другие)", @@ -6939,10 +6951,13 @@ "metal_card": { "name": "Металлическая карта", "price": "$199/год", - "feature_1": "Гравированная металлическая карта и виртуальная карта для Apple Pay и Google Pay", - "feature_2": "3% кэшбека на первые 10 000 долларов, потраченных в год, затем 1%", + "everything_in_virtual": "Everything in virtual, plus:", + "feature_1": "Premium engraved metal card", + "feature_2": "3% cashback on first $10,000/year", "feature_3": "Без комиссий за зарубежные транзакции" - } + }, + "earn_up_to_badge": "Earn up to $300 in cashback annually", + "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" }, "review_order": { "title": "Проверьте свой заказ", @@ -7090,7 +7105,8 @@ "ssn_label": "Номер социального страхования", "ssn_description": "Требуется эмитентом карты. Проверка кредитной истории проводиться не будет.", "invalid_ssn": "Недопустимый SSN", - "invalid_date_of_birth": "Неверная дата рождения. Ваш возраст должен быть не менее 18 лет" + "invalid_date_of_birth": "Неверная дата рождения. Ваш возраст должен быть не менее 18 лет", + "name_mismatch_error": "First and last name must match your verified identity" }, "physical_address": { "title": "Введите свой адрес", @@ -7174,6 +7190,7 @@ "enable_assets_button_label": "Активировать активы", "spending_limit_warning": "Вы почти достигли лимита расходов. Обновите, чтобы избежать отказов.", "logout": "Выйти", + "contact_support": "Связаться с поддержкой", "logout_confirmation_title": "Выйти из карты?", "logout_confirmation_message": "Вы уверены, что хотите выйти из своего счета Карты MetaMask?", "logout_confirmation_cancel": "Отмена", @@ -7727,6 +7744,18 @@ }, "active_boosts_title": "Активные повышающие коэффициенты", "season_1": "Сезон 1", + "musd": { + "title": "mUSD bonus calculator", + "description": "See how much you could earn by converting your stablecoins to mUSD.", + "amount_label": "Amount converted", + "estimated_bonus": "Estimated annualized bonus: up to 3%", + "initial_amount": "Initial amount", + "daily_bonus": "Daily claimable bonus", + "annualized_bonus": "Annualized bonus", + "disclaimer": "This is only an estimate. The bonus is subject to change.", + "buy_button": "Купить mUSD", + "swap_button": "Swap to mUSD" + }, "upcoming_rewards": { "title": "Заблокированные награды", "points_needed": "{{points}} балла(-ов)", @@ -8006,6 +8035,9 @@ }, "homepage": { "sections": { + "cash": "Cash", + "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", + "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", "tokens": "Токены", "perpetuals": "Бессрочные контракты", "predictions": "Прогнозы", @@ -8023,4 +8055,4 @@ "retry": "Повтор" } } -} \ No newline at end of file +} diff --git a/locales/languages/tl.json b/locales/languages/tl.json index d9e00bb31c2..90c9209f71f 100644 --- a/locales/languages/tl.json +++ b/locales/languages/tl.json @@ -119,6 +119,10 @@ "message": "Ipapadala mo ang mga asset mo sa burn address. Kapag nagpatuloy ka, mawawala sa iyo ang mga asset mo.", "title": "Ipapadala ang mga asset sa burn address" }, + "token_contract_warning": { + "title": "Token contract warning", + "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + }, "gas_sponsorship_reserve_balance": { "message": "Hindi available ang pag-iisponsor ng gas para sa transaksyong ito. Kakailanganin mo ng hindi bababa sa %{minBalance} %{nativeTokenSymbol} sa account mo.", "title": "Hindi available ang pag-iisponsor ng gas" @@ -689,7 +693,11 @@ "invisible_character_error": "May natukoy kaming hindi nakikitang character sa ENS name. Suriin ang ENS name para maiwasan ang potensyal na panloloko.", "could_not_resolve_name": "Hindi maresolba ang pangalan", "invalid_address": "Di-wastong address", - "contractAddressError": "Nagpapadala ka ng mga token sa address ng kontrata ng token. Maaari itong magresulta sa pagkawala ng mga token na ito." + "contractAddressError": "Nagpapadala ka ng mga token sa address ng kontrata ng token. Maaari itong magresulta sa pagkawala ng mga token na ito.", + "smart_contract_address": "Smart contract address", + "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "i_understand": "Nauunawaan ko", + "cancel": "Kanselahin" }, "unified_ramp": { "networks_filter_bar": { @@ -2154,6 +2162,7 @@ "sell_position": "Posisyon sa pagbebenta", "cash_out": "Mag-cash out", "cash_out_info": "Idadagdag ang mga pondo sa iyong available na balanse", + "buy_preview_outcome_at_price": "{{outcome}} sa {{price}}", "at_price_per_share": "Nagbebenta ng {{size}} na share sa {{price}}", "cashout_info": "{{amount}} para sa {{outcome}} sa presyong {{initialPrice}}", "cashout_info_multiple": "{{amount}} sa {{outcomeGroupTitle}} • {{outcome}} sa {{initialPrice}}", @@ -2338,7 +2347,9 @@ "exchange_fee_description": "Bayad sa palitan o market", "total_incl_fees": "kasama ang mga bayarin", "close": "Isara", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled." + "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", + "deposit_fee": "Deposit fee", + "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" }, "error": { "title": "Hindi maikonekta sa mga prediksyon", @@ -5921,6 +5932,7 @@ "earn": { "claimable_bonus_tooltip": "Ang kinita mong taunang bonus para sa pag-hold ng mUSD. Puwedeng ma-claim ang bonus mo sa Linea araw-araw.", "earn_a_percentage_bonus": "Kumita ng {{percentage}}% bonus", + "percentage_bonus": "{{percentage}}% bonus", "claimable_bonus": "Naki-claim na bonus", "claim_bonus": "I-claim ang bonus", "claim_bonus_subtitle": "Ibibigay ang bonus sa {{networkName}}.", @@ -6930,7 +6942,7 @@ "upgrade_title": "I-upgrade sa Metal", "continue_button": "Magpatuloy", "virtual_card": { - "name": "Orange na Virtual Card", + "name": "Virtual Card", "price": "Libre", "feature_1": "Virtual card para sa Apple Pay at Google Pay", "feature_2": "Magbayad gamit ang crypto (USDC, USDT, WETH, at iba pa)", @@ -6939,10 +6951,13 @@ "metal_card": { "name": "Metal Card", "price": "$199/taon", - "feature_1": "Nakaukit na metal card at virtual card para sa Apple Pay at Google Pay", - "feature_2": "3% cashback sa unang $10,000 na nagastos sa bawat taon, pagkatapos ay 1% sa sosobra pa", + "everything_in_virtual": "Everything in virtual, plus:", + "feature_1": "Premium engraved metal card", + "feature_2": "3% cashback on first $10,000/year", "feature_3": "Walang bayad sa transaksyon ang tagaibang bansa" - } + }, + "earn_up_to_badge": "Earn up to $300 in cashback annually", + "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" }, "review_order": { "title": "Suriin ang order mo", @@ -7090,7 +7105,8 @@ "ssn_label": "Social Security Number", "ssn_description": "Kinakailangan ng taga-isyu ng card. Hindi magsasagawa ng credit check.", "invalid_ssn": "Di-wastong SSN", - "invalid_date_of_birth": "Maling petsa ng kapanganakan. Dapat na hindi bababa sa 18 taong gulang ang edad mo" + "invalid_date_of_birth": "Maling petsa ng kapanganakan. Dapat na hindi bababa sa 18 taong gulang ang edad mo", + "name_mismatch_error": "First and last name must match your verified identity" }, "physical_address": { "title": "Ilagay ang address mo", @@ -7174,6 +7190,7 @@ "enable_assets_button_label": "I-enable ang mga asset", "spending_limit_warning": "Malapit mo nang maabot ang limit ng paggastos. Mag-update para maiwasan ang hindi pagtanggap.", "logout": "Mag-log out", + "contact_support": "Kontakin ang suporta", "logout_confirmation_title": "I-log out ang Card?", "logout_confirmation_message": "Sigurado ka bang gusto mong mag-log out sa MetaMask Card account mo?", "logout_confirmation_cancel": "Kanselahin", @@ -7727,6 +7744,18 @@ }, "active_boosts_title": "Mga active boost", "season_1": "Season 1", + "musd": { + "title": "mUSD bonus calculator", + "description": "See how much you could earn by converting your stablecoins to mUSD.", + "amount_label": "Amount converted", + "estimated_bonus": "Estimated annualized bonus: up to 3%", + "initial_amount": "Initial amount", + "daily_bonus": "Daily claimable bonus", + "annualized_bonus": "Annualized bonus", + "disclaimer": "This is only an estimate. The bonus is subject to change.", + "buy_button": "Bumili ng mUSD", + "swap_button": "Swap to mUSD" + }, "upcoming_rewards": { "title": "Naka-lock na mga reward", "points_needed": "{{points}} (na) point", @@ -8006,6 +8035,9 @@ }, "homepage": { "sections": { + "cash": "Cash", + "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", + "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", "tokens": "Mga Token", "perpetuals": "Perpetuals", "predictions": "Mga hula", @@ -8023,4 +8055,4 @@ "retry": "Subukang muli" } } -} \ No newline at end of file +} diff --git a/locales/languages/tr.json b/locales/languages/tr.json index 0e93c96c4b2..80fb879c2fc 100644 --- a/locales/languages/tr.json +++ b/locales/languages/tr.json @@ -119,6 +119,10 @@ "message": "Varlıklarınızı bir yakım adresine gönderiyorsunuz. Devam ederseniz varlıklarınızı kaybedeceksiniz.", "title": "Varlıklar yakım adresine gönderiliyor" }, + "token_contract_warning": { + "title": "Token contract warning", + "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + }, "gas_sponsorship_reserve_balance": { "message": "Gaz sponsorluğu bu işlem için kullanılamıyor. Hesabınızda en az %{minBalance} %{nativeTokenSymbol} tutmanız gerekecek.", "title": "Gaz sponsorluğu kullanılamıyor" @@ -689,7 +693,11 @@ "invisible_character_error": "ENS adında görünmez bir karakter algıladık. Olası bir dolandırıcılığı önlemek için ENS adını kontrol edin.", "could_not_resolve_name": "Adı çözümlenemedi", "invalid_address": "Geçersiz adres", - "contractAddressError": "Token'in sözleşme adresine token gönderiyorsunuz. Bu durum, bu token'lerin kaybedilmesine neden olabilir." + "contractAddressError": "Token'in sözleşme adresine token gönderiyorsunuz. Bu durum, bu token'lerin kaybedilmesine neden olabilir.", + "smart_contract_address": "Smart contract address", + "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "i_understand": "Anlıyorum", + "cancel": "İptal" }, "unified_ramp": { "networks_filter_bar": { @@ -2154,6 +2162,7 @@ "sell_position": "Sat pozisyonu", "cash_out": "Paraya çevir", "cash_out_info": "Fonlar kullanılabilir bakiyenize eklenecek", + "buy_preview_outcome_at_price": "{{outcome}} at {{price}}", "at_price_per_share": "{{size}} hisse {{price}} fiyatından satılıyor", "cashout_info": "{{amount}} {{outcome}} için {{initialPrice}} fiyatıyla", "cashout_info_multiple": "{{amount}} {{outcomeGroupTitle}} • {{outcome}} için {{initialPrice}} fiyatıyla", @@ -2338,7 +2347,9 @@ "exchange_fee_description": "Borsaya veya piyasaya ödenen ücret", "total_incl_fees": "ücretler dahil", "close": "Kapat", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled." + "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", + "deposit_fee": "Deposit fee", + "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" }, "error": { "title": "Tahminlere bağlanılamıyor", @@ -5921,6 +5932,7 @@ "earn": { "claimable_bonus_tooltip": "mUSD tuttuğunuz için kazandığınız yıllıklandırılmış bonus. Bonusunuzu Linea üzerinde günlük olarak alabilirsiniz.", "earn_a_percentage_bonus": "%{{percentage}} bonus kazanın", + "percentage_bonus": "%{{percentage}} bonus", "claimable_bonus": "Alınabilir bonus", "claim_bonus": "Bonusu al", "claim_bonus_subtitle": "Bonus, {{networkName}} üzerinde ödenecektir.", @@ -6930,7 +6942,7 @@ "upgrade_title": "Metale Yükselt", "continue_button": "Devam et", "virtual_card": { - "name": "Orange Sanal Kart", + "name": "Virtual Card", "price": "Ücretsiz", "feature_1": "Apple Pay ve Google Pay için sanal kart", "feature_2": "Kripto ile öde (USDC, USDT, WETH ve daha fazlası)", @@ -6939,10 +6951,13 @@ "metal_card": { "name": "Metal Kart", "price": "199$/yıl", - "feature_1": "Apple Pay ve Google Pay için gravürlü metal kart ve sanal kart", - "feature_2": "Her yıl harcanan ilk 10.000$ için %3, ardından %1 para iadesi", + "everything_in_virtual": "Everything in virtual, plus:", + "feature_1": "Premium engraved metal card", + "feature_2": "3% cashback on first $10,000/year", "feature_3": "Yabancı işlem ücretleri yok" - } + }, + "earn_up_to_badge": "Earn up to $300 in cashback annually", + "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" }, "review_order": { "title": "Emrinizi inceleyin", @@ -7090,7 +7105,8 @@ "ssn_label": "Sosyal Güvenlik Numarası", "ssn_description": "Kart düzenleyicisi tarafından talep edilir. Kredi notu sorgulaması yapılmayacaktır.", "invalid_ssn": "Geçersiz Sosyal Güvenlik Numarası (SSN)", - "invalid_date_of_birth": "Doğum tarihi geçersiz. En az 18 yaşında olmalısınız" + "invalid_date_of_birth": "Doğum tarihi geçersiz. En az 18 yaşında olmalısınız", + "name_mismatch_error": "First and last name must match your verified identity" }, "physical_address": { "title": "Adresinizi ekleyin", @@ -7174,6 +7190,7 @@ "enable_assets_button_label": "Varlıkları etkinleştir", "spending_limit_warning": "Harcama limitinize yaklaştınız. Reddedilmeleri önlemek için güncelleyin.", "logout": "Oturumu kapat", + "contact_support": "Destek ile iletişime geç", "logout_confirmation_title": "Kart oturumu kapatılsın mı?", "logout_confirmation_message": "MetaMask Kart hesabınızın oturumunu kapatmak istediğinizden emin misiniz?", "logout_confirmation_cancel": "İptal", @@ -7727,6 +7744,18 @@ }, "active_boosts_title": "Aktif yükseltmeler", "season_1": "Sezon 1", + "musd": { + "title": "mUSD bonus calculator", + "description": "See how much you could earn by converting your stablecoins to mUSD.", + "amount_label": "Amount converted", + "estimated_bonus": "Estimated annualized bonus: up to 3%", + "initial_amount": "Initial amount", + "daily_bonus": "Daily claimable bonus", + "annualized_bonus": "Annualized bonus", + "disclaimer": "This is only an estimate. The bonus is subject to change.", + "buy_button": "mUSD al", + "swap_button": "Swap to mUSD" + }, "upcoming_rewards": { "title": "Kilitli ödüller", "points_needed": "{{points}} puan", @@ -8006,6 +8035,9 @@ }, "homepage": { "sections": { + "cash": "Cash", + "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", + "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", "tokens": "tokenlar", "perpetuals": "Sürekli Vadeli İşlemler", "predictions": "Tahminler", @@ -8023,4 +8055,4 @@ "retry": "Tekrar Dene" } } -} \ No newline at end of file +} diff --git a/locales/languages/vi.json b/locales/languages/vi.json index fcdc4fe6c50..631a4187226 100644 --- a/locales/languages/vi.json +++ b/locales/languages/vi.json @@ -119,6 +119,10 @@ "message": "Bạn đang gửi tài sản của mình đến địa chỉ đốt. Nếu tiếp tục, bạn sẽ mất tài sản.", "title": "Gửi tài sản đến địa chỉ đốt" }, + "token_contract_warning": { + "title": "Token contract warning", + "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + }, "gas_sponsorship_reserve_balance": { "message": "Tài trợ phí gas không khả dụng cho giao dịch này. Bạn cần giữ ít nhất %{minBalance} %{nativeTokenSymbol} trong tài khoản của mình.", "title": "Tài trợ phí gas không khả dụng" @@ -689,7 +693,11 @@ "invisible_character_error": "Chúng tôi đã phát hiện một ký tự vô hình trong tên ENS. Hãy kiểm tra tên ENS để tránh nguy cơ bị lừa đảo.", "could_not_resolve_name": "Không thể giải mã tên", "invalid_address": "Địa chỉ không hợp lệ", - "contractAddressError": "Bạn đang gửi token đến địa chỉ hợp đồng của token. Điều này có thể dẫn đến việc mất token đó." + "contractAddressError": "Bạn đang gửi token đến địa chỉ hợp đồng của token. Điều này có thể dẫn đến việc mất token đó.", + "smart_contract_address": "Smart contract address", + "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "i_understand": "Tôi hiểu", + "cancel": "Hủy" }, "unified_ramp": { "networks_filter_bar": { @@ -2154,6 +2162,7 @@ "sell_position": "Vị thế bán", "cash_out": "Rút tiền", "cash_out_info": "Tiền sẽ được nạp vào số dư khả dụng của bạn", + "buy_preview_outcome_at_price": "{{outcome}} ở mức {{price}}", "at_price_per_share": "Bán {{size}} cổ phần với giá {{price}}", "cashout_info": "{{amount}} cho kết quả {{outcome}} với giá {{initialPrice}}", "cashout_info_multiple": "{{amount}} cho kết quả {{outcomeGroupTitle}} • {{outcome}} với giá {{initialPrice}}", @@ -2338,7 +2347,9 @@ "exchange_fee_description": "Phí trả cho sàn giao dịch hoặc thị trường", "total_incl_fees": "bao gồm phí", "close": "Đóng", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled." + "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", + "deposit_fee": "Deposit fee", + "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" }, "error": { "title": "Không thể kết nối với thị trường dự đoán", @@ -5921,6 +5932,7 @@ "earn": { "claimable_bonus_tooltip": "Phần thưởng quy đổi theo năm bạn đã nhận được khi nắm giữ mUSD. Bạn có thể nhận thưởng hằng ngày trên Linea.", "earn_a_percentage_bonus": "Nhận thưởng {{percentage}}%", + "percentage_bonus": "Thưởng {{percentage}}%", "claimable_bonus": "Thưởng có thể nhận", "claim_bonus": "Nhận thưởng", "claim_bonus_subtitle": "Tiền thưởng sẽ được trả trên {{networkName}}.", @@ -6930,7 +6942,7 @@ "upgrade_title": "Nâng cấp lên Metal", "continue_button": "Tiếp tục", "virtual_card": { - "name": "Thẻ ảo màu cam", + "name": "Virtual Card", "price": "Miễn phí", "feature_1": "Thẻ ảo dùng cho Apple Pay và Google Pay", "feature_2": "Thanh toán bằng tiền mã hoá (USDC, USDT, WETH, v.v.)", @@ -6939,10 +6951,13 @@ "metal_card": { "name": "Thẻ Metal", "price": "$199/năm", - "feature_1": "Thẻ kim loại khắc tên và thẻ ảo dùng cho Apple Pay và Google Pay", - "feature_2": "Hoàn tiền 3% cho $10.000 đầu tiên mỗi năm, sau đó là 1%", + "everything_in_virtual": "Everything in virtual, plus:", + "feature_1": "Premium engraved metal card", + "feature_2": "3% cashback on first $10,000/year", "feature_3": "Không tính phí giao dịch quốc tế" - } + }, + "earn_up_to_badge": "Earn up to $300 in cashback annually", + "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" }, "review_order": { "title": "Xem lại đơn hàng của bạn", @@ -7090,7 +7105,8 @@ "ssn_label": "Số An sinh Xã hội", "ssn_description": "Theo yêu cầu của đơn vị phát hành thẻ. Sẽ không thực hiện kiểm tra tín dụng.", "invalid_ssn": "Số An Sinh Xã Hội không hợp lệ", - "invalid_date_of_birth": "Ngày sinh không hợp lệ. Bạn phải ít nhất 18 tuổi" + "invalid_date_of_birth": "Ngày sinh không hợp lệ. Bạn phải ít nhất 18 tuổi", + "name_mismatch_error": "First and last name must match your verified identity" }, "physical_address": { "title": "Thêm địa chỉ của bạn", @@ -7174,6 +7190,7 @@ "enable_assets_button_label": "Kích hoạt tài sản", "spending_limit_warning": "Bạn sắp đạt đến hạn mức chi tiêu. Cập nhật để tránh bị từ chối.", "logout": "Đăng xuất", + "contact_support": "Liên hệ bộ phận hỗ trợ", "logout_confirmation_title": "Đăng xuất khỏi Thẻ?", "logout_confirmation_message": "Bạn có chắc chắn muốn đăng xuất khỏi tài khoản Thẻ MetaMask của mình không?", "logout_confirmation_cancel": "Hủy", @@ -7727,6 +7744,18 @@ }, "active_boosts_title": "Tính năng tăng cường đang hoạt động", "season_1": "Mùa 1", + "musd": { + "title": "mUSD bonus calculator", + "description": "See how much you could earn by converting your stablecoins to mUSD.", + "amount_label": "Amount converted", + "estimated_bonus": "Estimated annualized bonus: up to 3%", + "initial_amount": "Initial amount", + "daily_bonus": "Daily claimable bonus", + "annualized_bonus": "Annualized bonus", + "disclaimer": "This is only an estimate. The bonus is subject to change.", + "buy_button": "Mua mUSD", + "swap_button": "Swap to mUSD" + }, "upcoming_rewards": { "title": "Phần thưởng bị khóa", "points_needed": "{{points}} điểm", @@ -8006,6 +8035,9 @@ }, "homepage": { "sections": { + "cash": "Cash", + "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", + "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", "tokens": "Token", "perpetuals": "Hợp đồng vĩnh cửu", "predictions": "Dự đoán", @@ -8023,4 +8055,4 @@ "retry": "Thử lại" } } -} \ No newline at end of file +} diff --git a/locales/languages/zh.json b/locales/languages/zh.json index 89bfa35f297..c44adf00b36 100644 --- a/locales/languages/zh.json +++ b/locales/languages/zh.json @@ -119,6 +119,10 @@ "message": "您正在将资产发送至销毁地址。若继续操作,您将损失该笔资产。", "title": "正在向销毁地址发送资产" }, + "token_contract_warning": { + "title": "Token contract warning", + "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + }, "gas_sponsorship_reserve_balance": { "message": "本次交易无法使用燃料赞助。您的账户中需要至少保留 %{minBalance} %{nativeTokenSymbol}。", "title": "燃料赞助不可用" @@ -689,7 +693,11 @@ "invisible_character_error": "我们检测到 ENS 名称中包含不可见字符。请仔细检查该 ENS 名称,谨防潜在欺诈风险。", "could_not_resolve_name": "无法解析名称", "invalid_address": "地址无效", - "contractAddressError": "您正在向代币的合约地址发送代币。这可能导致这些代币丢失。" + "contractAddressError": "您正在向代币的合约地址发送代币。这可能导致这些代币丢失。", + "smart_contract_address": "Smart contract address", + "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "i_understand": "我理解", + "cancel": "取消" }, "unified_ramp": { "networks_filter_bar": { @@ -2154,6 +2162,7 @@ "sell_position": "卖出仓位", "cash_out": "提现", "cash_out_info": "资金将存入您的可用余额", + "buy_preview_outcome_at_price": "{{outcome}} 价格为 {{price}}", "at_price_per_share": "以 {{price}} 卖出 {{size}} 股票", "cashout_info": "针对 {{outcome}},以 {{initialPrice}} 的价格下注 {{amount}}", "cashout_info_multiple": "针对 {{outcomeGroupTitle}} • {{outcome}},以 {{initialPrice}} 的价格下注 {{amount}}", @@ -2338,7 +2347,9 @@ "exchange_fee_description": "支付给交易所或市场的费用", "total_incl_fees": "包含费用", "close": "关闭", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled." + "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", + "deposit_fee": "Deposit fee", + "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" }, "error": { "title": "无法连接预测市场", @@ -5921,6 +5932,7 @@ "earn": { "claimable_bonus_tooltip": "您因持有 mUSD 而获得的年化奖励。您可以在 Linea 上每日领取奖励。", "earn_a_percentage_bonus": "获得 {{percentage}}% 的奖励", + "percentage_bonus": "{{percentage}}% 奖励", "claimable_bonus": "可领取奖励", "claim_bonus": "领取奖励", "claim_bonus_subtitle": "奖励将在 {{networkName}} 上发放。", @@ -6930,7 +6942,7 @@ "upgrade_title": "升级至金属卡", "continue_button": "继续", "virtual_card": { - "name": "Orange 虚拟卡", + "name": "Virtual Card", "price": "免费", "feature_1": "用于 Apple Pay 和 Google Pay 的虚拟卡", "feature_2": "使用加密货币支付(支持 USDC、USDT、WETH 等代币)", @@ -6939,10 +6951,13 @@ "metal_card": { "name": "金属卡", "price": "199美元/年", - "feature_1": "雕刻金属卡以及用于 Apple Pay 和 Google Pay 的虚拟卡", - "feature_2": "每年首 1 万美元消费享 3% 返现,超额部分享 1% 返现", + "everything_in_virtual": "Everything in virtual, plus:", + "feature_1": "Premium engraved metal card", + "feature_2": "3% cashback on first $10,000/year", "feature_3": "无外币交易费用" - } + }, + "earn_up_to_badge": "Earn up to $300 in cashback annually", + "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" }, "review_order": { "title": "查看您的订单", @@ -7090,7 +7105,8 @@ "ssn_label": "社会安全号码", "ssn_description": "此为发卡机构的要求。不会进行信用检查。", "invalid_ssn": "社会安全号码无效", - "invalid_date_of_birth": "出生日期无效。您必须年满 18 周岁" + "invalid_date_of_birth": "出生日期无效。您必须年满 18 周岁", + "name_mismatch_error": "First and last name must match your verified identity" }, "physical_address": { "title": "填写您的地址", @@ -7174,6 +7190,7 @@ "enable_assets_button_label": "启用资产", "spending_limit_warning": "您已接近消费限额。请及时更新以避免交易被拒。", "logout": "退出登录", + "contact_support": "联系支持团队", "logout_confirmation_title": "确定要退出卡账户吗?", "logout_confirmation_message": "确定要退出您的 MetaMask 卡账户吗?", "logout_confirmation_cancel": "取消", @@ -7727,6 +7744,18 @@ }, "active_boosts_title": "活跃加成", "season_1": "第 1 季", + "musd": { + "title": "mUSD bonus calculator", + "description": "See how much you could earn by converting your stablecoins to mUSD.", + "amount_label": "Amount converted", + "estimated_bonus": "Estimated annualized bonus: up to 3%", + "initial_amount": "Initial amount", + "daily_bonus": "Daily claimable bonus", + "annualized_bonus": "Annualized bonus", + "disclaimer": "This is only an estimate. The bonus is subject to change.", + "buy_button": "购买 mUSD", + "swap_button": "Swap to mUSD" + }, "upcoming_rewards": { "title": "锁定的奖励", "points_needed": "{{points}} 积分", @@ -8006,6 +8035,9 @@ }, "homepage": { "sections": { + "cash": "Cash", + "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", + "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", "tokens": "代币", "perpetuals": "永续合约", "predictions": "预测", @@ -8023,4 +8055,4 @@ "retry": "重试" } } -} \ No newline at end of file +} From dab238fe13bef1a0fb200c7b692e092e12a5e735 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B3nio=20Regadas?= Date: Mon, 16 Mar 2026 18:38:49 +0100 Subject: [PATCH 032/206] chore: Market Insights design review cp-7.70.0 (#27259) ## **Description** Updates a few design elements In Market Insights. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Adds new Buy navigation and CAIP-asset parsing from Market Insights, plus a `react-native-video` background; these touch navigation/asset-id construction and could break CTAs or tests if ramp/swap inputs are wrong. > > **Overview** > Updates the Market Insights full-screen view to include an auto-playing light/dark background video and replaces the single Trade CTA with side-by-side **Swap** and **Buy** buttons. Swap interaction tracking is renamed from `trade` to `swap`, and a new `buy` interaction event is tracked; Buy routes through ramp navigation with best-effort `assetId` derivation from the token address/chain. > > Polishes several Market Insights components/styles (entry card header/disclaimer layout, icon sizing, feedback copy/typography, bottom sheet source list spacing) and tweaks behavior so tapping a source closes the trend sources sheet. Tests and Jest config are updated accordingly, including new mocks for `.mp4` assets and `react-native-video`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit d1e6ce21969ee8db4ffb3da0bff380d8aff94ea7. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/__mocks__/mp4Mock.js | 3 + app/__mocks__/react-native-video.tsx | 10 + .../MarketInsights/MarketInsights.testIds.ts | 4 +- .../MarketInsightsView.test.tsx | 77 ++++- .../MarketInsightsView/MarketInsightsView.tsx | 147 +++++++-- .../MarketInsightsViewSkeleton.tsx | 5 + .../market-insights-background-dark.mp4 | Bin 0 -> 546907 bytes .../market-insights-background-light.mp4 | Bin 0 -> 401600 bytes .../MarketInsightsEntryCard.test.tsx | 40 --- .../MarketInsightsEntryCard.tsx | 72 +---- .../MarketInsightsFeedbackBottomSheet.tsx | 9 +- .../MarketInsightsTrendItem.tsx | 7 +- ...etInsightsTrendSourcesBottomSheet.test.tsx | 18 -- .../MarketInsightsTrendSourcesBottomSheet.tsx | 305 +++++++++--------- .../MarketInsightsTweetCard.tsx | 2 +- jest.config.js | 2 + locales/languages/en.json | 5 +- 17 files changed, 386 insertions(+), 320 deletions(-) create mode 100644 app/__mocks__/mp4Mock.js create mode 100644 app/__mocks__/react-native-video.tsx create mode 100644 app/components/UI/MarketInsights/animations/market-insights-background-dark.mp4 create mode 100644 app/components/UI/MarketInsights/animations/market-insights-background-light.mp4 diff --git a/app/__mocks__/mp4Mock.js b/app/__mocks__/mp4Mock.js new file mode 100644 index 00000000000..86b87e1d9c2 --- /dev/null +++ b/app/__mocks__/mp4Mock.js @@ -0,0 +1,3 @@ +// When required, assets in React Native returns a number +// eslint-disable-next-line import/no-commonjs +module.exports = 1; diff --git a/app/__mocks__/react-native-video.tsx b/app/__mocks__/react-native-video.tsx new file mode 100644 index 00000000000..9242ec6bb17 --- /dev/null +++ b/app/__mocks__/react-native-video.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { View } from 'react-native'; + +const VideoMock = ({ testID }: { testID?: string }) => ( + +); + +VideoMock.displayName = 'VideoMock'; + +export default VideoMock; diff --git a/app/components/UI/MarketInsights/MarketInsights.testIds.ts b/app/components/UI/MarketInsights/MarketInsights.testIds.ts index 27738175727..5cb679e0029 100644 --- a/app/components/UI/MarketInsights/MarketInsights.testIds.ts +++ b/app/components/UI/MarketInsights/MarketInsights.testIds.ts @@ -9,7 +9,8 @@ export enum MarketInsightsSelectorsIDs { SOURCES_FOOTER = 'market-insights-sources-footer', THUMBS_UP_BUTTON = 'market-insights-thumbs-up-button', THUMBS_DOWN_BUTTON = 'market-insights-thumbs-down-button', - TRADE_BUTTON = 'market-insights-trade-button', + SWAP_BUTTON = 'market-insights-swap-button', + BUY_BUTTON = 'market-insights-buy-button', FEEDBACK_BOTTOM_SHEET = 'market-insights-feedback-bottom-sheet', FEEDBACK_OPTION_NOT_RELEVANT = 'market-insights-feedback-option-not-relevant', FEEDBACK_OPTION_NOT_ACCURATE = 'market-insights-feedback-option-not-accurate', @@ -18,4 +19,5 @@ export enum MarketInsightsSelectorsIDs { FEEDBACK_OPTION_SOMETHING_ELSE = 'market-insights-feedback-option-something-else', FEEDBACK_ADDITIONAL_INPUT = 'market-insights-feedback-additional-input', FEEDBACK_SUBMIT_BUTTON = 'market-insights-feedback-submit-button', + BACKGROUND_ANIMATION = 'market-insights-background-animation', } diff --git a/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.test.tsx b/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.test.tsx index c876aace8d3..325fe7b2363 100644 --- a/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.test.tsx +++ b/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.test.tsx @@ -10,6 +10,7 @@ import Routes from '../../../../../constants/navigation/Routes'; const mockGoBack = jest.fn(); const mockNavigate = jest.fn(); const mockGoToSwaps = jest.fn(); +const mockGoToBuy = jest.fn(); const mockUseMarketInsights = jest.fn(); const mockTrendSourcesBottomSheet = jest.fn(); const mockFeedbackBottomSheet = jest.fn(); @@ -43,6 +44,7 @@ jest.mock('@react-navigation/native', () => { useNavigation: () => ({ goBack: mockGoBack, navigate: mockNavigate, + addListener: jest.fn(() => jest.fn()), }), useRoute: () => ({ params: mockRouteParams, @@ -66,6 +68,17 @@ jest.mock('../../../Bridge/hooks/useSwapBridgeNavigation', () => ({ mockUseSwapBridgeNavigation(options), })); +jest.mock('../../../Ramp/hooks/useRampNavigation', () => ({ + useRampNavigation: () => ({ goToBuy: mockGoToBuy }), +})); + +jest.mock('../../../Ramp/utils/parseRampIntent', () => ({ + __esModule: true, + default: ({ chainId, address }: { chainId: string; address: string }) => ({ + assetId: `eip155:${chainId}/erc20:${address}`, + }), +})); + jest.mock( '../../../../../component-library/components/Avatars/Avatar/variants/AvatarToken', () => { @@ -289,7 +302,7 @@ describe('MarketInsightsView', () => { expect(queryByTestId(MarketInsightsSelectorsIDs.VIEW_CONTAINER)).toBeNull(); }); - it('renders report content and handles tweet/trade actions', () => { + it('renders report content and handles tweet/swap/buy actions', () => { mockUseMarketInsights.mockReturnValue({ report: { asset: 'eth', @@ -368,7 +381,7 @@ describe('MarketInsightsView', () => { 'https://x.com/user/status/100', ); - fireEvent.press(getByTestId(MarketInsightsSelectorsIDs.TRADE_BUTTON)); + fireEvent.press(getByTestId(MarketInsightsSelectorsIDs.SWAP_BUTTON)); expect(mockGoToSwaps).toHaveBeenCalledTimes(1); expect(mockUseSwapBridgeNavigation).toHaveBeenCalledWith( expect.objectContaining({ @@ -383,6 +396,9 @@ describe('MarketInsightsView', () => { }), ); + fireEvent.press(getByTestId(MarketInsightsSelectorsIDs.BUY_BUTTON)); + expect(mockGoToBuy).toHaveBeenCalledTimes(1); + fireEvent.press(getByTestId(`${MarketInsightsSelectorsIDs.TREND_ITEM}-0`)); expect( getByTestId('market-insights-trend-sources-bottom-sheet'), @@ -415,7 +431,16 @@ describe('MarketInsightsView', () => { category: MetaMetricsEvents.MARKET_INSIGHTS_INTERACTION, properties: expect.objectContaining({ caip19: 'eip155:1/erc20:0x123', - interaction_type: 'trade', + interaction_type: 'swap', + }), + }), + ); + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + category: MetaMetricsEvents.MARKET_INSIGHTS_INTERACTION, + properties: expect.objectContaining({ + caip19: 'eip155:1/erc20:0x123', + interaction_type: 'buy', }), }), ); @@ -493,7 +518,6 @@ describe('MarketInsightsView', () => { ).toBeOnTheScreen(); expect(mockTrendSourcesBottomSheet).toHaveBeenLastCalledWith( expect.objectContaining({ - trendTitle: 'On-chain demand', articles: [], tweets: [ expect.objectContaining({ @@ -505,6 +529,51 @@ describe('MarketInsightsView', () => { ); }); + it('closes trend sources bottom sheet when a source is pressed', () => { + mockUseMarketInsights.mockReturnValue({ + report: { + asset: 'eth', + generatedAt: '2026-02-17T11:55:00.000Z', + headline: 'ETH extends gains', + summary: 'Momentum improves on macro risk-on signals', + trends: [ + { + title: 'ETF inflows', + description: 'Spot ETF inflows remain elevated', + articles: [ + { + title: 'ETF demand remains high', + source: 'coindesk.com', + date: '2026-02-17T08:00:00.000Z', + url: 'https://www.coindesk.com/article', + }, + ], + tweets: [], + }, + ], + sources: [], + }, + isLoading: false, + error: null, + timeAgo: '5m ago', + }); + + const { getByTestId, queryByTestId } = renderWithProvider( + , + ); + + fireEvent.press(getByTestId(`${MarketInsightsSelectorsIDs.TREND_ITEM}-0`)); + expect( + getByTestId('market-insights-trend-sources-bottom-sheet'), + ).toBeOnTheScreen(); + + fireEvent.press(getByTestId('market-insights-trend-source-link-button')); + + expect( + queryByTestId('market-insights-trend-sources-bottom-sheet'), + ).toBeNull(); + }); + it('tracks viewed event again when caip19Id changes on mounted view', () => { mockUseMarketInsights.mockImplementation((caip19Id: string) => { if (caip19Id === 'eip155:1/erc20:0x456') { diff --git a/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx b/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx index 9baacd39883..54fb634111f 100644 --- a/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx +++ b/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx @@ -6,12 +6,23 @@ import React, { useRef, useState, } from 'react'; -import { ScrollView, Linking, Pressable, Animated } from 'react-native'; +import { + ScrollView, + Linking, + Pressable, + Animated, + useColorScheme, +} from 'react-native'; +import Video from 'react-native-video'; +// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import/no-commonjs +const MarketInsightsBackgroundVideoLight = require('../../animations/market-insights-background-light.mp4'); +// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import/no-commonjs +const MarketInsightsBackgroundVideoDark = require('../../animations/market-insights-background-dark.mp4'); import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import { useSelector } from 'react-redux'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { Hex, CaipChainId } from '@metamask/utils'; +import { Hex, CaipChainId, isCaipAssetType } from '@metamask/utils'; import { Box, Text, @@ -60,14 +71,17 @@ import { useAppThemeFromContext } from '../../../../../util/theme'; import MarketInsightsFeedbackBottomSheet, { MarketInsightsFeedbackReason, } from '../../components/MarketInsightsFeedbackBottomSheet'; +import { useRampNavigation } from '../../../Ramp/hooks/useRampNavigation'; +import parseRampIntent from '../../../Ramp/utils/parseRampIntent'; +import { getDecimalChainId } from '../../../../../util/networks'; const LOADING_SKELETON_DELAY_MS = 150; const SECTION_ANIMATION_DURATION_MS = 300; const SECTION_VERTICAL_OFFSET = 25; const SECTION_ANIMATION_DELAYS_MS = { - topArticle: 10, - closerLook: 80, - whatsBeingSaid: 160, + topArticle: 50, + closerLook: 130, + whatsBeingSaid: 210, }; interface AnimatedSectionProps { children: React.ReactNode; @@ -138,7 +152,7 @@ interface MarketInsightsRouteParams { * - Consolidated trends sources pill * - "What's being said" social section * - Feedback row with thumbs actions - * - Trade CTA button (navigates to Swaps with asset pre-filled) + * - Swap and Buy CTA buttons */ const MarketInsightsView: React.FC = () => { const tw = useTailwind(); @@ -161,6 +175,16 @@ const MarketInsightsView: React.FC = () => { caip19Id, isMarketInsightsEnabled, ); + + const isDarkMode = useColorScheme() === 'dark'; + const backgroundVideo = useMemo( + () => + isDarkMode + ? MarketInsightsBackgroundVideoDark + : MarketInsightsBackgroundVideoLight, + [isDarkMode], + ); + const { trackEvent, createEventBuilder } = useAnalytics(); const { toastRef } = useContext(ToastContext); const theme = useAppThemeFromContext(); @@ -199,6 +223,8 @@ const MarketInsightsView: React.FC = () => { sourceToken, }); + const { goToBuy } = useRampNavigation(); + // Collect all tweets from all trends for the "What people are saying" section const allTweets: MarketInsightsTweet[] = useMemo(() => { if (!report) return []; @@ -214,13 +240,13 @@ const MarketInsightsView: React.FC = () => { } }, []); - const handleTradePress = useCallback(() => { + const handleSwapPress = useCallback(() => { const event = createEventBuilder( MetaMetricsEvents.MARKET_INSIGHTS_INTERACTION, ) .addProperties({ caip19: caip19Id, - interaction_type: 'trade', + interaction_type: 'swap', }) .build(); trackEvent(event); @@ -228,6 +254,41 @@ const MarketInsightsView: React.FC = () => { goToSwaps(); }, [goToSwaps, trackEvent, createEventBuilder, caip19Id]); + const handleBuyPress = useCallback(() => { + const event = createEventBuilder( + MetaMetricsEvents.MARKET_INSIGHTS_INTERACTION, + ) + .addProperties({ + caip19: caip19Id, + interaction_type: 'buy', + }) + .build(); + trackEvent(event); + + let assetId: string | undefined; + try { + if (tokenAddress && isCaipAssetType(tokenAddress)) { + assetId = tokenAddress; + } else if (tokenChainId && tokenAddress) { + assetId = parseRampIntent({ + chainId: getDecimalChainId(tokenChainId), + address: tokenAddress, + })?.assetId; + } + } catch { + assetId = undefined; + } + + goToBuy({ assetId }); + }, [ + goToBuy, + trackEvent, + createEventBuilder, + caip19Id, + tokenAddress, + tokenChainId, + ]); + const handleTrendPress = useCallback((trend: MarketInsightsTrend) => { const hasArticles = trend.articles.length > 0; const hasTweets = (trend.tweets?.length ?? 0) > 0; @@ -347,6 +408,7 @@ const MarketInsightsView: React.FC = () => { return; } trackMarketInsightsInteraction('source_click', { source: url }); + setSelectedTrend(null); navigation.navigate( Routes.BROWSER.HOME as never, { @@ -359,7 +421,7 @@ const MarketInsightsView: React.FC = () => { } as never, ); }, - [trackMarketInsightsInteraction, navigation], + [trackMarketInsightsInteraction, navigation, setSelectedTrend], ); useEffect(() => { @@ -393,22 +455,36 @@ const MarketInsightsView: React.FC = () => { return ( - + + + + + {report.headline} - + { tw.style( - 'h-12 w-12 items-center justify-center rounded-full bg-muted', + 'h-12 w-12 items-center justify-center', pressed && 'opacity-70', ) } @@ -482,7 +557,7 @@ const MarketInsightsView: React.FC = () => { > @@ -490,7 +565,7 @@ const MarketInsightsView: React.FC = () => { onPress={handleThumbsDownPress} style={({ pressed }) => tw.style( - 'h-12 w-12 items-center justify-center rounded-full bg-muted', + 'h-12 w-12 items-center justify-center', pressed && 'opacity-70', ) } @@ -498,7 +573,7 @@ const MarketInsightsView: React.FC = () => { > @@ -506,7 +581,7 @@ const MarketInsightsView: React.FC = () => { {strings('market_insights.helpful_prompt')} @@ -516,15 +591,30 @@ const MarketInsightsView: React.FC = () => { - + + + + + + + + {strings('market_insights.footer_disclaimer')} @@ -536,7 +626,6 @@ const MarketInsightsView: React.FC = () => { = ({ contentContainerStyle={tw.style(`pb-[${insets.bottom + 8}px]`)} showsVerticalScrollIndicator={false} > + + diff --git a/app/components/UI/MarketInsights/animations/market-insights-background-dark.mp4 b/app/components/UI/MarketInsights/animations/market-insights-background-dark.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..93ad606cc75491e1ad035d5542a971d937faf690 GIT binary patch literal 546907 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

UE7AN3MM@hW%vOwt>K+j{N84DAp%->(! zUw=i*JqiGMHi@j}R!CM4TBP8Bt*DtNmLVJ^WHrQ0^1tj@>(q;NSy$et+xS7en)^7o zi~sGMWOf=VT|Ld<<K8u3ma~A0|eMntY?H!?2+*!0qbO)aJct8al3|_J!*BXqfk&;{%N^=T3c_TdwPNL+zP~YnDTekb-v2V#}6VPz88~ z+Ar=GzjZr6gB6H}#0JUkX>jFGDq56FUD8|JXEyx=7{rJ^yN&UVE7@K~E$0QX@zf{| zqgT%R+a#L7y6aIsG@nJtT60)h$eWJ$BK3P2&j!|-WBxA#iUY+01!B>Nu=&DDH<0JS zL$;K8x4VNt!Hn*Mz2wbd*J zKqhIw?b6xOt(?j%1mK#u(pnLNc|C3NuW{e8(_qeHSiDoGM}Y@UP!S9{;6z%m>PyvI zxL7Xa>PB9E*MNu|y};z_Jj$9^lfB`}ozUntV+pOy#XXpU)!#IT*jlHBjbg~;8J47i zK&6T)JL{-3-jg9Ig+D}DHNA23hBZ8e&Sw{G+0$v5(1=wsUS~F>NBco|kuym*rR-*E z0f%*uW&st>mE^d(059?E!5N`=i@@gN@yV3@Q1kw+wuXMdRMV|qv?TBsj*Y?$x(l82 z4^WP1vQBC8ULOvVsq;nxnFE<%JiRDMGoNc>1SQm8T>w2GxUM6ayN@=4{isq&*H&ZS z>OTMdJebRY!$WYIiVqiWQA8i&V<_(#Gv5e{4+t^>)l@wMH^Ds|&?~>(zdDeOCng*X z7>Ce|tu`+w7zoI{&S!tp-PGahmEilm=f)r2fw{@yo?CHGW)F{2yK7J!io(HdjL&oL z?b_H(Z;M**J7NMo*Yt3|%w ziQ7jCsnD7QK7#|`69V$j0!ihtj${_*?f02O^f{{qC6og-Mysi=@33COXKmfE=N~2X zzxq*FkjVV)!Ii0jx?>QBKC>6|P1jWgF3Aw3vTj)rugFLIgM)-_r@+{JiI+r$LhRbH z0t{=Nf1}2z;hMo|V(ghK#X44zn9p}wH+iq$-5uB5$vYB!yO}_uHDp5tdBBlMw@y~~ASdgy0)4&Ny^p-+j%NYgH zqCH>*1{pQp8^*a-pS7=a3bc(6YKOQ7e{)BO+v>guFBS8ezE}>+UY0>+Ldco(cg`NP zYaUm1v=RRTFV8L~@JM?@MMf6F5hP1$t*mNtm6|NE?5=Gb+$7Di}YC{LQ-^0l2))4PBIp-0SLa2RTK(l|Q zrbcVuo=Kh;z#do02i(rk^dTG+O2OkKGAwHVoCFZnSMxVC zhq)X<2rqn=eeHG7lbjpLe;4E%@Ud%?t>twP0a3 z;(`Fr8AC=K^135}5UV940iwo#$ACn`#p>G>Jeu1Q83XEx001l6L7Jsa;Scgx8fh>HJkv zY#>1d8B#u1_imW=AuDtWNuA7K0rGlVth;1ea3ood^;p)n!+NArz;)WLVAU)?FFDMS zek&Q+!?B5cLj5tzpckv#R}`<5o;aJ4*Fk0y=yB#}oq#fJ%7$KUTUbWDmilNigrFYL z2bvW+qBp706!{IggBV4k;=QR3#DnLpt$_gU$yBIn+mt=Je=b-d#CE=|sX(Z=vj$9E z8MshOmK(2(@{mZ67508`hX`U`C>r#zb6uke2oh*5G+9HkB~5IRdDl?Jkyv~M`254L zZ!|(ej)cIUxOj-)>UazETWb~R(ZFBg#JiRZ$bflA%Cg~nk*#LvPF4cO!)iW0x{BYy zE4I_&x|$#cb$TUbO?a%Xk&L313kejeU!`~a*C8f$0H&A~QsXa*0zX#GSUGErF>|n{_`ZAN zv9gIgGAYe%AIxv#55A~%H9d=VOS#W-dF#I)jMA;0%%Z3O7@;{ZaTgx%Lk>KRx|}P4ejmHkmJX@N1dmwDYVHvT z9cPw8zR;`c?Bc~eL@-aTZPNg6l@RHB0MpQkPTg~@!~U`O-ZN69q5$c^9=-2JlsFSCo@exXNi&J)>F-LhDu2*a;$LxW zO-yS?iG-i={J;9aW@iQIw;MO8*%df6k@-*v`=<~OtXwss0USAW@t0Y=Z2BF8t?m$X z{;p&tlKV4SwXxXO-Qc&&QOYPQL7&tdx! zvpy;$H#oI$K&&7Cd+yq%SOd)yY>~85_n^IdeNvyCUL)da*2>aEDL1yghbj4OqnQb> zl;y6Moj^*sTpw<<*_m0baamlUbvS1qK$3wzM^V4+#&!HTKbyoz@lZ!R-|~@|)ak=S zxSW1sZx|;EGaK?}bd^PLJd2h*{_(L=^PtD}AoS?BhJv1OoPcY+smSi%zVSlTw_{^? zAF)PyV&8A04!T)W(?4DILu{nu;_ozMgREOc6XLcrvj2$@jyfN0cNb67!37bQt#yJA zLGuZ)L-!{BFiGhP33#a$+Nl-LOtkWKhI~M_fuhnA9&;o<8aB%auR5v;D~U94$Ddz` z^#GIt2y0Qj1Q87h-R^XaVCacu;p-kTXUMFE`;@d=H|m~TVNR@xySnu|wm&QU<4Z5T zJB8QV@fwm{?>#Kj@p=6Je#mf;ZWZilVvAO0of^K=-9T;%=?LXtX3Svd1Y%F-kAE+= zV>`ym)*I=n(uoCHus|j}>Y?i6qpqxAZ{6E&X&!Hc8%w3Dbu|itSMY^KcNCza6urUm zM<#>+Z%6fHZ!&^s7Zg1%&NMvC1SKhnY8VgKNO4Lpf~Eq$BaJd-D;E4?-E$^;)|z3Wy=Fmk6BV;Y?&t11xo=7CM;Ji} z3^V)Fx}IhK^|1_DbecCAVbi5Ki^OZA_`BdsWj?24wR$9=!uxrQve@j327_SurIw67 z>sIScgDvC0c&;%1V)Iuu3ug;rb(1nl)^V>|SqA*XE)r(-q7pInO^bN957mGW&L%>G zE@;5kMLJy07N4BK2GV@PFFq04B->IigOF!q#GyhMOn<-UrJ3-DWPIY;tkz;B@4QaL@Y=f=Zopg$}e3)`k9Pb@#9D>ais!F z>S?rm%?XV_qt)l6hNF|An5e2k?u#`TJ2v$rznv3}vHh?KI6G3&Yd8P3GwhE^6Ub|< zrRuY^P6Tg$4 zNX#j)e`#@%-SnYv^isNuQ1Cq)~T-K2EHtbt4IBHnHBuC!4+gF;^ncFS75H z&mmDd&C=}M{5-V{pCfQYB)b~IBq5RS-kOCrhE^gk^uwq+kd5TiW^}6y?D-FZFp=et zUMb5gw*j^p`r$3Ri0!XEWZ5OLhpMahIRnszt2c@w>G?3`W!#3|R8d}SoBZPJ;OA-d zpp2t0x1k_)->tr^^D{bjZC(4byKGCp0(3JM6bS@Fg@uG=nN)#St8K*@4_BqAW?%D- z3U=ktI980$LYudabAwikm!Bwv;1=l`aK!{rt;HMYOQ0uvhv|;H3FDo+hSSpM-NqAE zB_WgC^&pHAX(hH+K=$gGnr&?U`GP)yF1 z3&)73dKe!vaBs5d)7JbkZUSaEzm8^B)u$E0@N7;clgVoqBT1=ex1RPtYl61xKxr~= z4fCm}j*NLV!hY}E1yCaXSeBJCcz>P$lb)o(c!*#!z~ZDWJ7GdERE}|M*+yRG#hOic zSdM`k+|OKmyy);jv+Cz5`+(}f{?nSb z(im5r!UZS_ChS;OfBUeC33?x#*3+z+ceSwNOsr2YSZvfga~x{)mYh*S#5D*~*Li|W zt0wm?Ok~Q$?34rREsYv6Wc^_vGIKmd_JCD=`S-_hzGy;37m+|I()bDR6!|$TR!By@ zUmjBzF?cj&_(1aLKj`~8V@f{$rA(igv27zNgweB-p+{nI(O5~NFX3{q|9w7B!lWt> z?7*!aIpA58OQ====#SDn1NJtZX00D}%jY*l=rdgX3GV|`Xb*z?K^+&P_YEMx!Fa;KyTPP_Jq!Id{90!`7m@1l2D3EmA_G+B7L2ZM@%tMrB z25NUP;M8yWgNGzmtPERipPu?t!wr(Y+UA4CxmJpsnIIp&vqkw+4PahQe8f^Vnh)Uu zJ?9gQBDJsP%3&Yk|4YW1!~V9NvaX1?_y)>Z85xK!r*`z?ITu|AfGIVtnW z!0Dj;Tf;CSJT?P^%_B?;26giBsI?2?2ANL~>O>x|Spw3aK0SP2o+C9>SI~e&^xg^a zy^Lv>(D9vRXSwpd&u9_R+ANRci$fFZxfetdy_uk2qRBiVi|siRTjh0PG{4#)#fgsA z76&OW|Nm=1!a)0g@YW7m9YN9ey@Un?j)WV>J-JVxF{@XP1%zq;wg%cEx2NKM0XQ+lTM=N6Qt4Ztar`kA2 zGGqh02@GTcw@^t4A{lA~6CvpOA4t#)CEMe#S|Yzn#jZDSSdTT$7rBT+VQQ)y85u`d+8yj2Rb^T!@`rvUQ?t_^)a`vraGrG zeF%hSCd`icJweh~`G4fXFMnpnF~48WK4l?70?qE$BP^!e9(GELtcCCp*wEt$F`GwF zd2FPprj)kGi@78`G5GMhgx<&m?gm6Vt&dI28^_N8=a%n$Bm5#Gm_m|LY$t)_gN5bJ z1in6Uxl{a!75c_L;TkyF``Z}0NWTzj@Ru~Ofjr0A-AhQa5gcS5^2M=0?kVWSUFTWZ zRIs8<;^TQ`&?-(_vJm`B&>=tg#$6(hU0e7`Gf}%4<7-iKj^leF6gs{t4zP>*i!z7| zK1E4ae|bk=9pwYj0cVv3=6g!!JzJz`+RC4Ol@i!3|L6EtW<%uB)beFJx)23Y2!xN z`MH?rE9?~Bdt?P+$&;elkGImvH8!3AJFsK~S(+xRahiF0!vUL9bKoQtV1wk23n`R) zWg%N5Uznoip90Qyk_qmP4jQ=S6z6gP7)@V)OT|-k6BRZB_&WT*;70)X790s}o~456 z{{#@$IGzDAeaAA}5@1Ls((K_Shono;f9B$+7OVS-)1JJ@g@5|zZIO?<00+I?L4Na? z5;v7;)*iGbjK!X?uT6urGxOov%K~Xp9#=HMGqCwNm3q6?&ox+lo>KE80NNd)q`h{P z>?lDq0|{>A6*(tufpif%D$h~Oq;g4gH*)&!cm}NY*63Bc=#k{vG=ftQAI-Ct^&Ah` zYkM>3i;~CI9wjHRTLrYH-UXo8UKtTLBsh zs8|I~AShgMcQ8MNQ)5@#*B^!7SPU%YpX6%<495`pwB{x3<9-^S3W&q{;dTaRY6zNM z7&vomm;Yd$*-v#1Q*+laL@scQD`f<-O4JHigN8{WkHQLkmi5Gyo8zh~y>P5oTwdBk znR{hKk{O~TROYpiILsCi0`0XRjW>m$D{#R!z`!JA<-PehD}yz&-?Ea=~sb%1s_?SaH=ZNws?%9$Xh_xMP| z9HVY8&)gvKGx6pRh5~ONOLqIe$gMZ&Zec2UX%?ui|G}(%9M~w(UyWy%*9aUkMEpuj zrtg-Pnq;D0H5p9zl0rUdrHHb4)quzoOlw`BoS^kZ2UIh_g4rkP^nyVXe|rnl>egOF zHsW`x41A-aR;Ny-XVS$T6w=~!+OrDJHRPZSX;2PS|?Obaskp}$fE~p z0r>!%RsCR6kG1CYT=M!U3>!w3RF1#6i`vxg+p34NW9U9`W#7kVDr98zvFf{f44`?A zc0D*f^ey#2D<1=+nJ{vk6L?(>e%7>Pj$H|&Ed!G`Pqs1#YX)X}O@p@UM2vK8C1e9n zfnmA0I&dMOo$cek5#lj4?n)3`n}mOU)(ZHsz%^k3HG?PN61}E_V;27Z4c7dNS(^YE zyvp;JuG~{jCJsVM2{QFN%PztW660yafjNj3^^b5`221yl4LbZ^sObuNvInS2#nCq$ zv6>={3=)~^6fZgV&~mBfnzW~59ExHkK}FyaSE^PIB**Y=9F05(#B4{m2P8%NSiQtX zcTO0%-&yJ0^*g#E!)8|N%mLPgJw!1{WAXLWoK+*xm)N7{O#gPCD)XZzkt6?66vV|d z6bOePLo90D-uiF5_z)D83Q^S5-<@h9tdJe37h3>z|8mj`Mz9785M1j;D{(3={9@a< zLx$C!3++I1nUz}Ae`A=1+TcSJBl<@2o+6L7V$>K4j6Qat4S;DW_7Ox;5jN_!kuz8A z@Ic^kA7j@Ij=o)`KelhoSXitHjr}k^aL6TSd{%K0A(M3X_8J_X zQ#8wyCQ%j`vkSuGQXN}B$`@al3l3@KvhnYRF)#?!}I!jCytng zWLvmTrELHmdW2||cj4Wd*N075s3Ao4BoOJ8oTFUl!9`jGd=c6b zpEK#V`d3Cd-G4ZWYU~YgR%ItzWFjH|>m0WPKFw*=>&XUnzhIyBU|hMJ#W$9C)F^kN zg6Y;OU>GR0_$@cQC9VHoqyXNk0k`P%roX!oA{~vA^pCCgJ5$lBq=24z?NCbu>6C9# z1f#@Bddz|z$L^CdHRoG30)u=_0o~3GeMrj}V``)fU&qU9t&bc*wzdb&UXdP9XPwc& zWMIBJ9xtW^i7pFKY0KT^Z5xEmBgZpLvD!3Jm>MQoOz1R@xByGJg!y= zyciIUXUv?G=}r4dfseT&bwX*m+TF6$t}qbX$k`a;g51^oMrA{`M7T+GDvHZ0t74k3 zO!%I%Icu(cUbfaUdXuRQo5tGxX$9N#rLh+DubX!kvpzHsr|<%QnStj@q$tGas7QSF zX@WfVg+ttqq8bOGOf<7W*1IB%jup;W%8xr4F6|JfD7BEn8s@jR{e9&$v@YT(q9Odw zvD@yolqOeKqm*s2!wDxji2O5!ST`mhvU|Sa00$)xrm6^lW1_iGlHlgq4e~>I?@fha47&&UC8)}qa!hv$-fJIq?0WY;lQ2l1*A zFN&53vT+n+dXV^a>L<+r0<0HThjBu6BBgww&ZAy`F1o0?7p@ zh;6`*nRW+K_i2yY{ei2zoH?NNBO3Apwj$01VmYg|%^9D(rJB64-7@R7Wjc~=$jj>|&Pi-T zk?i27R-Ncf*USxU)&^K(!87L&v!tt%j)b+y#`;2Va%q5Zi&l7;IySgBLJoge(9D}+k ze1*fmg?;uxPW0c)1r!hAsOjutC_aAu%bgDErlW1cJKFzh;XBGe}MOL)&| z6+QEONTjd?lBr_;$J(3x1A=p~>9q!o-#PEKAc{n>;pO6gdNyq+4#Gk6@$pOI1CY0& zbJDKevYU(Vp3)~lM;yOWaIw6|VJpze6A!KB=C*Lmm=UQomp~X7AhJSNp(wXw3UFh~aYtTMA<Eq{J7v=vtI&xJL}y$0`l9E!hX$;@gy(nbYB;9Oh&X*L=54SYSu!-c?F$}GTHNZ7QQ0tL9cM)>T!*qLCC>JC9@InuTR)V$*fzR}0K%L>13hoH z3%HM%q4s=q^@TCS$ts^g} z3mjuilk3tiQuu?lI2v=zC{0z9s6xBXBy4UZg(X2(m_6$a7qShdOc zNtHmD6O*z=Z`~_Zg99F83b(Y`7yEgpx?h7pt&grVUH~Z#6G~UBJ`l}QDj=;74HGV< z@1!)_4lO}e==Z|s4h2xvz0#bY(!3AONn5LDuh}2n;vR_SKD*@12LB``B3RmIrW@lw zRbw;?kmLu>O@nq&^r~PC1P*r}jht}!6}}Smqaf67+F{k~P~?G(6`%mHTL>3&9JAhS z`(pRjx(LeW>p}}S8oZdDc@NeO8L-c9Som;ADs2-=hdyP?ebVBlK4s|${J7!kR0hYN zV4X{B;YXSBsz>71?C1XA)2bf#6&u3OvadzJPJ5{)XM?0?l7Xt!2z4k1Q2OrMQ88ia zoEfD;aTE6gU~^omnw6yn%$pC(X4YRc>Fwg&ogaleOSH{N1Q5;e*-||Y5l#xsye~;y5XL-f&8(fb!dDA zgscrt>Vg%Q0V%M?1^IoNEC-dmry5*~3&6Up-|=f;5muV*X`t{P31&pc|`M-+m`G2(1Mg>(tFchTD^OfQ1x zV!8dERaf~NdyS4Xt%@xyk6S|p$a)f$9zQD=J>~Ul%4pD0&%b22{q7Wg&ldoOcHW{8 zIBrjJtHiS<%}e;3LrIpoKkTE?vevrXl`SVGeuc!L|6i752JxuN%`@)3L-N2mZRt8c zH7$}ko`JKd52v7osacY9B`55MK?%waY48v42g><2qK1N+j|$ZmiuPGxTi_M!<@390x4HO zr zj=|BIzwsuJ9B^V&k6Z>pa!d?CSd1rnNe0O36r2C3(dz^sFFbA}nR|gbe#S4vL5+o3 zZskG3ETe{)gAq1YQ}p#CiD}Pbjx`>*g_Ju;$h++*JE}9j+xmS15fI~^3jkHE1!-ToXXkz@hRS9Vr_O&e^>Qt1V*UI`is=`u_{c447gHg zXArRhEyWKSyMh=#F7G!`WK08W>f|8zaYBgqrdpC3J2QPoDkh1l7bRoZgoRdI8u{4` zl&~&foaOEtS2nGWW@&$sZw(&4$t8`8~MKdvm3ABmfj@Xze2>O0wv2-sLt3+L&_LM6m zXVkHP<7{a1lnEo%>Bg1D;8FI+I{6Vuc_VHkp&5lzTBU0CAU%GmE^yasV_=qG%X9Hi z>j>12;}Src!WEVvhvCYy{8U^N%aj@7DAwf|24gF4W#Nf)_FgP zN;t*GcFU6NO4-ewp}#OV77dNg6;MAs+2!3q^e4%=bN^_|l_dLvM;7POb(+f18FOAX zCIR$amQ7*I8hHQjwGGV&GW8;Y7GUvwH6g#!N5Lihhh4~r6l9W!7I zhPyMla&owp)Q?w?+3Pv!x&K24%t%&62q*N1{$wDbz5;K1mbcIx(Fosd*)pTJ9jWKx z-(b<^6(EMHF8%6tu>cx~(C~fboI8ljtFyPrt8U?XXw1jFh(cjPU5)NHLK~@I8mb4a zdI9FAKN@1M3FO_sG3a_y>1~{u($?HbWGb9tsVcY*J5ZT1hVrQ2&Q(jo1rCU_IF}=! zc@DKU{oA;Rl^=(Glu%vO{Oj9LV)Oj9gTrj2z=pY!^C)h4peuXrOn>;Mh-vL74fmlynhPNBs9TZ(8!gCU-M?kx+HWY7qQ5C?~t|r}W z7|7b*p(_Y*?c5EYU+_I+JZf6VPh`BmwXFM~IyBEFAdV|gH8CK)@-?NX6Y z?a82KMakt(2jwauaWd>xENsY1Tf>0Q$$i<)f2w5w(k@*&r4cRELzhll+J6pF{1!a* zT}|Q+^qB9lT{VU5KYjc}9)*JRXr9LrESV?U(lf>lN8iAcAr~QZ*sXmFFt)YZL@&hd z`r|}x0n3nBQQUoWLql8NB z;PS-Y?SnTX?A8`3Uh5%HLoQKm?F71v=;^KbSy@A-t{MU;V4IT!+l<&PXK`w0gg-0HjjT3hVO!Ajj4f|30DJJFTbhd_u<;$>KamgZG5pOYv4ix>Nu6 z@GA#2^iLw}yI`K&Z`CnC#Uc$fTq|hluCcfqTB=Zp4#wAE-HbCQ_PCOxsola<{kgQR ztJZ&1e3I-ns6W1>?KVXhoOKl&;5Deu)>f&!p;or)n4+6*q=SnXzXoh6m=KKLjos8x zA$UT5BioJzs&L@qBNt*K^G2Ko2dvdMiWZmKus&~N9zvOmAy+EUj^Ux+@D?W?kbibP zl5LMti5`;KAdM_R)#4gv@?t#969~?qV57rr>bh-U46gS0;WSqrO@NXx->*V_nOlRZ9VK?G6B1WTf89q9Sk1GH+%0j3UhM2Y_wyuF!w$Mq5kF1Q6ew+3|4 z*7zqCLW}?h1sR;w1R)ZUXLV}<#zd}8*6VUa(uPw?nG>AEl$u;+N&7Jp>vcBVlg0DR zc1M2PH{$0SmpHymx%(U`#D8V}Vc+`QqD9NCR55Z;^?@??^a)<09+&z7g&VbtN}Jo9 z{W+RUUU0b4odTJSJYdw|b)9*m}l`fevUk1`|6pX-L zm}Nj}1R=fn1eHX!C`G7CgdFcs^0g~EheIALsj-YvKr;IiQ4d1^040J!n#N7x5AtO& z0yqEu+Nb!*7$84M-Si6vJYWH+_<)T5KJ-;byd&en;Or0oTesnl?4IE44`D>hTXVsk zUs*Ipddz)y)QblkbpF_Ns>$=2#j)0DHci=&YF6MuhK~1zTugF?jIkTNCPoT5a881j zOt8pDlogs})#|hQz*#>{ZH);)N~l;|`usC}-B2WiG>EC4g^gXzd^lO!`6^HrvQ(@t zUa4-Gr7pkusDV0pbM;i|=PFU*R!8Lzw6Nd+00RIq!?jpcgaWzg29runq7N#Vi%Jp5 zNLJT#BCWg|;HIe~&_D@<)IjlW()W^^j2J?Fq~hY66|H7)Q#+UmaxRy z^7T8?hlg+-sur8eKKE@?Fl@g$tq0yGC5X1W!-fcp`&~%85&MuIY$M{|QF%Ccljsi5 zc`Sl!V&53Rv%|IJ!W5!dOoVlZ{*2{xr|}o6 zQP5L-hcx=Zpd|P5(#sBT|M<-T&=sa*dMr+k%~K#xT4^JEJ<1huQjZ1#G5_N*HrSlH z>mZSArZx3`hkf2fC+nYFCRxYScd*Cu@Xt?i8SvS2qU3_-!s}1$^uEukeGFj4%fCBqF_Kq`mjl{GgzJyO*|EEw^jn?&rH@oEBTx)SJKeP3(7* zugeeMWu!niQI)A17P#L-C`ckrcOa0av#GvZEF+Cf zXo+PQQr05#l7(1mNFy>>$$>sh>ATo=#xD^k@*zyTi+m@dsQQuk@3?*Wcv8sDKHguQQ0M{Ranl2qm21Yqo?*v^y zf3L|-e~b2Ozs%rC*Bw+vUajVT_*#bOzgEwm`)%cs&(46(r-Z%AI;VW$7oKUuMsYo9 zR$K1UuCRn-b$BL!tZnv+#$Pm5{sT45F*i=ha?m~8Cyoab6IS{m7dM`17Q=>0A!eZS z2Ra|bNH7v5is*%#>*EgX z1hY4Q#JamPbd6U2{8;BdF9uU0>V14f-&;+ZPr&mv>o67m;2)aeIG`|D@6iNNnm@J= zB(`P7TnKe{GW_tea2;=;R`EQs9_w`r#-NgIAyT+m)dKfIIhMeDQ53DI)t3yN{X~_b zRlTyg$K6FV6H~Tbp&$_-AbHFy+v>$Y1?NSg&86U=Xl=o;OUzjp$eU9X<$F0Uqi{!$ z>0)y0FQ~G&-j+^)9a5qOtulEPSfJKImQLK{AL1Hczr)1Ye(ekr1-q+-Dyz0V2|2+6cNjD%Ql&Gq$>B9O9dU7jkP{R8}7(%B(_ z7VMS1$Al%94v|f~65dcBo5HB&apIXb-OJO^#iNP%v$J2{l^Su=wwHP>f@!z{Hh*kQ zl^xyqL8QSii2)fW*R1F`3$;(f8BWgS1msx{&SafWMDn1km{zG~7jTGG%wM;Vv21GW zPl0!!+e?&vj(l7@kuRF+du8=op$f06)a9qIM!crAO+^*k!m0F4F+St{KL~%T(u;1~ zduBQTQt1>*z&dBVF7BNUU(c7P(1taiS!Hts;CENZ2v#Zll)N%o!})K=bc$aa>*f(| zY#4jRQxoqq1EjW9qd82YR9Mg!KeGBBC*mry@s+u%q05FytGjGxGkQnPfA^Jg5nIw1g z%6od80z}td^8(D$BsmI@tFVc|ELl5DQV_FXY256Xv@9?Z9#t#Fx1`!qkY_2L#^ufct~dV+=rS&3T1>gE0lb^g;yEjo_NrYN$eb z=eA`|4ZOY9@oExc^SBZ=8@rK)hQT(_?8({qI*KyJXQIR5$d_Ia3tWbOJrKAL0f5EM+}auP>D)@U0Cm z=G7CllE*q&V!vB0NM-vWfxY)p)-j-(+6XAzZp&EUQTG+DG_*xPdx*0hi)T{cJ*%bw zTT|wQAZ+U}9Vv`yzHs+sKT_ojs(cc5U8u|h)Nv!d^e98h24Kyg50Gf;Ed$o9&+TtB zxbexgO)%B`LU%c>HG!fmmv3CX@vE)LsT6-j;QvFeKpKks;X)5!6H-Ri04`ovg(|o) z6#p0*$x@-GgDm z@+H-k;AfdQmJgW53As6#Q}aj~Cx^Yhc10->4c1t6da5k$sOvp5^hI)LQ$+#o^5b&9 z9%_=o0E#C}Y3c$+^0I-1bFqb}Y$e0&=J6lBe&_MTTOdeHpLJcu z*sKY=Uoph-%{Dl#odtVfEuzxaoxdFsEb$l+4UBgI!J*|xyViJt=?`~?Dg<3-^<7^A z_PfKwrIiCdk(`3ymb1wfWj$?tahKkAw`=f)e1b~^Pbf%y4yogNWg)JA3;B7$yIk$0 zm!ZYXZ|@B!L&%jYxu1ihQP#qJ^fxWw@~SPeAxVd+mRZ}_Jq_7!H-Z0E8YlMQUe-aj!eBA z^g1@S^)&t|Y%wUc5XHVQDw(nI@y%)l9MWGO9O4ao zDUo;#Tv?F4+~LLVN10;ko{FeEC77^VOShInc^^BmJ&V=`XXUY$FlleiGNz1SlHQxE={AoJ<>bkQ#XNk;RGJ?+G`Zmi`NvtVP~JR+`ra>8YH3iW5z zj`*K4S~}U~>k%$i=*-KDc9nQ#vU48NYcQn~0Y-@cLpae`o^KU$4%k=k3Sgf8Z{$_qq--uN$jh4PtgDD62hym~698lF)Q0Wza8DP`;j2 z^_$DL6<+hCX%%8gqhFQQ9fu#OwXO0)@2~MrUJZ&s!~`=)HD2HZ?|1{QX8`nVjf)Z% z&~6V#z8f(!^)_n(V!`n{?kiu`SCL7tJ&Mu3oPLznKC_*X9{-@NCVu9i#>EWYf-d-K zRMX(6aY&kvZIH>ph~))*^`dUI9&@wzocVkyJW?eOVDr>)H3OS+v4CJUq|SM37Ot)@ zRw{X-SsMX7-NU4q(SuS&crsx4nCoBxTaS;Qb%G(y1?@Poc+#E@X(X^VVE4+8-zMj0 zB5c_d60WW=d1?J~kdlZ7leV>D);Gt);=b?VB*e{)0-Ev#OK^GB`%eYBj;}77+B|RU zH0u15R^jSbXN3z79JZO{I&QjmNsc=_HP&0;KBu-OLyzh}wH|f&g_VL0x*~TRlH3Aq zNZeW%3$R?@$N&NTHSn%j=wFmjbWRZu*0?s%Cxi!uX?X`l|K{{@|BFi(%lmNru60(W zql`EEH|6rYb%8sDZ8h)Y@%G|SHcJt~OD|&cRIhI0^pAx5%IzOqh2YU+m}cg(j$>QD zTjW2xD9N5BheU%fXRQ+0k+p2a?+TvW_41c=`c+?2_mNx;l4)FNiX-@@bh|5q?3Y`0 zU+17yRLmHE9A?EwE9A-(I2~1)j8&Aon?|7rf;nt0p9ow&2`KU(b4oM5p}VdpghgYG zz2FAC_xH&LZv8(2P5S4|FYQbQSl_iS8mQu2G3Fr85^9>p?IDElLY`sXR>*;UMO;M% zI2Hn^L77$XryO(S>YMwv3m+kZp$X4Ax*z3DeZ-hS^AFnP0EEs7*wf;Mf%xp9PYviJ z5sHX809J5qU6NQnp1&h7x4r{SNx))qcae>&?z`Yz8A z9gsx-x|@Fd9HEN*RFqE0e6KwXS)|C2TCa=+3|o9;s=*Z}z5-iwUVYTk!*R`-g|V3X zrsq#boX8vj(Hc98`zi^-b`RwfXKl>b$y`&S;Rp>{8Y+K2K{0;&uX0 zOF4dydsqcKm^`^n4IQ?t$k0eqIV-im1$i{Cu+dcaMv7QLY+nDiO@GsDN$@Z5An}mk zC-C_8x5SKmv2NV5Hg~phIv}X^_p=J%dPjyafBZ8o56>wfQ7mZ4Tpw__;lG)~S$cFu zjba}1@u`8UPUuN_$((g&IBs{&y^ahO+NYBC6LWRUyRz%GI`+UnSm>r#gZ6mbSt}CQ zE3izMgOl*o3ud<`!Y2^MuoSe@WV-bLt3T&t7%Qn$obNnzPA`<-wByx1-G|MUY2XuT z&({=xx=&NuE4LtBx*kL< z8f4F);uHzj(zFnbAm^ijv^9d1@|Eq5fDr z>`;28G)oD?!AMU?PWuhXf|{35=QpbNU}`U&#*@tJd+aUv8i+muS=YFdA%Mx!AasOd zdjrACQ4vB1OxernAB2*l-`>R5=`1?k!K_Gd?f-Lgg!(I2gv>+_l$JAzjVEy^kGYSy zJ2tWahY`Q;vp#ozo}}N~U;iMqJonT@91Ofvno;?h2O9+lN6nQ+0cMPZJvxftBz- z^`Qq5E~wrHU{u)pzad(wrxetvRh#POQViHQL%E27P*!98a~|%ytXRvxL&8h#QcDu% zj!sgd`H72WUI~U-BH%zBvkKv7f%^jl{j4Xf-%;q61)H;AT+zhpm-@uta5>(|5^LZ4 z@}vMX4&Wc7#A|zcc|j*VV+VH++o>MmBGUs4?a%?nZ;HY<$zhD}GsRtBOA95mG#~$+ zsooR$d1S+9HJ|`VL!=a}bXLjQJH#cGcb$~}WE%t4caaD3aWMg@!ChHNo3{#(9)FifIAsJ|Cz+-|iCYnLSQMxkru&DDv= zC80aPoYH*8J>shirO!&2)oZ(Ya(mk9cwXj#ChYTQSQyu;vK|W70N#U8&P_a_FB*6- zIRD&Y9Z@`JDTE_&ta2%HN;ZUtjtM4b@lRYNMFVtbNk|^%#Icj`R&ogB*ll-&B)Hsn z%lV&byv2w`h1#Au8n*9p!n441!D?8-IY>{gCj73d1^899yQ63>94;v4NzxZcTNdS= z;0SYxCFY7iPYud-+GX3T>S2ICM!W6B^gY!d&_^w1P|DD)7Q4o|9 zF)+w>;B=&7D^AgQO`6j%kJ)Ro& z5;&8p*;*=5bUJt9LB`RYU%B!+pLCbZ{e9}jnYF(QMX|q3(#1aUSk$lmhZy%<{2AJu zm0Pf$tZeY8zs1Jpoj}v};7`SCvL0zk>&0yu9YCXplKj=@Ljwcp_d`aO(kz7!h7npI zEdGxm{+}q^mI9b*R5%OP+7n8K7%6pWbp8@M(A(1JCWtmAuksCc%hUi8Y5Jx^G*`k; z=cT=tws3YZ<=_zgne*bbCIh+E_^(Mv8_xTE<+D1IyX08qV5Z88Kp}pSgl5wtMy2i^ zNs;Yk>MgP6rS@j)Hx28UxK^rITHVfaH+vVB&V0spVB2a^1mm0y1xHT=13qP^)395# zk!j=-wi#}~u1@ZQvVhY(paI{ieU%VE!*g`My6za?iV+qh_$VGMQaa`*3fRo=|1z(Z zyaFXqu?v&^Rk_EUg9AV)tn8qf>2?d$lth2eM1hu*-0h^z?*R~%o=(pi5a904MTY*O zw7CoI>4lGszf@B)JZIc_oGEX12NRQOmIF- z%AhIimQ5YAaAUj<$BvzqGP^?*@EIq2|9C$DA5gf*XEa4zE<@*p|Eu@Umw?xwp{6&= zZ$h(#?cFQkJ+`~Kh4L;s1o8S|`+Nt;t)pCAf;iHT##3-I0;f)`(;ur}_JTGlb}9 zUQwDvP;_edU($lwN6YEaV~MMAqd&F>S5MF<`ixN>znt{2xWc*SHx>pB6M5ca`g=Bu zywv83rlvHLML9r037r)R?|idZNg)|hlH#u@l13(N*|P_QA;UY03I2$3Y0I<`p51iU zpdp-7a`=ZC-inUP1|Yxn>b*jd4p-uNdpNJs@px#aA{-M|rhv*R@H_O zlC5llzo6=6>k+nX*K|C;_XKOmLF%w!gd@ArT52#@P2W0T!Oo2+9?=kXjbj$OepxR? zs$Gdw6+@JKI<#jhG-u)Hi(Ak2jjE!wA;QPfi6CFm0&7;m1q%UVD01Hv=3=S=AZrc+ zS1DsjCV}gOAlgz0m}cDDfLI_1(~-{F&-+=U9?5#PM)cNK6btrpGi@KCIsju!)Y_SD zEe)EJYPra>?iFxhJ+tDl)qt}!BzEV+Rg)3k-0wZR=zVU6j5aN+f5TZ70FL5o8~Z)~ zQk{R-s7z{}NuQL^fSr5)5FEL%L&dFq+0OlZbkJ!{ng+EKjICq#1+H+&gb{Z48)v?S zHo2N+BzNZe6y((}V@vWbDn+yN8vmrtD<&rh=N>g9mL6*^DZk!!4pz>mDm9OT~|q~({5MBa$;R=*HP z3n4r)_P87@Qe;oSYUg)<=`e!Xp;DqwQn=vA(BGhgedv+2u@8*c{a)yP)UtN#LXcU! zAjH}Wt{^U@h{Yup-E&LcH%yQ%G!iZ%CNwgw2t(I!>YW}%HpRwk3v}AZcvvyhO@rl; z&t0e^hEf@+59=*fh^AyKN2O+#5+v=_G)-jx3(llgq5v0je?o5G@%9v9pqGL?|5jP7H;uW08(K)K%W#sY_wa@Y0L86eKCr$DTT~<`rB8?4d*x__I-w zW%~D7^%4>Nxv&xBa!UWK#q(=Mk)CKml3Mr;g?_6fpXvkQx z6jOH?hMZ#@WDUOUN`#YLi%(P*L9M;X89ZD6B?@CAar6pklkw412d(eH!3=QSU;GbGIiNdReAVl`(4O zHydpzH31?(V78ds&~w-Xv;C zLb$66n101HDAN_CGhyfozyd_RlahLk7CU;m;dkv87tvth$ak7Yu*desq4vGI z1+^PxF(8-DbR*lh>s2Kj#<-!1cBSA30xURv&v?s}TU3CLt31K{PhreRs-zfpW~rwp zfMBn&L;N|Wdn_DdC)=J-8uGN7TYxI`3*?A+7968MQ-T=d4QN!jp{l+AyEONy3d9Hq z>v_a41}Nicy8SzC02KJ>?nnD6N&XH~#QmfbO|D|s9(t~7kFtXW)YkhNn;Fikh2hbx zaVi~R#Bxuy?6FFS>&)yA296M|`LvCnUZu+?VH~bJef5Px*O4cp%ld#RR_*tT6`_=6xT5G2Zv?Ub(gz`<8*JfES<0U*i%rRFMh(~~+Yd&g>B zl(@Te?UxqKYuf_06Ujl$BR=)kfuyX=9fU$~_EYxK6lHjz8^PBX%Z0rQ#K7Qsy8|-c zkAOGXdb;YEmjEcm3fA|BC}0<=F#qqJDj2rLxg&0Xq2gEw4j4Yaa$L&CQ9xWn@h*IK{i0;g*SrtH zTNiO9o6jgPjhqO3oQLV<9MWr9>p_+zgHTVrVD4^3PN3TSc`cPyF#0zkleDDk`;}vr z>`m8_<^W-)P24%*xhH?|ED-d|cEyfTjG`Vo$ zaO?iGWD7_GL<}JB>&x8<1B0R|GMyBcaiy)fSh8QS4G!q~$u?PpE~3}#DOR}_Sc8eouii@)Xp3>Qs;Xr`~2fB4a$ z0b{Ccg3j3xc9qYE9(S|Ka^ny-VvOZ0{BoJ*KSWTE*xHV+xs>dH_dkyE70vo#`cB~ zb(@GB7PYvjkH%Jmx9YX=Qb% zXS(9cDv+JG{G@@%J}}cQrnMwf8VJdFY`6|W4q?v&j!Sp^3BbX^#G2^AeM`IWpg2bl z!|y|#Cx-Ehk3u~`Lq{dm_!##6fSu-F7-fr6amul#-(BWxaz$2_El;vEB(_#Mn`b}* zrY-=HaA*o61pf40|H)$bH*G*lOUO%1Ra!(gwg!;%0=C3bqbaTuUq})0<7Dr+EEB}805@oFYac>>6E!%q3hlF_S z0l@S^cTg1Z>QokCFzDQ5wGDlXnFO|1y}X}+XX5D~y%}5I zkP}H`(~K&ee)E*NpWG16pyKLK1uG;fYOe)x>b!GXJ99j$3=P((HCq4vWbyP+eKHE! z$3q5X7b7~%PQ*71w`eq)kMKx+h?5syMssRs0a5OqoWbn9<-)rGKeV;$OGM6lrXyr(cEA0}@Pl7VQ411{z?iIW!r zU9!z1;!*fM(Q?9lU`SV9#p}+#l#YizG)e|U{cf+ml%p}ajAs3>e>r_K3BNPD()hf41;pVh z;~y9KOwC~dg|y#_KhlWd;=#nHt_>sQy|B>%QkHI2+}dulRi15EvkE)Nh*KX=CaRI+ z3-)yI2%Z=C1r=Z1Tsq$qfOt`5lP70k_wnuAG`h}^XQc(2(qNtFNt1ic{vU=EuGOdI z&lUmq1>F3Lq5AxLdp(i2++DRJC`NozgRp?Jz2(}Bk-6}j{Arq&|CStB))FWjytu?| z&ZXuwQun-aTQ4S`MHLaancBKOrUrhbfdBw8a6y{qP2msnWiSFa|NhxEUs?b&g9)E3 zRD{Dt3DV^_(4!ZSt%qa*vl!@G_QU*aJ^2-n!Mf$By;RW(+{p@*3JvkKfzvKCVR~-SoL6vKiLN)2}c&7j!=m_Myu2&j`%Y$-cR*rv+DDstH z4+Iadk{W+%KV$w>7^4aKm@>56xWq%t$wsZh{-gyvAh0-&ha78~N$Ob0jIby;v;bVQ zL`3GqEK)@*$o~Gq50m~^qrFFGEEV0>p0iqG$bZ*jG0{0G&$?bzPKk#3DymqM?(k)} zK9?wW;b7kKytNO!2#BTe@rCr){n}OxC?#5?XA%CMt9KfD4w~x$O2WlqKpaH_cDHg} zPkmP2SNa<(e`+)E9)YFG5Sd#BV-C&Vez1Zg&1ck_x&l}RlVZtnl?;QzzhiXP zE9YT9Snf}IZ|Xf$0z{~0WAvaT90@AEn_|;o2St1$QbxcO@%1o-alA>*nMV1ZCmtgtIqyE>#0AC4u4s^DR3*ptx*@Ra8 zvj_VyR2DCoV2}{s0gD+k8)2|EmU(?~2xrCH(!YrlMcYY#nRW*P6`OHGj^2 z3nHmK^w7wMK_s`azFkCj?A+>sxDC=@ubklTbZh*fIYF2%{jyBPavGe7M8Vs7Pwyc% zz4-Ef{#r;WWs7~~F9UBIDm{eVUml0_8(?pmJ^i&yGPX^j7y~zf{`u* zcJvm-$mgP9E(N(4c#T5W4Z(i8mf`#TIzbyJdS zLLc5Br|V@mpyv)6LI;QuYJA>b<;*^2L9H%ihJ>IbqRH{S*zvB;51CH=W?tS6eVDYpMQ2mZLI7F$tQXr!%PRyu^+5|fwS7@L;74RojZ?KVu zdRrnNHp@ExU<~X?m}2Q0#6=AHRvy#3Ls2x zidDHWKQKC0B~$>8p_dzI_w_3(zpwYuS9F*&g>iT_R>X3PVG}*tYdy{k!f|&IR-w1r zv1)0(Up-^MpM0sg4Q5hzfP{^Ws+&j*qs=asE_0jbnLHyIJ#m1X=^iLjwhsCLg@{X{ z4&pKIe!R_rGffRz`%vN5ZgY-sbvCA6?OU!D%%732$t%XcD}8OnTyaYUEYj#>W78%l zmA0q#V<-dagRr#!LH$?#HZnH80)4Q778R1vTPg|7`tm#KEK?xO}gB^ z=vj3(W*dM2HPGH-1Zo6~z3C-)Uf|Ot0brotF8maBg_xeD8z+qM#tT`5-{z?1m)l)o zROkorox})Ac~JTMQ-Z=g2o0y;!F!6JIF`VzR67E)e*-iVgHpNwzvcDiX>mC%E zJnH~#IK9n74M?+0t7KA%eq)1SLcg~S&4n19eO)4SPi0Rx8On{eNk3aHC$5u4)SrA9 z^s7jkZQn9n?Qov==!dL5dLJc6PQsIP12Ex*n6i_(-IiT+&|4sc4L7cWLDj|#Sb>Gj z_lBvomwa|qodib+RBVt-X(>t<`pOZ$l;%yop@^1Qhjc-KT`7@XUsVl z&4v1I@VM*Fset2SoroXRg--q+ClcuitoX@GZy@JZWo967w)o6h*nMT8$9E zofq47Z_JmI!A`P?5aAs(3XAnJ4!%o*85!;Z5f2BXZyhu#U+%E{;$kwdn75LX0J53vpt#o7-imx57gs4aiS_0&K%? zuc(H+SxIrTho4O-IAAUTkU&z@j9T0__XBXFN;5pmGZ6E!k5eRiT(l#W2b;Ptqz9GA z4YEZV_bIpUaD6e{%7Xt}=<$wuq&LZ44h1Z~%O{ zQJ)#^l&VZ>OR-kS=bPWVLk!%dfWK2NQ8NTN%7$$6Er5e?m`?q|m_=4vy+j=-$Fmvg zkaYx4Rk`mm!_I8wmVaKiAB7uU6q;5`AI@1;B&~Oop)yH{UKnNtrqmR0;dd=HS6GJ0 zYy#VpU8|xwQqpB`j`sNXWds@SB-E2u64eCH#Ux&Dghw;IXo|h+_e7$=Dn@k43tbdJ|(O6`oD3ipx(&zOgyLUe>Dt3U*VmAS>k z{0xvVW*{*@azR<0BfRNSyeAQQqCL+KVh>t`#Foj34I0Dnd1bVJEE279B=QqGJ$Fa@ zyU_!g36fSzGXyZZ?Y*@JpVt`S8`>QV|LG}uWc*6@vW7TY!fo={5wm;Z9oc-{@B>_e zFkrUWM2b@bvc`A_ErXY-m^#fB2(UlR@yKuffM zjDFQ*f5rlvb9_k9h(w9(iOd-t$c%H6O96vJ67dhS_%sGWxJ5xDAIX~Q23VtOf6bSh zPOGrxkLq#Mz_tP=_wy;}sTC{O!9>$H%)tqv0Y!nB-hf&UfDUyb3!9d?@U zgV6C>jn9=Sd;n9A_}ao2>gofM_$PZEA`4MaG%o03TJU<9ft{nNce1DZ<_4x1QBdg+ z*b_H51HF#A!nEdbI^S}s>aK+XZgHnM<9kS0SF_W5H^XP5+q#$Qlr-Zo-@_C0Wt95u z32ZmRit_0)nsU)il+DU;9wt2a9ihJcoB;j6?1bR8Ff_v%Pq>y_zocNxrep=a)0fyK zXF7-+qmj7#ES_}^UKlIW&M;VE2L#`w;#fl@32R0F$MgKkV#Quv2vHRX?fLppZ(VWW zWMM$gt*?V0a8-t@2!{-mqNx=n`Vy`TBW(9d56v1ov>EFe{(883jLE=n9MFh(vA1n_ zgaf3l>pTg6{+4(h^ik6DhnDc440#Htq!f-C&e$J}syVK5zbsmU!|{w~(zE?xG&Y?% zJ{-$7j8^eWi3KGm-}CIT($5cj(k!1dCEjLs#_%{Z9Z$f3O3zVEcfB6qjgEThh`-91 zvs)w21JZxSDn)^p@Pl`60cPR57)0>C<^(nX^bx!#-bOT0x%Lkw9GoFUizRUx{lKBh zR~^s_7x^>0?!tdF@q(so+I(IR+B-!{8Fj|pC-!dx7o8C_`^nm#32*p6V-N7m_Nb6v zRlHvb-|!qvN<8jP{6BOcB44(Jf?F1|tTB>tdk*I>xSj&CsX)xVJ(tQsS7%lI@~Fa^ z2WVl{XjVNVlEptcF<=gLo8#J}O20e$6pWRXxpXwPkW);pD{%Ub%B}!ck|bSzLin!F zijwFGnntoS^aI~T1CeXByyv0MD^A9GBE->{!wY>XWQ6iDeX6k0uZvTgTEJ#=vQcQ7 zN+vGx>@q7@YjT*~ccnJL+BC-BWSS}bSr7Tpsf@L+c4=3}$YkKjjf3^|$ZF5!0o0B@ z;YuNXRuf^8PW&Lw{&+HtosezSl|nw6jl!F{!tmKQA^hduC)&*b5#~!criXj>EC9R{ z4Qn)}Y92Iws&U@sgWDv5cF#{tbmlrdR19D?7r{x5{Bo?>I}}xKg;h}+MPZ~aOp_x| z-;>96LK}M zjpCwj#MSjhS?N|u{P}J&+?!u2JR)<@^=z)4dq5fQ8l{B553jfQD0_A4SHFOx(eBqO zGw-;r9poLc8GiDk!(2s1;@)5+fU!fcHNTJDxb)0o$20+V;A(b05?Q^wdz)9mvd;(De zL`)D@O@h>wCC$e#8T@L9dqs4jn6c}u{RGr_RgPI=^&r7?Yo$W{D%{j7R?Y-3C>c)r zaW&@?E!6^n7uMq^(025sK@<#^N<4+7mmz{w-T^Ey6?LaxRf^;0X6$c#CPYU{o!+gX z?X@ppUym+JR!?E$^&5$yS8B_uKp6K%d+5#u1?V?`>kM3fn|ZAcAr(*$_edUvhqj&@ zeW}?`iQkqFv^*g~wOZikhW_q+6)>!W{L+lc^)ItH`~`$T`|~!@$WISL@Nqvab(o`^ zDE$^1MQUi}s(`QFCH<7ajy-+_t@8@In2m0;s1(_(He5< zL(QYYuXMfGLvB&Df^F?lf|NMl+GP(+@G|UnO(;XGVPGt0*j12Jp}C`v;(NaapNSEw zham$&K(hUTxy_fIs>2Rhsp~KwLMru+=A`GsD7%JZ$2CjPEIyQqv(1JPW8;#DEV8*& zL!LP*^kRH&W9JDhVicEgW1o-D!BpoR5Na>YU#NdPWl8m!?B39z7KyaPGiYN)pa7Os zx?KU4l<>AMj^x*B6Hqcg-tutT0Jk}KU7ry=1f(ywf2@|hAWOWs6H2U;d-NCyPHg7_ zR>$Tu%F1UlWYeC~0@nIWcUS`6c(fv^F_ndO7*K@P7gEGzP*a z$p7bXFi%JN*7xw$;5=5Xv>GyD34?~jZX;+-b+;_P@7L8C zabL>Tomw~7C_gGgVmE{qc;H=?sc}McUYzG0M~Qhv&Nb7aBcrNX1SuH_<%b9a2MJ~lK>)1X1DOk zT;VJ^NBl#G>Q8b1>iI|Rj=tEajhn>+NcaAx=)$1Jk+Mkp(hC|aDk4^uso>o<@lGYzRf&}tW=}!&$;S`>aape%LcL#DmZw1jQ znb*BBJlJ{^DO(vAJg z*9GJb!xZhKaveJsaw>T!rsFFH=QyKcD^KLl_AOMq7Vx`P4V_-WR0z7RCG3g;ZAMey z38YTy1eN~(i@$iB`$RZ=S74*JByQINwn0c-AuLW7+25WL?iDW*b%ubfKecPUA>Rs! zWRwVjO7U&(=9G;7OZB{m1WoD9ntHd;Ma|vDPJaX+F1eIh~vf~yo;;* zxxCSSr-ROpKopHs@$q%xBXBs24hkeQe~%%zOD)3rR49WXdvifp z^c+->Sp9I$J@%DKwCWaXwM>9N;P2{&LSApO>xKQ?CFC3eqQ6AWfX(>9;JXEP6RN3V zPRvtSs4abJb0=CgvpUHBbG-kT8_v-PD0QGKw=Ss>k&z3V!Ytx_V55wbK=sP_IaCFL zd^yq;uXGPdOLP-WRwzn>q{`UqtY_??J4r04j4__BeRHLg^^p;bFi4tQNvaxp#T_XIYmWHbP+ew;Mt_0}f=86F0RCO3_3g7qkT; zhowOd>YfDoepneV} z5V8q<}x#gDrR%B;f_*GUif zU8)1!IW=VD%xRjDSKTwAkfDg}UhzCYtxivM?7^E@-KVxgYBkN#3v0KBb*xWB1h#qU zW;AQn=1Zw$^C|#fQsVcVQ_zW0c5%3VYDpxIP-vgS@hEtfub)A&&2wBad*Wj4Ux|=> zwUEm>Kpyyv`eF5<8@-`9#cxjbl)0Kqy|z9;c?nI-r?T65NlOZ>Sc%^m(@wN@9oPDh z#MS`l-q`rhag-{1vds}u4H}>rw+Ci!w}?wO1!0zkrOnF+XQF=F6lE;9>D)Rl`Pz>_ z@hVP4k(nmYN}hZVqAn{X)p$+r@&N|^t5~L5|Nkw3p*H%)vF?J4 z2hoJ>hWM3(6q95Wr#iSU*et;m`tXZ0zI3H8<=KlA(PFFp%l2=FN<%G*1~NVqH+87y zleI%ymlrRgP@XA-G!2gZU5XW+;G#+Z<^Bl*~kF%Sm!eTg%!LI2mLIWR@b-mm}u2B zE%*;oE?BjlE)e@_`KOe4>-Pkfg|K3HOICalQllmb5g`9B^Krp)>9ywv9vCVg4%}DX z`s`TexCj-VJl*>yRaP{-g&;4Gu4=N#@gj`FwI&X#vNWT&*R^VjPz!;dQDEYQGwOc% z7i?ch<|9^!qt3#&Tp!}^4nJsw&Qv@n`Ld{K=7QyAS)KPi18|Ub^u$m9i4p(ePzbhU zzGr28dJ+7HS{0Qu1FR9_qOd%^!#)>+%5pG5^t!8`(=DzgRVnI5kCU67F_j~oBS!R4 zDph8HBlbHfbUAk;)$}LWcZI_ip^voPt}cy+R$Wyvi-5|EWu^{zT}Z~?Kqzr(n#q90 z(gr&i?BeRan;mMJyLZ-$9*fH53Ckjm%29l-1D}`$uY-MO2sF9%|Enuj`BVEfZ3e&c zkQtB^mAlxXODl0btu%$Ry~Oqh_eZMv{g?*rKk0o0DL0AKd<+5QW6)dBpaoJh9737K zd~2H%Girpqm4sDXbilxL&}*u>XQU+{;0eD@53!q+M%sZzAa^5?BUlt$8G_lepcEfW zh|b-DHs59}{8W)*7Ls?cZavIRpP5a1sxO4)OoL}H(|tCt`wpYcMsU&lqT0%fY)ju) z;#RVcK7;GUqg%cU)~yI5OM=$Jv7=Wug@#^wbRzM$pI|i~d03#_1o=}EPJwJi@3sCe zfCk~vyK{cV0^58Z<>gX&5tsZ8HD5r1>5#3TpXQ zskPm`tEyEuwY_&z{Zv6n6s|>ux}x4xi0P}n)m=%bk?fJ$Bd7?+!w80he>gi8+k`(R zR)U3Rq)XxEJaulMyVO1OYKXj0#o?TnaUq^ll6>-yXk_};HwLqOS{`tA*W$uXbkpS& z#Pxmy#-372oPvxXvwdsEKGh*t+%Ve5D&)SQ1st)1Ck|eh%y&ChLW!~bKTB7PAo$gA z-`aYmamLNq2XqZ2dhiworV+~!-u4}P-Qep8)C!B07GG<4n@zoNY`1bGJ^yobQ3k)=*oxK_F8+jW@Cbrq^0q!t9|Pd<=uY>){-fdb2Sow zjqHY^!Bwg|JVeV~b32n1ulEW2LX>BLOwc%)lqmFQ8&B`~e#!WdC>q6xiN}snN5r%# z$R*F9F=a(}`Hh^T8|*?`VnJSq&nCY=&lP!#V5R<__|&)U9NpSopgM$!946!cGd$Fa z(wpT1(i*{EGp=_9q)`FqZ7dQ7hTyLX`?NqoD820;=pBlW$u;W)mDc8z?3UP(!B!83 zHfz4Yt;o|lAm2eZu@fq_2T$wF4C(O_(meY@R`;{}Kltn${U zi)PS@lm>R{X>Xp#Du?~$X)^cyL264}RHR5X!5np99yyEh(d~-SEtvG^8z=6Q#Nz?L zmz;*Rxm*wRTB-DMd%XaowH!(P$wa^Sp53rBYcQs=ajNe%5?4Y!WksP~58F7{#xCocSQ(WPL z(KVvo;gT_OP9pAk8+WStv18Q$VVjYdr`Z5;MIzlm~iUn55hCGX=(Qo#ku}3R7V%F>tgtJd-j}q6&HWgJL$E{Q-OHM z-30z{6hsnfeR@=`g;>-=FY*ni(+Jqpa&CsO7T@^f-GeIFWze_|bE{&vFuS#d%LhCW zSAzQ{1?fh9qm7WRwg{z^>}-qO4*%r=KN2USzJ#FBiSKh|ua%_2<9^-)Z2|sRKA?WZ zD?Dc=AVq&s^TGNnwjln~VJ2m?{O!yw(rtRT>x5f zpsGScQf?6WUueZDRM#0l>`5AiDU!Ohlxx%j`=(1WVStvGAuHbX2@Zv2i(x@=4!?}4kE_D( z&cDinlb(I0O8N28l2npX&$HH%AXuRNgDJ3sJW{>%yb*ep;ofJ3oXc6$Z_Lh>vN4VV zd|)3sT%U>`p!MNNKC@Eik^Ylf_iR)D-uVt|B9Pk5uZVQn&W(|L;`9&DE<=09TS5Qf zea3C{b6o-a30Za&)|dzen=Xa&qQBHak^V2Sbb|Cd7{B>j^dbJ_CgDqtsXK(4RArPS zVczS%C?TN9I!J%>CHm!NDv3g%-%(|S@H%|MKQQUly8!d6+c%e;AU?|1n$M{CB=p&8 zuWp9SCrwtF{IL8(jYsBGal%x5;YJa+N|g9|U!(QKOflnY=0V55M4eIwPxBr~sWqyML9MmkNKu z`eW+V6q~GM{p+G{Iu(hU$7{oV&8-zMZ|t2LqC-PZ=KT4yJHXsQ44@KT^mEzY%^?`IOs5!hn+2XVgjv2Yfz)9X=j1 zv)&QXX!VZVLwMKaG)(K>@QqFRMFoDlOiVGx@gY-*)8DZyR`(nhlupmErj2kP*kV)56&(HCr zVuPZOzk>{zf??$wgM^iQX<`>y#_eb7$hvXN@ zz||j3^B*RWgiyU@eaSYDOFZ%ozxTu;P1r)FkBga+(NgQY0K%8Y2rEj&FSpWLz(-2{ z<|bvR@c&DGqY9fe&LwYCPRCLB9dGf-?w-cI>1ySYJ^G<#e-XDgI{EF=w5zm*0v(Cb zo3z;n^BPm!IhnB9kVzBfB-dVwul2@CmdMQO{jGuI zKBgkV@$q?LSL(!O_y*yMyz^5u`MM5=*#j@{Df7|*8A)W1A`=v&r>G;#f>h|%hQe6W zM{$34rhwZp#K4jLRRJDVy$i(?eteUgccU@8sLM?2hyS{W(WeW)@QiZT*?mzzcZ=+@ zJor~KHA7@oVIUV^AfLOtc_m5om%*mw$$`uFSMXpOolT?PiRzrHP&5rH zakuHbAs6w(*8COCDj(4^Ol*?W#lTClS?-tVZ_U3u;N|{9=4;+cVy2$tq2RcgBo=N1 zX2e(kS7(DBq56&`77J48IN&%Q($e7|A|gTGWt3ZA_=@QH?y6~burS^1xK?@nvfFI(mBVGz zx`@WXiH_8g{T--RvRM|z*ctv-SW5Y0lPgWuy_u)uEUr20gGaVF32%Ee0W^eZUp6Dq z)_yD8_5a@?GQXA(bo2&x_8xDg+vJy-WhaaQI$UB>{524oq7FcUFG6N8Xq|aiRa`=; z)PoUH99}wb5T2mf_FRs=+}3qaV?ta@s~|GI9_=?|bVhv;G`yvuoGkU!3#rAGak20`$iBbeqY2W=2cxaUz6;A4mJEk^%koHn?4e@da&;DG9IzpjFVvoU5>fvmV zV_uIW7V{zNkLB0|Nh%e(D`_*p{MJ1U3w4CGFmcH?5gR_PV7K21o3Y&tg`|%!DOCZJ zA;#qk=__7-u_~dS&jQ6~nH`njPE;K_8C`tvQQaCjD2MTzqDHsk1Ahu5d(o8dgP;rR zSb4og6H0+DS_UvJfSs)!xj|wL(x>V}Mw}*bxE`MR^?r)7zq5=ZW_dSDmgG(zLYGHG ze$<2_gSED*f_D0SWKIHi*9KfV0~in7trFrkF*h_7Bg>lW)Ej%0hINi2r?o^3r<k^m**4Q#`M65V;@N*70SIcFP*3-D z0Hr|=^P!xt4Laq?NdO+a!?5hPeGlkeSSP}AFPCb6T%mknpfgGjfg;Qi?~Lc~MmIG- zI_fRHZyRZZn1tb=PqatYv*K;&K{+={PKDhJ`_a@G&LA|2UTubql23*ARAz z@NoCt$fL2?nE?sM{9DH{X;;X>J0++MMREN6${wL{vz_XMQDe#=`cP1>O|YMgLSsk( zAykq9o*|ErhTM_LH`&Jo+*M2oMp>!0*QNn(%Ejn0Yj0;B!f1@Ib1)#ML(F7PsPJo- zw7p>&FtbXtEY?dr`MA7(*&+8|L>(xV>Qt}?^U%v$7EAf@qCRaCfEog5gtgIiJoJq5 zT6KTdl~_o+f!a2iZczMk*-tH43$>6v?0IeqSO8CY*lM&p6mHqZZnHoTlKeYpn{u|q z_tv|>bnx0L$)^+*mu-b?2fi@5i(>Z(q0!pD$MiYOO57Vm;$pUjHhN;i z^{3E1`R?Zc z|1$DW4{0q=c$(9_+u z%4vKj8K}jlm&Hdcu2-U`im83fPPzNase56DB#=x**D0ke z1kG3zM8g$llp%2nxkFi#AaWzrptKN16-J!zIBr>QL>0O3Chsl3=?gS9y+YFo_$T}> zA_LG6rLqvR_6qEpd$@IOno@C`Pl=s@ z!VU)xDS85>r}Qu}#B2h?gR)4$or)7?_59#?RYbr)_&erXgFu@e%}-+LMwiXLp;@Pf zXtyj8Lu5wUh@pqEn^1@@0|Z3%t%H}|?#U4JO4h)Ru^HE;Nh@FzU&Iv83D?sGXzdWS z;|KY+T+jadFp~TD!;A0hK-4+aFNU`rDMr$U$#PTfVRO{i{(HH-Dj`}^gNAi4YQ!hv zA>NmBJTJ_f9slKjPNN#5RZa7zF8cK6?C0Mb`YdDfJ&Uws(UDrBuO$* zFUot{&-^#|2jYt^Vaa{A?Njy`&v!#H71Bx5rZ6x!haw^>$_&3TPe>Lj$-F|YA-{vI zAqW>*XP%UM0oNpReMp=JBQg)aD0GoHDWwY)t+4`+d%zQ48juy*7OSP7-Gg(ggDoK` z@SqaWPdRmNB9S?;03e}lehvd82bJK|Te3LCa&%DsAK2~cVe84;6=HIjv9`mhFAktf ztV*&C!PN&g$AOaxXRXJyIDOT_$08_>M{$51s@b+fsx4i$oQYaqzAz4LvqNcowN^&S z&*w>#zk9D)^<)cAmTpb4ez0!GZsHtsww)P;qBD}w0_Nlp%n)e~h+GJ*G0tKE<3Ef;^u7E5@82-{NWjGoLi|I@}~oNACa zTO!|8sTxf34?o$KYrBnflwpJQ2VZJ6t7EY35YpSSq%Uu6x+s+>LQsRPI7{O+`P|5u*Pn(2xvT9|^mUA4*T7t(BG zR}Q-m%PZFV|GrY{;2z z0d2shORkm$l&jHLoc)y~16*+M8#u*~R)be~8X|#H@I4y*^#KXi_4JXfI~$S2Cx?m5 za^4wW($C&V&1(QcEp z$TK=5ajEuT48$8(w-ZAci0r$Pu^H^gq7MT)@6M`JbEXl|r~$efhIPsWTO3dKzyJUP z01wOcXU?BvYHcy2lx9KxQnBQ;6cs>fnz?qYH=)&TuTvT6*$B%<9A1HlY8W`uF#}uy zo9>JIIgl5Y5z+wi;;>>5dKr{-81_AxPHkdxJ?A<>CW|&FO#~zUKZ_&@(11AS~k^ zJA_rgHq|YZo%na+F=!Ru`d$EM@>w(Doi0FyZM`k0`3P;}#!&$QV?cUTlh5ANi6ZOH z@a0XLN2M5rMOBboPc_oXTtHr^?4TET$sA}2H2*R%w0g!4javaVY=J5vybg~Dt$`;= zzND5}l$dQti{8?!7xE&z6IL;dU1WR6d{v2flQ#qm-OT-pJb$YLjV0Yf;R{db(9akj za$JZ)X4}D}euqoCz>&YU_Ey{AjKBz@{Zb~PVW@({&~GK7r7-wKmvVo)PcNwZq*ZrE zfF%NG^jZ1~<_KMKtat_Axd+M>TL_4f8m<@5`6v3s#j!muE-m_n%(rvu#RUv)%P^6L+%u6XE7 zuaEBDY<#-;!a13BqUVMs2BBI zgmOhuGaMte@If`J!tYjlJ+*WoJz>VHWq;i!+GQMaTD+;KQWZ)!PqWVNpg>PtNKo~2 zX~Yk)^bz{|;*}2S$d7f^y$?e=LN~n}MHKb}>ar@>^M5%s>0HU*nf?ffmModyD7 zI`V~S?zpL=*O6lW$#LE5Z)=-dd&L*~Yt9Bbq5sQK*%!wYo6p?5Z({A3nbjult9C#w zLjeTaWW-tW{5~Ijvq)WtZ#35aBYv0u6GJ9Tz{x_}7_RqFfT9wZB2pzfuAr+p|St`p?B?OotTRdtO<($vU>mjydp^m(zO<7(+LlluJGY#NzcD^ zKf*>>I$uRYMhDY7qsq@_Z9ti^sNbleau zoi#F=5o3DtQm?P8-Evu7vf`4tY2h64n~_L5mMD({E-OcL9V~Iiz-?nPyav@CbDS#a zw4y7lYl*n;U9Rpk2sgjc@#;`VukVHVyB89V=_;LvG7(Su2DCVfft=OH6&Ui!X2(`d z(z*p<%xXV~!7~R#1;0gGt2KCWzBvEH8vR>K#jdPN6+L3Ld)8sXexMgK86dT(8pYwf zHE5{;$f&Tt(KiPfs^*l7bO*Wts%Kto4>bbnnOLbD2U*aWvDLh&O00avhti{KCxf63 zp3G~+h6I)Q=|BB$bD3FgvMsZcJW}M*V|N>?Ucu6 zC6oB%I7R|VWzt|+T-7&ReTpV7<&57+bG;Dx*5!ZMoFPN(kkJ5$oJVRLS4Doyro2&M z>|j>aYi?GHGGZ4IK@2bNu~mk3i@`ULN8}8syZGL6{j(s#<`!qBl%HNj5unx%* zEQd=M-|y`~8rZ5taAUrX4{6c@dSHJ%l=s-3okXI3=O*KX^|TN4YSEp7Ak|<{#uQ4F z9{{fid^VPGI*gzA6jnDR#& zU%N%Sf&6niL@MINSB<9yNZ>K+H3%mplidA7IUooqP{9EUzS#Q1WANU&X>V0Mgg>`A z5@;OM+tX`{koPi(j-@DZ34cQTCuLlH&<^nXPy2(zF?^z;#3&ztxaw5c;tH}>2RN2; z(VYGgGN}&#r#<*VnKH~P?>M@TKmId|UvIujuD1~p_7p%hE7sR>uBKa5ULvDw9n-cw zUl%BrCp2ocJl8qrigTYDGPV^MQ!w`W)@A8gye_k6Ev1V=g;s_JHdxEZ48xEDW2QDr zx6IVV`_j;unK?egGu~NUM7GhZ7oNn5{9Rw{1yUzD6h$D+sCq=%!5d9HA{z4yrfaoN zPfOqwFF=Z^06Eo$~!I_-#UA=32^!RyVDi7_tDs+#*GRFN$}}lyOdht62BB$HGv=`t2iF)ELc*KJf(xeH>oWN;v&$nz(l{nXTBS3l0CS>EjluSzg^Cn{F7x7?KK9`1>j|v+ch^&V&GPd`+q~p4+E_-6LSq$GZSpt@IiIjs4#h=`RC5}b)^w>$ z-NYXqEvqR9=0`4bsO~qqeR%c&*?AMDwF>H5oy$rxT^8NS=#T$_9cB~!v~zO$Msw7G z;1{BYh3)J8zg#78cSf+MWsm5n15O_vFlo8tQ32(C?oy<)OxLh)Jm`ddi17QX3BpNNcFDG;~A=K@fLoij9Q8j6KPF5%)&ArhXt9s(8X1>>yr^^y=D`Y z;7`AsIwWQqa~uFSt_i0W0>8%N%d+e4CIm${BI#JEaZeR*&d3E>bbBtxieIWedcpIr zFiSa|Nf`BI8)7YtW$TlNo*u;;jvJh^<1Kn%TH-3jIL4rH(-Yq`un*ud#TYBNvRSyo z0cX8d|JJZrWDWz7t5GtaD99lbV>HA`1P@=}iG~)7eRgdlP7<;f|8IqsQKW~ zqeCz9QaniX`dXW-q-h5O6z0ugk8M%7^2*SF3e+B7MLRsesmPmbpzZu$Av4{ht)F_s`(_ zht`z@kOH!>nPK_@aZE6N2H*LiDiOLZiz(NLmkkFIVmlHQJ>^D-Y^mWw3^DNU)DY~l zB1q-oq1iOi2O-ctPi}kHhH2!sSsl%Q$Xg>|nrc8g%42cesH|tFo>(Qi;tEqP&mJ*! zfpvGaCts|2{6fEt4L<_mV@Of#l$$nUpa*-wiRv7J^>o1yh{=yXFbE(&)`)#BF!1*$ z?)9dlRJH9$uex7toEPhZTwFKOJ=K8=M>u8?F+ddu&mt4{t z`A`HvLF-#^Q-^UsmkIym;soyep*HjJ5#LFFLP%d?w$^jQdac+ z;imD-7XRv$G9~cdBf@}2OvSPq^7y_U zH##3wY=cRY-E+;MVj2fTT5k7`RqJRnE8I4sZtMISpa2nm{dWF#uCY<3Oxpz%P894O z>syXh23oZ?N}MDhj5zHzzu91u_gV0i{sDf&3=h0sr?XBCvH+f=6goCEx?0%ht69U3 zor1Bczkt2Pq$`)FSwOGf=cH4CZfk?8o(yJb#P1gf6q%iqbO?&8U`6vT@&IBZyITAt z310~MCmw6IwXimnDhO*|2#LqjA$0X}GmA)M9{lN*hD{o3apxi$}*o7QItDYQ@XMqobO_Py!7`K5bCW%iV?Mz)?wktnfs^gpd zn6F)3Dacvy`fNeWEqvHk%{9hM?GXYHiJOy5)HI3WDfC&!!cN%g!fNyPJ%$3hH zet@sEX#N;p8o9m5LTwqE{0{`-rBwAt;?2*{R$L=nb$GYQ)mTM+dP@tD_H`E(3cVDA zQE@C#BxjNi#1S408Ei;S?+6NV##9>?t#Q8Jaw+dKzqJt|ff$G~+hgHI%$65#>@ZveL`2lx5vHpAV>tfk>s=b)-d?!&3p(CrZNm3-rmvc4*es>N%*7thjDa!z9>qr;h z!&t=hM0tK|igZmFYTjz(JQdSSE3-i=CPD>rL6kgs`{1P3PVCTA3yl3hh9}Ibk0HiM z;5CB$kXHXjFqQfvE>$_EX50E2ms5p%X-Aq{VPlJ0onDssI69ru8wFw zn{UHR$@=)^ONpvSoNj0{4}Zfq+&%$6S_I#9)EA$N76a?$i(dhl=~T;Gtgd(Uou3|` z4k5phc~Z6KupqU@7-_&3-yC|LF*zu8Mp>zCSwui=H9~owp@L^MW2b4_?-%%7=kwe- z9XS)`X%9|MxbBP7JrsZ|j~Zgj!=h|AXl1*NeM$0&&*ZgJTX*iLD65;^d#W_;6Xa2< z-~E5Gm@_VKh2<^=WM9?{=6+lI$HElZTp5fWr|VY&aByyb5rt62p>_X>$*@h(CmiHv zaJmtGd~y80`5sw==*dejrnw^yjBUaPx+hM*5C4Y2CK}?VR5YO}tC_z3fVj z4@t&JM%BAP1Dwlu_BT8>4hFwAlsp+~Y_$QsuQ4?w%lNkzd4lzdM^yk%>mGAdbk_kw zAUL||Aka3F7atQ*Rp226yB~btcKEq}KZrpFUSeCn6PNt0I9T(DF+Po zKH?u`94bjs?f#3W#G5vE>i}*IE%pGm09E7|gK|jqFD$L7?i4o<`J$Xlu9lqz@rs6z(6^^gV*>_++5ibV~k#hc2kI zLvEnVKnD26vHIZ+@A@>ldt339KLbe(6}u|vL^bwOABu-O7?ErLpuWFcEVW*$wG z-03^Wyy8WombG8TSq*7|!CqARteW@rKf7cKcc*yruBh}wtvlyN4rvz*>)>)n$@VniXJaypn#D{f7L&5=MchbnwqgJ6bcc~lj znX);i(y{yLko7-Xb=Y{xlbWvKhgfy(akzZQ4idRF$D;yo1X_rs@bS0hbXOkJvhEGM zHU|XTm#^#qSxScd1=`u0i zbsD?{QqjEJJ!=uaov3b1k$?<>7otQj5iv?&r9x^OJWXqQS}3CNYA%@q>ArG9(I72z zr7uCuzZkLJ#QK)ks-8F#a zLExeV4Pj>~hyhxnj$b{H(6NA}EX1a^M-#fji>DreKxN!qZmC75tJRCgqzT7F6P_8B z*&+==KdM>6@y7B0XVvqY_D#wmIXnLwaIN5g{rE7-Drkq;br;4|)#LZZ-N(ez2w09jEI4#G!W|LR zvP7(X#`asmvSMT@;b(>E(RWmlpb7@2Equ)Dop7{23ili{wf_A-0okx>5Ce{Hdfh&f z)YwfwG~QSKpB<&vny=Zp3TM_MAqVk@h5DMNipTD{3F@1VE|NdpP9c8X0ZlE{s&n3s zry-;{Z$`+0{W{dW{s4vH=%DYjT4>5huc}u}c_#C>@Mp^yS`%%QgeKm+y=yW++436* zwDK?_QQ8F-BNnBn=-v+3s9zgkR`Q}J8kiM&x z(*my10FHOair|ets%U)llkpfa9wz3$X3C91K;5CQd^xA zh{5dq|A)-4- zPm7y=QmqYtC*jQEd+w((xAywh)CTfS>)rZ|oG6c*!@2kE-en?ETz7PZ9nV&bx&TZ$ zK$}&YU#FkAiNAt;@8eix1{7rrS=e}xfSGWqhpCmTjfEeF1|r)B{tDW_zBa}W6Kiz& zPP7;x;*>%Pj@I&6&WcX}piRDt$)kcC?-Qg@SeSK#WPuyXZU>X8e2EQ?bhDgVRSX5h z1AqTes{z%HMNj&85G%V@f)Y|I>CSFPRd5^O8TX{>GtT+(3`Z2)#C)eUAV6u-zka}~ z-y*V4y*Z@?l{+P07h>Oaicx!#bPZ1M+(p${c>a--+R+m5xINHe`3v;WbR3td2jET| zLtVGiO-UMN@=cDSeGvFaC0EQi7`GB%3G2i_f9q%3t-I8X+piVC;@S`V#b3GuRCQJ2 z;SoOxF~|FLuWEMVy01jc5S=HNr2k9{fplBz$i#TU{UJREgyZowu}0WWxNOO@lbh7; z4pndIxuss9bSeL0dawUc`^>@fPF zm)_b(I!oJq0F_}_!1FMIh5?2JW~K;Lv+i+Fcs-|muz(4$T%L@Ms!#J;cey%o6diQ( z!^UN(E?!o;K5|n%<0v(^Z<;@EIZeD>+4P1bn5Vjgfuu9lX>qj?!Q=;|C?I`;xKXwC z&_e&d<95g+=70G`K1Ammu$NiSwB<{qq#@pN5M(i=U@TAL&ACsOM7C!S2x>MZnLPIA zY&|?FI&z*yzaRiW6fl~$JM6)E>Smp>hw%C2rD?#BiuE?FMdrbsPH`iCN)C!nSpnZc zr!)S$rKn#O37LjHxKP#eo@RegO(__ zZF-CUF_F$*N=NbKXhSXlpTwo;4vsZx=@kw+?-QxQVd*e*Wqbf8jS+T*t?pQ2O3jg3 z$Izk=!`iQfZE*2Yx0S`G!b_9}$Gze5n~mvmw}B~nDm%KNk8?JA1(zZ^q?B@}e{yE` zicSUt-XV$$Ha5brBrbU+B8ZfN{##Pph^Ui;MU7ms*k+ved52QVY;62oN4!bTSD+7w zmxZ7VPiD|C(9~t~v$_V^>xe3rvA2&ysf!RJemFBSZx@_R($a%6(EvM}IIhQJ#>6^d zn2JA!rp03j%lke%i6p>iH1zxNG-`elaFAb-XmN#Ib**KzHtJMa4(0R!7fkG@QAj&A zcJPrFAG4f_imVA$nIdJzGN%w`yvH^ zN_{J^U%$1Z6e-n?v3r%+!5M-}pjud9_QOjtLcBXsp?(25kWyLZFpDn5aEQ|mV|q&6 zDPa{2*JPZ#zkY$mg$Lwiwbbmg_z}X(lxb^FJ=`LujIiYeh!#ykFB&Dzr{akEx0Qo- zw)C3cowczPOMP^ZEBb17*~m2q`wkK67mcyLdkuowf$o|KmwdrgpMpnQpXPHAW^Xfu zbg4AzttKsI-G?K#@9cu_W2KbOIyL>}l9aM04Q0tElTZev??$?)2fyHY&4V=1ydil? z)|i)qJY~h><*L&LhS{21m9yPUhryZyEMjAfF*6ka6*BRMkiilEoYB{$lXN3sl(OdR zw6H0_mhP`ydIfa~H@WaPFvWU;u84mvDl>o(p|J_jTq*T7`>_;)?4mKlWU6({@p{UK z$Y-<*X0uvkVot-+lYclTa7_nhFZb`pxSC+-Q`YDbwF39#Cm&uO(!1bvyfujYdPQEN zh1CHBh*Y2wtB2bAIf7yikt~xJ6RhNem*gIeD9Vn| zVE$AefswyDf*`&eh@$xiZ|EHqE=a;p3?F?1r_=)!H<{au!LNlCbs#H$h#{ ze#;p7+p(v1NoT1Bx2$oIY>Io(sxd5fEY~Qd^Z{Cv_O*6F)-vMao2Xg@cmCRuES8=3 zqiXJ(I_}==eYDP+k}xI(#b3sQwK4i=)EzQ9ychBQ4m+dLxz8M4P`w2V9t?Z@yV=e? zjOf+UDSvm4f<5hsEKE<5b@Cg*5K__=B0tkIt2L`&cjT}Lmt$pvBe;4}&SYc8HQHAR z6da!iV;#N2`F}g70++2{sjnlTzpuh1EAeBhpiXWg^HO&faaU$RUThB+ zu3Jr7C}Ch8FpyeL^V+>%fGRcAK52aas#2g3q=;${$ogvkKlV0DXEydRI>&{p`#t`o zXsbod^TjuEMYjHC(MLYF^8nh&pWgj5*)ZvXJTK130%}mdlc|?at5h7=)xarZk?#Pk zk{FsICW8VtZGLq)ZCOieGU@|s2jNiR3kY9JWn0+(UF6}_I_|ZuXMlIf!KFB+v*qSq zaE#a(BcK3iBBu!7g2kJaog$=UhBm*HWu=RU@}kQ*NM%~q&pxdqbcKxg-oOgp`HwPL zGhkZ;=LCSKY}Sx)T`vJwaGZk#J3oXwhmMNd3I7Z<_2uWDsXEA;XtPtLOh&}TuXKia z`D&{Bmcv+Qwbe2h!2k#uHMdy>jZFC`IkJg0(sf5uD=EH48zv1d5eMHx+90y(F|_BA zVeKL#O8H@xVtkiyGax7iqwWXExQhdc9o-w=;N__*{fR$sJ3Nz!j$|3ZRsN`ZBboG< zDm-bNFF=o|ocRDn>`y_&QGeto4kE`8>FOI+N}$ltqsT_G9AzwhMNxO$j%TFVP(<>{ z%mn0m)`)IT2udw@#TXy{IPWeT$$y#e_2af-E3amiU6C9LjN4J4#s<^Xsd=WQGSdTf zVQ)^#vF3J4G&(Ky70Y6UaW5~FzGZPb(mPPdiNzLQB^JDC$WtjF8i9JadU*h1K%Kv~ zPhM*Q-aWTfG?cIn&+aXuptykQ$xq^QWQY`c{uyl>n68Zj2hUduHj9mMmk7Q9K~maq zO#zIa<4A6IV>-%3F_vHut$@$aV7$(ycg8j;I}l@-@tjf3Jb<u2$zMU@x7V&uHM<@nA0-ALtw1)~Hq=KwZvxW5NW@M-a9*XR6{KC}6 zZL@9N#-8%b!CW(WOsYGIMi+{%k_(MMK zb1AE9qEQCVz3v#^Yv;UQqJQ4sc{Y0aF}= z%+C=pPlYJ{ZvnrjtjEb!|1Z_qV_iCNr6%&93rP5O*nGTpck!cS!&u14{RWL(D*y(+ z9_rCatnNP5BDHYV2Zmr3kgCRrynhqVCQYE*-PP%q+<>8N`70aQW!Z%u9YtyAPn z?TeJIQj>xCZSz@9cggejLW*@`Tl?B)8N%(DE6e}qRMoBU9HLXK6rWaVhsJ2?ClT5@ z(+e&_=ZQwuN~;+`&`B7+%P{kkm&Awunh$O08ow$|R>t82`XkEVc*_V=`?-Qr+#?b^ znNO#oKZ%fzv6-?VrZ&4LYy{)6J)gHr8KM3yz5#?#N&<|;YCm%F-!{~)G@@O<&qGve zxv+$^47oFg!2?LL7ac8_&syq1$P6m=nWCS*M9siW0?G*s8eXOlmtD&J zPwOpqQ)`|NUt|RAV(u3F zZ+&<#yU|y`pnweB2S28UKgQ2|{&0k_B)ItPcu`J7BDYb(bB>w!l^{-TCXhf!q?qxN zwhjs_BwT9Xbl$!}o-i--5qdyQBroB6O`S3F)*7YLm3-S3vB9M3dqZjIR3+!T)*t^@ zyZ^SK#m5q}AMJTptA?HIx^RP0qhk=1Z|}6~4w`xU|KU&Xsk1SKbKZe`@p(^Rrct6y z9uuoKaJefFeVmD1)6@JTf8V}Ew>tjj?8y3rn?C>tQ6zIE_0~qqn(}ED-xY6 zdJZjOHU2}M9V~Pbj=(P`Dfw3Xdny-&lx2A^FU%X6d!q@Zbx}1YL>FB<8jxp;YRUrL z*yF@4m@X&>3@8SrA5dVTkKAdf-xO>58;4d`!YP9EwyPmDk!65FGprze?gMfeNpw+MKx&CG=dACX*ChDb?9n{f@u-Yts>w!;ha#b9_d<9IaxoG3V>w_+h1aMkQ=3XANo_AG=XwWgTdL2l=Jr zjx;R}A|yNAOoe&38%IyKVTt$%j_|q>RxSfNQdD99vTal4$9{{p=@9h}(M7eU@(U6Q z^Jw^9`+~yufl-FvN`b+;$MaAF^DC2r9Jd`x-gb1opDz9*00Q-t2Vgh)B3(zEQZ*6KuQn}md zs$WVSni-mg_A;iKcd|2m33ON9dzDkgR@7=ROE_5li1XYQxo+iF#jgOMFijTbif8z8 z(j4M=x;M7Kl6^OS!@nA&UDc&Nx^}!97>*Y01{}5=FTb0+N+aD-oAZi=>%HJQ$y{V7 z-r*%df&TPWXi5MfG&%U<=t4c0;s@#W4~ClgGw zwM}D;QjcV!iKL1Q_)Bq5edwB73{r)B@yBAetcr4g11_V4?XaX`Ayu8PSJSTEgf6hR zzv$9S_0vMrX3@?EpIJxovQ2v5Q9XOXT{-qLP7&GjcH(PqW3$bwcA-f z2P{LlBn)vYpN#`wNkjB*Gf$pr`na%x3ca(VWlZ;JNNX@^=VIq=%i}g>YNARhb@5Omm0h#Xdnr-LI< zkNUQh8Q=ClV3>B!#g%12}GIL~HiwO+S zHatx^$E(4~oT3k0Rt*Xopk^BHB9z@_zBb?`oapm+Vvj7Nn#gPg*(0tWu?4&!J$~`t zZL()*QUCxmKS7%*P2msnWiSFa|Nhx81q#ps3ksAkrXz2do6v%;;Wl{uUG*W?jYull z4}NpfkG*yFMz?tyI*IzQP0e9gzbq<+%<4gOQ3_iZ^>K!C`+7ObL^~2IJC0z8aBF9dq&ZRy4ZpaJ&3iF%y z+lfQ5rXsIr)FE1tnVtxw*IzQcKs9+9XA89A+c%g4oO@O9 zZOkO1I|A-|w9D{UjyqY62^|J&o-ev5pJrPhcLo#|9!^$GJ>#pE^A z^7SMkkeXt6-DFwD5%CI%$y~IrbWXO#wp}0n8rL)(k6y=4zZ2$%{4PkaTQ~X)YM9CUz5&Z{JN+>ygKFMC!rv`Et zkQ(#CS0qAirCbK_`yN+I$S8JaCI0(ZHA?O0`r_Eze@|6~#2tJWGurT1?^if=LDC$) zrUgxWJw#oMM5%L$-Du(+5H*F~O5!wZQfS!Xek|_KQ|M%`vYTqi?<1}0`CLDD3kKCR zY~0Z?#^63yQT0fqbkxN`yx{dxZ>N9I0URpT%EG}_SIPn4UQ9E zU?C)vHe+Wtlnm6ASDn4Pe}&{ZCW?O%Ieyv1ia54#aPDk!tdP zOIUyc?*MZwElf zOeDI}9pUzS?h27Vj9_p`Ay-rE^ICcEyBlVIUH4)!-LJR17zJ}+DFai`EjVaQ!TqmI z6TIgNG_tQTVDODc&ZI4N#c!`kLAC>*sEf#>lg#<>unWKs7^l~6vML_z<9-0cax2uU z5boE{?UeE9=(l%P!ZTXRg|e0Se_7{wvh;!p0R$w|UtzA>KY_!*#;;YEFbFg>T&Py{ zdDmlEtZr-_^I}@~N{Sai(Lu^2$tR0Z;(HuSB4$7n5PA#wX2xl(gZ6DO0UZb!2m|kX z_$L_Rxj9Z#+z8HAat{ZUZTzTs7@#WpdJ}B{Y38sdJk;hs)74He=*sm-9;m*bF7*Pm z;!X!}D9bo(1g;ptH+*jFa&eDZMRSx-uUI)M+J>kxJ$x)=euhK?S)S0gf1v3Is>Ekc{<8~pZ~BtKU9Ouzuf!7o=V)tRl;S!4&)XVsHh9G0Bwyysj^)WJHmD!#nl)jVv&tw?Gk<&>)@g79N>|in z1%OzeGFy0oVK8e41}xeC4RMk$XJD}-77fE?GU&4pHTybDMKjpM53kIM5&$N%# zLxaw>Z^n913sfL1_39_7wb0)237C5)9H-A>pu} z_HtgZwTtDk_qmb*nuKBV!C&0`=NZ5;?fm8UE1NGy{cXv8!|xngEJ?}K-)(mtbI&P-<(RnpXf|T-&88kvq(YI(Pc|I` z;n0jVKTgGFTSy5d!LR#?kKVnddrK>{m=bRk9o6|s;{^6@zKVos6MCZ-HvF{a(5bbg zv$~s%>8-#pHhi(`P5>6PNN=ZG@}%?i?`V0^hwRTOQd!GJNIQ$e5K;L*3lW@%S6G-y z%VSq%ji$E)`U)=`h56<8hHSZC7@DaUm{2o02~=I-YeJfxyJ4E0aF1ft9W_0moq0_u#m`Heix(H@+>6+IwL)ZtPCiCQq$~q~jAQH?Q4Z-t6nA!$7 z9IUmeli^6ejx9OIME@-xkzB*ocP_Jgb1)t4>e%$o9O6gdr#Z0*b&P6kAz;|FD+`3WzrP&7N8_WowZ+{+WKIfREMg~?>uVaomg2+e3@F@G--;I7tEwKkP7uDr5Qr;x{){I@76-P8s_{hRuBniR1c~^ zh_gsNe@twG0BGo%Dk^_scIi_QeJS>(=Q|fTT_X&W*&VW!p&pkmV>jJ6-dAfVvEnmF zMDl|FPDqN5_K0GM0U$3iRjCx%i6iIaPRR91%&!_t|8XZC0%`|RZ6ts~;sp)X*#gw2 z*T7oi9=O{jsN;g6F{uo~(JD8HDJsfecYE)W1f%q;djvXRpM&#OrMXDYdRh@uS`NuS z$#otrzren+tizi+at3!ObP1V{R4GWpYmWk!$P9CJ8i_gsFA@D7v99?W;^JM6f&BgiAF|C z^-870r^+~zjV(baE&=67{cJ4^9nF=_>7mwT|e}k^tT|s^;n6# zm&m>{UzmwiaaMHI4FA^Rvk`#w(NkQyn#4ki#vYd_Ibr1tHcM2N)-tr&&)~{Uu%R$2 zSTt!Hp~N3E^agqj^0S*fdp+g5IR=i)UkFI6VLtsGp36fn+EGn4wN?PV|7?iFlIPen zk8D!=`%utM@KNJ>#7#f%CHPlj(657~4IO|5omES+Du?BRLPaVIDCNfOwNi&M z&cCSTa!G342Cs+!0Q!6lA^x8d+5&Mu-5!kXX`0#`D`5SZ*;d6?GwXChri`sLKk^xO zm`sTu!I%oD=z)(*e!*W~0^Avx`a%LT--^JXj{lCv*q8cPf9o=Fc!w zZe&}(@Tzq z)_iT=;`yP8jIk17$whfy{`MI4+f#QtvRwzSpoD;6dJ;9}>RvB|*g7u5K239DU68}x zEA`l&^b{7H)C0Cj0<8Q&W!x6g%$lx2JE;=X+*g7_yd1rVZBE{J)}r}egtAn@wH_02 ziM|akXCr=fzRj6A>8_iq`U2xh{_l=x z-ff<8h~}(}-`wVHVWD*xod6wdoCLr|K@o<_&uG|y{T*KFG2-Eov)Ap_#|Y5c%`<26y@P^>JxE zZNbO~ApbbQx)e0_LRLQds;1ME#Mq)QFCvE@1OnK{MdkU1`S*K20Hpy6iTu?9At81H zcoZ8}yhXTYR~hYA%2RwGPr|vCf~zk8@PIxD@QkH&(9h)j1W5|mlG9+@k2D9twbqxJ zKhbj}E1|qXz~o;b$4FKmlYDi?6~&B;8XLYtWp-YaI|8gbHR3O^^*N)JUhiGLZd8@q z8vaQt#{~;5#k?bD)#pFQC)l~Ul{B@)h4Gyv*26Jjw!prjbK#CATs66`tx#BG*Ih@v*SGFHC8sD}r->kx#5_;2 zP=2^DJ$S|U(}o5MxsIA%Y31zI<-<3M%ET0J2%QVw>Mu)0Mrv;t(ITS{obVFR{PD1# zqZSA_^ly#1p3u@>z78c}Yy_B;F3NyvY;+n5i|(nXUQ-C3ZCFbubmEN9swZ;~WpU#d zZwJ;`JyQ{rgYx4I93mk2MfD7TVCo8C03BzRpxo%Cs)OntsD2~rym}wWlN9KUWdDH` z8Q`^q0k%@#2mF(Yuqlm_P(Z(0{zeWDyj9b$9FLMJa(*%d6wzO8_t1b)Ww)Em6odB9 zRqO?j#E(~70qu6Z$E{?D4%X{h{4C7)NX9Dfc4oKnKf5mkcq?>Rre-rP6qo<{EANXP zN8ZQW{egCvDfqOr@3mVmxKiwgyVaL39^xy7&(afCI2vJwkUXf>L%hLV^pQ?WIcV$C zw344el@S-_Ie+*a?C=>rlY4G06UfWq-O4=Roy*r&n;sJ*^5yT6^MQt1q>NH16qfE~ z)PM;m3a@Jaetk^%rl{$K1$Yq>lrn{wx>u7#!yE&}y;9@506YkD8FZ!6deajXH^L?-~?&1-;aZGEl0!qIU;i&|BAX41%Ysmo157=2yTO=8jE27$B* zotzW+g}~{uc3BB)WRw}H6B}cWXqHD#BSufYLD>RSO@hJy%*Fj-J|?Ws(jJV19!^}w ze)+F5qRAN--7Pxy0ozL6i^-!YT#bj?PV0@~A-;d66(_gKs-|;vxjnM7y8eKLtbQ7q z=PvUx_`Is%1!atlrd?>}8h(s7-qP<<0A*$l9GskIdD&GH|GC3rjW@6^IPkfa!;hoR zCY>O;cgydrE%dONmNo7;B54Q_hw%Lk4WaZX@Z)KBN#6|KaEuo9tDnsEiQN z)~wsSDhD=IbM{@0m<$pP5tU$=Uq}@Vgast$y;ISj?6RVL_&vX6tckRL?b(>SDqk{e z8+8yvV}?Cr@az(pajm9(?}3F@IGlv4mm>Nd5I{1`%Dy-~0|abxg@1O2hQ>R<$mq5u zbM@U7%>p>Nnd=#0uijq#+e8lzD0_&_t{<=V5umM%qTQNBg)C-k{BU$|yfr|Y zSrHB`hUH2Tw%6Al>n@qRCj|y1O1j!EdfbnnFMyh}EH93oI!qNK zCNAnAg}yp1k1C&r?;3bz0Vsm>W#3*KnutB6s);~=BzVVgWKs5I!f(yxtI!g&HJ?%T zm!=ATkYPM_ZUjc0C{0Fv0rGNDa1BEfsW5&DR7NuKU`(7vC%%a$>;k|c8PKK{%vr*L z*@EZ_6E3wJ%nF)#N`wTjSr`(siFebn(J%#LfwO9M3*num4~yMxQ$YLx0Stop%Ly0H zo`HrS!NYJvDjN@|YZej?u2YW{-0yz(qEt9Q?uWU0d%SB|yaX@ROKGCivO&`7WUGOX+5DE;mf3Tz4_#{ybbQ3zg~A zm-1y+-Q=!?2p66uLX019DJPjP_ExLs@I@%`Q@IDEhq0a1jQ`;PwW=q?g3o%pB@kW$>1cj zp=ie<6^RY%%@E=A1gO#5lZwd%`X-6-fvuDtFb^G%rP@0Q!7Sco__T8zqFk+O1`hy8 zcsu!W2;!lLp3uHbgYNSZd6!zKkOK?o6W()0OWJUUu5Eq#_MO*k6v=mhY`%V)I=-W6 zqL(3T_Sk@=QanSlct+*+&TdB)=N((UrL{B%qONRlQ;rp;7t9&wtV2~8GMMJyxKd05 z0GI#V4u-Zhp+DZaJ$1Jy#w)%n zhK*Z0hVt?7XJZ5ImU^i<^N}-LWW`EI&*pXByYE+*bsOW*&1xBPI+3aP(rZfrcPi(z zjPp(>_M>aYrPesS2IgyICmpe%Qn4>Kky8cT5h2j@j9%@<1Cq`a!^PT>L)Oh@%(6Ri z2g4d0UIgRIusNwd$4WHXMECY08vidI7=3EV!Gd+;3ioYSg8yTv`-bKe#v+-v&Onbl zxZJg-(LytJqu1%>s@!9reR_!lH{~b#j`Ialob!=ymw>eWkav^)#9bsKX`_dcOkR4l z85RUn7{$%}GRec9xbI7&M7kHZ>XxW2=P!_c`Z0T8@B)P=1AL@l(qIWpm#J1cvMczXo>r^ghJWG$I9C@09veU!gQ4QPCXpHM@ zwN;&c16(gN%Ninxv^iPP(!KLEcJ;8&r+!~f>IcfHk>y3=?a~v!UwCiL$RtI3RPaj0 zJ7L)B&@uG?aue55cSpz+VHg9r`Tv9BiI909rAca%e(JRfFq#aUpEkusJaiWoT%Z0p z_re_ev2vBkCzlJv)1HlS(og%c0QzS+gd~wKmKE!CoRzgHmcV?YtR!+Pb>qtZ!rEW4L}?4}-@~ zfT24v>X_Ut>_!$N_4Ia9=ZS?6YPYnz=j#xsbD}xPdzh27vDOT)MbKY~g=@5whE68V zetZ2q_ZEpyQ(DV(J#v$4VPMDB89g>p)@Wylg*Zr7F#`-~F~khU;(UA9iaLvQd`iIq zC0^LI0yBj^6cT=7?}s2S=jMtkjDUQK_Q6Yki7{w0=z)z zIVPN-!D340IO&J0_YMHtnx(F{Q^Ps290xVYj#EJp&?S5i9rjRBSXMR5c2k;$p#f)- zklrmi9EQ^bl9bd+>t+K;eGD~6L_h?(cA{eNr)UX$W^7u0Z*Rg*#S?e`e-4=bat+Wr zl|!r#la+XGyEbEwd$>67C1$ntJ2-GDuZc-{qq%g%iSRt(xG1*#xe0Y9wec%X)n&{5 zMfL_$5;8qBzL{M-ajN}?Fcsgw_))2pWFu-hl8MfmEPbS5LP?ZCS=Vn31}3Z!{gE|l z3ZW14nq8{65hz(9Kjki4@&;IW1a}ALWYu;?^p5X)!dTrQWv0^SHpU}D0nDC=wc8bK z;}FrS5O6*_YyS<5>wrP~;i&{m zYLCQPIS@j8Q!q~%mOpvzSuCQ{Y7MjJ3`UoZA#ao!5%G})jW2w^GOhFv-!+$>Q+}#F zGFg#Wp>9oFWN@{L>A6%ye~@SOuaa5`bW$ZuMTS^GG=O0S|8gxjQ91o2^nU+W%)@1o z&5UZgGiZmP6vAnOY_u2mnc`@KR@|7_aYz8UZv7j{0c#QM=}DtnpI}jd9Xam3)PIu_ zTmH-|RD?lMM9eSAK`BUf`7-Ak3cL|0XoH9omb((q`5qLD2C}(x=me4Na=@o9R>nh9 zq}fKIgW#n=jh`1rKej}ZKf8}RyCzqQ0Dj9@pK>`f41#^7Y<{I}3n*ccH~%Do|b>!6eVZZ`O+UA*jV7oR{^j?1ji%>7}dAC7mVGeiNe5q8h@8G z+P^EY#RVNb^x}Q$YVdQ-NNYTaFjw@(3zhcXwyF?qOJ2P&@sux$|Azg1@vDr_LabD* z61CYKl6ZJN`ZA!T;HMwx3gHA}{zG^Gvss*rFi%n3Mp-b^QzLW&c`Fmh+Tib8B-zDB za=$sl{<73I!msy+3QTb>lASwdrmeQq0@S{P+w&2~dv8&>HPW9F`?bpxYGVRDwI^ir z%B%Lyk|ZccH8n^>_X_OU$=m@%q~2|4Rp3uoIcFHq!AtMck5QzIF1qZy%R@6)%~C|N zfjnHG7$9^@h6o@aX)wZQ!$PsYc2CCuQ7e~bJ*I{$c)v~7SnGFuY`!qx|1YW+y)_#)MzzNawvDaR3V@9 zCe#_0UVzBkZY(QB4o#*c(9MXY?=qa(1mA|Bbz;PA7aR};d&4-(C*!Z-y; zJ8!l1ROITwALWw(Rfj#W=k5Yb5KbNhr)8*};qH*fFV4 zoW8^m{gZ;gvBKSMHnoGx)5NV z6D-ffLqt@|@CUwCOtlmFxf!OcDa-pTNI)HJ6C$RwB{inapAnvne4kWIr|f9KG5m6z zEuD`B@dko4^;>hcNKYBg)p~lzkoxaH!@&E8I5yQB?Pouv(CtN*POZ|^4XKBO%xuLv zaSGpB(_Rvz@8+$Y2uzz1`HmIIJ+h5)HeIMQ=zF|f?LpCPyqV5phNR+A(_K+uGl+d0 z)g9RnJhbk2o@vnyAZ!a%Ja=|wzPjHUTzLub@{{Jp1H zvDqNDF8rx-a6bkPsk&D3?3T`F8cGfQ!XbapG%dwPMoszSKe&IJ^b1@JVlTM9Cr^XC*`y*see#pGYZ>h#R^E|j7)_(b=)*GhH7(;?$+$ict<_}zwYRD&SP0|oot zR8#7{ug{&*6bquPwc*BNy?Fea$2p%cI6xs>s1+^V1ncXNa;2HMoRh-<96}J*V!us$ z;;t;;-?l*EwwUyzZ=* z+>@Z#Qf8jtMZYp!fA z&y$9>FK@zo-#4W-GmZ#gwWM}0P zHeg|wAYq`BM2I1qt1sbudR;Z_SE@Z}d!(Q0|6=I6NZ~n)-cr8QJ<2MVvW>?6%T@Y$)N4405!k z`lN@$bBcyy%NkVjVe+OQ)4?Z<5jh!EnTQVCv}Q#4G|jwy91Z<^@?-u3PkG`axh&Rk z3F!!ki~*S(yXPF)L2TE|@`p2rISEFb-67JmAUfI@>uOmvXFDBy6vkEQ@6iy5FrX%b zKDMI{+UDynn8fQfDKEe0@hfF4QW0j-z9R*a9aMR94xK?LO?JwQp=1%qhZ~uAz2Dfu z9{1=d@D3|Y#|at6if3ZR-+^P&I?EAohlxwxLAewAM5EtxTCxX7kh};JZV70w*}lSn zW{{Xl8GzkR0s3W6$y`eQdf{Nn2e zh@Ge3f^-6*ks;QNF!5*8z8WN;OxT|$$#o{YKxq3?=>BB1^uz>@VJ-K@UI^@m$b6%U zK^|?y4n7*zHsJTn7nr@yw|NUBUzqM&B3Tpktd_hl_sTfjmjztC%5kP%dd2bYVCpH2 z55`Hx2X|4l5`OzZ;hZIvYpRAMC!UAtv}02)Vd?r`A%iN1MfA6^fM||o zrdt6bz^<}J@OuUn*q(k6#=fIx1yN*_v~CumTZl4Y>-svr2F!mCsXEHfMyetKSR2GE z?-^g2m&4y6Fc+VWhZr!AcGQG6LH9lR)&yyGRSI(!`%QY!L>(0uLrUK?JACSQcqOjB``NR-b$W`zn5OF{YwA@JE`K{2H^q zOEX~R$kHjnvVR;BP7zj{ftu+0sXjz``k-y9=?68M*LU<%FNk&uEXTf%M-v{jZ`XyK z0gLrI$0p>+A@^81DT2}t1HnKmQj+8cvseUOV%@2v-dh57r`Bmn(`BLOKnjoAQgi`g zb@Cs3TE7zJyrL-$dhFrbd@?D@zXKufv(p$Dd1*hE>-cW$n^M~5sxR9Eyhge1K!OP? ze2^5JZqU2@T$45f9@Ni-dC}V5 z#*unKR`iduy{<&-zOs?9KD=u;YXf>^GjQYSgm32(8Si<(fEHyzw&}miXPN7fD*=Pr z^Bt~obl>q@8bM}&0fAoY;Y>MiGdu-)4W(3hbta>(yrqs7Asrh6)In&X8~I$h*2GzT zO6YBtp#DW9J7PfH#)BQ7gqFg$O27HYXIXo&Ic#cGQ}oC6#5$$Ssc&NiecA9#*9K4s zx^vq)Kcz;g3&bHhXK*N#PKd?*?S&e1N%NnoMINe4%o{P|szyGdt9;O^Pr$-U7eBCz z9&7wbXKp2&3Ix&hnrTe4H`u@Bsbj9i2z%RlRwdBp#tLuH;4~_k7i=jvnPVu4eK%uH zjRyG-e_+WcmxaztjT{MgG#3af2>=0WsxYoe>uA{VVN*NYArtBCSdUoDlqt#Pl-wHz zsR*`z`_T;8_?GH>su!drOR~ONTCNWVXahc}H?MHo?Kk{}n**zS1<3|qyN|VHh{yR# zSG4Purqt)8a~ekIEb`yBpOf&Ek(}7?FGJKgw(s*qL^o0ox;_9Zyy03p__(d`&ZV#4 zyOxm9Y+_TD7gDwurbo0w8VP`@NPhZugctSX*=79b5rbTKaf*)`3swo8D7T21unoQI zg4$@2wf)&Gyef=F?_H#0YW|L?%1^2xQBS)xQh0$G)Do2nh&pvkyF1K;%m@7Jy!hdI zuVOeq;t=j2EI<&J3^%>X=pb&)BG?`W3QO|3in1x|xGnsL{3iYv8tiOmqrB>QK}{vt zWpYHqzj}k&SiMc^LI+};fd2X|DI&ObGX>PNW;d`MuYSL|M1BeLP}V%*ux8Kj3l+}VSM}a?UNx8IX5C>K9oa~mJke<5x0i1=eP9X~ zusSikQ{7vcY5aITFoS-}t);rzrrzN&5I9px_8wPe3K^^huG-2|-#~V2X|}2qri$YC z{K>$kfnM{Mj1~&iS7y@TCKnZBd?3zB_)sb_C>lM!&;OX}PWo#*mY!HEd92yMMBp>Y zivKAmt=qoU+&E0+<{RU~ahP(1z*qIRrZ6m!Hbcf3&%Q2JaYq|Q7mK;X{{uy>MP0~U zU>wwtdX2zz#vVplBYFI$muS*x>8F`m>>=XPKtZ(2)N*Ah&(l$DP%p?S7Z=|hp-`A+eRy ztES*Ta7D;`1?zs6Moly9E@R`BIzv=QPk|u1O=ZEbvUH+AC`@d3N4Fg=4aL(X9i;+;xb5jboER^9=k2%3gI&ygpJDlnoM5bqQP$5l->g zr-Pg+9#*EoeZ@iKCXn9=^Ls-s2f^Bdod!kuoZRkT=ORB)Kt1c36J@+kBueZ8yg_t(Lz^)%^O}s~7{>iDt(!n~suJODUu?1rmWmw#hLOj;*sD$VLI(}JX$@X}>TxbYJXL!z9y z9JCCd1M@qQXQxUVyVMAuc9Zoy+^8dyzD@W<9lp3Fc9unEuc)CHjn~%I(C}*c1kD=^ z5dfGr?xgKGVzW6Mm*t&A~j4uj_mCcT@ zQK=RMi$?l5G5f>;LJkT3{!DRB7eC{vsXzB!hPRfd;jF+fO|sDkA*xlJVP0reHZHE) zA}aenT8PZI-t{r&A1lnI|8wBSKsy-sb0r0!(PTP>Bv7RNKJ`hqE$J|DU{%c;Sw4%O z>j={L^D_X{*EyH%5dmEzA9JZ1xG# zTM1?BWVLl0TCUKUK7HcJeFV3%NBq~kTir2JER^F*>*<$KHtLtc0-#-kOpH^MG~COP z$+#?McJ~^YEHK0W>Aed^I!ws-2!KL*001+HL7PfV;ScgtmV6;YXTwym-OURHf}9^)pz%Xrer#`I@u0JM{qVBPM5f~C~LiM)Wmf&jX9+25KakmN@mMR~AMk<8eKqQNY8^f+Y{&Qk*^z5@w$I(bH5CJDwG>5eKt5XvUXTW z=mrpRI_?k`249;m+m=8AvxoER3Orl&JibF`$j4XH?zJcwS6>?lxa5*{ zfY0xj`$J5qYQiZGJ@2^=UNb#Ml+^!uXjhWTQ{O;uM&H^JHlR4X6-wt{W(k;%!Ul>F z7(-*r1lrNI+w$INP(?CM%Lsf)3^z5!W+d^v-K_I38+OCAhLk>f0^zEsNGK0T$aTTT z1NZu$$g*MLQgka$nTMnrT^V2 zi_R2JXMC?c>iS8%xbydd9hM44v{hK%%0bhZRe4w^YscDuQ4k{Z+LBQdJzz&1mpC&U zrCaT?o5KG4UcK(HijtD2T~ORU!P@bX7a7yijZqP+lF1d!4q9BuVqF7YDF^Hg-Pdvh z(qF18hadPAqXeg^Fh4nmRV1@1i>R*`O4R_;o=>jnPlPY~DK~{Z0L`WNN%o0UQkM*`8EMZD~DN^*03Uk7(1cI+2K%!h^zC z{b9h{=m(b(c?in2ofe@H9!(nxs1$t@&|pKVKT)9{HkJ7f?>#2f6(Nj~ zSGJCj%x)v{=x7FXm&3rKKi@h`1$%|iO+zWE!75?`SCpN0qXp5fN4E+C@4_})qK`0m zu5t{I4&dbN5?mEYIVG{{`(L8!Um*4cS!B(LRk7!D%4EX(Z$JA<>=_m+j%HJayWWTi zYNb#$U)3jM+A7oa>ucfZD9jQM69nr`wCSEE?o@o~=hd9{u zE9mo83FI)WS})a0pSQ_qLGIqk$uJASg%v4p;_K^d7$Ake(1x4Z>4*jc*hEimdSE$s zuq=M)3%3F`7Z?+(iEq|sPXqJd2=8>1F!0Pe_T;kRXQJA^w{2buN?SR5$ zu{i%Vqls-8>_DEnq}flANj1+}rde z5dgoa^PdK*dmX6LIQmv#p;{^LD?9eVPiTD3c5D?B#u&8`C&sDipaXve(nB`zepxAi zOFOR{Pn&y`uiE&N{nQ@a>_+ckJyjn zRs3oE-931_x8Q^m|>rQk)o}U^)i7%;z8L>$ z$M9C}zN)@Q1`#V+ow@Q0#;{*d!Wta_F@MNIGzv6tE$Z#<3_Aey$Kb2#4{f>WGI_Fc zu=yx2(;1QmJv9_JT@1gIMynNR(HaM$t-yPB-h56Xt5!imKn1>*&k;V`$9Liu&e07i z+6$wYc`h3q8x$D0#E98|ODH0-#nDxf>^m!fR%(g}S$|6e9^0AK(XAwWy*Ihuk_D9`irl%n+X zSb6aN%gc_rLM8A1%=#yk5Sn8ZI>d}%oK9X>NBOO3E#_gR2~iBGMqIBzLzy>%3GYOi zuyTcjPxLJEZ#gS{nf>wDr#JHs$9%5?PKdgY!As#t&L#$E&z`d%RJY(M_c1_3P%kFy{RA1CJvKb*piq)*$CyUm5Csid{{CHzxJz?f^ew zlAu5ecsM>xp20^#IV2ix>wnJ)WVly=ow4&>=KNVati&b6u15PBk>!ss6|&eR|9MD= zpl$W1rbpZUv@m=xHHg~^*z=}_N+Yb7Oj+&a{(@4tYkpavzE74+tUyvSx>%5wDQo)K zkC9CcVdZGE2J`kz8#1SVDpg%2h{-qHK%yAEitQ!Ri9^(n0|hIP6^J^2K?v^L{xqDP zA3K`PgrBfKBzRzc$4#DXHh5^EtmXGe&z?0ev;XeC{8rr`kCYSb?AMkb(%v=hQt`_) z^O@eabT})xUJ-AbWm_n(n>fv={HG7ORrQTtQf&Cu<-(kb<})=q(v5S%5uGhE*eZ^` z@bMob$yLAn>do{x0^3rL@`W)KxCAV6W)nOxJ(S**wA6!l39G=}7vL$;^j+K0!mdu| zdIM}bPefzkUT;>lc2em#@v+RpS~-7q9WLUni2J;^l}wgLwRXBx3AxA9F>$Vrm-I#G z;mqB#0f0-t zo(A|97y#5_lhay^xJQmEKFkJ*p8_|l*RFSqWgxMD3jwGHeKMJXmMykCNgc>S zHEjK+3}?yffO3mooW_{+WRB<14g0qXd{xMyfh4x4X^}Ot%o3&bPHVmjLTF+D-+qyM z|1KaFOY@Su-&}a!b<5!pvUrI&AUl^w5(d;odGd@oysXc_Pjx4NXcwG_J5rWa!-OTsn59qG$Qc&bQMUHwyw8ytIqb495jx66&1BX_xY8Tw#Ui8cVA%fz&9*e2L%H{U1tu%*t`_Tio zOU0C!i}2j8A*7+J=br^CkYUB3*s3UFva^*^w`qW+)HgG;5tqcjsfhrbLFt1UFsd5* z7PT8%k*n(LB;^|?$S8%=6bvcn6=*p3@Fz(dU8(i6(WGf6J+vCUBIMr`sho|^d&4|J zTFcWEs}Yu`@2*b;q&9;o7&~fa7d?(RQozx)(aznM&D-d1AMmDO8$O|K(6Rl_JNsAow3D5q=uJ(BP07)zQ?CTws5NzH^!Sg7deYr%jm=+2#gwf z2l4kkJR`FwFfDUg7DhR#Ae1Qn&}yZBgvE3l2*0?=PgoY}yij?L4{U$ovyzLJ5Ilky zwx{6OTAu4hca5WxED?9em{R`RHCc06l>p;3f2ONdxJ23C$XFB@9E5~YV|Xqi#c0n*;go*n0|XaEjvnS!TJ{YaEQ4H~PX zxXF|UB8aqfgN70~z+b=r#yjk|Bphq0R%cv0!+V_y$_(c8#>;Sr&V`v5CV`k+G&C>(^-|D#cX8#_jV??fIviK&&>c98izo z0S^3E?B2?st}xvq_D05$z9Z2e71|7^%9~bI%tJbSK(mfJVX@GpT}du!0FHG-488f4 zE;=V~bR6c_qyIEe)6x*(c;|1CR$2tD@ZbjM0vZv(Y+V%bZu-LX>0)44N_!RtzsTU`=HhIs`t-&r}FVGG`Zbu<;|(Y)ky(Nx~G zc|iyl+a9W%JM%j5bvfn{$;XKuWIptUMt>&a$^PG7IXWibfjo;-@m)6G3nG%cW-h{O zUbfk$gRs>)NG3Q6gh!nxiZ3lVzHr`9iQiwBCk%R|qM35ddWf&8BI#?&hx+=MpVq)e zgRxFM0o=XHO??Xdo!7hG%NtORp`zj*5%*%Lo#$6w55U^;(vU;er$Yj0mC853oHJgP z3unIxNjHc6hNg2D)BhWfnox$bezfk>A_0N3U^4mxOGG3fvM?D*M?B>Dhf@GuN9Tw+nR`SAbH?*AgioHU&`j1cqFyMc6@3q5l7P z;mTIUkLsf6urd0wf%HNtTNIl%@<5`L7E8>Ld&^_u15+}2VwX?w$Xdr}Lk_~rzD#d z&XC-p3#Ld?fR^lal)GB2V=^|?8jHsQ*67-1{)Vo)pY(xudP00P`%vRDuuG-Lk|F3< z$Mkl)XL%aA2eRKhn}9MUF$DhP{n`S|-`jrIRHtW1fo9kw6k+ z!hsln#uu-$y6CN*$`1<8At_f&#oe~b^rrW&;1{)=oKjcvy*lhg$lxknb^fVzjsf*Y zq@f^fBr*;LFUVUO*s4wBlC1vTF=+GV1@K<3IgsZ`Ru=j!DBWo!rd$;kmYg`b?P>us z)Vjp=+8`>iV~8O&*T`4^Qtm^TWU#e6lqB%jbY54Dkj_&j?eKtTmaD$3 z1KYVRr(dpz%W0Q*EJ|6gfT5oAP>>-rm?;5SwHf|@c<)}j=;&u)BtMGb9AQbmvZRkR zsGyib57OB;B|l+5e)vA({ab;Z(sS(&cwx=GcT18^x+RTla{N}EC^(~J-AVe*jv?Xb zjHVe4s>BCo?_k3EF&dTXs7d)Vo!CBpkb-8CDNdk0);oJj{hse3sTqi6Go^_##`uPO zTu3Cm3Cot(!6|edyw3wUiI+0K9`R!+M_$(!Ya+C%||aSev~rre3Z?9B?Wk> zAL@-p5Yg*UHJHDzc$Z`7&y^5D7%b50hO?y?%%NK(ujB^hDklkgWhd6AhO0u1%)_PE zG@!mYu0)SD^T8o0?cnksnTZ9+a!?YAgYmhm>h^MqW26QIQ#5CDIGvek2M+4G&m2@$ zCSb8SKV@RuoL9x5r+-fq=McKE6Z~rRFch()N%7R&3gG;#o+O$AFEOT5)AwngxU+_x z$wb$6Qso5G$JJ12HX-fWaj-F_R5T8WI>O>J`oWvGtq$LXwAP6-Pa*#^Np(Y={NrZ+ z`R3by8?t;%^p$n_&ZHVZj*a)U4O+-fW@%n9aSPspnu2ZYm9GvLWIg^+TjLPm6Z6O+ zSBH}`BZNK+cIQZd7O;oot~M&pIWpGF#{Ky@1JD5QXpbElmpwLamGh&NDL~xf_SyRr z75#B9t?;FH$;=Wp1%Cj23!f|FOKus^yNQaFOacwKAA8D-qA!G9gMd`a=B|VQ+82@R z6~*+)I<^105G!akm3(=I)jkx}69qrWCHiS3Je*yL1u`Z|JeGEkUjsay@se`hKtF$N zQAyaCN7Lp*%M>vsyu(QIoa!;l$AVvIiv!nF5Miq^>gTxV*8Nk8_kg z2i-W=kjkWgZ|*o?W+^}WNH#o;78sA$PFLWzb37j%+lHd8*23)qkoRUK({C0cc*6uP z1MP_`&c>|M3%Bg~$g2)Yqs%KQ^cT=!vS&rDkO;{+#6m^B8DqEdBQfIi+q!NK6XIMV z4t@2RJ32{dn!|i;t&TSKkdel}_Z{bxnv8>Fbd>GZfT;u^K7o(hgRti4Q(tv`S4+!% z7^r@0_h?fzki*OgAPsIZM?>vDr+{_>>>Wcn(`x?|P2a54wmX78@9Ge-7e{*xGEdDj zPW#e;L1CS`Vm8&%r$e+4e=7cV@llw`Xlx?LSF^A@P(aw&vM2H0ovEA=8as&5$J|m# z0R9lI5*U77ei*evLQA@U-8m130`S&<7NzBjfLrmfUf*y&d6yJv_#QDf43f4$EscKV z_2*gDeIS3UUqZ3&fUD5=;U5)Au(cSR~Lrm5YN$s3WIVmNL zIkn&&rw$$XxuQVB`nv*LOB?;+Y6!gHe=auCONC#uBeeb+_wJ|dWC~pyK(Sp84al_-(<{F? zKd+efhcu{Yib_mK2rg)I*upy3h{+~F;eJ7Z-eJEi8pUA{f2uA?v7+6<9Tbp_!{5-K zttji$SLi+tfFD6zCDlZ16G2IS;d+TuCLQgwXnST|BCUN6s@D};acPb~fP%6sKBiQh z;{t~U8Z55Cgz17*dSO)_F4OUar0C^)AlhaJW+|>Kbe0q{m0Xo%gLv&Sb=V{0pPf>a z%Y6PIXw;c^NM@__-11X?F}I&TU}%*C0Knj$8tpQocE@CMv%fam33`zeG3b*;NGA=Q zMb%xm)4HTcxGF=-NahIr3Q^JmoBK{^*K+YnRgKSt)VaeFt7I#?KH+|T6q^3~eM{zh zk5bFn0DPId!w|_|DFFd!8XKF-FrZzi>RMtRs~MnlL_LVDyG2}d)`8T7Kh!z3`$@cW zJ&2VGFno5~FaOMu>P1kBqZoh*?UeeI;22`=cD$>0-AZs)pfJe*XY@xgetM66@ahC* zk$7rPYve@G>h^C0D}U%5QsDKyKM}IF5yxf>fbsu0I=7%0xHh(p_w-C*`1GrtDJK6& z9#ZMl#NDe>A9^#GgujKfAL8po9i z?X(@gr>F5fu1{V62!4BrBc0{2CzYsbo1K@)cQCXDdtoVI87|=+B}ikOw!J~UeRo(! zR69mGF+fwzx&(r*#s@OpY9wo#>vyqWg+0Amt{yc@Yr8a<({+DoC{PK!V}_M(#*{J~ zscD!JP96)nB_lX}TTeIHJDEIfu;f_Hz?C>^;=)7D9bkpxVcK7vVc@W@)t%{h6(@4#>c3+&5?uNpwih}zq+c9P?Pl-gE4MO;gZ`~jNxrG4A zIWMQ%KUW3bw6vbSQm9J?OR_RpgF}p@25egKUsb3KWW(~y zhIbEu`hZ6JMS}#<;b(^J9I7syJO*eIt(3V*@DPC%AS1X@Q_^(b#sm^As<>qUCeOTX z50q-S&Qnj>cw-PA=hM%To173Ahvo#DV<~v@Ds$;e(#wjC$U4YkxY&kOefC>!uf7JC z&@r21%S%aQPkEuIe(m3RBQAiNj*5eYe!IOx`_gAMPLRR96Tiia6y^^40l8tYPcfnX zxXs{NHxFccPR%TABVlLe*VX6`T=$p(F=@XBbVm~sPM?!%v&;0ITGq^>q)y?hZs_zK zp8xN@^utwm^>=_QqqPi-bGftL1;+XT#KDDp2MAKR-)0y08^*O3*FiS}d2aKAlhA;r z;l1`Eq{M5`vLX03;(l2uzqNJ?H*2E?0BlIi_t2a_{O3wqN$A1R8*p-FE5*WymN&qY zOxz!98}hQx2uwvh^}avaZA?P*o?`m>uV0(}(;tDDjLv`+bqGZc@t`crZygHELKR{o z;I3(+vDF4MiVi)iH{brf zAqhHrH$CTF@HTr{;B?!NeLSWU$b6(rwhM)oQjgj+xY7bQ8kS?&W|}*IZ&B_HDie}9cGLK&EE^whK--o|uhpZO+?=U&l8rMaR*#_{^m@JWIXB$LD z=kx$_gqU4g&`H8+YNIzYV;jYYd~(jm*|YZ?$t7P$&X%TE{h-n?(=LldKU&Bs<5V+0 zY%aeEq$Hj6nDCwWVPPApfqZ6%);JE|ssle)XQ+*(0l^ba z@s2-`NXrldRhU-kWH&46%YDneXL z7bR;P`9JufuE73F6VqFG2=>LKa}hh09vlgp9oqI3e$o&Q6R}hE>g$ z5Q{}@FoF$(85;^&;+wL+T2L>X>*x|3%`vrq@v+xv1w7yu@Dq^rle9!Dq-1(45N8@p zQ~-wDF1+^e9Eb5iC^~S2GQ|JL6*9phC9t_`Mu2I7#0`f%q^;0LpWdk?L%~*#R;}+h zirF#MUbAC4#Lx{)0GxaU!Y56Fd$mKyb}=YMb_pxspM2r`1F>jw;DtF>BR+DD z9y3?C9wF}BDY2itoRbOh&(HpT-YGE9+p=^##`If5R2BQ8a%vjwbY*)Ecq1ei%gh6ukUr&9z~Gy+T^HwF>Mx@oS<|1M%oS6q}KToKdO@ zP&*ab?6A(~%{R1vPN#dZhCMrZ9RdGEbBF6{(^O)m(>7I+?@Q2bvzgx^bVG#OeO+6a$()QhKs6P@&tzpVV zNO!;N$YJ%jwSbK9z2JDt{_k;TJVZiK_g{$jSVOTg0luWA@_GJ^*SBVewa1bK1QZU> zNUYPruWFzF06+nr)Dh5p=(xP^<=ObnxyaGUqGPU7d6<&Z!_DS&vIfXA7L{~}bkqWo zrk0esfK27O)L`g?Kc}7sJ&odXvk(Q{NQvgQ;U|juBecU3#~7~glScr2{MT}i*87si zSu~fCRCY)VjjXl5lVv5QA&_3;&566D{-s{TICyJM>YG3kv1L}`d zqayzk*6g!qehhDt51_scm&FT@n#sgrA*>y2-03it8v8_xmOK`JNj}$KPHj`uvpl5I zMD_2cyK_cefd8B|xu+qi*hnAtxwnp}C=DLf#=aaX_M#^0{J1a94UXUYdvE(UPv$#7 zbHI83#*Qs|u{yc@WX9PCP7P8iW*~3-s0|GVk@E~QK#W8MZmV!VS;5W3Z_$0aM;=6> z2$e<1KtnkXOUBE+Zg4nb^;Y=xCT-2trr*p(xl@=J?Vgn5)=Fj=K);&_hPq^b=+vmZ z?Je*|BynN*@Iyl!ZWnJemB|t2BIsCGMNT#JIiq!$r$1e>eG+rNN?vp;*>Xc|*8|04 z9b#6E-g*^M{yfuQx?q<9g8AlPoysG$u0E>X0dy=OZTDHyMD1`dA=?aJq0_aK`hShA z6NBK!1ywPcImAQ$QFNa6Y;URD|ESSWZ``k(AKe2yxRl%tQDw#_0U}v;veB1M@pYIi zhfFK*aV*$8Ql7w&0T`2J9?O8l)a&`{vz+`g7GZ-0%VI#Z-o5px%v|+G61Qf-r{1~l zZ_iTteMc3BX&6kl;y%`B!vQ&u)`Z>BkJk|%h-HB0DZPYR%M9Xi@=I6}E2|HrEd3>r zBLwK`7Nk>vRe8P{UXkq4g-l36EG)z@4$eU>W!4v7(Y{=Uj>;O8OSdc5KyJ$iTea;x^G?~lwUHDTwBT; z#ihdJPwY(W#fBL=xKxD9s07J_?QBjT#uzzz&3zGfW3t1~{<91N5GtIK?3yZQZUZ~- z(l}dTyK3XgV|42^g5XzSr=vopTKL8HIO{!xZ{`y~v8j%MYl}6(XYSlpsVp3sdF5{vTvsZ0^*(-Y zsg)7!Uwzp63JThD#TKPstIzGSm*;;#BjLR@FO%yNPD1Dul4rp`YrLtv%gk{^jGVJ8 zs!=2A!fRtvbTva`xW~>BH@RQ*`Skey3Dm=VSTXdi0wU?cVfcD$8(i=Kb6T;IUl{)|F#Z0#FA9|HoC;jv5Aiq2W)@ z52Y$wE+jeHOlzG;bzJoou#%`xw`Yn~7yq2sI1JcnZ~_|L9bh(Dfj~yr-mxL(BO~OVXB39=3 zV(gg4hVmKkc=x2PxC!q}#@PY0`rpl-nO}JlG zk9d;OJQ$F9ogaUc{T^@i=Z!5xHY#@}q`WjG(?>n^nge!Kw6}`}E7;f^M;*q42%f^dC3SN1g%9N5 zRT}f{mPx1s19Kg(fK6XuWFsnIzw%{%WwQB?e~jdD?_-q%;=hPt^?oi$_-;5;wTEAV z)1+{U?-wAQMPWYuotlpkVXNq>;D`*DlkwvSUZA(Zv)*d{6aVn5 zgz1?OAV3#$$FP#SfzupZyp5qrYpGC&_Az9dr02;IHm5$Wz_U(Q^i3G%mzA+EiZ!++ zcoc!g>Z3w~=89&`-3qWu+5!}FMZwNV=47;O?dg2=^~j><6w0s)yD-r6uce^>h;n^~ zGvScHI4R1ndecXLD6&(Ul7;bh@Lc*Rve#*(Q7;QQFNJZ&I7)@4h#{enM>K}Qz^u>Y zg+$hMFR)DLMLqTVxdVnevI_-W1bWg_qGsIZC0EQr4Q2=RcB{ zRYO=H8T5Qo?q__fzmv-TX_ZaVr^~N@_J966Prl4ud8V%o{Dqv#U}q2c&FAUPL-g^z zcWRoPof+?Wy?|yyqAQC#B<7=ujL{<>P5_9Bx4r$OItK{w^Xu=ptrIm02cb0K_aa21OM{l+8uyC6DkqvcQ`bW%omX5@-km6TbNy zCjKk@26M}-VyQ;+cAF?H09Xs4ny8m;@;b-h(=gfgz$a8I^w4DRAbeQ?_<1+e=NQ)= zaq{LW)Nscn*Fb^i#KL#KoLn(a&l2|IAtW#8V*vBaA{%yRpsKmkHBkOocOv`MCcFD? z?49!ceK{bs)GPBXMRZS=r?mb;UZUOqlU9(P$odqOzgys-p zykWZ)F-Rs}vdeEBEG5H57}Bunw+QduI!tYTG7S}nJL5AS-?JAVBN3nHCGzk&lfd;5 z{wNp3@~PHI(?~2Evjr^^428C|LA?c#l+g{_vG^TRkq?F{cgYFC<#W@QoVKoz9(OdP`4n~FF@iYjNbKGFI;|;*M2_}e_fhyh``VN(GuE=l(#7pkQ#$} z_|Q`@my4{DsKk)LJ%Qz#HOuz7d+`eDJ|Ckyv@v$^q+9Xv=Clx8wx`{0PSM*v6Ay*1 zW{Hc)x!`Zwrqnny-cYGWbA>c-G_apHy6)Pop@w4 ziLrZXPzCJ2P@&tukzw|!-TsS67#t^a2knzQ4EPT!o+?9<6CivPQ<#kfY+$OYL=jv3 z&UP*mR%1@j|Nd>l@$d|277LeVK?R5d5Q7EAa{mPKjK#f4fZ6|bBv-;$K=ilfMf`R& zTi^+R;9zSP)<<&&K4U&BwKHdhE~I&4vIVYv`y575Zg&C-TsN{a1V^PKsu)S((5kYqivsPgS7Gwx?q&XscffB~+9ZwW*FR*2F-+F~Fkx`)L|=!v^R=0Tsa(oqnJ3aVWiX$DiL z^yR=1Z<#3Y^PPRqovyR^{Y)|xGOAsQ5qP_l**?Ss?MOg9u8L9FRDn2Z2~=juJ9^@o zR2AKkglvQUKdyRqEll!jJ}xUTBr?TooAwK#g5XP29P@!HSUvfHRM9Ig9G>Q`g>%izRw(F$K4Ka{J)OLB)@W|P(Tgg2 zdaFO}Tzw2s_>gm(@Q~Vdq>_Mo8q%FT$H0uQHO7351CS1-5|qI1Q8Z`Zhb?AcV$d0` ziPONVb)bfylQ;M6ps#L2meW~JM)ScC`5q}zY$~ai6=c-2R8Z0E=N&rak zhZ%kC67LKKWKq7k8;l=$&f#<`vC#Fdma*MvSl}ysLyh?*wUYE6x>m1WnBOFO%Yfip zozh&4yE$w~mZNzoLbej*CA6vSjZ-tKbPG+yQGC@Xnir)YSL4D?i4tY)5GPY<3Yb z9y#NOkRBSo(whSfRDkQ zl6(xjI=A}A>m!h*(5gyfV*mg+i$R-fP2msnWiSFa|Nhx74*Sgjr3uref4nw;!XWT0 zft4jVp0(gbJNsoEQy>UTxWk@=Tqx%q`r1d|>*cj)8?;wRrs?P1yO_$P*0=$Ao^6QA z^J_f-xPiQ%#SR##*9=(o3HpZWeR7NrNeWUTbgT*94VvtL8fo@E?)u1VN34g{rG`kE z@0WG)L;MOtEv~vR%t0PuE#HOgnr2{8GX5b0j?X!=21}%qONg%s{yn7sYtg%n^7+#6 z1qPpM+Q2+HvUTLPr0y6G9(FR0TtM$Nd0Wx>oVnjTm@`E%C9Dxaj>qqar%e{$JCq8z z(?zH2+kQ#ZtP+L&C~hz4lgFj6Q`F9$h*~&ySt-LPRCch% zbMaJ6VD}npPBUlEM@Prl-xUIrDNa>x-Y+L3XC_=8W31fK%(2g9Fh$G%Y^|J?;~_7B zr6zZ@5`e*G&~Db8aWB~RegOgPZnUiFYh+TzUeep7;xpG;z-$=i^|t%U7`#*!nLB%_ zWcTMlmAHG+;*wzM+QnWIO@faJ#p)Ri)dEuS!mpzkV}TJOP7%29Ueb0GEki}JV4=I% zxnzGJAbN^y1I$Az;)<+h1QK&1mtFl2;l9%Bkei7k+zu;*Yf;{jGZg8)6v=+nWolJv zSCD?Q2w8f+8;P@5>&3Ng)dWFE>nf4M176@YD-jmojeY+5;>iXNR)z3Cl4-79Z+xSo zv&fLH_)2i?U-jaq{p{gO425Ew(^i1HnDhcTh#@c*w!v(9tigLy)1kYzIij=((xs`IMAqKi-N@wUYQ%R} z8iS5sC)aRjS59kR0#X?oOMOmw*0txh>S<&rn3Zg^KCq5KR39l&BV3Yx?O@^kDUcc*DYDt5ZM31 z>6D-ygt;sA@k$MwSPl5`uf@gpO1NtB7BR4zpJ=t=KU4t|>bPA2|g{7g(heKT@CFTDi zBatA|V-Ht9jO5K514*>`BM;=_71OUa8`;^ty&Zq+S3q2{lPOEIetF_=DNCSAipn1N zxTfBUOhl%Cz(v!iz;9~+$52@4;V)(DncCAc*#7wi_Xox2)oD4iL~pExpGq&XPezV) zn-~RoLjtEnrVX~9Am*U*Q4WPi7&g1 zn>$IWz*YZlrw1YDCAt3wn7)@}s<<&y zA11HI?1r$%E+)2eM%L9|58e%{YEvjvh4TU^9eQ>ZSf$>!M*;wQTcop!T}oIguSAs^ z-?Q*IY46sKGES{K*lyl0;}CMLeUQ;b)f^QHQPG;YC_VMM1RqU^liluhDoky?XoVi5 zZd0*7?DQWh^)X@Ebo?X)drm)>FUJ595`DTXoOFW4aWP(M5GCFaqgIQ?)<9Pbb{>d&e_V-s3nO2KS5CvSE{_ z*CtFiRgq4xV9bDMXc}4HwnEII>%n@Gv?s0EEZKHBbraC^Io~c(j-iguHb}%o_jpz>FpBBt zsh;D-#!CMK3V`}nN(8MLkF0Ih_%~x!>TJ2UwyY#y~z%+TG%~+Fi z-Tbm~dNw#?B+3MjilQ(sF8r!V*-+}&y_ywyvw!-YHJ5}?%!Sgxm{T8i5 zH*St}-OX-nUjpo977FDkcvX)y&C`PRtv977#U1@EQw&mJpj8@sAvia5$1OM!Gx0di zBG5Nq9&bsLZ{0!~?EF|S81#SuWN2$xYGspl7ziH5masu8^`$zsxNA6(Xb_kf$+{Y{ z87G@SVz&Cpe$7BLid?(i!JKlrL|pyIcGq-sycw-R2s%w$4OQK~tVGZ{el%~6m@6RB69c#15r@28-*ptX)N{w~5>|TSB-4fAW7R zYwX24>4)O9Kkf1L#AhnrKp|etQuB|h$bL*Q=pZbBRNri0@uh+@C)}Y9#B8gmex?-R zma{x4elq1GYa>q~h*{MwE7b#$sZyGyx=aBr?WP3G1ezY}n{l?#BN9}t@IfCgJZQ$F zKe`ILa4<7mRXUrN))~ALCU zB=83*_kBSj?4cd8woI2t8OJ`(nRvzU=h|k_W6`8-pq6IJ(AJj}@O5VBWdZPPQqATk z+}{GVm;zxrO5h>PqaieQ7>ExdIZ$a-k468tkjxo2pq|$TNbj;lJ$81hzr%UIHLgop z$Syt+0z+YcgpmjEhImpQ78uyhs{jEWbhjxGW7@Ft2*|OvUJ%H@W0!1fZjpjB;!Z$| zly4m%J61aZ(a|n*{^T%rbpj94eMm!N|t=j zmMo{P25wJh#i>j5ff108Q%5%DsKlE*I=Md7JoqkRYiw+~eVge(ew^vWmzs&8kTHYt zK*p^R#aL@n(gC4scSx)7bLfvMC;Wn&1>pMXy8HcxNPw+uZ7M?#ifWxl5_GXFUC<|Z zVrl&tHZbog)q`1peJ04ilVuVkT=`7wPxwr=_bc>o!w6{Ttb%o9&Y-;`CK-f&c;tBV z)8V6v=j?kosu&dpmtkR4tG)P$5P%oz{|tkP<=nD=|BA`8|Mn+f8JsCu9dGtAsVIc# zsOkJS*JpB0QW2xA6T{%nDv61DF_Hi>pnukHJ@xNl^unGg@o~$%b0O-FP3`n2j<>w|!sQ#ua zK?1pZ09shaz0*!ie1|D(mzsyp92 zV)u*-0edKs#=nnY%hQk;t)Y(n-!p|GH!3%C&1HKX`Z%0tzu;oE&wzf342h`eR-DP% zR@0RIH7S}fKjFf#9X6OXc7vE^YE%F^YW>cIZKHTm<>UZCK)%1376CKWY79wm2GSz2 z7|7ks; zwTEFXTw7amY={jdj^@1M1-Ggh$HorSJ3T;80EY^|szMAbs%b|0e>#9Qw`uWhn4UsV z*LVSYte>Gzwye9MT$0|~=+4dXkC#4y`{$Xz+_?!uGUbQe@^~eru(O3UKTy{w@Od{oo?h11nbW<+ zulF)>6Vcx{IBS5%iye@L1rdsA%Z&;WWS1#zShF+<-}vEe{|&SVWgcJF8S0&0ud;U6 zR92z8X0Dt8EX6ac-}8Xos4~ogl@?^kjpo>*n>VEl?A?-z6v#?1CQ4Bv=w%>{1$>oz zX>oc@RyvL@a8I*NUIYo*BI7$<$EED+-Qx}i5DjYrz{(#dyCuxT@Oixs$0(m)a(M?E ze{YBp^I5p*#sb>Vum#bI_h5rd4IOMX<)vIlAPfePJC^!WMaxSP6{OElm_`?FYn6OA4{e;H&Dck;vd$dBzcSVVzLfSR3~8W_1(I4=ql z$7BD2*`2V62e@o9f^)}-9bA=#AYStQ){3uG1x>tcCW{D8tAHv)Vkyf(8+KZ#!w+;n z+at(;2b~7$^t!tv#h|q7zfXVg1TJ%j-$v%IbK2bi2+&_UQNp5=Q->7F!_l{1ZVC&B z8@01{46yT>!dr~7cND-swv=5C@nr{3B=Ma@X*yVSWyu;|%zbA4%uK|wc<>s(6`y%| z&k^Xf9p5~Rk||G4Af`^)M#oU?vvh9@m{{UgU)RVSTHudqG}p{6`NB$UpX2^Gii z8aWEF(wLfPEjN<^Gw^nGa9Lz@F$}W{^DFo3fGtFy3oI9cd7&7AR@-1Fl`%o^5^`NG zZ2gGe?3w0xx48|;uvFYC$f3Br;EFhH7>&^v5s!<@Gy9?`6$NK!h1&(-zC&pxRNxVNtQ2eTSb_yRJ+oQ-mv)ZK2ChWkj`;JJ_j0ydF9{y zTY%Gu9;2gXkKmIa6@Zbh8-iX=7%#8&1aVR^2p&TJ=a<5lWhc zg7zpuO#W~gVi-d}7pzwoSndUovKqg0r?hqBTCK%GmBr;S{A|)ZvHn-O7UVhw8rk=P z@Z3$yhT-z!f(iotL!Ru;e1)SdhXohEEobJoue$I|*kXl-@vuQwwc(HSp1$+taLm11 zLl%AH-YUlMxx%f)HBy%7x2>xsnf81=Zd}!AUuKe8!bsD|w`0}a{(-(DUb^$P^uA}pnZ!O`Vf30U2X;95Wy0&@wpqA}Iphnep2R4NO(+BG339Z&`5xFC86^+Qt zvsg_;xxbo_SK$>(miQ81I_SbHND4J?K-&Lkd~R9`OxrbwpeqNAT9}=f;t%|nFBwL+ z@e2&Q*GmrdZC>5NF%qJN|6DuHG{%=Nk?u~PFDAC#`=m7Eh%tVrF#wg(Y+0#^Rz_wv zrAoxnNx6_?l6fBcpoEgH*OqY4`WqL0Zwh%-Fy^VTA8i0!nt4G9TD=DJOmtRNiGS`e z=%js%`8OmQXORt5uqn#BRbqAu*`|W zbsJfo-;>=zRHLweuTEk58|@`o^Oy2F(yD1P-w*B#P;ySk(fU@pUd+$gAO^N z49$jnHNYjeh!7fop*pZ2A*P%zB$hdH_B8asnf}1w>v9ly^z>)qqv-*0TTs=-{HfQi zdB^+Ht*A6v&B2t|pf?QK0SMyQ@MEv($V3=XKhbmL7SoWfOoiEpE|X0ODDDK43Y2LB zTl#eKXixh?W_khXXhFT*GliIA1YPF;ly%!|465Tb@w+rDMzym73Gw(BpG(4Sm7E=hL< z|BE@WwXFO!#Gg}#LYtuszIF;N!j#g*3QOX%Nw_SU?urq#fBuwqT8|$S}N}9S)yd<@|a74uWJ4@Dno?af!Yq!AugUT zgm)u-mvQ%#hNUy!3XFL|_W(_o00ai3@m6dib4;%Eabr_fI0x~2OGmxdHYYEC64pPL z`cJp}NYYA24|DxhsGGGvP>Rn;F1fdoXlApbUTMu88l;0M4`vhFB+;iM=l8xc=x-{9 zi~1UZHs(ia0VwF?12~rZ#ThwDHb6dA_FQ@OKEnX>vGfGo6^85*YRGhI=_1$Y=-9c= zX+Vk+24htc8>luyZaG-2UI=s$0mXk#!asXyip~bSV8;QF1eRmcq&@xl1Fq&3VM?Ya zotHm@U1Hcj_SPB)fvoOIt+Hx0!WN!BQ*B{|2mn^6hWOh)cn=U;3vX1o31lhaVUW&Q zKoiHU?;c(D#=nCIgbF!@5+R8Ky}Y!T>rH1v`gH}6g9OU_jg3Eln&#;jhe@xY%!*fa z+-ycLSy%}ZaU1{>=)8p#vRi98YmFTD@ES*aL<4=fPO8vZcOfvgh&Rqz9M<-eR_{3n z+5dp#&HTZ1Z|(fdnpyVITpxabB}DFw;>$`TUrKr5Pcv`qL3+RYMx9TkL8g;5D9c6S zHX$P^6qY<2 zC3h1KwlOg?X%+xt3uCG>oj^4EkHgD_GSJ&d#fEeXsm}}YIsfCacH;hfDw1h%78}s$ zfI zNkYYYw!=)Z@Zv_b6>no1NIXcp7Np0XB6Hed2_!`1j`=LMJ}pCV)eqGeM>QjcJvC+% zGSdI8CT_K!?l1hwxpQj(39JcQ=|t`K1!kS!BG5~dNK-!cRypKj%E%u1BKA86gF2tH zoUkM_6l6X=U6j!>16xpA7mp;LZd78k;_; z+!JqgdrUBo`V2IMMK2~OrGr)+K~*s%J{@CC9)3OG z>C8?hb2R!xo{0+<7SjN9ft@90+ONkC2;Ym@4{n0$#ru}m<)DLaAGGkZl?wD3Q;`=Sx;}N;@beY|9dRiYm(F zN`N|)d*f($WVQxnW_dD4&d6(ZlM+SMdKQ_N{nrJU-3*;ZvQd_9M=M*O!`x0PiC zY5sw;EA1O(>p^gGlQIl`-b#!>Z z13&zCWS4icKfsaJ(s@ehS08oYnLh<9MHA9Rl9FRELpVV=8aTi6NK&(M!|XqW8t)us1oJj{pCgS z0c$vLU-HfLcX*?w4t{~48Vudpy04tlvHotB=xzH3%^ToS0mgUfHMH2^5*c0qS5%p3 zl6ev}EmZ%H69%k1wjsVE2>rWsD-gVwM*5rDB{G6)p!`6i$&WV1S#Ljs=&ykHKjtVE zhV)2#Fcrx&gWK?#jEGt-Vai6Wm1GUOJQ`4i-TJ8Q_Ar3AK~p}wGSoUAu+c}DaRGe; zBB`nPS#`sdsz}x{(Uzk<+R5zakoX6~1%+78O^yU`yvsEhZ4AO{(Bpd))c)**m!`|Q z1+Cv#;!wLK_FDnj-(TONw)U0IU+)~tcuK2urBoJu@z(xk>P6RaE;zkMB9&nw1Ybl} z0&yd}h+~KmtJPsD@->XOXti0uuSWMvS>fVcDb(!JSKh1zd)j>lg&AaN00I&_8|DFv z$B3Rs5Dn-|Mwzd5ZxT)CxW>rpZNl-^KAKXJ-bR2U&^HuR`fwd}(v%`WI+`8S@DoMB zMp4N7HFqJ8H`R0hkv<3?1uqbzL;pk#ZDR)RF9fHLAy`A-g-9gJGbQaphlHTe-Oiy0 zSU^`U%h=^yS>H&Bsc-!&KoGqnO1(XFd45KDfiiag?N4(sga5`L6Y#`r<-wcehM3Ba zhscilDPXe>p_YfPMps6&FpGE6)-%{Uu2)S>LD=+PInLn5ocO}Y<&!A>Kk*f&A4DHU zr-)~z{N@i28H|RA%B5!0c55l90Y5j$lWz%Zc+l_dAv$>eZO%Y=vVfdBKOc$;a7wf? zzqtV)D1w}JF-8{SXwdYkf1V}P{7sd@JmoyZW?h&X}1kTvqQ~6Q+ z6Vl=+%9ZpXM2LgS{0I@1A@@?$^O6ad{i^HJ5QbU6`z|-7sFUlOQyo7Sp3EvaiZgnr zDCG~R*xOr8rsZSao?O(G=2a`f$F^Qs!VGp_X6z`d9Q7vi{AjG&N?^rF-~joK0$6Si z(L7F!trQG5IH4NH5QlQb6*kx^DIjv6^gSls58$&Jt*Ha?aU-7PZ70CF2k5iOZD^;L zDTT&a0s8Yle!u)+k41^XdEL|YJ`gZ-${^q{AA)+s8&G5KLUpU=$B@3 zd~K~5Ojy~MunN54UNna{{q}bXUUS`7+xW8Gs#w74*F&n|@ z2aNyqcBv1iK*laTeF6Bvto3tU7I=hs?xu!ki6viy2zGGAv)|<{oQ1}K%tabYRc*9c z?Pw8k#-i`TKS=OW;#;`ePM(uymzmnvQQ6Tyi@a!`+(L<&k48`2Zq zgN5=cw^V1xs_PrDR$r@CCPeydQw<{iRD2Fe$Y}ZeZjx&c8VK(D(SD=r1PkK7Z7w+3 zMZxp^b$1v36FoRbB3rYo0yR0^efx`1A4M8ea+|O7zzp-N(Tzm4r*)s>gfYv+na4zS zVk#wwndZ$g$%k;(+i{kvN!)BgRATX5QO&w!zJ&U@%OC5TeHZAkq}k`@IytLyz4=Ju zVo-J1r5SINXKtCwY{sNW)BfRC4(X$%W{E=tY}>NvTX~_p*U)%51w4+apG$#GPUwh$ z%0A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 z≪bq*igHp!De>^hBPQVU609acBR)ylaRu4^A$vxNVdqK@-^7(38OJiu5_xB)mPw&Er zAm>ULQN=ij?JVV_+`6RgBakONg0IZ8YrER!4zrET?rNdVvz)PPg#Jhl__~FS`l0AZoJb08(qj62<(~&Ev)VqX5wCW7;IZ8hVsTw@vsd>}L(~}%UPP-m_W1NWyDo!U^l#pk#2)MQ%g5+|MLAg6iDB$g!i-|~?FXEVnTAuX z<2ra`7*tV|t4oZSH!C|w)+t-Km}$^nWh=+qRE_CVop5YLixR*^V5Q4Vr@I+G$IJ_# zrQ9eLG|vCy*jeCWcF3A5lDrn+!<;oKYNM-X!R_B@4N8dJ-m2}i6k-(q!BEAy>*brn zz?Lu0g167U;dd|{*ljs`*pOxmMJQ-;YUJW5%RAbCgts$pi{rdAk3jhJ>-eQr?xcEM;T9pEc!@xr7x5GC6UJSRw|m=t&sYDD>pYxE@Pd zn9xQhCUvqiRDsYDH}EUb)|mlZNyR+UF+BmRCVNFDieuVm(bhuq763To>S)a}WPj8i50xCROf~N>@BlxCQ3Yy(fReA{3H2D2C@N93~w8?a`W)I6Lw|O44 z*=4q4a2AR^a*$R-hB0=9p%+|{A`w}Co8Aaq0ItEEN;$>RIXz>8?kP9$cB~cM zl!y8kMoq@8ZtiE6x55+=+E4N z2oRzOktPP1)Psh}ei_g;P^_lZlQG7J!wPcco2=jSjmy0DPr?uR7#B04QFkBzBUJDI7KaMH@bFaDc6NKsh=!?BO^jy zSmPOWf1jj6+Ro?{{F%I&)XRF0LiM3C!0wJ~?l6^@O9JC$c*aDm_qx_Zm95{C&k9}? z4FQ@_Y8k7Hxh7d2$~-032yOy7>Qy4nE~)A-La#^wwIn&i*5DQK&t=6{r5LO;+8DmB z0Nj=}oHk8zHmZz}jLGKkl~C8-XX&%*()Y%rin98NoUs+Oq_?!f1&FiR$L9s7XF7Kv zs<)T*Lhb|qc*a?2=4eiTo!-;1Dw~vWTh_TQt-kfXVm~cLYFIO7|Fgqyc2T+KrewK8 zEOxXpUnaHc4APdT(D>X&5upY=hf0n~7_Fs}kY?FJPhK2NhEiGTg7ij##3N!SK@tU58S4`j z1?)`-R?5NKCRbIh9(onSfWCLPgGhxc5_W!111hno$Gl{wXZ%m0_6{ zIdN3)2I#m4?-R-CzvH)p5CBB&e(QZT+X_G8=e-iC+{Rv4^=?ir-Y2oF<6tx-3^a+0 zjb=l4aY4Iqd`g2E>1*^Pil?sUh`z*IPY-(4`}1*bsLm{|$j$8jQ8d@RFym0%_DdK7 zu*?)>LVn_;Myxf(`qS9~Soe7C^ODFFz1IkS-4Q8sDGMzV05Z73*dbL$b$i+YpfvUF%E_eej>Z|Q z!n^*~G%nNRQx{CPOJsERA!1!9UTIX;fADnRn#`B3vPaw|&JoLi2Ad(F&`t4fcx4krdG335W zGjQb@t#aIO*^{LK1!O2bW2Xs&YHq*!ti}o1(;Bl;0?fH(9AK=S;7`^DdqUi7mGu@+ zf|K1tptz>F*VRVl*NY+FL| zB+Gnp4I6&!)4*r4qJt`865Kc#fZ0^g{EMq?tBLJS;%RAgb=n*_4)I?O=#h6%VPfB# zJc_)T2wnt3S4?psJ2J{XC;XL`cP&~aX&rBcSFxC%v4P0#5yq>yN0A2JGdW)X4l#Y3I2)t;Zsml?}Ijd&HvdD{MBg)d7Zb2j` zx-e&8)WPs`G7-DEQxSkL#NDtfiu+yMbp{z1CsUw0E-x!fqu`n?n+N)_wU%2(`T8kp z2zh5Xna!f7SO#Pvz*z;06{o5vvWPMzJ~odeF@1trOKtD%i1UD>*tr_FYcx8IhpRA ztxHv%SAwgQ0z5J|89pl6aVC~l1b)Y3)1+8??p;{X%l#%}#S(}!I12tdtLWx0#7LAG z62vG7-v_yG&zAWAj_zwpg9VJBE|JU^kxCjnnLcKRGY!q4G#%4-mFKhaLcdjYj7e_Q zUqTnS`l%Q_|7I!|JeXnbh?1x!L7M4lRZbor?SPYn!ez%V`tqV%EPU0(DId@w)gW9y z?YkpD-lM`d=hJse1sQ^Yl7M`uAh@O;)#|O9&ue8w9N8>E@o?Q)GZA)+$$M9vpibx* z?3}79V({+>8q3`au5!l9w8mz^hQG*^Ea$6Q2wD&foR9#j6%oJW`{L(xerddZAYne* z0vbee6K2a?$=rr19hbv{C)MD#6>#Fz_c;&Pymerw-W2}bXBU&A3llkW@iy!HY0b7aG+>Dkv0Djpl0 z`69!w%GG#dX;c0R!t!H&9NMzry=~~upnaU0;i=l;;q-|6QV^-z0Xw@lT|TKc=mlm- z{HlgKs!pZX(NRexh`SP`m`Cq1e97#Bx#*E@x7clsSEagKnubMRja4dEks3I?y}vpI zI`0ttUP-d9PWU1XU@g6}xmAvXpRffXEQ0jie{S0W9>{b>f4juA;mn#mmHE}b zn?)Yx9F+N4Fvw^Qn8x4MYN_$To8M6%K#WybjUPOh$l7Qeotb6rkH?cK=)-2mL^qFP zz{w{sD=I1?4NrLsanNqnB}K8wyidvn{YANpb7^tLd^NOmE+TlFEQ7d&ol9_he@5qf zCZs7HzKu|#L@t;DUXvzMa*yL^kvvM2o4LY4LnGT4Mm(rbpZ}bPmycCIPL4(Y4!K+6 zf9=hyc4V0;l;Oc&Xl&{?|2M(DClCpFaww-N|A6m)H0-)_Yi8X zm0w&}R~5149HtB+RBSE!b2O*9KgOs6u78!R_@tW8AJtd5kj)g|U>OymE%|K5;+S10 zS*tyQU#-7Ylk-RLkJup@x0gA>=D1mn80QA=Vtui}x0fUcRGQkU%Dy}?%Z>8=+wW=W z>0p=PL_`FgTEby()pecIfB${s^ECE3<2Sai0q4w9b=GB9fhN0TT(i{($FDjfoD=3{ zlTHP0#xlh5kYwF}GJlwXm=wn3yYX&tsK>Hl92eMfHlD-%JH!X$x3^1>$gs&A+-mCu zNWGL@dbYj$ruBSKU=s2o1cW!EG`c?MThck4#5x7tCz9<|2 zP!#lD7TxY|)gih}*@Ndb;%D2d_a3Xd;%pkJ?+SuF`XBveSdZlX;maX{)~TszO0t<# zcnRrl_uTDmm=S74KwF*lLG&T5H$CCsn{=SF@n+vufsM+KWYi;^f{lO7TUQM67>i87 z{29ead(~4?0)X*uvlAGWBFKhN#;1PStha~s`AH{LB&SZy{QWk;3VcG2rPjNFt#TFKffy+tn-V|+Kqmv4 z@-vaQ@;rE$lejJgP3s^N!tcoRKb`!V@EQ{$mCx2PE&%mlj>DaFMaNWhmdpBZMNXdw zMvTgcY&BIBv)Z^S2SdaxT!bx#?D7+LJF0liAWvKb6EU``G9cnZ;Fo(SD!9%W3VMZ< zxy~A1{%VNps%nxz+*OQGv7>P%U6`qGb3CQLx7Mll)`3r#JkM8b`}AQL;tW8m?PSh$p?%{eZ2Kb3c-4{qj?hDFSXmc;2-ya2{jdSWk{S<`MZ&5>v%sW%R^rN2 zZ@gpF*Z=I+76WT}vxD&5?}b313X{tX;(Ej`j>?hL1zEv~!~0vNl|&I~Pe)hgLM{`^HPgW~2H$f22_*uF2A- z4;I7}g}gDwnxTo38WDPA_E-|v{##_B%k*LJNV6f-O6rBd>g$M!q&{$KkSNPB@i6}9 zQxLEJ#=Vizh#4G)cL$EySKnzI!7{bce&K8!eIp&_Nh(kPqV@r}5|g(p9jLX7%x|X%!d@yW|j;Vs!R*rT(Am_8IM)c$59$npti9qV1>x| z%A!WU^99Lov~Lb;o;UAJKljH~ZOks~9WqZ1-~CzfPr&?Ew&nJyL$im`s6Ec{nBWrU z`v;$o8hEA?qad-)WNHb`gc`s16+##HJY2L4G-|I=?9h=3OE65iOTTgOtE8Q>4TzV^ znl;zsGtusp1Q_9~J^aRoj>Q3I4eP>N9$56RkY_)4a-dq3i<#U$mr=-W7v+3 z!Tj!-N8X#MKaSUj_J1$J-1zK%m92-y3{5ZMrd66W2zq{j zDU@SDW+eY`aT2|cuk##mrLGMoQ;%Iv#WaQ`H=M-*x*3zZRlo314;<3|^MDLx{)-(3 zhd43c1a*MCN`T%2yl4eFS7;*ICj=Ig`bjP$3}c4n4B;qne21IGr~VG!>d>5b?9`kl z)a?u-X2fM8AnlV5{X*|=1d6=uA})&_r2T?L7$DY^6CkaztW`G2p44;BTT>9Dd61a> zD7w7^m!(USu|OFoUWWFMqlptpV*4}ulc!Lm86iaI2K=QIfHuqKlP-|riKx6mD=j_8 zE1bhD!Ob?^^fY6F5jOsG=ykw-rlbqbVWMqQgv>&l@S-RKF|tH9iQQ$8@Vvb-V92tY zx4o;ClM(#nh?$l`Rhi(HG5`Si?sVerPq(b`t3^2mAjh{xM>#Mz;lj7m>(cJ@b5NX{ z#P2j5&l}h8eX7xYEx<+OUyc}9!j&7JE0}t@N0%riHPPM!qy>7sEZ6p3%;4mT5bE2r z<})Z5_j{{dw3(Xn+St3wh-n-SEEwzO7BGsN6%klFRx#*}h}=N+08XMi1DK*NHKWn2 zSo4_Ahd?g}*@E&TAzbi@R*U4iO1uS??S^pZm^1}@tXk%`x0fZ9P!|X@OX=!qjF|h! z5o(|(9Uj0q@pH}agx^SGIO0Me=pEOu?dMqD+f4*j`opGdiIivh>5VFZvVj9ylShq?}Bk`g>H z*H#jLOg#$(7}H1q&QN|5V$;ZScJx`0X%dS~mV+J8Ps& zj8bmy7#Dcs;f*tDvj9-XoQg>S!<$P7kl`QiK1X2Z4mTxWPxJ*5GZEI5eBWKk5B90y z<+jcbMfSjk5DH%+`iUZ@Eo7UyIGBCT??E_Jn3u*!af9VcfHubJHqJzxD$=@o6dP+7 zR_BPL)nE{T4?duA8IF<~uvS=5pcbJVFte8gG>t>h#-{-6~nn}N|rRp#xpD%z9M;A@nWvE%NYUc3}JiWU);NA@U`K(uWXvn zWFv$`i?aEC3Fd&=6@re1;iRUgEf<8}#|Btt=iih%98* zjHeiWgh?W@3$rbymE2NpJ*)ac+eky3&A;^uIJByx3sK!3R5?mV2YCs9V~ATYV!A-T zU5p))Xz>SRhKX^ahK1wMlR z{9Rmky)ZryMGzuvsw~EjgO1vMG*vzu*$6AqWb3FB_W7+_pf6za6G8;$e zGDj(Mz%&ocNM!sZ%I#97iKx}Kc9@DCP%I`XF$kq{ae{P8e@ck#?yD!z3j3@V9 zqWm0BhOO%)-g#}+NKnj~B!!CiaRekW#M7~h73WsiSO1ph=zYLn-PDR!8RqlL4UQ(< zO{-pYcV0Ukzt6-GQ0mw9%q6~IREP1?<;aQWpM>o-a@F&adq_*xZNm8R$0i2~XItM~?8WbNL6G|Bnkg8>%v$28GO1K^U0*wkBx|B7Og$eW&lvWf`@Vvz(z>@Q6nst#?t!3ELAN_*~?|No>gw;fuGD_z5^+U>H4gvrvAN9MNY zOVRo$v#D+$v11Bp?YXbEo~Nau6y<;Uj9*cfO&d-9%GN8``Jr^bV=9QapU4&s>}5B? zNBRBbd3-(J=>PGF4#87E&eXM1s4O9tcdMPlrdJYwbr9|*CDwSJ{!yuDvnLgz5$7#m zSi*J|w=J61r#A#fd~c(2BNzIEBQvr+EI7d8lM^MNZ7!!s=n$1qtw&MbBBrqk=}HmJ zHjilyXxWDZ3!c4VZCp}UVw&My{XD=Ziw+}DGX*~Y6d8X*%*(ASlOh4}zJeOVFKmOO|l(u+zUucr5I)ps)b4^icJj`$OP2S_a* z{tcOeZPfvTKdSgNsyp^e8g0@bM0TP>mqGhZcE{0n01|C?r;ty&Y!OR*-~aiN$Vq+D z^}M5C5c}s7uYDIHL1L)43_0~5%;m8F!nRzt(Vv9@-A#VGmO(_r`!E*TZQ2x_ml4T!`4+5 znbxAnsWvDVav@G*$gUqft^OHPno4J+D&_t8n>su=6Pfn0y7a}JqLNv) z=Dx{Yfh#}j^f8O=d<70;VJEUS@jV;`>g+msK#-0pGenYzZBr~?GL;W4$u~=B9(rbB zKrP<`yWAt3ISnyqknks&ZZH z+NckBLJXt)TuF}*R?}X1S4wI!p%8SuW1i%c$3!@wdLh7B2!S>`le4aMu7O6(m{NoH?D*1%RgwY zL3SwldGyyg@Yz`@V$uGV!8Ua;BD(S5>mB_Tl`C?LyizVij}T;ZRMK-gu6lRS9@#PwlLuycu>|G{b9pJ&rYK_Y?$N54GYH^Sfv+4o7Oxooq zD}>8_+r~s+Icrs5mUu_Oj3INlwj&E4fa#zjUap6P<>wyZ1+VH`9^F{f2si{e3@@|0 z;BUkT+fuvmd&s9-=`<=i;8>FfosBnFozbw5PmTf_qm0BqD9))4;TwX!7(-P=I`#?X zsb;C4hj0U&T7x9n*wsKNTkxFvhdr9RT-cuKST6tTQ*Fho5oTC)T(h9fA?x9onV_$a z6UMQFFF%@QsN@`^_xhQRk-x0IH1-eWw<}IAzYa?O*MD(iHE3C(kD+DL$=i=s)hJ41 zJG?^u#QUIkOeG``t=a_{wgQFrebI?aHFQrp81s6QU)`5gH%nAYMG;l-7b?+x zoGHu~L1B1xLR!c_w*+lkfp69fs#lQWBh^6(N*4Ckn7B>T1`*+_A8{T-`#cHQ?K$6b zf4Ro~@Y|?&xhd(t6{cvf)H$*Oy>@h{dMXJ@O7=2dmyARBkBn-zyVmvX8TH(cxaE6a zJK=V#Z_JLoAS}uPt|=IjtgQ#G%uM*LciUX2psd1Ap(|UC{p=pBsVkEfFYC@JR@G5Y zj(hL>HqWL~Ou%@^1J+uMdnAs9u>z_tD9MY&p|ge~Oj{DWr~(%UlEILdS(2@eO`ks> ziaoufL4U-x$Z+6=$K&546Q;3hEYCoae5+Vw6aqJo9VDPY3y&mAiNC{xZr|KV; z8eCts`c?#IbeENZyY(Z=@{z{10gPfczRh|x8$KI9w!=eF7*p})LQ3+OG58Rt#!r~L zpFzYw%n73056q8nLz$K=|1-5vcCr%2LKA0ecsP0K{^+u095uSqU9nCKi{TY5efp*} z>no}Cq~DlSu&=WW<(G6?KgPsUwzlV2tVz;T+(<{ZG|ifwoImN18IkO8a~Ob~I9YB` z+0A%aJN*>~#PGTGV%Sk}NUeJ0G>7<~r2Kep($6|csa;6cz1Z#yU&$&2D}OgXB%jz3 zf0&ntkG>u{uQsGZ^t_Gzio#5*MAiTV6%VGs& z<^8{88-Qo@3Y;N0d~drP*jrIgm0_}X-zsZu_Xe(Iiano5!<#>9Z3^ zoJ|TOmvA2r#-hx?c7!)~^Kee9Lo68cby$vNQD=0h~KJ2!dU#|D#!jNM@PiM(giZ-qI-fl@k7@8M3NwS8cK*{ze)a$g0qk8g3 zPyrFB350>IiNnooY$FO=EH@1jm*^NLw>vqdv`>luDc$3r8^S(V52R^2xz@k)-7}#{ z_v$FV0- zTP|d~mmaemZJj*y4B%eMtTol*bSXIR$ra@yNX`-sjn^Nr&=RS}gdq?ifh-*CR6Y=I7ZNC^9``zM1XReTpR##Z1y<&@|{Iu=Fh-3)+M4CzeQHac490j*b zp|9PW>NV62*#-Xju?wIp8C+YXQa1+5`PX@>UKv-GAUsETfNxvhzw86?mQNazCPtUv zV~>f0|J&zO%IH#rYMz*aREXf!E&nFzWe3eFnoS<`bB!g@>SF^sV&CZUUV|~v@}XT{ z&%4?YbTLQ^!;uI?!d^5Z%L!Gsi_&$C5_eR5I7Wm8=mcuP6&Z(fiiHaAc0TX`ERJ9p zV15QXpyf?WC~RR2FJ}NcdbL>d*D9!8nZ|GCal36ZoxjGh6e4k}7o)r)nfOhB4cdYeZynyDVVp^x z0NQN$L$Lf2R3Zj!MF9{?YRx^R8ym|}YQU2$JeW3hSv7CqtW+2c%L4v;?ln}JQy^t5 z&}r@s)*q5d9qEV`J?%L)@~dQ69y?k_ODKLY=>C7>?6Hx=n|zOG3(%Uk4)gEPB~&n;eQA zX%fA}cWAPwuUVS)y?NtF!YHx)--p0_-e6gLDB^wBy8q+pq?FoOA&E*M;P{JHy?+wf zmms)Tg=D^lKRj<`8)-^P_1Wp`!JK=d`$um?zF4IXDt69~G6Lj(ZZOJ*pHc~myZ2Cm z1F;b?_+N5T=#Ae)GCp-#+&|lk30J7KSp>~)YXZsVA{fSTb(dofSgU!1hoefz0317f z1?L%3@p>okdieOaUt^$(R*) z`_j&rS?goOC;BHNB!hY}H&wG)JeV6~%V6dOnb{E(FaZ{y$AD=}7ux6h|4Q19(>GM4 z6!f>$+Q}t%23uYyvwOTkBwrH@>mgZzS(ns0YA*WuF}*(e$d-x=xZ5`Q%4F`<&E#Uy z6RLHcn_;2*kS2@IPv+;oi>Mwo%T!VNTA3cHs&zhvSz`_A@$wL@aq^M4k=I|#_nx~W zi5-f_XA3C*Ok{+P*R`D-yOwO%a zAsSXNH+v~jm@J?1Vpk=tdQ@;<_BE3j{kq^ z7|3o}UJ+lSdb_cR$7XrfxeB&jEu2k0UC2S=xK#L|TqVZ<#%t}OaP963sR{=RLp;Y+ zLGE^UZs2zW7yYuZ)TlVd%!uQMgX{RW8ChG)HfY= zVjO)Q*K0g$p{hKFa465=aG}!`wsrNSmUDrNrn;B`xrM{#3&wyPcz+)>CvkZ`=}>}j z>du&q;+eI3iBVA#-gFuh9n2cIw(@9V1j=xqYcv8MZp4*ac52AQ+4AIx+Oss#MvQAJ_XMW`BiV2SU9|b^QZsNKS&j_s_~{DEji5p z6AQRWJB>6$cIcw;7PEaKL^U3W^OLa%00u-kbHr@DY^62o!$AR1_8Zg$rv$ox#i+>S z-@uNZA8O*_%0+nhvO% zeNHYdvQ$K(XI=SSKf z2*h^W@v;Y}k!cfUZ9@3W=uE2$dti`Z4&Iy8jh4=an5s3hIYLJxwxu zC2J%?HZpo)K7E-H956L((%|F^ojr0E$}(0|$Wf>BW=NjkFRTDVq#j+n@Dk4rHs7TE zSP;sOk*Rjzs{mOm`jgeE5yGHgnPwpRLoL<;*hEmwm-?QB}I zh`@>HL%?hz{Pz=nOVKkdasTl@ULH2zk9e4qac;O`Qh0^MudFdwDJhuhw!3s8OX!PF zKhf!7l#aK6(Dm&?fR_RrYTM5*wJ5oksWbP9OkyzB69?EguEf79pguCL;089YugTQ` z<%zcaJ*%965NY?*EOOXeukpEtW@Vnxf@xijE^qjCcey; zW^cL%i=lR8s4Ci#j?;UbaFs+yW1tW84li4z)tpJvHz@a!X4+5+eSyZ*w5UT_asH6^m>;rrnWspl}?wOFNs94 zz78@BKv3@kTzqt}fq6>p94etX<>Wu!opO8D`L1Zyi#z3^j1V7-pL(^AEu>L!ik^F2pvT4SRrSe~fhryAVA;Ki!F4&&+%(H0$mk{${-}y{GnfY+QxI;SJzE@SO|0!bIhM2wdqd`e8$KX#- zTNSPv-p;z{dUbLLN|c}MvuiJAnV#e}ZGF=zLttnZs|U4~uE2(%>2LSl#k-tQfiq>iaDarH3=>>H7JLWB~QtxB>ovubOz6xt0GA z&|c|=F0DE%oLhs0^Tyy*wwEeTXf>cbvWJGFe_A^i9B{PzSP=ZgX5#afBcgB_KS-Zt zpnN5`tp!9EY14SWr&b^~tcnrt&BtxeI55W|JXDpklB;oG>r~}P_XINJY>$W+^#cO_ z;<18mG+ckJ-9ehHg0EG(1V9^6_kQ^1?a}Nl0*rE<+m5Io?NOEPy$&SNjwY^Y`Y64Y zmm{O+biPm5C&B5}!DTl;;Rn{Lv}Jwi*eAjbNQ7h2nUO;QKbb^TCN&(wdnUye7Sb&n zY#NJXiT{qBv_T&Vq) z5LkbsFz?IxR#$}gpiG%%^PB=yQ?MnEo|nF2-e_AZu}ak-*7U!oT8xNFEFje#a6Q#Z z#s57;A3l}-9ozKA>m!lH8@Jv57xq{Bqe7}lDK#pEgsUqdMV%QXsq!HNU^NEBCB_pS znSfUejki~E^X^)}(c44r5HT8a(;}MT2A8S!Z?m2Qa>b`1{p0Kq!!vq`1-RF^49~HF z|3&pI{dkO`)m3qIlj+PC?G1jW9+Z$KQRcC!Fniw>bNsHl1S>6Dt&IJSX~KK)L(X>M zqg(cye&XA~o^$5lZRov-LyZN8Np$R}kv);8EU1Cpn@fg>WK8+wHANiT_BkNgEbz#48 zv<{(!AqbIW^VSVfxhD%?k1JIoNyz-6>xR?&%v^K%SMPU(-u8vJQIMhdt1&D>&~Di@ z6*uqP+;T?8j|i=YQ&dtqD7Tyw>F0h1McUE&j?;`@+A2*VxaOwN>bdJO@wO2WgXt-1 zWz1+67d*iyY`5?mFj1(Ji){yuA!H1Gjx_=j;~I8sH|5sSe!T>?zOzQH0RECJ9v`jr z44q|w1HItZpvIx{2Gx%|Z1H$BmI0OUdES-F9SK4l-DMvJW|5qs{F&1g1kOcqLq%_l zN%dY^^e2LT96>KkW@iuwS)Pc!zGiUZ%@OipZ>}WV;3vP9q`$w7Q4HmvB!^U`s zRuh*8f2^STlm~gI$=<ZTqV z=#e%3ipOCSpDluoPf#Xj7O>Qcp+&8Pmnu~wlD>M39HQP-zZHC=cZ z-8*-DtKTaqFOobt@f43i5n^>&E%@IL)RbulefgGmN6LUa>Pm{E`iWSxX)5!_F_)oU_z2_c4g<-dEu`ZO8f;-}PPz9?{P4VV`{6m+_ zSo6lSDt51+#V#=8Rxp;lfi79*^@>r|umjDWUFe(6Bi0(%&nFt3>W=)7nurp$wk9&U z()`)ayYxk;TYB~~vO8ZoqEDyvS|MR*#ec+zwZQxG7SjnhvVxZwb!}!ZkGY`xbZ zsMr|&OlpFO)Sc^<9T)j`z@m#_v?dlzU<~qB-kW<}3ooVe+igCK3 zS3-_@1D*+slvQhzl#}Np5RmI%2Q1Rok)+QPPyx<528h-+wDOS}h>*uk4;g6nIWUgF z*JmJ~$NIR*D#R*zKSrQoFY@^_q>->q-DsXJPp@aMOhjfa7PULw>lLL=5)3ab-;-pq zlm;)g;eimafVQ2Md1BaQ@s-9OhWXJEQIRkSF1)CcSImdmVhZ#wm}j0uC+ghssv;l; z-@y=2R_HrLyw9R}wPchU2cCR6^L1ZUED1B8`0;Z6ExCs!)XU%DSzcoKv)F&d3fBns z?(>6Wwk)(sCq2jMK>j}_HNX6XElI`U)ZT53ts~J}p}R2ikZ3T%U#Rb?m1csz(d^r94NU~B^K(>MrSmC<=8WY@UmpbczC$Db@#II%`*^H z%)-W^hlDG9_7*~?6^&_V{~EWv!9`mLDj`)pIWM65oo%V8+l5`phK(<$P6CN8z<V46p|C@;*DhrT<=!BV8}hI9+6~wB6#RhRVK? zN|I-IhZt+M?>00bd3d7-eyI|4c1#qQRnR2-89G@-OmNxq$*^*JuGb6T3jwuN#`U*d zpahQmKFDCW$!2V`dBgvbNH!`0wLE{ByUUka`L!c^<@7FBA(17_aDcud#b~a5kV@Z- zm-fhd%R~@Ng}?+H_WYWM@Jmj;8asI-nK1Yd<`um>iW$3Y-jlwi`qGGFqLNIu_+@!`jR>0}kxVP+y*HANfPYIx@F^jCbGtPXrm zf*RaLttB$D^Ba#=675S=1@~$7eZF>Df~38R8H%E3B=K6iX;DZq>y!;KvpL!<^K!UOHF6k=Q-d1i4` z?G8J&Izn_HPyj1U`9FPp_pT^fouD=MYp4p>Ysxyveql^L`wQI9^m_D4-wKu|~OU>=9Sc<8M&pkpOSHniryGMs1k5^M1Bb|s1VMtH$*V3sO zEpr0zt?by`H2_hm49l#Kia);Y`@1w-`alQjt#&I=(1@?@!AX&tX^IaezMk@EHUIJ~ zkce%rW>mL!-Uz+1_d@f zT~zMGv#mw|L4A?j+V`s#R_G=_;GP6Z8sILl#q{dPf0H!O?asF534*sSnqc#1;uykA z27=S2lUQ8U+cB2Oc@+joA3o;+CYlkVMpTrvd(uUyseU}lJNgz$dTY9rGG~DNAR9uu zsveis%7U!_Q2rM)mkdW;5st2mr{Yl1QS0L2vk zjqm;$(P%fpIR|-go;B~T*5SYrf=rn+*Sq~-@iaY}UfEd95Y&>7b4K1PU}hTTvDxIq z1$R&&5V!^wlVCJCa)AQGXV|p40i)|*GeN!hEP1&w3MP`9?Q=a~Cs=vCShOnD8K~DwckIH3p36 zB-AO$@E+eUn@H;AFoR{|X(w85SxEATh101DRV}B9c zsvrAX`9|;SM1%}ouB3YWwF6?nhR!+fc%=8)MW*}?HQXAVMj^UM4$y5+b_9Rna(NF+ z2VYMQtc05IZg^`&YGGn*>SG7&-}_jp{|n=(x6Uj5?C^^^gITUuC)R`~V9?HQQdrLC z3>H}e3GvDCl)F>=D~pSmHg{Ikr(b1Cvl|gSWa2JfCTsVH5R`#R7s>Ip`%3KX9mo+3 z6m%;b78|kqE-gb4PTIi&8tGj#xSiQ74G+8;iYSQxfxDqTUn0v-D&q;fDiipp0XuPG z(pp=iahSZ2X~hN~M>d8UL-0FzEyI9eOy^eJn^$6nDxy!Qp_~Q~a5LkPPvHJe0~*uM zPbXNCMpNBtLS5=Z#wk3#0%G%3=r%3lRa>?XkcVC> zKzUyxbZ%R^sRJ0GA(-vjES&jg8`@&eS2eVqr4ETBPTd9{G*cQvPR`NftJ{5vvc4>N z7u~IR`@DCXC{xBORfwa&=)3TW+}#)XnDOqTIp(*#yYZBR!>_v6GZZy;f}*Z?uucOu zO}e|UkgH~6VjnWpXMWmSMEL5!GcfjqDiOz0Ax}!BN0B%d(UZGBHk58Ii>a)nTSQWd&jt3QtJ^^tGEB&XM zTY0W$=7vj`Y$5Hgz*n-n;R7~)bc4vM(nZ&bZs9}ApU(0ex}3gK2qZK471yA?0J2b` z+X~{m59&(sZU~d_Wo2~i;&M@9i-H~jO}v?@?Fmg4ExM*-n8=5tJm_V)i-<}pr@u3J zJ*JA+OaC^)3Pt;2CA_huML!MEqc01O0_U}psCAG@7u*F=q1keEG=!gvZb&~~NTd>`ehOvp7 z?y@kx9){0|r5Qu?wL^;g9(n&2EEBJO2Wv)P90i47tfKsUgrZ-w$Rf#W{)w3Qm%KJW zemC1xC@$JL7*j4VnQnNm^i96;H2~P>6!oI~SNyarf5?E`z_`M(Z%8!~>Z*kz5N9@< z5*u!k=bCVp>q;vdhA^6qdHX#wPPz^QpZ_X-+(twOE=(>Fnm+elW~SH#D>f-%b6wz^ zE)yD6s5zpTMG_v>FC3#~b&)-sj*vxPm-vPXr zda6=WtV3)!gkh4}c+ko!`s-nPAG4#MYx*s37uro~I#SWg`?xk-=S3_hL~7fL^hiCs zq+c6&1S)&6`F+k_uU1+wiA~W4Y4o_6!gRk!SUgv%fc_Sdf!juW}{H|;c z?h+TYJXJ`bUx<(hEnmx7dM+ZIZQpPL5C85HczDFbco82s>?e5hWi%I;qCPM)vedD| zEabX-hP`s$2T>V#r1{3(+4jUk9ta%1GOtf+aHkyZLdHztQUA|Txg}j^eZ6DNy?MmP z4#u?iT>sstLnHkb%!#;gy3LR<^IIHH*AReu6-cl29!5vVvLMl#A6^IKfZ}?>RVb&z zb;_V#iNxw}aBQ~f(dLcR&#Q6F!%Ya}Ad{}avzu=m+qlR@5aXlJAUzv^OE&IhsTV0&GPTTQxs zR6~;y3m=tO>WZslu3zut+~2h`R=AQqBtOVY((9w297&9S4ARdGv;3|SM3v7%WY>z& z+8E`o=yV2?@i>A;uB?vWME-7TCxKN!=4t55OlW)vaH9Pp9LRUGBkbpyF?FR#MbzHWWGP zuP+xvmJ)D9heVAolH#hutBEBj33z#3BydRQY8yM8R*QxXHStgKr~3xBjjG|ZN_XUZ|Xsm)CYkSP(LSSayl zH-j>%Y^`ZEKQoJ|5M{?8P{d5*s0Z_?yj-9`Mur`JK8<69jz@$AmSvLXzwca;Qtrnb z!?F6y^Un7;5$kkffU2)C_!pnaRVEJ~Au4-R=EfKbed!PC(1=L@8kt<(yFr9^O`H(4 zk#UqJJ+S~j^uzvm05hd~e#qNlgvBS`bEG4xtI0p6`9sP-m`?_g?K=lOG@AFgZG(zY zV?^)_xdre(@%i&7`+jTn?$VR>bL1(-iH45&1^xm%F`S$+6^&%AE5K&y@`VSQ46#z*_c5-uVNkM|DcfhV+QK5_C1N5%n2VdGxmTL3bP*MU0`%hM0r{*` zTPOpnne9wO<2MfECZa(pD)ZGo!xd_Wvycx)a9($r979usdJksI_v#@h2R<5sI)@sH z(z}z4@#V}v|=k)?gM&=-QjjVx&2P zY|jBWz*p^m+1yoh^*Jbmi(==Tjrg&YK<9{4Kh+td?MFjFKni6dM2-epQ~b0tgU0pp z$mK{Z;r|y1$d(g~F>ok!LRUc$yow7V(GFC`1#ugCwtjZZ(Co+mux^#;5<919Su4X$ z{PiKAgxW-W|12&oJlKDGpfE{y zP=gIRP>&&k^5Eyv=-miNtj_zriyxixV-P6kK193VK!gQ@lWUV-3xt3e%0Xf%?f}4- zOWTtN8Cl9u-#VW+;j-y(Hb_dU-%g95A1B=m_u?ZaVrW6y+IpdXVjvoFYjRmSWnRMg z*^bA-wIStezhvlHGxt7c+KB^nP%Ir`44xT~YLl=;^)&?KBXWvy@$rU6bZO>tEA;$K zSbA%-ZWpwu!5vDreN=hDIb37(%-S{2yzqF>t3gjIJ0oKOl%vd2AVOK9wS-8N%?kZ_ zGX*xyNuo4`5R4F_d)nedt|{4smH$M5R-eChas=LX2#l*5D zPNkjqGJ499V^RW1s+}}O=`j02!b)#`dYYInPwAVYAA$h=Z30W%OEXTOuuO{Az!`-r zR~I!6QjXpo+w*ItjustO7diP=LFiC`z=Gl$Vg8n|8H$t}Ad5~#GEG&wVzr?89WNR; zNrMxaHe8SCerQ4xJ5s0YDp>;!tb-;33Ri^_9DLT3g>SCK|7WNl^u1jt5_uh0sHoJL z>pPMvW`x`4w^L=S?g^#IKo*|p{{^CMPozz!niC_3)=q&^$Vp&E^;%xMYU4JtmpqaT z+%iwm{z{zyC2fb#2t(LynwM21D}o51O*{RmDo2|7weWugWl4-sbQ|J*a z^yYzm73=bPY3uJ9AB@2BX%0L*EkQx4v=aW>%u$jjss1 zQo>j63=|@tPh3te8*nkK3Y*;b&3fgVGY>-1?*{c@&6*R-md`j0lXKYZZsE_HY+<~L z-FB~5ubsxL8zBD{7sv;mtmJ>&o+qq4$`Unui{N?mpf)?Rn71chdF{v%9uvKWX9Lz+ z0pD`DPUd_=h>qLCYt}Ux1+^pmjuyP9F5=p_FIMxSt0+I@el*?;aDO#hZa0*hy7v6* zc;!E2dyUV3-YZrD5T3m^=_gp!~ zGJtE`+i@wnmTUA)QpdT}O8D%q+knKAOV0toaBl;Qe2j9z?{4P(TzL@@J1TtXw&|EG zN=~f4nq?&VuMDJp9z%>4DzR<13>CKQFyz$R249@9)~h6Ts+iVL0&$kJfr%|iaGc^bVzt5VG8%U@%SgqU$$_geBQnmz*d+-CRrJ|w8ISC2ZejK-bueYZ*Z29@Dkfpn*-ZEerieoOzVjF#hcGZW8s z@Ey%fSa$4vJwVZ=7c`36N7&4sONF>4$YHuQ)Gk?xWg(+SOf3a%!c?RFk$nZ;0I?R5 zR_UuhVwlK0o9_vs-eM7#);@DzfeKf|qH`3+u)e}A5!rrr{wT^vL95&>KRA1fMD?@K z_uy4aW-(ZHzI0!eQ*o3|)Cn(jtc3{_Vl$9hQd>+f)h200{T9sTwqRby?_(iO@5B?W z;7;#M-)fo--C#BBx{Lp#S4a3a9?r_HFMKY>pfqO8LhjabM%Ald?}nyWavhz%$ z-OWF&R}!j7jRBq-_@kX?pm()idSUcghRq4Wa^^Yh4pyWW(jie}O%%W2qGvvE77GZ> zihR$=f5DrX(oZk2C>Rc=7q9@Z-M=9^l@Se)79^*%l@j{U_jk$X;>$vwl92PWa*33l z=%}lt79SUm+kiqa`m_=+NplS^nLU2Ah!gaMX(IulqF9ziC!G>`XHQ*hXaACGj;?Q< zH9_3c2CR7yFdUrDn(h{6jm~Ni{Ty^I)KZ(^(xCOGR!!?JfOwHX*GoA`lu_1(GUaUx ze{gwLGl2H0WF5{wrX@wk4!0&hY^ zK0cl3YghnvyZx&zl96TYUnyBUtQA*>%4}HTB_aSN{+7sbQBeP`B93XgYsMw7cyGal zPCV8Bm}1ooA^a(kmJX88!;1uoRb$J-rBc_UxvSu9YEgk#+Ngt8-Fj|Y_NZl~M`?I( z77rB^WP`4`Ot*d&8{(jvo5-J;H_Ew$jIVmvI~C_uA?-I3Nlzh?F1&|5N>nhzDASYrZUK!%$m|8^-X6w1B35yo3SsYok#|)qYUR zVGpc-jM>rzgn*7Vi~H`wh+uL_j%PGZUWq#r{G;5Z#y zN*9=Xd)^_RxyrX3h9ut#QQp>I5mt3YtN^D(>m6bGQl_J}V`^Fuoi^GPn6qrQxZREO zQIB^+!gzkcW;q(M=#dz2>RtMNZbVp)da7eBO;-VAyC)ZowU4ubd=yqJq~X@Ex_@BJ zvQ?_knK^aT5=50R8V%l$x{X#PGQ$lzd0j3JO+g?>+^&oUo@c)VzKlKT+$DBGO`HE( zUQL92m{yn7I~thC2&F#;_99-uDNM!eIV5-P;FzQ;Mx888B-%ZPm;c?Nzq^`5BI!`a zG7@g`6mgY?#>SQ9RSo=U5%}mDyyU7_k8b^YE;5QlC^@aWaL-u^1N3>YL5g#BnJ54u zDrfa4e5gh@ESoliO!nV7t8nRh4OAtPqg z{{pQ9KL!F5kb#jozcxEzyAUSDQ3<232KP!E)Xy`%FPO}N06qqG^f(mFC2>|-Y2Wxd%^c+ z8+tdqPe-LAgeaKKf>5H{n~PFm;pew27TKuvPohabN~mXI)GL2j?l-f#6C7RiMf9Ud z5~Y9noD2sSMUBBKcF4sH_0+VeU8qPP2KkOGD{G9o;hH@;S4S?1*4N)P0c$bMcm&G~jm|PRm|sG87dCnDrawH;{pF6>tl93nrW^u9bEN)6Eg@Y0F&bUr!3+Nl zpzPO*5sErZ`ae2V)pry7(F(M0i2klUW6>jI0AS489!H3$=jWSxY|5s(uyug}p0zp83V0_x&ok1r_Sqtf;en?*P#8BXwDt zum3>aEb2Y7Fnb)6A&b!cFYn08^>oq~8!d$b91d7niub;nn%0#ja@Ga*!fP$)$(yMe zw>!g%`qiMruFrO%p-+dZgkOC@Q5Db~;-~-%X*JnW7p8~a5X$%Agm7h7yQO>TR>XAz zX!|`C?31^`aX74cV@IPV{AKM?3#<8}s$+@ajD41Aa`DlIv;7(~ucGq(Owc6+u@P&| zhS15ei>?G$TzIMD_;1n&8&^_6?SfW~nhemQ;HO+24AGc5kSFeHp87$%t&)7#=IA%v z0EFsLbo@=2%lF7d?H%T&pXrL@8_Y=cr96USujRk>DHxyoYD8sx3+WXuqrA+B(P$kg zo#T$~S;0@g_OvDCc?J#`_ryc=#zM@!&ibviU6FO+V!O9Sa9Vgxznx|%lz-V5rM=ti z@u?cS+dlOVei^3jkS zd17d4)uC!UW_`KUje)nA)%6@u$qrjj&ZP|VI2}eN?5j{XT_zJgV{BnwonYR2o0%X% zaHvTK!=ax=8wZ4cSX3tah-{tVD-am4p#&C!>Dg8{D;{~#E>V)2}eM}$~m*!I|u!rqQ!vL?43=UooSa2m^>8fGx^mn4Mn9= z5~v5kD7%7Ia39qm$|@FYq`=; zjmio!g#)XvYaItPqy9Pn8rJ$!IrA+YcubFb^fgqvehg!O+3pNNpM0|#p zsGQd8n&R01JOP?Ub&&%Rd1Ges4dsvu)f~^hwhpXtx2Kpckh(=B_AtArn(Yh1N5(K+ zWagZdtP7SXset?4mP1Zym0_o>`T2JhHDuw&;2BG&OxE>RcOlKTpaEVkhftNX1ozUj z07iBd~~xK7_Zd=ktx%>B#Jb4B`A~#W`ENuf1Az zO>p7_Mnti?mm5!O9zkevO?&kak8WP~bl5J53>zL+Oy7!ie6(<`!OiwMov6yIdrXHR zzo|CJ@P$8hff-m`Vg)|YX7#GRs9h8*F;m2Zb@{m63FO5|HWmAn)c3#sK2Humk0P6g zU|@#I4I8CW;P#Ukt$J{;??&?~7W~~($0z*zd7>O%Ta$wyvjJbd+bL!p(8z}R2QeF& zBSorW(K6+HQurlxQ5L4#YM;*hgGM@U>elq004y>&iVf;}fq|ayDgfwi<(;5A`mR6w@ZEzjEf>Dy;dNhh*XxMW>Ds(u-P;aQaKm?iA92kwJ~H@J02H(5SW-qfp^q3<0mU^?15O18z?q28+ITN3gG(wCvl_7uQ#)L#ZeIn z2=7u-91G{LPSF*Ju}vJgybp19-w+=orHsvT{YvZ+V2t_C(hDtX}${)XpVpjvYhBxRv1#FsQg^ zszVwi!}432X6rgRY{%{8*sUyTl77zr5{!iWX7=UDBA%-P@BIYJwjE-#3iLN zOG}+SbTZ7{__N!>i>7gcu!&=-x297A>ZVG7KqM$y|L;AT3yO6~Z4M-q1kg5R^dluA z9igKfo{Th^V5L?DH6P+jfrNTl3EE&!a2Sg3RkDU|f;&TM!nUf5-9Lm^?{>Qt{;G6; z5f$=D3^{r?Z7K6DWSfOSPd~|5+q4kmzrP7Y%@ISthL6LX)5$zI2?a|iHg@okwL%ze)c|Dm_LT*`lgzPqdq1GUMM^0mXyRxC(ImhNkg22?|@5 z-qxl>Z1zH())oUeZAqH!g0r(1Z10+_}$%vMS^wf!MX6!{K2u9 z8H2tB zQhQ=b1HlAjizF#%@Yegl{)GHRNtDgoFu$`+tng@&levb*Qe~x;F7x7@Q@S};v=MlZ zr}GW=wO_-K0O9JOBnAS<;0_u#`2;(Cs7$Rke0@xT3KRQ%v{2Exmo}9%M|IG^tq&3B z2k`TMF~{dOcQ~tzKV`TW5Ww)FtJjcVY@dnbyKR>a?MX*r&9KPyesqR=;ssQ%j_xJb z)jXJXR{C|QA|rA_8#`~AOC53}N%O^ZXXws&O#vlMan9qhE?7J+aM~5u>P?mblziW9 zZk%Pcop<1u^mjif1~iOQ6F$iuXMEMn0R4!#OeZA%VdO&wWf2~&FR&dq4zx= zRXJ7dM_yRkFo*n~ZV-fTNdWc&hj;|K|QQB5>@; zsfO$Qzo*mPHEOJb4s!ug;a;y#z3cMQdzGE+vrA=1f!!^S_Ky%Bi4*to;4JeVCO;Le z<<{@~^NUdQH#zZ0s%mW$gI*m{i&Fo%#NL0^1u3{(%?7@|h4P+COY>BY>#b+(x2kGS zk76bj56NX$*Lvh((C?ajKJB8|ce(0F*&ANe?F-A(mB9hEDFCYCmi;#^vjb+Lh zTw3l_f*`EKsv!7#y3ddQojP4JRm!aMZYf~68C>&X1yJaUh3pZ4pAnqd)}tIkKi?vo zo)n~n(H>M){Lwo7pB1Vr5ENnNA8e;WHbt&N@}QXb{z?l8x(rERv7p?~e>(4?5?m*~ zMWVpFv*N850){`qM@jl^8UDpKD3&l7j=qe7kSQYyo6?XmWdSixxwnFhf17MObGcAn za0Le5B!&p)i5}92T%-*Y%t}PEG=mVnNFsct3&@ihu7+m9LqJk9oGwV8Vs#n80+# z4HCr{%an#LPV=i+CIe$rh!I^uUV(eVa?}D-nENMpOzlPo40)Z^beUi9vx(~6?H#}O zmC$*CW>O-qw6WP2!zXxFQW&8bj=jb*&ulvkd&c_4QP89P#o?DZN$ssvEeXCaErZlY zs$K5I{n1d0W*=y!4dkKJ&+!LLaaw4Mo#rJoPQMG;Jy50n;7UEu{v##Aa7tyili zsgGH3US@3z#%aMHR@9FylyaS=oyqV!5dIhQ+?wjy47$TEi{vl&K@l*>Z^+^rTI(bD8$BQF@qDq=-yqOO&=%33|!I3mhUu!iA zu=E%C($+jP5(nVtSHQWRY-Q9Cgq=-uK8s@kI)c-zHjDC~zM)>oSU%e#_(=VVH8x`( z0hR+!D1z@iqj$|SX-uOT<+#ei(waQI6xk7u`*P37`IH={#h4a8hew2 z(Kf(nZP@>+rwrPwq;iS7pY50H;qwm6hpN5esqUM^hoiy($xT|^x~_i;xO0EN6M#FG zR3EpitdJ<_0Z zwd+n3pr;cx=>S3ZYXW=w9aQ+5?{K2mMzkOB&Qtw%9e;U%q5EDwJZ+Z^L84l z)BQbrT4(l5#n(B+>S}H=@>G0K^S-VJAp(jVykt^Qx05(+^xCZ%&86NH?DR!4nix1F z(qgA)iTQYv&Nr;w2Xvi!-a(ZMYOJy~cPq(K8ijFCybQu&?h68#8_)f`7u<~Z+Cz~y z26nNP+Ni}sJ^(QQdd}2g5B;2G1j$)z8LABAoMn(tNBj?K;aU7HH=vRH+N9%N!{;O( zwl+HrL+LDml=jtJ}qW0LB;gw*OzFTFpqNuHk{rKqFRcOP z|9AWtqop9GV{d83beqm{V6vx~SF1u7;7t)CN-ir{~b?2%s~t9YBo5gm$2q__4<{$$PG$W1jR$*FY=a|O!#+$BS*@H zhZh^xqafRoR3=KV-{7-%!f6+6Z_^G+}6p9hC!*3>Gvn7!`6|op*kH<<(?k6 zXg#@aX1HPo0@fIHW{I`nmbG>Z4|Oo-HaJR5=3~x0010RL7&bf zlvGS_29^{AbbxCC{>jV+%ah-$f3k8vw>0L;RK&N@3!WOgb?2b&Jj`Lqs)btKb!L)o zB{p!-x0YZ$uejo5=@FEU)w{=M5B@Kha~J;+OXjKeUZ~I9z@brIDB=GUl9_;Z=CG@w zsGq>nh(6hZaL)fCpFHd!mR?nNz#P;2^T)t&W*hgpAQNI8pX-UWBseiFS*B>98hI@G zCtx5o7CSSeH`mA<(uH!CB=6AK%!hf?i~H|JC@e`maRI2T<;kvi-`sJEkUyKpgbtDa zheh?kGAzNY)mi6KULPmU@(-8UP0xQK+GJPR95n;a2i;$C!fK@WHaWFklS{KJjL9)D zMvVDN#9w6Iqrb&oY+Ovp`vLBv+)v{2q%{*J2RijEcxF#hE-7ums9Gqno)z8CkS4D0 zC)qaubJ{bXi9k@2PfT^*P_`%S?fs<8K~4An4>;8;wnW+X%y6Q0jsf8jc>U|?lwfC; zMJUU&6&;T6-^LVT9#y;b99r0)i|eeE8&*(q0gYk$O0MeUju0q?`N_n{eh0`J^|W^(mwg#L5fQhM1j+6wQA zdCk@1lDG7ND!#Q<+qfMJEMyn*T&fHsronv0d}csf7Us=H>j79B*L_vRR*ndlH(_xl zv5(@C0qeKQp2w9p_K_z#WJwvEml~Hb#@Dy1ZtbEw6nuR6nKL~wBj)4U;jZK_d@Y9I zL-Lcb4_m6RB;P&hW%Ny(Q)$8FMI;In?X6zthUYZWe?#DE9<{7$Sh0N{BiC%s-F*?2B zk_ip5H}i7oTUElhyO#L$uy>6(av2ph|3hi?Mg&?jBt`O&i!S^A#BEqNA`IP$*LRwF z9{Uk=o-z|y2x;v}eJ+4$7?GtoH6_yk8WwY6*qwWF|K3y=<;rZGnNvcrG{`4Wi+u98 z(4tKfg>!;KCAewTQ;y|VMGjm?UV#W_PZ)h@!I~N5pkjZafrA;WM^jg4UqsuX#M3Gt z(f?)CKR3jgOD?biY_I(ksb45mTq399S@diD^O`hz0Tc>U6a1GwTe%U!bD5X-sCh%$-|NsL z4*dj?!54)i4Tz7SF|6+Pt1Ls+;K#@C^e921flLU+%_wLJ5>GVy0T%;&?Gk{gzRGCv z$4_qdkwgG5a#^}KVnlfSz7=&(1Je;c2IPEV2Zl;zT%YhHtC&bnq4f`U&ROEPUb=lh zI|T1Yu0pkU=m+4P!uy%XAaF?*X*>!(bU1BhY{DF z6<*t` zJPtJT0a>ZkmF}Gu)K#lx%&@BrL2kHjg%ro>^&RIqK3GxJrq5Y0c8~%%-9JbS&Oj$T z|E!a@X|`)5aYERzzBd}M2`%dOxFY4SKV)Z8$UPz(3e-f-AN_Z8xN zNr9oJMR&?=q^>%CJq-4cPrOkIqOII?;>=#wUX=U@6FT7{8B*dOpuAC3+w+)qpigErJq*xUx@uwAraf`g}8LSiv#G+u2`;FIrq{eG^M-@u!fSjxJ;t@E6^u3=b^# zQ>cqaHfszP(qL1Zpr?}3zz!^G+^m_tByL15}*&Fs1B>+>h> zDCM-f{~Tmyng-$c^29$L*E|fR5{(4P)P3O(9=(XhXE@`zB45sw`gB4u!+upV&tk>UiXZ1negp@4 z(Ans30JI|x=rQc@k=BEtTwlYN?r!DI&7YE<69dOP?X8W<#6xI$$B0{o$;o$)8t_uA zphR#^fuyjLn5M^o$oBEn zcob>$cz@^q>KtEqxHF?qUQ>!TLU2tX-|uuwxFQbS6Gb`QYAN>=)=wsRo$#-yg2BW~ zeqPScWa~u{$WF>h`@ST<)iir}wrh=n5`#;EH^-v{Lo??q6hQ6;Nxp3`Jir@C&)B_r z^=wno^?oz2iI3@psQ4$EJO%d12~*4{O0J$`sf_QcWVK^#7XSS`g*kDQBKN54u;D8h zqt{E}8#L=o%AcF=Xs9&Fw&vZNBD#iy_jVJd&ApbCYGHy?K`2sp9IpSpHzH zZ9rKwdIH)V{kOV5!nc`7Z~(mqE__`HBRrm|48vt{n3Q6|(6HNJf+UC$Z}9xrK2M!J z&Ux&0+f6L@yVQ~Vkirx$yjscmO=v*8XPvCbgTdR5 zPuQ0VAp3^j9KkcUW%j=8N?Iwe*Ow1kH!Iu`l+io*&xEiE34Ds-O{#(z=nu;v=X~CI48Ru9)&Q&fP3|He%3Ts5F8vXcN3i3ORSt zm;cSir~LeU3HcOgu3l(a6QBSt%9Vcd^NtY7-awbCaKEugoNG;?D|YV_E&Z-)=z8R* z?|q5-Sq+9_ott8tz-%2{Ui@9e=joCPEeKKAih!@m-Ql7zM)d~6SfJUf=s|YX_S@57 z9=_$_e%`m|tuK4+$_5QU$urUa2g$Po9>>eQ|LTfr*@kon_Tsu}M-s)xF^evYRUwtz zCou;wKpq`+(Y$mSab)a`DLsC!riD_=@&I z(|aKpzP$7s&*KXRQpm|l%3Zw2<<@f69l_3nS((9H_yHFC-AnvG%e)Gkyaj+Sfap!W z|E;|vrpF9$%{sq^r{kPPpjD%0Aw#c@JB7x%qwUaSZtK1IL|9O~+YZ%|o1-=%N~|nk z6K*38cTYZ|jF-x%uVR2tVybEx!)}4EUzlhHtS%bFL~_BkQj445(wQ?VPEc;{kT-w( zOA9koNI)`llyNs;0sR1s%AOur+4DcIAH`CV{YvOUi$>i;zo)CCU7iFM{aZVA|$ZEExK$iHW30XX2$oxdr}<%HN_&x&g)>&{~`L6$14wx`BD4q z*}IFA7e-P@Rlf`n-BPLp+#61`*gE_aM3Lp>ag5;4@BmJcoRG@{>*V)ZJev!Q_s|-% zCk|rS@<~=f60%~6p3QczL^pSD2-pUL_V0PrOg@Y$+rlo0k+SQDy>EzE+-W7aW+z}( zd>NvD*3!PYW7mpijroiK8nEJs=D(LZS}+%kFuGdVdM8MMZtu|f2+QlP6ZhOwr-Ju9&u zDL#}xk9`LrHYK--#B1R>_DFJ)p!jgwDvFJi#r6Orp6}Rz^P7ie1;?{LH*>N`=oX9r zfQbUg-9&5?9qO)P%so>OZ6Fh}+W5<@O$lH8B@#yKJBM?dhk|X^qMp2ub4@UlqsxYZ zQ_5-;i~8UH3Qj54dOZ0l3n9jx2I5BO2Ks>I0p-TW;OA7~wZQ5GId4~;tSipwy`KP- zF(boA=W&+p?in$B4pM!kpJ{03mqMheBp21-UmO+>f*(GkXc&yj zpm$+PV5I#_vYhHbb|h*ZPQ%d$kAB;ZpUexzSUIvhCXj9q_?Un0*X!?y8FY{lgucE>-!DGGo;N@BU_LaV)jVF|+2bUyTb+yy8i zOm5m{`(}zVg)&BtPM@iQy#)=F;XstaAl9u|+;dB-8|{EzO1h)&<+XMH-#Mrki9lxN&N zbgmXZDsl}&kej-L>Y|Lc{U1HCZ3LQN>5b?CXKc>?aR@aWk^4Nug^ebzU+@g-pU$;j6YXUi+SI2MF*)ML{8zua zseF=LsE`1#&nkR(Vlp~>&A#&Iu#bd;43N_E!Cv@J#u(CbUz(N5tUBKPExr|$TNKO< zG^a-5a;gGIQu;w8YP>pmE%NqF3Y?l0JuRR({F+S0`{F231kPFYU15JINNA9(BP>j- zDh-RZo@A;>>=G;)UY|3weba+8V>6? zMjJk^wa$Qsur$aaZSfbN15_rPmrn0MB0gA^-r1@@!chn0E$ z%Bor;XA7g_LbT?Pv_jV9DWKU2%iW`5L^>$s4~S(bidz%F<2;=&4607l#ECvK0q@sW z8gi?Guk}_Allcvx`eu%Peee!#4LgS3np%z7stNQ#3ZwGRh1ep7k?*0kB3|htf4w7m zB?z#GU(E-hbR>LS`?~is^b_*_ysvi1v4=7ur*!iGk|!D$y~72Znu(mUa3z>eKiJHN zkl6=vMmHX6WgoG;v1{aow}@e+T~=Fu&EVbDGQX+<#aZba5A6;`0oo$vF< za|b@hVL7?Hkb@uk<$>m~xJ%a>9W%Snakvk>0BgobpirT@iNHg&26nGRSR~7`Z~qk8=G+K$T&MKo^ zww8>Eh)Fo2v-WRYu7Pzt(wgh^s_Dgmi=v;LBYp_z6IhkU-32nMMpnf$NFI0e);~KF z7>yX_4-nC2^O}^96=yL{o{YlQ@$pZ(V$u}XIH|hxa@fYj3uBTw;xXc*|d)AUfPYptk3!#0xltO3%BBTnm$!! z*XmIx^~TbQo<1AAcGG?^8I)*S)~)-Ji+(<2xy9%wCYgPWy^}b7sxV5q@L-Ii8Z_rs zk(f~8E9WeBeNgs>d^Ry+9%|V$j5oI0RkP2YFxdhNGJWcPfKahGNkgimVvOyBHHaKu z(avUt?xIe0Lts^iTs`>jc9M)2!Qy6m4+^WQ+dg4PYulpv@bZ&!d%?zoOvwWhyMy*~ z&F`m2O??COk>?5zw^P_ZQj;LmULU*c|F(U}~U^W0{ND=-$+ zCZ2%c&yJ5Wm2Jr)roJ83LiLf4qu)6=!peN=_gU9a-AjC&iFBwOW;BkLcLc+P^n`>u zOM1Z_dLRgFmcjNc_G(os6Eb=2Y)gjqbJsEs=8i?DG;v|o_rfcH1zQhG{5=_V>e7=K zdV82xv7{pa&PthuGR`!s(rOLMRvfTxKmY&(0zSONXWQSSFDlk%re0RWRC?uEPO1)U zf+yCPUXZ=QrjD?5Hc$r!x=uaD_W5dAzGy<}zPWT{W_YomFfIjtz&!Dc?xj7h`cu)g-b-=2Zzs5k~An)dDwWv`$?Y4>FMASSlau+g(~D<*UFtsw%f9h1AEsC1(UGs^Z_c8#wq$PN6{RVu07HcaDaW42s*Usrd|nG`|* z^Cfc7h5}hxLDzm1?yuJJyGYXFQpk9xAU!f|P2dex@XF!?J6BQQlS!xs(P#knz@{=8 zO&ZENYUK(w{$?_}6f^est?GCGa?nzym8#Q9=0-^408c$Ac8vT1zEP@ET`e^#XZ2QT zYkL2iS!`X%eF{g?!PP8CWRnMuoFe>@^cFne`{%FggwdT?8g8xBHTsZPF^v;;b3R6M zB&d9uZ{(2cxyRX6xY`YnxZAQj;wCP|jBFSH#{XJKAz3wXMF+~zHgK2QhOXc07VAsr z22&1dErfRrZ@J428?hS1S5p#uS&-{dVU;86a2_6?OYhm|y34*NbVOcGNA*aY#0ge| zdJUOU@r-tG_khJG;c|RlV`Fy51?~=ZKO(nlJuhy)8A4A5TeL zARR@kc9c7Sq6Fb;B6>jQt#iXv08lUhCA;fz0~?AZC~@ru^>Fq|aC1NygE3`e?;H?! zFKYTdn3j{#<4M%@lSPgWYeH~~^N0+;3?r;;Mg~MT(#7h{RL8v&m2+HasfxRWm`2KR zg3!Z@RkM{0Od&aUT{U$Ll}gzS8v23JZQhZx zUOq;c){-5HhHGN-o?5~YNMUY8dZ(>p7_EHox+bJD?K1lz5fi}$@SYtpCp>-=MT}tk z(T<7h5d;7J@$woqbz2JX@m5i#VI8gkusg$P&x0y5K@zP3#i3*jO}8Fu-*EgnogsG2 z+%DkQ!h2iS`~s^DUM0ldqE$p$V)o}adaSS4EguoapcR4LCL{<3eu~fg>X1VT z&ZH|Y{J9y6Ca>1Zb3~Bz^R+dY$7H(9ba36Jx0SA__S67^*Bpok!^&l18zwhyD|b6i z81wtekE%y&D=A5=YVw$mi;hb<@{d!2J;&nx!n>uyH89|is!KR}<@2QjBW%cR_DnW` zGwH*xUwH=FSJQql!IK%s(-5R*+_`^bcsoJzx5NN>+dLT=|000nh0iW$D5x)kN;?MT~QIGNfCU{mD4h=6tPK!Z7 zbjLb91!D6xWm1o8|A=L~b?+lE=oGfGD$Vbm?i(=s#2PS}hvw*Y zNUK9iM+=_ix(=ju8Bo3{fh+u5V1UMa8$714O4nTQD_oN9d=a%dvxl4ac#ml^POl`f z160&cDJ_ujBOj9tBwQ8t%Tn0CSuF)jmIvNq^*yf(3jJ}^_UupL}7D$j@qNl%>6=#2^HGSN@H zPB)rt{ks+n6zbcK z!P)D!icRbH6K8qi>VmYF6OavNpe+NanUpeV;d_=32jFV=%NMR;XTt`xKl6BL?Y?qo zx(N6}w7^U$-77*CcwS=(juY6&2Z$tHkpqxFf%H-OUj2xo`E?REnu4)^%XJA|X?6PJ zAa*x6;d&KAqcBTKXQ6j{OTVKYBM1#?FmikcU>msysZ-bm@̬bf*sWsOqBguE2E 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≧jbCl2hb>KY12p|G7N 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 z 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¨$)$P&CR+pgx2V|;sXssCJAW$xtw+X0rD` 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 literal 0 HcmV?d00001 diff --git a/app/components/UI/MarketInsights/animations/market-insights-background-light.mp4 b/app/components/UI/MarketInsights/animations/market-insights-background-light.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..7d00b40c452d382f038d582df0ac4e6859da083e GIT binary patch literal 401600 zcmYIu19WIV@Mdk>wr#w&ueRO3+O}=mwr$(CZQJekx4Zw{bM8$tlkb~kGRe6oxd8wG zATV}xvo&+Du>t@90{AcgeVO!}4H&I#Sr`ET03eL*jf?=mu=uSE^&EdO)nK4MKjoXk zXI;l@VlBxus|0Jr*Eg#x$((b1NRp5Dd9h0fW`(8$J8 z&zjE0-h}?YR_II}tt@{rHnxstHr5VY1O|HgdIr3V1olS8ysQL*RwV;;$>tbFfg^ZvC`B1r7{vY+8bF~nmO*F*bHEa^$6DB5*Xd|7AGv z{$ed{Y%KIleO7D2(0b@cOwR7R(g*Ab;Qit(a7FX?-%r|*SB=C z*K^Y~u(7h$bNrPJe#gksUeC<>*TgT-UhhAQvAv#^kpnL?fxfP-+i%&-@Yf4{T|GlR z+yC6q*VQ-EbNH_jGkc?7$CwFRjLb|-9rb_fY;29Jbxmw+e{27b()O2XVdVB}o0o}| z;r|wOt<0={MFIx{BWoiACr4fuhX1;0ulL`W+8a5T{?hFYbpM~byuVC)16~7rV*)Gv z-+}$ltzUtcjgEo9?mu668R*!4i?;vS{olEsD=!=8ufoC6$d;Fdz|8hHNWT-}H-^7W z>Dm3p|G#Jg`2NmdeWTDo0GscZ#g@tXD@B`T=+c+`Y%*OTCsM$SnT)L*J05t<|mh*D+07(k=K zDE6#2*YC?23FmcgMIt3ZomuAgO-4s;^j^t(2Gers$^rke{l)owGpq18VdVQXs;A_X zhL~8$-TRGoVIkLZd6PT`%UjHMVIt!4SRF|Q#9-EI#LFE7B40`d8s!;(%p#Dmv-PU% zEkaoypnsc5pAP$X-zr)`uiJOV5Mq|mTpK+7>dCoZFCP3X;}s&Cb|Wa-sn(JmSj38l z4a?->fX`z41Z74i{t3ARj*C~zScqzM<3j==5Z!W}M|YSAat}N7v$l!dK+Ui}n5Y1K z8N1!KDLaVSXaMFjp**!GTeY;N)e=p2*ST&HGMHHS3V^9fIlFxLV(tt^%hHa@ylCc? z0=HV2Tgc#oCV@jvm2fK^PMT?ag9MA3Llo0Z#L);n`&eZ@S2`#-zF(d)e_+?O~PTk28l zJn4Rq#U;R=`lkCduF5V8lxuwVGQp|!{jjh!PGLV71%fwd36~P(Di#%=+0cbUSbL4q zYfJK(bAD8$<-!YI+@ykS{Ku@ zBfP*!4Lg}Ohyb*zpH{=j2H(Lx;RQ7<+%Vy3x0EbYG_w^#ImVC^+;#8IkG!Lbb8NFE^T+z zCH-gY3<5Uy$~oN1tUIdQDi8-b>5@}W#@SdZJ;BD!L4)D#DH#xt1E+yUDC0bO-DmxYSCIAy;>$TYn30Bytt{%~)YtagHZl7(!bRZ4Ol%$$qY z!2DHy(m3&`UgaZm`mbdVpi)KUz8D~%RcGu~P7SgG3B=7io=YW-O*%&h@h zn(3QN@~WMhRiPIUZ__Db!Hd%4Kx`e72pv@;xs}{HDWv7lH?W#*U?K~6A;%>w_aTCk zq_wMngGh2k#&@upRY7e24)S|D;RFe@DCg%JWlxY@F^!7q;$M9CV(irdbK-PEs6hn- zff0*93**Qs5-FPvN(ZW&IULVE=zYv(d<8;@dF9c~bhO*Fly8=NCzVFx&me~hT}+F+ z(cRn5wLF(@`XK+;O9ib0K9CUJGP#Kln1v4fsdI0E`$eraRss3l8xE99{61u|=52I+8ov{VoB89b#R&ol&$@cq~2qx|rbac3cbPFURnhRfRL~ zZEB|FTv=8x-IM&dK{d?50%7k!xLAgW272vV;$xn+>!)w(kG{UbS{tcRlA+`wQeh3~ zRpX8dQQx`(Dn)Kq+@beg=}8d)5aXkxO+y+k_uuby zSx`f9N?JP&jC-sYOeu>Flxfhx)ewUti65^DzfdwP7{6Yd<_1smWsD3o>zk68w%A46 zB32oJ8{{k^u;}}dS_vO71#}GU8uyWzN#f46FjP5s9h=%YGfqRUX{s(VJOID7P|M({ z>sw9o6Wxb{;|KbbcTW0`Rv#z_r#y{Ltr5oyEie?j4OdyqKT;1ObvO=dt|AadkMDvDdK4BQl>o+ zXORGm!R9%#rN<8t6Ub}PY%L!{GqTxM#3i+bB^c0;tQYWFPM)a^wZ;4>h%PDrMYE{j zi{u{=Ql}4QLE3tnzn8q+ISyvwqX!;uiG(xwLV_FOQmuWOXP3gr7H2%xLV1e2Z%TIV z!-3&YxipygNB7SNrnnzurUTAiiE#X|Cq#GR_GZ${9ai$w!$%PdqmyJE2f9%4wnC{L z$~n8Fq`O@E^RFwEY8fk8DRyOn1}Nn?39EP7RQE!Rc>!Kw;+$X+RGjtYfBCiiLy=Wt zi$@#l*U?$`ja5j7_Hw=|nr+f>^WTr;>WsY>A7$n9_~`B5Y*T1_N!_X?G8k6+Xz&{Zz$GS9_*-m<3y~qqP=F=7cKaG}2Q0wHv z51-n>|A9ZUm<~e6U6bf4mpnspR^rlP;qNU>5nq+l!goH`f@GxZ&b6}Q`Q!A#4{F{r)UC;obq2U zv?v4@SOe2$^}%$Emq~uU#|(jO>E&9)At5S89=if&cH5`>R5ID!76mEO{-)GPr;C}R z!~C%OMx12UrDp7jKhW$l+rog}KlCFNXsDExxOv3CUTQW~J6Xp|IzN?Nd;;nc&FoM* z?;zePMM6MffU!^HAGaw0!i->;2{zUj$v6WM-fwyD!yEu~B}@PdLIZm&8?xxcNYEOM zv!c4COIfY#D=BCFs+CRu%1YNW)jV_0Nf~F}IU9J`-)B-S_uj)HTj{%KuTZ(8x#Tje z_4Tw<{dVZZzo@1W+=W3ys&z2#jQCg%6D+BY#Si8GrbQ<#d8JbDJqA??5`1`=?ZNOb zAD@h8R@((}k6(cjDaUpxNhcN2v(@%#ac`U;SE4oD>PN}fA6TLX>sETdB|2a3;vEPb>vk1bJqCa%1ln19 zn6C}9p|;><<$LxnUxP3NX{KoM$uM%g3(4DPWTH}mHr@2sBgygdMiO=r$=Pv2yJ|TKOzsy3$0=w z5_59#Eg|x~tWAhs>q^(T@=w4AvH0=yua?TT^3;@|Gn)(~?1;CH-p?pogl|v6{%KHT zk>47HKTvFH7gX?AyM@Up$(SZpq%qN@N*IS4UGb)$*lF^&)HD1Wf+H zju0jiB{laXy47|O_tWQzB00uiU?*3{uZLI~&YPurRjSf#c#Osc;=IJ6;~TmdK&HT_ z-8d3Rkkh;WaxQF}yiPv=_R>&pe3N~aGe~O=A*pa$_}CiKPBwG_z$cpDdhI;YW2tGI zx=&-9ip%do>H{6n0sEoMcLsP3q?i!sNo}ARedTMJ;`f}SU12u9_y&&T^9yeZ6UO-f zNY!H~-aQ=JcDI2v%QIk%-%h5 zmgx$tqY4MxiWQOn)oz%_(=f;kI$;jaR7MbX^@kzH5F zSoY9#SnVLPjEuME4eZ1o3w7$(N!A_ot|VYfv}Ce)tGjL zL!0draY}f_9H)0UpT4=c(AN29yP!q(spy{t0Ke0GH&2VVPqiJ_vHXV;*XE2`77;@Md27s3C z4-_Fb=H(>b=e|E1YDBYGnpJJ90^^?6*Xz%kPY~EydE1?;(CPR;fVXtf=_0q)I}>SE zdq~Bva4X|^Exp)XUtlYt(kAPs6TZ#*O zMt;IJ6l@y#n_o41y~m&WjWf5yd@6@XM5wshQ9Faz8jo-T&I;y5n&c-fZbP$FKJF(x z;GkZ)v9wy>g<9x8z%q~QoVNYJ5NLAw0=^={$rPA6^DO&oS-f0Gs$nmXVwBdJN0}fvPX`@co3PL5BYr%<=`jR1+%S z5o>N>G4v$+LL8bbJNHrevWZp31DhuAt#Sq*m^j~0;VdVWiB{MKbg8&5f0Co7G1ZR? z66cbaGzkCz$44ODKzt9LOc99v=X;G+=bLD*X#+7OdGG*0U#Ic2s$%`10)#eo6tlv& zSwCy{z~c19GRdTI`+>riChz=C4FSE@C2=pGw*RHG})3Ocb7h z{&bB0rG4HJWQz7EjG`OR3vuP@QT0rBa0{NQZ{!SpDEs z8SVK(0fOR4A08^`buy@%^J}Sj=jYON207WVbAhG@w8NSYGxya8(WD`D0iQ9!$Y5=^ z=U?;|JX?U{nwLaw2sRReD3un)>tLZKKo^R>(fo|23d(X7lM zMkVb%4Q()~P>FIegb3O+AawpPr0X0sRw&PD*|keoy#zvFJw!g7t?*K5pOO~{ z?VbGv8WUh&Dq-x;(UOpaHvN<0yu{Rtj_5xT2rEN8UlS%4+FrAbPlgSV6x@M4D}FOu z^>GvbZ0_%#<{#`XmskW)5}=zD6Z@tMcygD^ca}-Tp^`#8RwgoOAu|H0F|~`~|G21g zUUpk0{1jnZ1f3lE;(94%rr`Y3w=AL`eo)G9E|ppmy^p2o{RdvT@iP(2Dm-6=?Z^WP z3Izg({+xMs+i&vr#X!CBAA6@MqUL=&m-cAMi?N7zotLnP>0*3Z)>sc2Z|-$UehCw^ zs`49)=decn&kNfnv?qjbM)TJ@;APz3>K1!>#A2uLV>K*1P^#aZbJ)}ZgvwdJfx?LA z=KLR+(JZco*pKgGU15Vq9?&J>a-xxMz^Kg<(lM_1hR3Hn(+I?NFgH)|XXa$OM_4E5EZi%AAr3I4-QOkQrC6*QoU zK^*wP9;=}SgC`J}b{|KJBUoQq?Cp`Fl5GkJ$wR(u*%LfT@S}ffRHTocJ|85J1NfEB z=22fdP+EYoKOC>bS9;I%;hicg%*IeJz8pJlwV#o9$EB!95J-HfE2o;NtlBxbtCarr z8^o#Ji6=z|`-H>*m6Ig$JxhSCtRwm@f+H!)am6PB;c(DQ$Ui^Z&@sdIRnkv`B$8og z{woOschT7{%DO-?=Lq50e{v`GcRkN)Q$4$NRc+d=doUm(Tbi7A^{$Cyr{RDSl-MD zEO3MHG*3oOIC!WkWNjD#KG-$=3I@|iovXQn`u@cHysC^qi`hHKy+`q!_#OBC!<&%& zlp2H+dAs4(p9}rR=L)`(E=N-3fz8_OfpuQ=RUsQ z7-ISjJB;4hJ!cP+<~i@jd=iNuJek4TCsVWUl+ba-(z*4xZCSMtJD~>tvC&CLedOa2 ze&0_ZG=}c^mPlF%0GPM>#rP8=i3fanek;J;cVA)~AooJ&M?Y zaOG29=}_JAgHDxd5RNRu8_q|lj85;?j&vpiNWA*pvFZdGYm<)43l+=pDjO#un(O4WEH3Fthy$Rm9zzIV z;tP&Fu;XWa#%fsW%JSzfz=?@eiPV-l(B={y=?cGTlJwHdh|4gc-Ba(+$CtXLn&`{F zBfAI&>)HdidRsyu^Xk!w9jL+tCw}?!%-NW9)_N&`whx(ofuGrP(>Aqe?HmR#Bqlx; zsuiNgD@w348dS%nz2v~n2*%s_%OPvv(98%YX&x6TBdvKiFLqSRhl8IFAU|J;ZOUS~ z%--ePT-#`|LSMqWSa23WS*>d1Gr4aPddhNCEMC(d|%SOh(QE;xlud#)* zJ_GU-^5lIpX>}E-pGb+1*?&`CE!d#V>K3vkN)joN)UojyA?h*0Q!W!qX__@f_c!OO zc`-NyfxYmusVYr8Fdx2cwUYfj^o)Hr78WcWasm*fMa%EG)cq+W?wc3S4tyV5Q)ckT ziA?(LQCMDW44G2{&O;WdEif%85e~H9H~@H%~E7~@c1zD5@6Y!!k>_*XSyrX+n10di#q&iRlBAf;)^K_Ff_ zri@=%)zG@1RN2OCoaniIGDNww3mU1yC%+DoB!BlkaAtX@8tGH8iBqdmuk_4cj6O`y z@c;TX7etUFqidq73rtL5sEq4k^~ca_ki5WLO0L;-8S+!^k5hHp%?($AOKs1_j8`9( z9vK6~{OohtI~oIZF}>4-#p5Fa!;`P}V5*N^k!zMz4m6M9&0&f?k&FHH;T3%wI>3j( z$vl((;I2ZHUnfG>cVA+bdl^D!xQktG+n5l06Zz>Igf;Wi@tR%)B%E3lHdnoIM z9h}YrpWV|1Uzv(%S$j%-(!nbx$$fS=lVj4f<{A_cU^dKb(Ijy_3X-{lqJ16MrNGfH zmaL6~5rw>nQ*4uH5xtGw)glNfGolp>X&zi+uEZlhlohf0H|Q_*U@z6p^gE_%5x$ko zuU7w5Xdp)ZZ9T`oFj%ety*!8VAIan~>6T#dwI7jM2iHfo#?47>yTU>f4AEDin$lGfDLQlvp^oTC7tRd@$F;x!STG- z-3JuxdjqH`FoY&CJ=Wkw?Re3TWUBHDx?|!EX_4(w~*7vylOLt$zneg*r z=P&}C#eA_zSe?_y68u00gOND(Lwb5S_%uiiF_e_%Mm0JiV7bw>0`|&%0B#68K}DF6 zVL?C(<%RciDR5os=p_*gryH~TrZN48YJGZ5KAw(E$Rip{2)|%d02QV4wO9hlm7DDA z&-TA5D^PTOcF$#&g=rR|*SVyw64MBea%NO}uOc_iSmaPmhcinAO+Dx`AUj%Q6CoKy zVKczM0GBMGt&Bf+KcHc_|lPocg;JY%VGwsX^1z1GhRL;m@ zQ)_Z;VxnAnW5qr6X!pdZx=H5wi~N*8!A5U#6VqV^I#J>bzw<)%x`!a5S%Zx~njzn^ zej(p}NDM7h-rv3oZ-k~;F*!EP*)5c{8Z!9wf);|6AnH9K-2JMbI&-rWZDKi$aWdSC z4XmM5JWuXP3?8kI)KBK0eh9liVyz8i8Hww)aN>=*;z*pko__l{i?qN)p4Pl^vT8VVLPB1HUnmMiQ%ezLo)l4tLhulOy>`i6oL}+bn z^5nr{$tB>F6z+jd%{~p*pTIX=Z*K9!(X|*OPJet@MTaIzV{#nWPbTnD=aXRMhKN zY+u9fFWFux^DO}Ol%5moD>T7)`TpY|l9dQ%3ATMWX#VBXL@bYi%rRg@58f*JJZ#D@ z&L36$8g*8;SPkx5vN^cV!%&|15m}}(JM*Yf)ovc74*e{U0t}^%CXUjzxaDXS>Om^e zZZk-BhqNnEkJSq!{}$i^`mQdSULE>jtjxJaY8LMRg}n_N#f}Lq-txGGX#DeHG9L(L zZ)lt`t7tO-0iqtPu**pe4E?Un4HU`D|D5o&6D5xP-J<>{#Wq)!Eon$tLYl4H@4eokoDrq60lxRfa41#SY#u>dF^z zTMU0D7Lb{4NpA_LMvO;4b&!JTuaR?mK4pti#N5BKE0c0Px_F*7mGD$7UD#!0oGUW4 z4wJ^J%J28Rm~&`q9;7AHU}@OZZIwNcLmpK~X4uI49>2np@eKwjX^RFm?D}KFR3C>S zpeuiqGnat5vCXTU%IVF-D!x;}5bgh~(Up zMi|KLIm%r@`5^``$e_c>oM(`D;X#9r9P*gW3gX5+KWxqAb&8msTiFd}=LcA`J08-y zxGb&;ve!HrfJW0vaA|5yPX>G&v+r-)83+5|&z(wLDl`&r!?|WJrwjrRuzp5|Ki7Yc z$}zjR+FJ{w&AaM|RsCefan=|+8~d>L7Fc(s#~b%&7!|D_)#Z(#@9m+PMZR)+nlK^G zM$m$MrL=fG1U(CK)=jGBG={u}DNZK$Qvl1r2H7{;`m2AMsP!ydb0x#IE!lyU45ZC>;D<;3+x`mw+QcvWQ&PF=5M*T4sL z)e3t=5*5ya9Exq>6J%@3S-s7_qK0U&e)2vjmXMTe=_oQus=RUaW1TYkuh>LLO11C= zHZQgW+ecej36b@UT)TAL1Vdn~)+qkM>>ye)Wewx^JuA0;;Y8l*VPuBiHD*9*ShQC$ zMPoTOMhu2t!Mstn5G-(C@2uDqmO<}F5M^_yFJQ8@sU#p;M_FZ;Lr{C}Ro>~&GW3gh zd_nR_KU~V=VW+^7&FGX){Q)&9fI>xdk;P?NOUyj!MkB^nX1SU5v>rlV!_UhPOXT{Q zSvM-z+|?r4X1Rm&b4V~x`XKygPYxkNgbi?hv;ySE=D^-)@9M`Gz6l0`XvYsP6#@*D z3X~Yrb}dIsRqaE-RJI9q1NZuG&aPa*x2A>u$m1&`=)rN0O4`lF7}Aqt?#ARStXWGv zF1HW&1%0WQ5sbPX=c?u1Y%IAqr=P;UA{4WL`7RE~jLT(&05wF-K0B(@l2uuk|b07XShr*8vyp4E#1t zx3*fFe6vcyCtVprC{6w!@>c<(oI!flO*+51)@ww`M=l|t^u`d{Gv;s69frY>s}99& zV};q2M<>7rXRO8uNXO8CAtTygR%)#>#^x6|`LIv+Q~=&yJ-sppYa&^m91%-)&g*A#h>{-NuDj zBV$Jz;9o|2ju0!bhduj8JWo07*LcwFp^GsRM54=0zCz8xqB;SO1nuEZVAeA*>?^neht01m6Ih$bg)Vl!pgB*s$w+o_J* zzfUX~apenvkKB&$6So}?omMAShwsX`sd^~69?QvBrxD5^JREG|g^GKyA55TS93t_x z3?5@UfEZku86TdKk1_Afi|LrfTAXl@H4f^)pz999>aU2TXoWL+dM`#7-X>#qmYL0! z)GCQQa%c9T15_h%~C2t#`KKtW{-KKi~yV1{!jt;v|Cg97>n7r)eVl+t;-pduHG#6)>n z@O`X7TE=oshroR?GoXLXopBLjyll+J-&D4nHHe#m;UFYY9tL2y78d~wInL1KV|V5J zQyk5p7$Hifjtz63qyfd-?}U6cO4JA9nQlQr!;q;tg31=YCLsF{kQ9Y_Fr#N{=CDB_ zaoN|1654C01@37NiX>?cUB`nf8qv;e7BIpHDMULs9`3w`FV2S1GB{6qD>Q1Vy=h^0 zP9)-zObzBrLrYW~Jm`%94U0Ljgx{P{3DF18C|Z~XPgurl84De!GSa#0-k%qKXX+B( z<^ms(l76YXomTjrk_vg_h)ih`+@-5m@^if|NU=J{rDbRd9%ENwh_(OjBNDTTx`X|T z&IhHYu%vft*=&|G4_&jRV5ZMCIf6sy&+nU;cb7h5H(iY^`!@m+^&AFTdql z`;dXp2RUzV*vXL#0+uSw59Sn$wY92R$R4{i%*-*zSXR$ZY6f0Pc_AI$5f7BgOFv+! z1|^{CHX+im#3-=Ze(p|G*T+@PIlnRO`LfSR!*n_%5~bw@Pv9=M>Zn;BG_mRr*AElc z->4mfoz!hw>M63jJD54oseDeGq?qm;MB{d^#z$VZTb*?*o{6q|vN6vVtJjuXZsGip zpW_)1kSAj6-H+Br)7Emo0-~g2e%Jwgz;z{ZLzRql=MY&s73G4D%;#7tVI~oQ#%SMj zOHp8q8Qh2n0bz+qHP zRkVh~eGaIIaSRX2&X+s{B4WBC;PqW=g~Vihj#hkAOw{57sezt_3meaT3nFH{VQ> zbsnqX)>I|kyBtQ<31e#0E(1;D{5JMKLR5BcaCfQh47meE<&$z(8X$myvFl|qa* z1g8vnLDf%~1f56PvSqZOIWkTlI*FFf0^CD(6Pz&6G;*PI*4qdzl7GbVqCERg zi)GWf(5wGc4Y6?sZrnGFknv?p@M+zMvGLBnZ+bKDluHm5>D*!dAY}DqpjN-^vIdBS zbpQr_4VN6?8=GB^U#obB7Eb{^awpZCz8Q6aUpP znH}wpoTwr{XAHMR%xD3}LKM{%9i}`thDn|Jt0i%9{axeno#LJ3^Jn zDx&)+2Svr;^)lgX-5~L^ZW)N5uw0r+Qf2#w?0Wtt%} z%(n5Pi21sKOr4+ELMsV4VCQI;ATA??9W{p#yWNJmh77L;oyYdFDD)jdR-E23V^)I) zODhY-z43ERFRf5aGe|EXETNF8x@S>pa4V+%)5{FPdFlaFFmJXW5u;h>aLtNHZ{Md? zdIY-{o!PNmkQ5ca6p+OmhzdFBH#Qyhtx|Qi9c@zw<`XBH-EBT&ERE~>&G%sIixiuQ zH_Az3N-TeS)-%Ok&o9{K|e|{Q3G@VB? zZ~?JV^#q2}5hdRaD{BOmrFuLdE(m^7HH(nww@!?F^jbmje2dn7M}jgqdnb1w_r%&y zJ{WV`$o?gY_|si4Rp-w9WmYV6Zk-5 z(-n3%9*X4>>Hr0@wSv#(H%(sG5yW9;`_AO4x2v`NVuc!fr5BwmXEWC7Aw{FRSqN)>UnPHU2b1sCPeL zsWOVz+W6G{)yb^sqW0O&^5O(iul+k#DC%){ks>W(k|*T*=$+~%s_>zg6t{@FPKO8d z#ru&@7@LFoyq!A7LLclL*&OVJw0Dw?h-;$4sQ!*Nii7A+)eMlGbWq2bm-A;YaInWX zRdvZ}n{9j`CFYjCoLY^196cZ)&4!adM^T5W_^1vl+E~`zc~pTYt;DSUI{L?>m11uz zFB|HyQUoeRH)c(hZxi3Sv1+@5(FYHtz3EFgp#(BkX1TvPhD%_fu2bZIK<0EKLH!=m^9@MYY zNF+OhC{VI(fmORjn^6^=QTCV~YGC(&?(B<8u0{a#G?PyKe4Zo;gSTZKUWQp;d`u_* z8)VBtpnlxJW71+%TpG3``gF;DCuB&`C;kiIuARUOc=YXu%k!c}jq|KE*lv3n=HuFW zgAR>lqz)M(_XJsSfackHNXIl32n3!<=PTzpP@TN;u{wL= zV69_vNykB|Z0Zsg;FHw*W-D&)6?Zu^mjvTPfgEySaU%S^+*t#B0sLP<$yE|5HnB8- zE{5yBpTWl^mzSbt*Vm1)9*&WgdxC%w8yTVI2P?R(iR9aqMNlOpHkItB089TMHHT+j z7frfHz>2-es-v|^`9U53n8V$V<{nk6b6`Xa><3rNn1rglWD*KCkK#!+ps zjX?4AHVC442J7m|(Zb;=(?lk|%>2zjZR;=+ngI?6Qvp*Baxg?+CuF{bPvo(npD59$ zS-=WdUvX;hk^m4=RuUHaq+|^f@T&+&hyY}jDxru@Eu{yD-&*}&>>HKMUYm&tZsQ!R z%z$VrS_knIXF?CT)-H8aSylbE84gBql!~qiyx;{AC6E0rT125X;WY|*yi>()X02XK zjmo6&*-Lyvpb&N88e!c)V5s7m0hHWvn^NhY%i6ErX6oi2PLphQF5OgynPEQ>6o{Y9 z>IK4d&3xnO)i2u$PD@u`&3VaCTt;|U4>^C0E%fSwHFip1gmBE!m@-GBC>1^@>cg== zXrd~o-{GbavIu6fm_^bp+r?6|c~5*voC;Y@qIuXc5S1VHeV?_f?^Nh`&N^h8KiPB>;I%{s~A zsADS{jDl7>0<&4Pb_>7ftPhzbgV+EBVTKz&PHCfK8iF58IWNwP6ULHNNpLUbM;~Xd z>s*k{&dmblT_gOB-5z}2c&I6r@tXDXuqw+vIEYh|)Mk}kM~o1$0)NZ;51%se`xu%iLP|ftiPu(CIdqwd-4E&9I9gVW7xeN?;&@3HqqNhc>Hg>R(3UiEyJ{(<5KXG;D9rUfY)=J^s%VjJhK{6=bfcDkjVi2 zpat-cPHKgUsJp_a^C$8U z{HMr!>{Uv##o!Msm0iKbK@n<&&G2AH<(tU$JxAQ}tW6D}-+ofqE(&!^NphYhz^Gqm z$ajYpiIAF=^oQp?l!?GD@hH+s#C-MdDb&skwlHO8*KzvGmkIH{^3M&|bszrS97_3# z6Vyn1d7}i#pb?}Q9gGEvYue?;4zDyzb3sb$b4I15 z3`!DV7*H(ghZRCH9?%X!<%%0pMh+Z7+G4Ed@wz8Y-`DOs;{+|J9%dD-2%^J@ z)h+sWxz2@Kh*KuSm&*-z`W0#PxgxZ6fOyC**quml821_fAjoiRG+e23DI2vDBKk#F zEK$PUVVd8r4Gxv3PMaog2O5An8h&YAcJz1viSN}E6^hnr6!(Ujbo$`LNJ*oyKJ?My zbBj&?BCsKVwfC(Paf{H+eKc3f8223z#huUik6XKKDwIk^}(*>KFwf&6spf7 zEa+Wcbd!ba1gAz4ydC$6d!-}&WsYPqo>A{Nu6|1M5(7wM?yi4p7^PS%_9ypaPid0S zdNs_|HBVxOKb(Rn`Rzol@)p)^T!-))z|gIHCy=S8ZQ^QErvcN<@SR<%mu^mwwqopL zQDq$oTC`atq7{1Q4>z_F%3E3*d`CHR?k)p&uoKXkot!wrl7X7Vbv4 zK#<0=LfieY!^Z&N%8Z6(BaoBvC5w5EJKMIYO0}SE?z=7Wu|w&f-O|)X!6JHtYW<)& za}GRZr6C}&ESdiOr6eMCj_cz_E^!LKxq~=u#!cxQrhmDd! zglhrVjRst7>ixmX@5K-yoli+$yo*MQkKS|1{R9rDVseOPHkzeu%jOtljhp1HJtTH+ zJN)NqaIdE5ZaEqwL{k_}%k6W=F^W({+t)kS>Os+&PCgkcBDr!>K9=3&% zP_%48qdzt}75$h^3}v>Q%jTd7VzLbJ5P*)n&JyaEn&s1ils;CQH~6nu@MNe&qZ68v z!Ee(l3r^a-?eLVZKML2rK`f2+*ceBvFsm321)g%;BR=;dFluZSG!CwF}bqZxoLtuU9R=LguGPQkPr6!o#kD1uO&=Wdp7yezsafs`x}! zRVJ$@NF{;I5wF6w9^s1cCxZn!K6?4WclS@$?d#t+*a_jc`BQJwroUrWlVG|u#@M0c-ho2e1_I!&%RrCn@ z=;%l(XYiW5AKC@@JtC0m@$hR}0+wJd*e!W)`y+y?8ouc~A!kaMHD)>kvIDXq77WLw zgZr~FPVI8eUBdY3OVU3^qZ57I(+qRnZ`=yHlq-S2lfavbTKPxoAzU!9YUkTSSC7eO zk;B5scqD)GR)j6d{L=ZfWWOpMSY-WtvN=RSPe7wc<7tpiX3Ckz3+O(3swg85!rMq_YTc(FiEkjP*n;g~RO9`2v6l8|>73=v#%(G=)g;7EUahDRC@0+?VcCP#ee z1?DQu8l6hVCr3j?n=r!U-TW#qLf0~QM0bNK-0sVmSh8AW@nc{mzTYQuyhPO_9JW#S zChH+XpJVkDSbr961-vM3bT*-YvMD~R^a?7_KJFM@q%z+m_qq3b;N^xaQsoaunw(!z z%{s^aJ^Cy9S1c*6gzWsO3i$fwmdHHE9OZaijhb=>3nkk1j6HHFQNATF90||$G^Sg4saWj@eRwRms~fp#wE3Y z_0gXlY&dBB5z=pwnnWEj56BBkf2O|o~f&HNwg&&!8OTXQaxtR1tRO1*%J4eS}yaqh*?4ghm;QfE{-TMrjJ&?Pr z!74;rN;P@=g@-gF6E~nzD>GR9`$)5B*aheN=MZt*VreYw{G{vl{?_$ zMC^^Mv*Ah17D?_bJ8+8)5Xq{7OLGRID%BN8=7;dC>PPzf;g8^!@zv!qFem4DwBqIM z^S>H^sq8S^Rij|-b1N5R^Avk>1-iP4w@%Mmvbdv94`!j*FHor7VM%WJn%W_f z-L{wBU*xpnkjQANPlBYi;mAG@2vl;i7MAC}l@A?x1B}5!r8I^~MbEW3wPj3|xIcT~ zAVClS<*<3T#__wOV-0V&wtj0Dt4d7{gaM+PH!d(Pq&cZqxYw|Sh<=+h_Q~0siQCDz zYk3`r;xKh!bWdj~kM((za`PW1HRc$AzX^R&jlj_%KfN+G*RgpEh_OmJ5=E|sLq$TIihCwc>wj893X7adn>f$hRpyVBW`vcTTBxc~hA}#` z>k%&_IwCKUUfp;dF{@}!ZrL-K-0+-NsF%InWI`kg)(;jHq!P~9lt&EMfo5IY=b#6agY2vW!qJ&kYo2G zLbi#ABAxC_rk+Y!MQ>a5Npj&9Tb$qM5RhO{CnCyCvZTzD{upvy_;I8F24MQcbdOJ0 z+Batg=jpw)$v-q>kr?H+)Pv5!c-c(m{o-_yzp0nHyr<;ZZO^Y%Az~36=_dfY;%Ob= zR)I;|4lX8vLZ?PznWgACU?rn-Dd#()Db9}OeIUOQzSTr~;`NTF2P9w8mk&7+5gY17 zm&8tmV}uR||CtD-zFr~X9|`k{2O1fxM3o&4C;_M+r)-tFCGub7e?;21$i(X#TlA*Z zji_>VkbE#l^`6=YxtgO*vA!bJ#$o-zZ~41?fd>ZoQ&}|=KryJQDhiEl-yz^Ga=B$- z@1d!Ph{=8b3URRwmEaoUx}#sF6GJcpuE+A)p6$mfN`n z80B*+19v;aGs^|tTdM{CsdE8=y_HP@5)%9?$|?86#eBZOYIj^h@W*_D0~+@a(}-_C8kwLgPMyHG>ScjweC zM`Ste;wB4m=KzE;S+X&!{9Y+!=eB)f^ZpdQA5CNuA|72E z{zXDC|6nYbv?!BgxD`I88U#^?=W@p}R}#L^KgJ8wn$nM$jyu2PFTWzZ@x3h0By3s} zrJ5)K(!*Z{wKG_Nc4L~4;S_1P)O?~JYC|Ec(A2kn?%HJPYmC9==!kzZqyypkzL2{z zHPQm=W5&#QinYm!5fF2p;g!pQbo2a3M)|$&<)p-J*&abL>TvOQ5G75w!0jHU{4>!P zX5}avU*%ErGOcw|&JE-O-j5r#bfgI>9b-J0oZdh40yyP_E^kewH%^09K|!v>ea_Wk z-soq`euLRcK48ytffgIEFvD9H<8NSTh+rk?3R1ben0`#uR!_?R5Mgj5(|b(>K*In~ zhY;oGG|mu{Yf|Q2wMFRFsm7vyEdYKA2lR9i;@)^jm&H3i&R zif(p$6GXYu6d6jRIfdUid(cxo$Sf(C>Blb8l3osYBlc<0M>4T#Hq-n&C1s7k&_=nA z0$pfaXLxYC@i<`c!@u9dq|W+(5G%uOLN6K{%$+dgX~?v-<_dS0MNFMV4tCN1uIG1H zTb0m*v8ZL@R%NIuMdZgU-@KbN;2xv5@gjRGKd)6K6PdrPwyPJOmC_TkCkY&0^hQwl zGN$2KfV-+9BAdIq{YP-m2Eghn&F{^}!4U2Xx`ax%&GkK15>6z5G2SX!gcVi4X#R6s zb*u7Kjj~CrPu0tcU#w3s*3q5==vQxdlF+W*9`rl?rstnyK?Gak?3<80JJKrue)}^^ zoQZRex;TyWzHnrHE7pKz&8_zp$sJClt?4EB&o0lF?5&B7;P5<8J{Slk{%r(g)2gy* z`TqCIB@)T})i*4pHq|k-OwX!=x;;)12)#ZIQTnfY&`5#48~nV&24{z4TX+GAd+a8= z|2Sg4dAwp=@ZNyyN{-_?P~UR!ZoaAPI%-<0C-)X=7fNVz6Tv~EQ7*EM+DpH^zoo7o zB1ydkF_~U{7@dS+#{u6vhc}ucg}W16p&u^PtH|XPuDI@oEBEGS`O2F|6=D~|;48ek zH~+MI1-xW|V>)58%Wxb7*wwu#IZ_MLg>GMXRlIVoe=7%0@O_C7a?oWiG*_Ld*?!wG zEoLl)!fcF0G7pM=D3w3mmFAVc&P#*^5u098mj>u+FQM;NACKqTSQLf|K#mrZ2-x@x z*!9HTAUn=UdMF*2)wvAr@l^G#9W6>?e@*aZ2X{sH{0)lqiqd&j6s*Dhb$el~ZvXM? zOZJ&qttO23Ly!oN39cW~`~uisT1wUs?HzA#?V`t*esU{h*x>=leThen#xD+BU4Nf3 zmjgG$H3^~}Z`NU8#BXnM_ZnQ${c;bM-}=`{!VmbD?d=ap%Ly#+*ezNnJ$x3SxN3RHj&4bfz^DnFOa2n5a2vD2l#uhQGmPyOvbRU zx6ogkQCS=Byv4EfY?=ZKm+)4^gWvnBNNRWcsKiMEN zYFmf>h4#1Z%81CQqlzDkMiYaeb;r1K_?j2^noP+&#PkNZG6h{qv8$1rTh%{Jf77RB?Ofj`tdtR%opqU5reauG%8!+M z2DFqGpX>0Qq2XptL-N zswQMB>8fIXxV3B@hReSGUB}DkTd`hsFIzQI9yzqHVz^szNOjxOhPp^Iv0?h7mxWe~ zzZ>V+lKc;FO zP2Z`zVJ$@AUGQtkrzsLgTP^Z|RYik^bKJ2OEPw6B?L3Y$`V@ipw)ja9J3}G~U1#N6 zP``t*IbZ#tJA&C7h=>`hw$Oetl=2KQQ z)(qY6c)Z416#*}3CTTTy>KD8MGjiDRIM5bRmZ{n3dC2>^=pN1LFP{vH&^@@PG@ZR$ zE+?NtXySrA-PBkd6HdoHk6^iz?ch2H zoxQq7L+X#9Y-Ke^|;lnyN*>Yh-XHB(M%p6sXFa=~W1Ndg+EBMqoI9Zl{3qF8U!O&cF z+B$uE2>QC);AqvXy)Wwo4gJgMyaL+l`8rY0d_HGA77OqsjLYdkcgE26L157-*o;CS zMg#IRn$kyAVOR*3vHHptgJW|n_qD1u1aGy{0_vOf6~GERt8_BlP;L~IT4ffY+{0{~ zQo>t{U7>P3Hy?cw>FOp~*2bn;z2Z7+hF8YI&bNmNIMa_d4&i2P8hS<%i^F2c*!=*w z-+vnJe^a21?SWIvSf4=EC^8ud@RwH^&u_7X(NCWT11WaJ6~SAlsyqKANd6RF>1~0>OiS}(IVsOtFzXnZ+GnOR6&!EKn^;~M zIo9y8vR&XNL67+RMRiIelRT)rh9Uj)ZKA!8Lh zTzfsCCPqPHk&ORwpeeLR_sGGTg!)^}yy7CVH&D=6PkeAEQMz$E`2Umdul`X8vFiwf zB48nUnR1Wg89=k0!EbON?%I7+4QjtwHYShMj2BDJDoM*f+BXY(TQ;L30o$-;u?@%5 zLga4~@7=-UH*%_;wsyVZ0qxSOCl;{MQ^O3e#g4P|)4x^4 zUE}_;`ClkiMqQ)nYdCVV?wmukx_EupVKF;vXT;gL&JebDpPo71fqxQFwBgqji)D)0 z=;p)6PlsV+PAE4hpeW=0ke4DQl315TIT^$)#lbvXEb4H61LQuK00ejs@ zB*A+!@Xyip(xHsB-N66=DSJVhrA^@v@?|grH~;?GHD6i+FgZkrkaB+m`w#)%)ado~ zIL5^H44N;&ZgUsRyoIWXfx*g`bSW(ewpa61h}lVM&R~3NJNSjt;|(<78cY@>E%`$w z!{dE-uQ5CdM|FX+*pcd6wO{=wj5<){vosv}kqIJ0Q(NUvbbm~7iRPma*!~u@o<^FRPk80F~&N+57FAf|<9OItiJ?klKnHqT!5vWPM2iagGSv1uhOBF_< zh4%db+ZrN~^X1(2yA54SqG-~|< z_I!k(M0!^9`gt2c<=Ej8JSzxX8*Ae};yn`TM0S-dySKvre|IKK{l^qJ9rwP|voPUtbbUfuN_p7H z@|_F(2ycZLy72Xa*6?~dsdu388e^vozaSr0UvkQq=kuf?mZ(|YKUKVUN?nu zuDq4_cjxqPIkN6LdY&(fkPa|8up!(D%klKw6m+EWL0w5T1zIO87~7jqw@+zVr@iEq z!j8T;|C5JzVYBH;B3z>bFGAJ~cLFKdZU^_lrV4Bz`wS^ z`}|B6Tiv#$fNOC}qRlGT-S7&Ranfc8dAtn%orSEUZGkCP)y+rZ*pLnm$_cmQIBdjk zJ&>GPm!d!DG{xz;sp9men<-Jt4zXPftEm)S8@dIcCBL0_7^Xu*n-37=n=adrK+$|i{qXQ zDmFB2_e`}W3sN}}&d$vT^-Z83^mmY_6rWxpDKIF`ZjAWpiJE4fV^H+V3-yZtUSqYd zfVP=|u}cc~P8QK&q0;M)oc@%i`}E&_1nq36DzyA=+*5aBmKJ#TO*9%ezEy=ja!zk- zpI0~lD}25Ov2Z;}akZaOMk0|{(^&dSj&O@(AHr{~ZN9rh*^b*qW93T@Vpm#;C~bP@ zNSGtc80!m{+n>FOD^eYEQCm5oo=y+TBmX*(G&*_~p<)i@7}ub^bj~)@f`jxnPs*cH zVjS_N+!V{EZ4!YrHce%`j(4&UX!-tz8j(z?pqQ&8BGwt9`5&R5?x|`wI4=hE`U?GUsR}ss_5fLDc#SihrxvY4Y3ZikLUrEl^0g z4WDE94B)o(9E-ttFGyv}9bT7vBSd4TiB6m)iYS-s9RlId-`x`-m3_)@2!07ByQf_^ z2~^%KJIfL z4YH3=|2;Wpf5T4ic>Sd2J(9_-gbWcw&y97L8&S7$L~heN!tBVNRpHFm<2F1%sAX$1koNL+7b2dwm^Bq^P7;h|8PXTLWF_uRIjlGr3 zL`D#lxZs@0T3Z>lBB8yXFm1@4P>n4G01FpgxzUtw^0q~RfSoLc15RlHZ?qrDqvl?U z<$!X^y=v99dqsL~tnbrwP0|5nO!$_!E>{w00Ysf$(hwI1+xy@8em5qCl&(Z0oNF!5 z1@-}c^tW=QbkZmgLF6Xu3?y*jEYOEVW()}9{^5`7*+sP*AnZcK_lYgS0mIRo?SuY- zNy|i%Lh0=VR|j6Vw#ZZGg*ZO^wAkP{2j>oY+;QUDc{4{kP}T(v!cx9{ShQF* zmUFaSF_yoC`}*m_8=0T%TajX4oZ&FGJl%AJT<*Iait(M?bEG3*5r z*HSExqcjYdeL8g}kZ-UK2ut<{E6=q?u4+E-cNZ*1T?Sp>pcs3!`F7=ud3s#$Hv9po zxT*_0YqScV<~lM?O+UpE*!>QwE9uu>K`lATH~{S3*I`Vki8wC@Zl2kIh#uBDT|3)AW4?`lC6?PU&dLTxFwt6t)vDm7RS~iHBDe_zwFR`8!im%a7fhyZp8#6|Z;PkLZEG9E(Px<+vJP<& z21{!7Xwm*95VCnTFXtm+qZk|AD(3T(DIXbE_|qpl4!{=>`J`sFv!oOw5^?VmCS}7P z_E$owswLZ;_T0_+5$s40d<#7F2ujeDhjUK=<`o>iV95YmOln3VzXA=?>rUnSDEVyw zRvdzTy`P^Zin$nPp^rDohXu2yZ&M9Owq~SJK%6_}vYpiL?d(cTtEtTRToVyDRmE6( zc9A41BgOK(me3$%8B6#kmXkdMw|pT9 zg3(b{q_B(Qb>spIENHLm#sTTMWOBm77)d!pyXSIy=<2eW5L}9Fm&;Xws6)c$*&Tta zC>e)f2hK01et@@h)?BRrY0rV;oL;?;$Yo*n|BUo&280*_eR1$3mfvcq6r&>%T$?!+ zIrJJh)~9|nG9oCTraHq>Ol7$T1HDK;iNR%N0*Dp18Ch~O!@Z;!W?02fP{yUjbBJ$_ z=S3N@a+)8u*Ay!RVw$HR*u8Hv&e;J|?Kd^;IOrWsIXW;i=WNnp563^m*aQp!7z_=w8e~Ce+ z(A4^>pofSEy(}%PLjfZ#Y|&*YfWr@!fRR30Ay z+k3tmLR%SithG@*<}+dwRgMqhX3JfJ&8JrKD)=Y|F^GQq*+m)AW}c9rjfp770O%$Z znlc~X*xSjwp>HBV7VjhdaoEHIir9(nz(3`Ol@F~ACK5r5J292xt)|VMyAd0s*Vyu$ zC00H05ki@B*=OiooExs&jD@>O-SFWrMb&fXdWi=y`oJAxf0nh%?N+O0S3M2G@Odlb zYyh9yZ#$7*RNu z9iGQiN!(+m?ERAJJEF#ZD!n`$f?iDBaT(P4AY^|ii^3)LqSEirA+39~C~;`}Ro#t3 zXU8TL>;REQ;0c&qhnO=Jr+DaE9`CiT5Q+(ax2BT!z!Pux$vZE=rPibgwZf|PBGA5vLC+RRZl>}>QayY=ut zer0vha&BY4H41D*1eeO2$*!8E?AW|0hB&tYeU0-qGsgyJ`ZBaA7ax$aApc5Bbpkm_ z|L<)tA8lMoG=`yg?lE6o;?=k2xByW=zyrEkqbX#)wn*$qQe6{iJRFMYub&1r;y+ob zJ%j(vbx>64kkCEnQ$cy@{Aoxr3{8l45k8EoEmpxZ9MCai(n%ZTjm=bq{6&_*GW^Jz zeu3bUJEV7(Pg6k)lFQRQ0%h>VD4^yL;E#;5HYfX5>$=_ zyX3O_AT4JQe0@{>j_cXlc<#WMKaR=fov@LOszQRuU5S%{G!md9lgrSmkln~f@EK3n zz8)zM6rLOAU=nwR)@;`qKWGkux3?bWA^exDE%+8%R}yMlLKSnhF|#fSK=3e8-TE1Cs<#uE!&5bN;&Zbf&Z^#^jz?zYdW=dg#q=wuwGbS>Cx;D@Q2ey2R?E}&;lc3OsWapa z94--CBg+tp4y{>WpC%%ReD@VO`vsmU;G16)<^a-d%xwFJzm0xxc=j$_{pRwMd53(D_i@l80H2}C>O55P>%nq&_Mppd3` zS-2}Ii77Ogyg6*mxft`5OEdrH)wt_cg>?Uyo&^F6GGrj1v*NcHw3m#5WF*rVvclK?1O(wxClxFxC@s%ad07azv$jpQFT+dcICg>%JC(lk`kBKDCbyXX^?LHRu*(%6rzz>}2&8QZRu~ zhHaOgj6Cz=rUk6(K=!S{lUoBFQSEfMuLh^Zj+)r57+ToXkg3v}ii+1N~Ef%JGjzdGSumzsu2f2euv%c;Yh1?44j|g~L&cbXO%EZ$eP8E*1SO7i zy$({@#oV2;dzb2<11Q(AQ2XKcKtH{p)Pj1e!*Q#Zx0D_9eE2h9QmPA#Tw*Uo{wIhl zta8{>f*#L<)(jG3E+YS$PN1DYSN>BbeP#zE-tFy2CIB%3Xbf$vhvOzGc4`6YKo9UO zT@q8O;#`MQdd4O%21azUw9cW6W_N)%-WRfOS<=Gz^~^@qwN^k1zI^z$BrQK{HX}u$ z9cC$h!NzT*@}P>_7Xs`DqVE3HLn&!+pPzv#D z1*(a@DzGLimwdM(NndH@Y9!-NwY0Tgt7@;Ut<&p?RVd`5Z5m|w?L0gA<0OxZGpvjOBh)OJ@o=XV`edo9H$w&GG2I$8^}CxoauHYXY>seZBD<1uvCZHv(lV?{1r^5wv=tZOeMP?L0Kd<`zbx<1X8Y( zq({XmLXHsDPnCudv>qv=E4e}dx-os^OX%4!maJQ$4=K3~#qB2+P<&j-<2CLiq zr8(JRlslu>b*i1FlQjgpCMrZNf+xNQ7;g0YBKPV6&@b0Mj9GBv_W7LRx~Db_!vDj( zDvAbF<4S;}Dha(IJ5$v;G}fy}CK5;b})hLN>Ts`| z2+EtQ(xI37(CYg)Upqq)iTiEN6%VzL5~ER^K0Bc~%!XobcH8X6bTVlXWNpLlAyn57 z9NILTJMW{tJjY9e9?*(>IA>=OUNeEVK`e5@GlyytwS<{g^GPbz?=+z(p8}-x z44gzPLVUCogER;&K;Q>hoj?6#6##IS)_VNGDpQwT^Zk+dny*%z?E}_EU*NozDbg$I zN;roIK}^TT7Y-UsEVHNc!x~~A{=k8WH~F4eyw936qI0BOR>=~Q_~Ue&{*JI-2@K`# z!aZcb5>8r$%TsZl3EiqTP(f7*iK?biBV|F=B}r)iGrA7JA3I`JY6YsEB|;a2AbtgB z&u9F%WX{z}!9qOAW^h3_Nw~v$#_C*@SR+x0lbQWhwzAkrzZ@ju4E*@_!9M-C8_iws zhbFOC-p#++77osrt;FoLa;$@Pp9R|-jgin5lvC>uMH^u>Vv=b?wwNylV-}J1(EQm9 zxQO_2?~(BY(N!)x)}9S;St41Da2mQopJw0w=B?M_=ECpJ93337L&vUcT}OJonD^NS z>2k4W9_sc;d4{R$qm8oPZr_ABSK&eAD z{a82S8xoppAX}QyR7coVB%AUFu`70x32sg&=R+Fs08!8Or_nU#iFWv8E z@?VLxy2j`9Nn+kkw3lBsf6}$(+JJd#w9C2A_BkxMu2~ps)bWn)FiZdnh;rcMGoIiQ zP3*F}M7)o!MnIE;;9o77Wev>Q<5BYCfMzXE|{y8Q5R7{@tmYlhxo z+>e#$U6aWr3CCUXt0de9`^0A@88_LgOxSEWC|X|u!U zm$hxV0h0_zyd1<{8Q{lX87nN%%_Vx}Yp8fW(K#=PtU-%*gP(FMmw}v3WG$Kv>VTxA zh7*C;VFIu}fVaH#xNRgs#A*CB9cZBi(O1OTT7r}(QZJ**8}HJ@rCg8F1B-DOM7&qg zf3ZiWFZFr{PLWZa0+O0U#9buw`bFCDwOp`4Nt44uhCGXdA&hw0ICGG@!qt~ZM1s7f@fTgM5Sp|a zb{5r%@?V8zoglq|Co$uPO8O~D=9=+{1By%zolC=7O2JV1rCJfMM?8-9j+6=cRMFvW}#Jgw9fE8y8- z2`s_pwezA!4Rr-pkXeWqol&R2PQno%BFuE`fwl6un|`LafPSN%8N>|lui-G#`z*=F z{CI-^wG=Wfv1TX6T-Xf7yBAOj&07J_;}to{%vSTkS^yDIhPJk(&7)HE+bT713;wmP za!TBQ8t$pfhPmqwjOr z4osrag{LG5F4h68(^x4q)PLFbN>f(lEcFyDGAHWe_7Y~z|f0vk7`o9HV~T?WLy>H-v?xQ!D(^v;t}Jn3kJ zx(3kE+UAunS{U*grey7Y)mocr6J|VC-)`R`x`FQ%oEy1iI6^g{~_s3t?;* z^3mI(TO+T$@c@}f|0opTOk8YEZLZWv38x_ays)_!4Pa7;VGSZgCTng18=tNZ&W{pP zQA%O3nmlY_xs8o2+wJ+}`9+ejKb@Re(QuC-Mr7uCp{$MCDrW*!(A_dozug~F3QSLm zTLXV@z7JKY`YR563LFD7hQJXO5jp6ma_yH)gd^eKggk1b-cd9HWl zlDBMxCTLezGDVo?j93F1GQQ;JH?UlIynABi;krtDkEf&k6aEyp{Sn8X_G%vxm=szn z-_g0h*urgUD1_N+(c?)GDMDPn%}BFTI1Ovw_?56SC8PZwkF8ica7pxJm#uK z{vN^qX}q#vmGGPJ2BN_d=F<3S2^>m)?%e+=@h5c;L zSEVF2?Pq#|*3aZksS~=(X!SgSYV>5INZomj5IrV@^Y*@PJnGRiZwF}+0HDgk?S<{H zc7Zg2)!2Wob9t^7rKDQW5q+rBeYe^kg?=omNaV%&-_>s*HH0QY_WAR6B z0Qr`?aj|8$WE=hkvF}p+y$xt-z7t0(P;W1at=uax$}^g z>`FK5IiIRcb|}bIT(QxmOhsK)5l74_r7`PP3k#J}H)@?%U2v4|0EXDht(@`8JI@c9 z8>pk+hcw#bhC39T)m|pUv_LB>Y`-@NPlkQCLe=S1>K>a={3g1{7g)vI6&M+`&d7O% z{6&LmED1FDQctzHUt<@)3nvU2YfmEfGvj_O2a6A+ zSHnaX@s4P9bSF4Rx%Yx^ELkWFX!uy_*W4D(wwOa2G*cL1)`s5C7meyVB0zb*-HvVl z!=v_1F=wt0Ae#Dd!NxGUJ_p)~eq`B!8fgQgBdSmNftxXdWOR0)n|Gw%2(J{7td$*} zL~K?`jnbMI4Eq@CR#9SAghp9DC=Uvt+E}v5J4|MQPmBke&PzbCwgZVSDwbLysQ?7( zs-a!Gsx1pP!`wp&+{MBRmJtgCtL z`4aa-l$Fmb`G96nn?gFz6AUuCOI^KO7H{y|c zALEGxD@`vFZB=j-hILDvYcKBQ4fr*fpkZR<{1oOa`}YlwgSbMm3hM|l@>#3UAC>x% zNz@M(a}F=~e)m)q#PeWBeQy%*0%j32{%LF?;>H@Do{m-uz*}b_glJq{A_yY`6}K{B z4(UhzZVLx5^@?EZa$jpy7PXoy0tX>9U4#$CbkGFm6Mp7;CpF@g%kYI^0=FPG7C7mN zbo>7BnsHzM&ihDOy5Cvn_G~Ji1qVixG$pOi4j=ho&?T7HP77lDyCrwD3MyGhd}6z}jU51y}0*iA04Fqb0b?8<0lEDSP#w2RUiCNiBu5>$5em zlQ2|zB*hj@!uz*Bh9HaDQEC+jxVkj(6(+Y=Qc1E0`&6P{oz+!;d%g!qwE2)dJo;8* z4AsD7q*HCnusi_e<2Ox~YtPO2e_1_$yUQ#e-ChtLU@rN=MbH`>?YouDjF@0P=l z9Ses3njXh;3CUA?L#LhJMEWIv*C|g}1bTAf)%aZMogDZ0E0NG8)l&{K^=QeH*H6$&nsPym52U5nw*I{|(V%jtb&GC$Ysp;wrqK}O$^=|yU- zvtOpJ0+lPJATNoRohZgWv2f^a$fxW6~Z_7 z7kxXeNLyg}=?KIE6sgkYKWd{3tv}7n>Es~pv;MPJpe+2a1b76U{PQ3@!V}M~_-&}Xm!qsy+RY4o-ZW5U!1jLOU{zp*o54a>3kn#>{#{m&f zTGh=Kji6coxzsJGH###`J>NLi*de^54Z(HMAihCng3FY-;+3YY8KrEB=-%!$$>7i< zJHZ%b2+22)A_ro9NNa`9wfp&xE#VZ9u(e%qQ4~~A@4mHD4YXT?2+lj@MysQ*rNY=F z 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

gpM@M!$`cOfY9 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 { fireEvent.press(getByTestId('market-insights-entry-card')); expect(mockPress).toHaveBeenCalledTimes(1); }); - - it('deduplicates source logos by favicon identity', () => { - const report = { - headline: 'BTC consolidates', - summary: 'Mixed macro signals keep price range-bound.', - trends: [{ title: 'Macro' }], - sources: [ - { - name: 'Cointelegraph', - type: 'news', - url: 'https://cointelegraph.com/news/a', - }, - { - name: 'Cointelegraph URL variant', - type: 'news', - url: 'https://cointelegraph.com/news/b', - }, - { - name: 'The Block', - type: 'news', - url: 'https://www.theblock.co/post/123', - }, - ], - }; - - const { UNSAFE_getAllByType } = renderWithProvider( - , - ); - - const sourceIcons = UNSAFE_getAllByType(Image); - // 1 icon is SparkleIcon (SVG/Icon), Image nodes here correspond to source favicons - expect(sourceIcons).toHaveLength(2); - }); }); diff --git a/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.tsx b/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.tsx index a9a415be7af..d2d33dee398 100644 --- a/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.tsx +++ b/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useMemo, useRef } from 'react'; -import { Animated, Pressable } from 'react-native'; +import React, { useEffect } from 'react'; +import { Pressable } from 'react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { Box, @@ -12,49 +12,14 @@ import { IconColor, BoxFlexDirection, BoxAlignItems, - BoxJustifyContent, - FontWeight, } from '@metamask/design-system-react-native'; import { strings } from '../../../../../../locales/i18n'; import type { MarketInsightsEntryCardProps } from './MarketInsightsEntryCard.types'; -import { getUniqueSourcesByFavicon } from '../../utils/marketInsightsFormatting'; import { endTrace, TraceName } from '../../../../../util/trace'; -import SourceLogoGroup from '../SourceLogoGroup'; -const SparkleIcon: React.FC = () => { - const opacity = useRef(new Animated.Value(0.45)).current; - - useEffect(() => { - const animation = Animated.loop( - Animated.sequence([ - Animated.timing(opacity, { - toValue: 1, - duration: 900, - useNativeDriver: true, - }), - Animated.timing(opacity, { - toValue: 0.45, - duration: 900, - useNativeDriver: true, - }), - ]), - { iterations: 3 }, - ); - - animation.start(); - return () => animation.stop(); - }, [opacity]); - - return ( - - - - ); -}; +const SparkleIcon: React.FC = () => ( + +); /** * MarketInsightsEntryCard is the entry point card shown on the token details page. @@ -68,10 +33,6 @@ const MarketInsightsEntryCard: React.FC = ({ testID, }) => { const tw = useTailwind(); - const uniqueSources = useMemo( - () => getUniqueSourcesByFavicon(report.sources ?? []), - [report.sources], - ); useEffect(() => { // End the trace started by the parent (AssetOverviewContent) to measure @@ -94,22 +55,15 @@ const MarketInsightsEntryCard: React.FC = ({ - - - {strings('market_insights.title')} - - - + + {strings('market_insights.title')} + @@ -117,14 +71,12 @@ const MarketInsightsEntryCard: React.FC = ({ {report.summary} - - - + {strings('market_insights.feedback.additional_feedback_label')} @@ -224,13 +223,13 @@ const MarketInsightsFeedbackBottomSheet: React.FC< 'market_insights.feedback.additional_feedback_placeholder', )} style={tw.style( - 'min-h-[96px] rounded-xl border border-muted bg-muted px-3 py-3 text-default', + 'min-h-[96px] rounded-xl border border-muted bg-muted px-3 py-3 text-body-md text-default', )} testID={MarketInsightsSelectorsIDs.FEEDBACK_ADDITIONAL_INPUT} /> {strings('market_insights.feedback.characters_remaining', { diff --git a/app/components/UI/MarketInsights/components/MarketInsightsTrendItem/MarketInsightsTrendItem.tsx b/app/components/UI/MarketInsights/components/MarketInsightsTrendItem/MarketInsightsTrendItem.tsx index 3d61970bc63..320811eff61 100644 --- a/app/components/UI/MarketInsights/components/MarketInsightsTrendItem/MarketInsightsTrendItem.tsx +++ b/app/components/UI/MarketInsights/components/MarketInsightsTrendItem/MarketInsightsTrendItem.tsx @@ -6,7 +6,6 @@ import { BoxAlignItems, BoxFlexDirection, Text, - FontWeight, TextColor, TextVariant, } from '@metamask/design-system-react-native'; @@ -59,11 +58,7 @@ const MarketInsightsTrendItem: React.FC = ({ testID={testID} accessibilityRole={onPress ? 'button' : undefined} > - + {trend.title} diff --git a/app/components/UI/MarketInsights/components/MarketInsightsTrendSourcesBottomSheet.test.tsx b/app/components/UI/MarketInsights/components/MarketInsightsTrendSourcesBottomSheet.test.tsx index d9dd7ac0f94..91fd15b6a00 100644 --- a/app/components/UI/MarketInsights/components/MarketInsightsTrendSourcesBottomSheet.test.tsx +++ b/app/components/UI/MarketInsights/components/MarketInsightsTrendSourcesBottomSheet.test.tsx @@ -49,7 +49,6 @@ describe('MarketInsightsTrendSourcesBottomSheet', () => { isVisible onClose={onClose} onSourcePress={onSourcePress} - trendTitle="Developer debates" articles={ [ { @@ -73,7 +72,6 @@ describe('MarketInsightsTrendSourcesBottomSheet', () => { />, ); - expect(getByText('Developer debates')).toBeOnTheScreen(); expect(getByText('coindesk.com')).toBeOnTheScreen(); expect(getByText('@adam3us')).toBeOnTheScreen(); @@ -83,20 +81,4 @@ describe('MarketInsightsTrendSourcesBottomSheet', () => { fireEvent.press(getByText('@adam3us')); expect(onSourcePress).toHaveBeenCalledWith(tweetUrl); }); - - it('renders safely when hidden and has no callbacks', () => { - const onClose = jest.fn(); - - const { queryByText } = renderWithProvider( - , - ); - - expect(queryByText('Hidden')).toBeOnTheScreen(); - }); }); diff --git a/app/components/UI/MarketInsights/components/MarketInsightsTrendSourcesBottomSheet.tsx b/app/components/UI/MarketInsights/components/MarketInsightsTrendSourcesBottomSheet.tsx index 9033a0b7d44..67563d194a5 100644 --- a/app/components/UI/MarketInsights/components/MarketInsightsTrendSourcesBottomSheet.tsx +++ b/app/components/UI/MarketInsights/components/MarketInsightsTrendSourcesBottomSheet.tsx @@ -32,7 +32,6 @@ import { interface MarketInsightsTrendSourcesBottomSheetProps { isVisible: boolean; onClose: () => void; - trendTitle: string; articles: MarketInsightsArticle[]; tweets?: MarketInsightsTweet[]; onSourcePress?: (url: string) => void; @@ -40,14 +39,7 @@ interface MarketInsightsTrendSourcesBottomSheetProps { const MarketInsightsTrendSourcesBottomSheet: React.FC< MarketInsightsTrendSourcesBottomSheetProps -> = ({ - isVisible, - onClose, - trendTitle, - articles, - tweets = [], - onSourcePress, -}) => { +> = ({ isVisible, onClose, articles, tweets = [], onSourcePress }) => { const tw = useTailwind(); const bottomSheetRef = useRef(null); @@ -81,171 +73,176 @@ const MarketInsightsTrendSourcesBottomSheet: React.FC< - - - {trendTitle} - - - - {articles.map((article) => ( - handleSourcePress(article.url)} - style={({ pressed }) => - tw.style( - 'flex-row items-start py-3 border-b border-muted', - pressed && 'opacity-70', - ) - } - > - - - { + const isLastItem = + index === articles.length - 1 && tweets.length === 0; + return ( + handleSourcePress(article.url)} + style={({ pressed }) => + tw.style( + 'flex-row items-start py-3', + !isLastItem && 'border-b border-muted', + pressed && 'opacity-70', + ) + } + > + + - {article.title} - - - - - - - - + + {article.title} + + + + - + + + - {article.source} - - {article.date ? ( - <> - - {'•'} - - - {formatRelativeTime(article.date, { nowLabel: 'now' })} - - - ) : null} + + {article.source} + + {article.date ? ( + <> + + {'•'} + + + {formatRelativeTime(article.date, { + nowLabel: 'now', + })} + + + ) : null} + - - - ))} + + ); + })} - {tweets.map((tweet) => ( - handleSourcePress(tweet.url)} - style={({ pressed }) => - tw.style( - 'flex-row items-start py-3 border-b border-muted', - pressed && 'opacity-70', - ) - } - > - - - { + const isLastItem = index === tweets.length - 1; + return ( + handleSourcePress(tweet.url)} + style={({ pressed }) => + tw.style( + 'flex-row items-start py-3', + !isLastItem && 'border-b border-muted', + pressed && 'opacity-70', + ) + } + > + + - {tweet.contentSummary} - - - - - - - - + + {tweet.contentSummary} + + + + - + + + - {getNormalizedHandle(tweet.author)} - - {tweet.date ? ( - <> - - {'•'} - - - {formatRelativeTime(tweet.date, { nowLabel: 'now' })} - - - ) : null} + + {getNormalizedHandle(tweet.author)} + + {tweet.date ? ( + <> + + {'•'} + + + {formatRelativeTime(tweet.date, { nowLabel: 'now' })} + + + ) : null} + - - - ))} + + ); + })} ); diff --git a/app/components/UI/MarketInsights/components/MarketInsightsTweetCard/MarketInsightsTweetCard.tsx b/app/components/UI/MarketInsights/components/MarketInsightsTweetCard/MarketInsightsTweetCard.tsx index b74cc6f412a..f248cc2f6fd 100644 --- a/app/components/UI/MarketInsights/components/MarketInsightsTweetCard/MarketInsightsTweetCard.tsx +++ b/app/components/UI/MarketInsights/components/MarketInsightsTweetCard/MarketInsightsTweetCard.tsx @@ -79,7 +79,7 @@ const MarketInsightsTweetCard: React.FC = ({ diff --git a/jest.config.js b/jest.config.js index 9eaa1b8901b..a6b58475fd1 100644 --- a/jest.config.js +++ b/jest.config.js @@ -62,6 +62,8 @@ const config = { moduleNameMapper: { '\\.(svg)$': '/app/__mocks__/svgMock.js', '\\.(png)$': '/app/__mocks__/pngMock.js', + '\\.(mp4)$': '/app/__mocks__/mp4Mock.js', + '^react-native-video$': '/app/__mocks__/react-native-video.tsx', '\\webview/index.html': '/app/__mocks__/htmlMock.ts', '^@expo/vector-icons@expo/vector-icons$': 'react-native-vector-icons', '^@expo/vector-icons/(.*)': 'react-native-vector-icons/$1', diff --git a/locales/languages/en.json b/locales/languages/en.json index 2c5c7978d6c..138d91ba1d1 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -2086,14 +2086,15 @@ "a_closer_look": "A closer look", "whats_being_said": "What's being said", "footer_disclaimer": "AI summary for information only", - "trade_button": "Trade", + "swap_button": "Swap", + "buy_button": "Buy", "sources_count": "+{{count}} sources", "sources_title": "News sources", "feedback_submitted": "Feedback submitted", "helpful_prompt": "Was this helpful?", "feedback": { "title": "Feedback", - "description": "Help improve our AI-generated market insights.", + "description": "Your answer helps improve our AI summaries.", "not_relevant": "Not relevant", "not_accurate": "Not accurate", "hard_to_understand": "Hard to understand", From 81f6bcb2e04f38f5171da21be3ff870b7cd0e6a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20=C5=81ucka?= <5708018+PatrykLucka@users.noreply.github.com> Date: Mon, 16 Mar 2026 18:45:06 +0100 Subject: [PATCH 033/206] fix: use sentence case for remove-network modal header (#27480) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adjusted the remove-network confirmation header shown from `NetworkDetailsView` to sentence case. - Updated `DeleteNetworkModal` header composition to use `strings('app_settings.network')` (lowercase) instead of `strings('asset_details.network')` (title case). - This changes the UI from `Delete Mantle Network` to `Delete Mantle network`. - Added a regression assertion in `NetworkDetailsView.test.tsx` to validate the exact header text shape. ## **Changelog** CHANGELOG entry: Fixed remove network confirmation header casing to sentence case. ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TMCU-533 ## **Manual testing steps** ```gherkin Feature: Remove network confirmation header casing Scenario: User opens remove network confirmation from network details Given the user is editing a custom network in Network Details When the user taps the trash icon to remove the network Then the bottom sheet header displays "Delete network" in sentence case ``` ## **Screenshots/Recordings** ### **Before** image ### **After** Screenshot 2026-03-16 at 14 24 19 ## **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. ## **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.

Open in Web Open in Cursor 
Co-authored-by: Cursor Agent Co-authored-by: Patryk Łucka --- .../NetworkDetailsView/NetworkDetailsView.test.tsx | 7 +++++++ .../NetworksManagement/components/DeleteNetworkModal.tsx | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.test.tsx b/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.test.tsx index ad7f94e5fd4..1ebb0fb6c45 100644 --- a/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.test.tsx +++ b/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.test.tsx @@ -746,6 +746,13 @@ describe('NetworkDetailsView', () => { expect( getByText(strings('app_settings.network_delete')), ).toBeOnTheScreen(); + expect( + getByText( + `${strings('app_settings.delete')} TestNet ${strings( + 'app_settings.network', + )}`, + ), + ).toBeOnTheScreen(); }); it('calls operations.removeNetwork on confirm delete', () => { diff --git a/app/components/Views/NetworksManagement/components/DeleteNetworkModal.tsx b/app/components/Views/NetworksManagement/components/DeleteNetworkModal.tsx index 37a0007887d..5e3374df305 100644 --- a/app/components/Views/NetworksManagement/components/DeleteNetworkModal.tsx +++ b/app/components/Views/NetworksManagement/components/DeleteNetworkModal.tsx @@ -53,7 +53,7 @@ const DeleteNetworkModal = forwardRef( testID={NetworksManagementViewSelectorsIDs.DELETE_MODAL} > - {`${strings('app_settings.delete')} ${networkName} ${strings('asset_details.network')}`} + {`${strings('app_settings.delete')} ${networkName} ${strings('app_settings.network')}`} From fac3ea203e389fe872bab95e5c754e87d4a522ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20=C5=81ucka?= <5708018+PatrykLucka@users.noreply.github.com> Date: Mon, 16 Mar 2026 18:47:29 +0100 Subject: [PATCH 034/206] fix(networks): align network header trash icon color (#27481) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR fixes a UI inconsistency in the custom network details screen. - The header trash icon on `NetworkDetailsView` was using `IconColor.Error` (red). - Other network delete/trash icons in the app use the default icon color. - Updated the header trash icon to use `IconColor.Default` so it aligns with the existing app pattern. - Added a regression unit test to assert the header trash icon uses the default icon color in edit mode. ## **Changelog** CHANGELOG entry: Fixed the custom network header trash icon color to match other trash icons in the app. ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TMCU-534 ## **Manual testing steps** ```gherkin Feature: Custom network delete icon consistency Scenario: user opens custom network details in edit mode Given the user has at least one deletable custom network When user opens that network in the network details screen Then the trash icon in the header is displayed with the standard default icon color (not red) Scenario: delete flow still works Given the user is on the custom network details screen in edit mode When user taps the header trash icon Then the delete confirmation modal is shown ``` ## **Screenshots/Recordings** ### **Before** Screenshot 2026-03-16 at 14 24 11 ### **After** Screenshot 2026-03-16 at 14 22 32 ## **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. ## **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.
Open in Web Open in Cursor 
Co-authored-by: Cursor Agent Co-authored-by: Patryk Łucka --- .../NetworkDetailsView/NetworkDetailsView.test.tsx | 13 +++++++++++++ .../NetworkDetailsView/NetworkDetailsView.tsx | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.test.tsx b/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.test.tsx index 1ebb0fb6c45..29a111f4655 100644 --- a/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.test.tsx +++ b/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; import { strings } from '../../../../../locales/i18n'; +import { IconColor } from '../../../../component-library/components/Icons/Icon'; import { NetworkDetailsViewSelectorsIDs } from './NetworkDetailsView.testIds'; import NetworkDetailsView from './NetworkDetailsView'; @@ -755,6 +756,18 @@ describe('NetworkDetailsView', () => { ).toBeOnTheScreen(); }); + it('renders header trash icon using default icon color', () => { + mockFormHook.mockReturnValue(editForm()); + + const { getByTestId } = render(); + + const trashIcon = getByTestId( + NetworkDetailsViewSelectorsIDs.CONTAINER, + ).findAllByProps({ name: 'Trash' })[0]; + + expect(trashIcon.props.color).toBe(IconColor.Default); + }); + it('calls operations.removeNetwork on confirm delete', () => { const ops = createMockOperations(); mockOperations.mockReturnValue(ops); diff --git a/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.tsx b/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.tsx index d75b4e83141..c0df596a4c7 100644 --- a/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.tsx +++ b/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.tsx @@ -204,7 +204,7 @@ const NetworkDetailsView = () => { ) : undefined From 3bdf001678a7f0b84d9e2796534a9d65cc81797a Mon Sep 17 00:00:00 2001 From: Matthew Grainger <46547583+Matt561@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:54:00 -0400 Subject: [PATCH 035/206] feat: MUSD-508 align earn balance rows with parent asset layout and add privacy mode support (#27457) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Fixes visual inconsistencies in the "Staked Ethereum" (`StakingBalance`) and Stablecoin lending (`EarnLendingBalance`) balance rows so they match the layout of their parent asset rows above them. **Layout fixes (both components):** - Increased asset logo from 32×32 to 40×40 to match parent row - Removed explicit `size={AvatarSize.Lg}` from the network `Badge` to use the default size - Moved percentage change to the right side (as `secondaryBalanceElement` on `AssetElement`) - Moved crypto balance to the left side, directly beneath the asset name **Privacy mode:** - Fiat balance (right, top) is now hidden via `AssetElement`'s `privacyMode` prop - Crypto balance (left, bottom) is now wrapped in `SensitiveText` and hidden when privacy mode is on - Percentage change remains visible in privacy mode (matching parent row behaviour) ## **Changelog** CHANGELOG entry: update earn balance row layout (logo size, badge size, balance/percentage placement) and add privacy mode support for StakingBalance and EarnLendingBalance ## **Related issues** Fixes: [MUSD-508: Earn asset details screen paper cuts](https://consensyssoftware.atlassian.net/browse/MUSD-508) ## **Manual testing steps** ```gherkin Feature: Earn balance row layout and privacy mode Scenario: user views earn balance rows Given user has staked ETH or lent stablecoins When user views the asset details screen Then the earn balance row matches the layout of the parent asset row above it And the asset logo, network badge, crypto balance, fiat balance, and percentage change are correctly positioned Scenario: user enables privacy mode with earn positions Given user has staked ETH or lent stablecoins When user enables privacy mode Then the fiat balance and crypto balance in the earn balance row are masked And the percentage change remains visible ``` ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/4c67b8fc-a89a-4bea-a497-f52fb0d32b65 ## **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. ## **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** > Moderate UI risk: changes how balances/percent change render and switches some buttons to the design-system components, which could affect layout, accessibility, or privacy masking behavior. > > **Overview** > Updates `EarnLendingBalance` and `StakingBalance` to match the parent asset-row layout by moving **percentage change** into `AssetElement`’s `secondaryBalanceElement` (right side) and displaying the **token amount** beneath the asset name (left side), along with associated style tweaks (spacing/alignment and larger 40×40 logos for staking). > > Adds **privacy mode support** to these rows by passing `privacyMode` into `AssetElement` (masking fiat) and wrapping the token amount in `SensitiveText` (masking crypto), while keeping percentage change visible. > > Standardizes several action buttons (`EarnLendingBalance`, `StakingButtons`) to `@metamask/design-system-react-native` and adjusts sizing (`Md`), updates `EarningsHistoryButton` button size, and refreshes Jest snapshots accordingly (plus adds bottom padding in `StakingEarnings`). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 1e2bf3bb575dc1f127bcfc8c8141b0a529d116e1. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../EarnLendingBalance.styles.ts | 19 +- .../EarnLendingBalance.test.tsx.snap | 396 ++++++++---- .../components/EarnLendingBalance/index.tsx | 50 +- .../EarningsHistoryButton.tsx | 2 + .../StakingBalance/StakingBalance.styles.ts | 22 +- .../StakingBalance/StakingBalance.tsx | 22 +- .../StakingButtons/StakingButtons.tsx | 33 +- .../StakingBalance.test.tsx.snap | 602 ++++++++++++------ .../StakingEarnings.styles.tsx | 3 + .../StakingEarnings.test.tsx.snap | 16 +- .../components/StakingEarnings/index.tsx | 2 +- 11 files changed, 778 insertions(+), 389 deletions(-) diff --git a/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.styles.ts b/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.styles.ts index 0f2ebbc2001..6dbe4ccb2b5 100644 --- a/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.styles.ts +++ b/app/components/UI/Earn/components/EarnLendingBalance/EarnLendingBalance.styles.ts @@ -1,4 +1,4 @@ -import { StyleSheet } from 'react-native'; +import { StyleSheet, TextStyle } from 'react-native'; import { Theme } from '../../../../../util/theme/models'; const styleSheet = (params: { @@ -16,10 +16,7 @@ const styleSheet = (params: { gap: 16, }, buttonsContainer: { - marginTop: 16, - padding: 16, borderRadius: 12, - backgroundColor: theme.colors.background.section, }, button: { flex: 1, @@ -29,10 +26,15 @@ const styleSheet = (params: { }, balances: { flex: 1, - justifyContent: 'center', - marginLeft: 16, - alignSelf: 'center', - }, + flexDirection: 'column', + alignItems: 'flex-start', + alignContent: 'flex-start', + paddingLeft: 16, + }, + tokenAmount: { + ...theme.typography.sBodySM, + color: theme.colors.text.alternative, + } as TextStyle, musdConversionCta: { paddingTop: 16, paddingBottom: userHasLendingPositions ? 8 : 0, @@ -41,7 +43,6 @@ const styleSheet = (params: { paddingTop: 16, }, earnings: { - paddingHorizontal: 16, paddingTop: 16, }, }); diff --git a/app/components/UI/Earn/components/EarnLendingBalance/__snapshots__/EarnLendingBalance.test.tsx.snap b/app/components/UI/Earn/components/EarnLendingBalance/__snapshots__/EarnLendingBalance.test.tsx.snap index 32786f9cd2d..ed3a2a3a78b 100644 --- a/app/components/UI/Earn/components/EarnLendingBalance/__snapshots__/EarnLendingBalance.test.tsx.snap +++ b/app/components/UI/Earn/components/EarnLendingBalance/__snapshots__/EarnLendingBalance.test.tsx.snap @@ -12,59 +12,102 @@ exports[`EarnLendingBalance does renders earnings for output tokens 1`] = ` "paddingTop": 14, }, { - "backgroundColor": "#f3f3f4", "borderRadius": 12, - "marginTop": 16, - "padding": 16, }, ] } > - - - Withdraw - - + + Withdraw + + + @@ -420,22 +464,21 @@ exports[`EarnLendingBalance renders balance and buttons when user has lending po > ADAI
- - - +5.20% - - + } + > + 32.05 ADAI + $76.00 - + - 32.05 ADAI + +5.20% - + - - - Withdraw - - - + Withdraw + + + + - - Deposit more - - + + Deposit more + + + `; diff --git a/app/components/UI/Earn/components/EarnLendingBalance/index.tsx b/app/components/UI/Earn/components/EarnLendingBalance/index.tsx index 2caae94baf7..6e6df00857c 100644 --- a/app/components/UI/Earn/components/EarnLendingBalance/index.tsx +++ b/app/components/UI/Earn/components/EarnLendingBalance/index.tsx @@ -14,10 +14,9 @@ import Badge, { import BadgeWrapper, { BadgePosition, } from '../../../../../component-library/components/Badges/BadgeWrapper'; -import Button, { - ButtonSize, - ButtonVariants, -} from '../../../../../component-library/components/Buttons/Button'; +import SensitiveText, { + SensitiveTextLength, +} from '../../../../../component-library/components/Texts/SensitiveText'; import Text, { TextVariant, } from '../../../../../component-library/components/Texts/Text'; @@ -26,6 +25,7 @@ import Engine from '../../../../../core/Engine'; import { RootState } from '../../../../../reducers'; import { earnSelectors } from '../../../../../selectors/earnController'; import { selectNetworkConfigurationByChainId } from '../../../../../selectors/networkController'; +import { selectPrivacyMode } from '../../../../../selectors/preferencesController'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { useStyles } from '../../../../hooks/useStyles'; @@ -44,6 +44,12 @@ import { trace, TraceName } from '../../../../../util/trace'; import MusdConversionAssetOverviewCta from '../Musd/MusdConversionAssetOverviewCta'; import useStakingEligibility from '../../../Stake/hooks/useStakingEligibility'; import { useMusdCtaVisibility } from '../../hooks/useMusdCtaVisibility'; +import { + Button, + ButtonVariant, + ButtonSize, + Text as DesignSystemText, +} from '@metamask/design-system-react-native'; export const EARN_LENDING_BALANCE_TEST_IDS = { RECEIPT_TOKEN_BALANCE_ASSET_LOGO: 'receipt-token-balance-asset-logo', @@ -74,6 +80,7 @@ const EarnLendingBalance = ({ asset }: EarnLendingBalanceProps) => { const isStablecoinLendingEnabled = useSelector( selectStablecoinLendingEnabledFlag, ); + const privacyMode = useSelector(selectPrivacyMode); const navigation = useNavigation(); @@ -236,7 +243,11 @@ const EarnLendingBalance = ({ asset }: EarnLendingBalanceProps) => { + } > { > {receiptToken.name} - + + {receiptToken.balanceFormatted} + )} @@ -275,25 +293,29 @@ const EarnLendingBalance = ({ asset }: EarnLendingBalanceProps) => { {Boolean(receiptToken) && ( )} {userHasUnderlyingTokensAvailableToLend && !isAssetReceiptToken && isEligible && ( )} )} diff --git a/app/components/UI/Earn/components/Earnings/EarningsHistoryButton/EarningsHistoryButton.tsx b/app/components/UI/Earn/components/Earnings/EarningsHistoryButton/EarningsHistoryButton.tsx index 80f3e8bdcdc..1d686c4c61b 100644 --- a/app/components/UI/Earn/components/Earnings/EarningsHistoryButton/EarningsHistoryButton.tsx +++ b/app/components/UI/Earn/components/Earnings/EarningsHistoryButton/EarningsHistoryButton.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { View } from 'react-native'; import { strings } from '../../../../../../../locales/i18n'; import Button, { + ButtonSize, ButtonVariants, ButtonWidthTypes, } from '../../../../../../component-library/components/Buttons/Button'; @@ -37,6 +38,7 @@ const EarningsHistoryButton = ({ asset }: EarningsHistoryButtonProps) => { testID={WalletViewSelectorsIDs.EARN_EARNINGS_HISTORY_BUTTON} width={ButtonWidthTypes.Full} variant={ButtonVariants.Secondary} + size={ButtonSize.Md} label={ outputToken?.experience?.type === EARN_EXPERIENCES.STABLECOIN_LENDING ? strings('earn.view_earnings_history.lending') diff --git a/app/components/UI/Stake/components/StakingBalance/StakingBalance.styles.ts b/app/components/UI/Stake/components/StakingBalance/StakingBalance.styles.ts index 2084372cce1..9f57fb6c855 100644 --- a/app/components/UI/Stake/components/StakingBalance/StakingBalance.styles.ts +++ b/app/components/UI/Stake/components/StakingBalance/StakingBalance.styles.ts @@ -1,16 +1,13 @@ -import { StyleSheet } from 'react-native'; +import { StyleSheet, TextStyle } from 'react-native'; import { Theme } from '../../../../../util/theme/models'; const styleSheet = (params: { theme: Theme }) => StyleSheet.create({ container: { marginTop: 16, - padding: 16, borderRadius: 12, - backgroundColor: params.theme.colors.background.section, }, stakingEarnings: { - paddingHorizontal: 16, paddingTop: 16, }, badgeWrapper: { @@ -18,16 +15,21 @@ const styleSheet = (params: { theme: Theme }) => }, balances: { flex: 1, - justifyContent: 'center', - marginLeft: 16, - alignSelf: 'center', + flexDirection: 'column', + alignItems: 'flex-start', + alignContent: 'flex-start', + paddingLeft: 16, }, ethLogo: { - width: 32, - height: 32, - borderRadius: 16, + width: 40, + height: 40, + borderRadius: 20, overflow: 'hidden', }, + tokenAmount: { + ...params.theme.typography.sBodySM, + color: params.theme.colors.text.alternative, + } as TextStyle, bannerStyles: { marginVertical: 8, }, diff --git a/app/components/UI/Stake/components/StakingBalance/StakingBalance.tsx b/app/components/UI/Stake/components/StakingBalance/StakingBalance.tsx index 6eb4fe4a66e..008d9dabc6e 100644 --- a/app/components/UI/Stake/components/StakingBalance/StakingBalance.tsx +++ b/app/components/UI/Stake/components/StakingBalance/StakingBalance.tsx @@ -12,12 +12,16 @@ import Badge, { import BadgeWrapper, { BadgePosition, } from '../../../../../component-library/components/Badges/BadgeWrapper'; +import SensitiveText, { + SensitiveTextLength, +} from '../../../../../component-library/components/Texts/SensitiveText'; import Text, { TextVariant, } from '../../../../../component-library/components/Texts/Text'; import { useStyles } from '../../../../../component-library/hooks'; import { RootState } from '../../../../../reducers'; import { selectNetworkConfigurationByChainId } from '../../../../../selectors/networkController'; +import { selectPrivacyMode } from '../../../../../selectors/preferencesController'; import { getTimeDifferenceFromNow } from '../../../../../util/date'; import { getDecimalChainId } from '../../../../../util/networks'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; @@ -68,6 +72,7 @@ const StakingBalanceContent = ({ asset }: StakingBalanceProps) => { ); const isPooledStakingEnabled = useSelector(selectPooledStakingEnabledFlag); + const privacyMode = useSelector(selectPrivacyMode); const { styles } = useStyles(styleSheet, { theme }); @@ -211,8 +216,12 @@ const StakingBalanceContent = ({ asset }: StakingBalanceProps) => { {hasEthToUnstake && !isLoadingPooledStakesData && ( + } > { {strings('stake.staked_ethereum')} - - - + + {stakedBalanceETH} + )} diff --git a/app/components/UI/Stake/components/StakingBalance/StakingButtons/StakingButtons.tsx b/app/components/UI/Stake/components/StakingBalance/StakingButtons/StakingButtons.tsx index 835fff6f656..a3b0e2ca419 100644 --- a/app/components/UI/Stake/components/StakingBalance/StakingButtons/StakingButtons.tsx +++ b/app/components/UI/Stake/components/StakingBalance/StakingButtons/StakingButtons.tsx @@ -3,9 +3,6 @@ import React from 'react'; import { View, ViewProps } from 'react-native'; import { useSelector } from 'react-redux'; import { strings } from '../../../../../../../locales/i18n'; -import Button, { - ButtonVariants, -} from '../../../../../../component-library/components/Buttons/Button'; import { useStyles } from '../../../../../../component-library/hooks'; import Routes from '../../../../../../constants/navigation/Routes'; import Engine from '../../../../../../core/Engine'; @@ -21,6 +18,12 @@ import useStakingChain from '../../../hooks/useStakingChain'; import styleSheet from './StakingButtons.styles'; import { trace, TraceName } from '../../../../../../util/trace'; import useStakingEligibility from '../../../hooks/useStakingEligibility'; +import { + Button, + ButtonSize, + ButtonVariant, + Text, +} from '@metamask/design-system-react-native'; interface StakingButtonsProps extends Pick { asset: TokenI; @@ -106,23 +109,27 @@ const StakingButtons = ({ )} {isPooledStakingEnabled && isEligible && ( )} ); diff --git a/app/components/UI/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap b/app/components/UI/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap index 661e248b588..8f9ae540fe1 100644 --- a/app/components/UI/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap +++ b/app/components/UI/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap @@ -49,10 +49,10 @@ exports[`StakingBalance render matches snapshot 1`] = ` false, false, { - "borderRadius": 16, - "height": 32, + "borderRadius": 20, + "height": 40, "overflow": "hidden", - "width": 32, + "width": 40, }, ] } @@ -75,10 +75,10 @@ exports[`StakingBalance render matches snapshot 1`] = ` style={ [ { - "borderRadius": 16, - "height": 32, + "borderRadius": 20, + "height": 40, "overflow": "hidden", - "width": 32, + "width": 40, }, { "backgroundColor": "#eee", @@ -172,10 +172,11 @@ exports[`StakingBalance render matches snapshot 1`] = ` @@ -198,31 +199,15 @@ exports[`StakingBalance render matches snapshot 1`] = ` accessibilityRole="text" style={ { - "color": "#131416", + "color": "#66676a", "fontFamily": "Geist-Regular", - "fontSize": 16, + "fontSize": 14, + "fontWeight": "400", "letterSpacing": 0, - "lineHeight": 24, + "lineHeight": 22, } } - > - - - +0.00% - - - + /> + > + + + +0.00% + + + @@ -414,92 +414,185 @@ exports[`StakingBalance render matches snapshot 1`] = ` ] } > - - - Unstake - - - + Unstake + + + + - - Stake more - - + + Stake more + + + Your earnings - + @@ -991,31 +1091,15 @@ exports[`StakingBalance should match the snapshot 1`] = ` accessibilityRole="text" style={ { - "color": "#131416", + "color": "#66676a", "fontFamily": "Geist-Regular", - "fontSize": 16, + "fontSize": 14, + "fontWeight": "400", "letterSpacing": 0, - "lineHeight": 24, + "lineHeight": 22, } } - > - - - +0.00% - - - + /> + > + + + +0.00% + + + @@ -1207,92 +1306,185 @@ exports[`StakingBalance should match the snapshot 1`] = ` ] } > - - - Unstake - - - + Unstake + + + + - - Stake more - - + + Stake more + + + Your earnings - + { keyValueSecondaryText: { alignItems: 'flex-end', }, + stakingEarningsContent: { + paddingBottom: 24, + }, }); }; diff --git a/app/components/UI/Stake/components/StakingEarnings/__snapshots__/StakingEarnings.test.tsx.snap b/app/components/UI/Stake/components/StakingEarnings/__snapshots__/StakingEarnings.test.tsx.snap index e6e495cb298..b4f506a35ca 100644 --- a/app/components/UI/Stake/components/StakingEarnings/__snapshots__/StakingEarnings.test.tsx.snap +++ b/app/components/UI/Stake/components/StakingEarnings/__snapshots__/StakingEarnings.test.tsx.snap @@ -23,7 +23,13 @@ exports[`Staking Earnings displays pooled-staking maintenance banner when featur > Your earnings - + Your earnings - + { {strings('stake.your_earnings')} - + {isPooledStakingServiceInterruptionBannerEnabled && ( )} From 7e1cc5346a712d079ef379f3fc1845995684ffbc Mon Sep 17 00:00:00 2001 From: cmd-ob Date: Mon, 16 Mar 2026 17:57:17 +0000 Subject: [PATCH 036/206] feat: add risk label to PRs from Smart E2E selection output (#27474) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** - Surfaces the `riskLevel` output (`low` / `medium` / `high`) from the Smart E2E AI analyzer as a GitHub Actions output (`ai_risk_level`) on the `smart-e2e-selection` action - Adds a new standalone script (`e2e-risk-label.mjs`) that applies a `risk-low`, `risk-medium`, or `risk-high` label to the PR based on that output - Stale risk labels are removed before the new one is applied, so re-runs always reflect the latest assessment - When `force_run=true` (i.e. `skip-smart-e2e-selection` label is present), risk is pinned to `high` - Adds `issues: write` permission to the `smart-e2e-selection` CI job to allow repo-level label creation ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [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. ## **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** > Medium risk because it changes CI workflow permissions and adds GitHub API automation that can fail or mislabel PRs, affecting the PR workflow but not production code. > > **Overview** > Surfaces the AI analyzer’s `riskLevel` as a new `ai_risk_level` output from the `smart-e2e-selection` composite action (with `force_run=true` overriding the value to `high`). > > Adds a new script, `e2e-risk-label.mjs`, that ensures `risk-low`/`risk-medium`/`risk-high` labels exist, removes any stale risk label from the PR, and applies the current one. > > Updates the `ci.yml` `smart-e2e-selection` job to grant `issues: write` and to sparse-checkout the new script so the job can create and manage labels. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 0fd7dbf603dbd279c464ad8b1458e9b2948a9609. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../actions/smart-e2e-selection/action.yml | 23 ++++ .github/scripts/e2e-risk-label.mjs | 110 ++++++++++++++++++ .github/scripts/e2e-smart-selection.mjs | 3 +- .github/workflows/ci.yml | 2 + 4 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 .github/scripts/e2e-risk-label.mjs diff --git a/.github/actions/smart-e2e-selection/action.yml b/.github/actions/smart-e2e-selection/action.yml index 16803ae5be5..4af12a52ae0 100644 --- a/.github/actions/smart-e2e-selection/action.yml +++ b/.github/actions/smart-e2e-selection/action.yml @@ -41,6 +41,9 @@ outputs: ai_confidence: description: 'AI confidence score (0-100)' value: ${{ steps.final-outputs.outputs.ai_confidence }} + ai_risk_level: + description: 'Risk level of the PR (low, medium, high) — indicates testing need and bug introduction likelihood' + value: ${{ steps.final-outputs.outputs.ai_risk_level }} ai_performance_test_tags: description: 'Performance test tags to run (JSON array format, empty [] means no performance tests)' value: ${{ steps.final-outputs.outputs.ai_performance_test_tags }} @@ -188,6 +191,15 @@ runs: else echo "force_run=false" >> "$GITHUB_OUTPUT" fi + # Risk level: force_run → always high; otherwise use AI output + AI_RISK='${{ steps.ai-analysis.outputs.ai_risk_level }}' + if [[ "$FORCE_RUN" == "true" ]]; then + echo "ai_risk_level=high" >> "$GITHUB_OUTPUT" + elif [[ -n "$AI_RISK" ]]; then + echo "ai_risk_level=$AI_RISK" >> "$GITHUB_OUTPUT" + else + echo "ai_risk_level=" >> "$GITHUB_OUTPUT" + fi - name: Display AI Analysis Outputs if: always() @@ -197,6 +209,7 @@ runs: echo "================================" echo "ai_e2e_test_tags: ${{ steps.final-outputs.outputs.ai_e2e_test_tags }}" echo "ai_confidence: ${{ steps.final-outputs.outputs.ai_confidence }}" + echo "ai_risk_level: ${{ steps.final-outputs.outputs.ai_risk_level }}" echo "ai_performance_test_tags: ${{ steps.final-outputs.outputs.ai_performance_test_tags }}" echo "force_run: ${{ steps.final-outputs.outputs.force_run }}" echo "================================" @@ -231,6 +244,16 @@ runs: echo "📝 No Smart E2E selection comments found" fi + - name: Apply risk label to PR + if: inputs.pr-number != '' && inputs.github-token != '' + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + GITHUB_REPOSITORY: ${{ inputs.repository }} + PR_NUMBER: ${{ inputs.pr-number }} + RISK_LEVEL: ${{ steps.final-outputs.outputs.ai_risk_level }} + run: node .github/scripts/e2e-risk-label.mjs + - name: Create PR comment if: inputs.post-comment == 'true' && inputs.pr-number != '' && inputs.github-token != '' shell: bash diff --git a/.github/scripts/e2e-risk-label.mjs b/.github/scripts/e2e-risk-label.mjs new file mode 100644 index 00000000000..158f0a2325d --- /dev/null +++ b/.github/scripts/e2e-risk-label.mjs @@ -0,0 +1,110 @@ +#!/usr/bin/env node + +/** + * Applies a risk label (risk-low / risk-medium / risk-high) to a PR based on + * the risk level output from the Smart E2E selection step. + * + * Required environment variables: + * RISK_LEVEL - 'low' | 'medium' | 'high' + * GH_TOKEN - GitHub token with pull-requests:write and issues:write + * GITHUB_REPOSITORY - owner/repo + * PR_NUMBER - pull request number + */ + +const RISK_LEVEL = process.env.RISK_LEVEL || ''; +const GH_TOKEN = process.env.GH_TOKEN || ''; +const GITHUB_REPOSITORY = process.env.GITHUB_REPOSITORY || ''; +const PR_NUMBER = process.env.PR_NUMBER || ''; + +const RISK_LABELS = { + low: { + color: '0E8A16', + description: 'Low testing needed · Low bug introduction risk', + }, + medium: { + color: 'FBCA04', + description: 'Moderate testing recommended · Possible bug introduction risk', + }, + high: { + color: 'B60205', + description: 'Extensive testing required · High bug introduction risk', + }, +}; + +async function githubApi(path, options = {}) { + const res = await fetch(`https://api.github.com${path}`, { + ...options, + headers: { + Authorization: `Bearer ${GH_TOKEN}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + return res; +} + +async function main() { + if (!RISK_LEVEL) { + console.log('⏭️ No risk level provided, skipping label'); + return; + } + + if (!RISK_LABELS[RISK_LEVEL]) { + console.error(`❌ Unknown risk level: "${RISK_LEVEL}"`); + process.exit(1); + } + + if (!GH_TOKEN || !GITHUB_REPOSITORY || !PR_NUMBER) { + console.error('❌ Missing required env: GH_TOKEN, GITHUB_REPOSITORY, PR_NUMBER'); + process.exit(1); + } + + // Ensure all three risk labels exist on the repo (idempotent — 422 = already exists) + for (const [level, meta] of Object.entries(RISK_LABELS)) { + await githubApi(`/repos/${GITHUB_REPOSITORY}/labels`, { + method: 'POST', + body: JSON.stringify({ name: `risk-${level}`, color: meta.color, description: meta.description }), + }); + } + + // Fetch current PR labels + const labelsRes = await githubApi(`/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/labels`); + if (!labelsRes.ok) { + const body = await labelsRes.text(); + console.error(`❌ Failed to fetch PR labels: ${labelsRes.status} ${body}`); + process.exit(1); + } + const currentLabels = await labelsRes.json(); + + // Remove stale risk labels + for (const label of currentLabels) { + if (Object.keys(RISK_LABELS).map(l => `risk-${l}`).includes(label.name)) { + await githubApi(`/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/labels/${encodeURIComponent(label.name)}`, { + method: 'DELETE', + }); + console.log(`🗑️ Removed stale label: ${label.name}`); + } + } + + // Add the new risk label + const newLabel = `risk-${RISK_LEVEL}`; + const addRes = await githubApi(`/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/labels`, { + method: 'POST', + body: JSON.stringify({ labels: [newLabel] }), + }); + + if (!addRes.ok) { + const body = await addRes.text(); + console.error(`❌ Failed to add label "${newLabel}": ${addRes.status} ${body}`); + process.exit(1); + } + + console.log(`✅ Applied risk label: ${newLabel}`); +} + +main().catch(error => { + console.error('❌ Unexpected error:', error); + process.exit(1); +}); diff --git a/.github/scripts/e2e-smart-selection.mjs b/.github/scripts/e2e-smart-selection.mjs index 33c0b2bc205..e6c1ea1736d 100644 --- a/.github/scripts/e2e-smart-selection.mjs +++ b/.github/scripts/e2e-smart-selection.mjs @@ -62,9 +62,10 @@ function generatePRComment(summaryContent) { } function setGitHubOutputs(analysis) { - const { tags, confidence, performanceTests } = analysis; + const { tags, confidence, riskLevel, performanceTests } = analysis; setGithubOutputs('ai_e2e_test_tags', tags); setGithubOutputs('ai_confidence', confidence); + setGithubOutputs('ai_risk_level', riskLevel); // Performance test tags (empty array means no performance tests needed) setGithubOutputs('ai_performance_test_tags', JSON.stringify(performanceTests.selectedTags)); } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7e34932157..661be2d0bfd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -408,6 +408,7 @@ jobs: continue-on-error: true permissions: contents: read + issues: write pull-requests: write outputs: ai_e2e_test_tags: ${{ steps.e2e-selection.outputs.ai_e2e_test_tags }} @@ -419,6 +420,7 @@ jobs: with: sparse-checkout: | .github/actions/smart-e2e-selection + .github/scripts/e2e-risk-label.mjs sparse-checkout-cone-mode: false fetch-depth: 1 From af384ae5fd3c75a06f6111ea3a79b8da280f7ec4 Mon Sep 17 00:00:00 2001 From: Juanmi <95381763+juanmigdr@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:07:26 +0100 Subject: [PATCH 037/206] chore: correct token price formatting in wallet token list cp-7.69.1 cp-7.70.0 (#27485) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Two visual bugs were affecting the token price display in the wallet token list (`TokenListItemV2`) and the explore/trending page. **Bug 1 — Missing thousand-separator commas** The token price in the second row of each list item (e.g. `$2283.65` for ETH) was formatted with `addCurrencySymbol`, which constructs the string via a plain `.toString()` call and never applies locale-aware number formatting. This meant that prices ≥ $1,000 rendered without commas (e.g. `$2283.65` instead of `$2,283.65`). Fixed by switching to `formatPriceWithSubscriptNotation`, the same formatter already used on the asset-overview/price chart page, which uses `Intl.NumberFormat('en-US', …)` and produces properly comma-separated output. **Bug 2 — Too many decimal places for large prices** `formatPriceWithSubscriptNotation` was configured with `maximumFractionDigits: 4` unconditionally, so a price like `$2,285.013` (3 significant decimal digits within the allowed 2–4 range) rendered incorrectly. This also affected the explore/trending token row. Fixed by scoping `maximumFractionDigits` to the magnitude of the price: 2 decimals for values ≥ 1, 4 decimals for values < 1 (where extra precision matters, e.g. `$0.1446`). Subscript notation for very small values is unaffected. **Bug 3 — Unintentional bold weight on fiat balance** The fiat balance text variant was `BodyMDBold` instead of `BodyMDMedium`, making the balance appear heavier than intended. The full description has been saved to `.agent/chore-remove-bold-and-add-commas-list-item-v2.PR-desc.md`. ## **Changelog** CHANGELOG entry: fixed token prices in the wallet list displaying without thousand-separator commas and with too many decimal places ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-2909 & https://consensyssoftware.atlassian.net/browse/ASSETS-2921 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** image image ### **After** Look at the title and the USD amount. As you can see it is not bold anymore: image Look at the decimals in Ethereum on Mainnet: image ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Adjusts a shared price formatter and switches wallet token list rendering to it, which could change how fiat prices appear across multiple screens. Risk is limited to display/formatting (no transaction or balance calculations). > > **Overview** > Fixes token price display in the wallet token list by replacing `addCurrencySymbol` with `formatPriceWithSubscriptNotation`, restoring locale-aware formatting (e.g., thousand separators) and consistent currency symbol/suffix handling. > > Updates `formatPriceWithSubscriptNotation` to **cap decimals at 2 for values >= 1** while keeping up to 4 decimals (and subscript notation) for smaller values, and adds unit tests to cover the new truncation behavior. Also tweaks the fiat balance text style in `TokenListItemV2` from bold to a medium weight. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 18f74fe5b4279af2e31b33336972905096b9f6c1. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/components/UI/Predict/utils/format.test.ts | 16 ++++++++++++++++ app/components/UI/Predict/utils/format.ts | 8 +++++--- .../TokenListItemV2/TokenListItemV2.tsx | 8 +++----- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/app/components/UI/Predict/utils/format.test.ts b/app/components/UI/Predict/utils/format.test.ts index 483a3941a7f..af58869975c 100644 --- a/app/components/UI/Predict/utils/format.test.ts +++ b/app/components/UI/Predict/utils/format.test.ts @@ -1618,6 +1618,22 @@ describe('format utils', () => { // Assert expect(result).toBe('$0.5678'); }); + + it('truncates to 2 decimals for prices >= 1 with extra decimal places', () => { + // Arrange & Act + const result = formatPriceWithSubscriptNotation(2285.013); + + // Assert + expect(result).toBe('$2,285.01'); + }); + + it('truncates to 2 decimals for prices >= 1 with 4 decimal places', () => { + // Arrange & Act + const result = formatPriceWithSubscriptNotation(1.2345); + + // Assert + expect(result).toBe('$1.23'); + }); }); describe('Zero value', () => { diff --git a/app/components/UI/Predict/utils/format.ts b/app/components/UI/Predict/utils/format.ts index 45122edcc98..13797d2890d 100644 --- a/app/components/UI/Predict/utils/format.ts +++ b/app/components/UI/Predict/utils/format.ts @@ -114,15 +114,17 @@ export const formatPrice = ( * - Uses subscript notation for values with 4+ leading zeros (e.g., 0.00000614 → $0.0₅614) * - The subscript indicates the number of leading zeros after the decimal point * - Returns "—" for zero values - * - Uses min 2, max 4 decimal places for regular values + * - Values >= 1: exactly 2 decimal places (e.g. $2,285.01) + * - Values < 1: up to 4 decimal places (e.g. $0.1446) * @param price - The price value to format (string or number) * @param currencyCode - ISO 4217 currency code (e.g. 'USD', 'EUR'). Defaults to 'USD'. * @returns Formatted price string with currency symbol or "—" for zero + * @example formatPriceWithSubscriptNotation(2285.013) => "$2,285.01" * @example formatPriceWithSubscriptNotation(1.99) => "$1.99" * @example formatPriceWithSubscriptNotation(0.144566) => "$0.1446" * @example formatPriceWithSubscriptNotation(0.00000614) => "$0.0₅614" * @example formatPriceWithSubscriptNotation(0) => "—" - * @example formatPriceWithSubscriptNotation(1.2345, 'EUR') => "€1.2345" + * @example formatPriceWithSubscriptNotation(1.2345, 'EUR') => "€1.23" */ export const formatPriceWithSubscriptNotation = ( price: string | number, @@ -151,7 +153,7 @@ export const formatPriceWithSubscriptNotation = ( const formattedNumber = new Intl.NumberFormat('en-US', { style: 'decimal', minimumFractionDigits: 2, - maximumFractionDigits: 4, + maximumFractionDigits: num >= 1 ? 2 : 4, }).format(num); return addSymbol(formattedNumber); }; diff --git a/app/components/UI/Tokens/TokenList/TokenListItemV2/TokenListItemV2.tsx b/app/components/UI/Tokens/TokenList/TokenListItemV2/TokenListItemV2.tsx index 61196afe365..ec354395b0f 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItemV2/TokenListItemV2.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItemV2/TokenListItemV2.tsx @@ -72,7 +72,7 @@ import { } from '../../../../../selectors/networkController'; import { selectShowFiatInTestnets } from '../../../../../selectors/settings'; import { getNativeTokenAddress } from '@metamask/assets-controllers'; -import { addCurrencySymbol } from '../../../../../util/number'; +import { formatPriceWithSubscriptNotation } from '../../../Predict/utils/format'; import { safeToChecksumAddress } from '../../../../../util/address'; import generateTestId from '../../../../../../wdio/utils/generateTestId'; import { getAssetTestId } from '../../../../../../wdio/screen-objects/testIDs/Screens/WalletView.testIds'; @@ -631,7 +631,7 @@ export const TokenListItemV2 = React.memo( asset.balanceFiat === TOKEN_RATE_UNDEFINED || hideFiatForTestnet ? CLTextVariant.BodySM - : CLTextVariant.BodyMDBold + : CLTextVariant.BodyMDMedium } isHidden={privacyMode} length={SensitiveTextLength.Medium} @@ -662,11 +662,9 @@ export const TokenListItemV2 = React.memo( twClassName="uppercase" > {tokenPriceInFiat && !hideFiatForScamWarning - ? addCurrencySymbol( + ? formatPriceWithSubscriptNotation( tokenPriceInFiat, currentCurrency, - true, - true, ) : '-'} {' \u2022 '} From 91f809cc6e10df9cb85f84c2681f021dacef2b98 Mon Sep 17 00:00:00 2001 From: Alexey Kureev Date: Mon, 16 Mar 2026 21:22:46 +0100 Subject: [PATCH 038/206] refactor(predict): migrate usePredictPriceHistory to React Query (#26876) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Migrate `usePredictPriceHistory` hook from manual state management to React Query (`useQueries`) - Extract query key factory and query options into `queries/priceHistory.ts` - Create one query per market ID for independent caching and refetch of each market's price history ## Test plan - [x] Unit tests updated and passing - [ ] Verify price history charts render correctly on market detail screens - [ ] Verify interval switching (1D, 1W, 1M, etc.) refetches data properly --- > [!NOTE] > **Medium Risk** > Migrates a data-fetching hook from manual state/effects to React Query, changing caching/refetch behavior and error reporting; regressions would impact Predict chart loading across market detail views. > > **Overview** > **Migrates `usePredictPriceHistory` to React Query.** The hook now uses `useQueries` (one query per `marketId`) and derives `priceHistories`, `isFetching`, and `errors` from query results, while keeping the same outward API and gating outputs when `enabled` is false. > > **Adds shared query definitions for price history.** Introduces `queries/priceHistory.ts` (query keys + `queryOptions` calling `Engine.context.PredictController.getPriceHistory`) and wires it into `predictQueries`. > > **Adjusts error reporting + tests.** Error logging is deduped per market via a ref and `Logger.error` (with query params captured via ref), and unit tests are updated to run under a `QueryClientProvider` and assert React Query-driven fetching/refetching behavior. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 5b42c14f9a4053f2f02e8f5a78194bcfd49359a4. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Luis Taniça Co-authored-by: George Marshall --- .../hooks/usePredictPriceHistory.test.tsx | 517 ++++++++---------- .../Predict/hooks/usePredictPriceHistory.tsx | 242 +++----- app/components/UI/Predict/queries/index.ts | 8 + .../UI/Predict/queries/priceHistory.ts | 59 ++ 4 files changed, 400 insertions(+), 426 deletions(-) create mode 100644 app/components/UI/Predict/queries/priceHistory.ts diff --git a/app/components/UI/Predict/hooks/usePredictPriceHistory.test.tsx b/app/components/UI/Predict/hooks/usePredictPriceHistory.test.tsx index 425cfd23636..faa05af1ff4 100644 --- a/app/components/UI/Predict/hooks/usePredictPriceHistory.test.tsx +++ b/app/components/UI/Predict/hooks/usePredictPriceHistory.test.tsx @@ -1,30 +1,35 @@ -import { renderHook, act } from '@testing-library/react-hooks'; -import Engine from '../../../../core/Engine'; -import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger'; +import React from 'react'; +import { act, renderHook, waitFor } from '@testing-library/react-native'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { PredictPriceHistoryInterval, PredictPriceHistoryPoint, } from '../types'; import { usePredictPriceHistory } from './usePredictPriceHistory'; -jest.mock('../../../../core/Engine', () => { - const mockContext = { +const mockGetPriceHistory = jest.fn(); +jest.mock('../../../../core/Engine', () => ({ + context: { PredictController: { - getPriceHistory: jest.fn(), + getPriceHistory: (...args: unknown[]) => mockGetPriceHistory(...args), }, - }; - - return { - context: mockContext, - }; -}); - -jest.mock('../../../../core/SDKConnect/utils/DevLogger', () => ({ - DevLogger: { - log: jest.fn(), }, })); +jest.mock('../../../../util/Logger', () => ({ + __esModule: true, + default: { error: jest.fn() }, +})); + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const Wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + return { Wrapper, queryClient }; +}; + describe('usePredictPriceHistory', () => { const mockPriceHistory: PredictPriceHistoryPoint[] = [ { timestamp: 1234567890, price: 0.5 }, @@ -33,92 +38,88 @@ describe('usePredictPriceHistory', () => { beforeEach(() => { jest.clearAllMocks(); - // Reset the mock implementation for getPriceHistory - ( - Engine.context.PredictController.getPriceHistory as jest.Mock - ).mockResolvedValue(mockPriceHistory); - }); - - afterEach(() => { - jest.useRealTimers(); + mockGetPriceHistory.mockResolvedValue(mockPriceHistory); }); describe('initial state', () => { - it('returns empty price histories and not fetching when no markets provided', () => { - const { result } = renderHook(() => - usePredictPriceHistory({ marketIds: [] }), + it('returns empty price histories when no markets provided', async () => { + const { Wrapper } = createWrapper(); + const { result } = renderHook( + () => usePredictPriceHistory({ marketIds: [] }), + { wrapper: Wrapper }, ); expect(result.current.priceHistories).toEqual([]); - expect(result.current.isFetching).toBe(false); expect(result.current.errors).toEqual([]); expect(typeof result.current.refetch).toBe('function'); }); - it('returns empty price histories when disabled', () => { - const { result } = renderHook(() => - usePredictPriceHistory({ - marketIds: ['market-1'], - enabled: false, - }), + it('returns empty price histories when disabled', async () => { + const { Wrapper } = createWrapper(); + const { result } = renderHook( + () => + usePredictPriceHistory({ + marketIds: ['market-1'], + enabled: false, + }), + { wrapper: Wrapper }, ); expect(result.current.priceHistories).toEqual([]); - expect(result.current.isFetching).toBe(false); expect(result.current.errors).toEqual([]); }); }); describe('single market fetching', () => { it('fetches price history for a single market', async () => { - const { result, waitForNextUpdate } = renderHook(() => - usePredictPriceHistory({ - marketIds: ['market-1'], - }), + const { Wrapper } = createWrapper(); + const { result } = renderHook( + () => + usePredictPriceHistory({ + marketIds: ['market-1'], + }), + { wrapper: Wrapper }, ); - expect(result.current.isFetching).toBe(true); - - await waitForNextUpdate(); + await waitFor(() => { + expect(result.current.isFetching).toBe(false); + }); - expect( - Engine.context.PredictController.getPriceHistory, - ).toHaveBeenCalledWith({ + expect(mockGetPriceHistory).toHaveBeenCalledWith({ marketId: 'market-1', interval: PredictPriceHistoryInterval.ONE_DAY, fidelity: undefined, + startTs: undefined, + endTs: undefined, }); expect(result.current.priceHistories).toEqual([mockPriceHistory]); - expect(result.current.isFetching).toBe(false); expect(result.current.errors).toEqual([null]); }); it('handles error when fetching single market fails', async () => { - const mockError = new Error('Failed to fetch'); - ( - Engine.context.PredictController.getPriceHistory as jest.Mock - ).mockRejectedValueOnce(mockError); - - const { result, waitForNextUpdate } = renderHook(() => - usePredictPriceHistory({ - marketIds: ['market-1'], - }), + const { Wrapper } = createWrapper(); + mockGetPriceHistory.mockRejectedValue(new Error('Failed to fetch')); + + const { result } = renderHook( + () => + usePredictPriceHistory({ + marketIds: ['market-1'], + }), + { wrapper: Wrapper }, ); - await waitForNextUpdate(); + await waitFor(() => { + expect(result.current.isFetching).toBe(false); + }); expect(result.current.priceHistories).toEqual([[]]); expect(result.current.errors).toEqual(['Failed to fetch']); - expect(result.current.isFetching).toBe(false); - expect(DevLogger.log).toHaveBeenCalledWith( - 'usePredictPriceHistory: Error fetching price history for market market-1', - mockError, - ); }); }); describe('multiple markets fetching', () => { it('fetches price history for multiple markets in parallel', async () => { + const { Wrapper } = createWrapper(); const mockHistory1: PredictPriceHistoryPoint[] = [ { timestamp: 1234567890, price: 0.5 }, ]; @@ -129,69 +130,74 @@ describe('usePredictPriceHistory', () => { { timestamp: 1234567890, price: 0.2 }, ]; - ( - Engine.context.PredictController.getPriceHistory as jest.Mock - ).mockImplementation(({ marketId }) => { - switch (marketId) { - case 'market-1': - return Promise.resolve(mockHistory1); - case 'market-2': - return Promise.resolve(mockHistory2); - case 'market-3': - return Promise.resolve(mockHistory3); - default: - return Promise.resolve([]); - } - }); + mockGetPriceHistory.mockImplementation( + ({ marketId }: { marketId: string }) => { + switch (marketId) { + case 'market-1': + return Promise.resolve(mockHistory1); + case 'market-2': + return Promise.resolve(mockHistory2); + case 'market-3': + return Promise.resolve(mockHistory3); + default: + return Promise.resolve([]); + } + }, + ); - const { result, waitForNextUpdate } = renderHook(() => - usePredictPriceHistory({ - marketIds: ['market-1', 'market-2', 'market-3'], - }), + const { result } = renderHook( + () => + usePredictPriceHistory({ + marketIds: ['market-1', 'market-2', 'market-3'], + }), + { wrapper: Wrapper }, ); - await waitForNextUpdate(); + await waitFor(() => { + expect(result.current.isFetching).toBe(false); + }); - expect( - Engine.context.PredictController.getPriceHistory, - ).toHaveBeenCalledTimes(3); + expect(mockGetPriceHistory).toHaveBeenCalledTimes(3); expect(result.current.priceHistories).toEqual([ mockHistory1, mockHistory2, mockHistory3, ]); expect(result.current.errors).toEqual([null, null, null]); - expect(result.current.isFetching).toBe(false); }); it('handles partial failures in multiple markets', async () => { + const { Wrapper } = createWrapper(); const mockHistory1: PredictPriceHistoryPoint[] = [ { timestamp: 1234567890, price: 0.5 }, ]; - const mockError = new Error('Failed to fetch market-2'); - - ( - Engine.context.PredictController.getPriceHistory as jest.Mock - ).mockImplementation(({ marketId }) => { - switch (marketId) { - case 'market-1': - return Promise.resolve(mockHistory1); - case 'market-2': - return Promise.reject(mockError); - case 'market-3': - return Promise.resolve(mockPriceHistory); - default: - return Promise.resolve([]); - } - }); - const { result, waitForNextUpdate } = renderHook(() => - usePredictPriceHistory({ - marketIds: ['market-1', 'market-2', 'market-3'], - }), + mockGetPriceHistory.mockImplementation( + ({ marketId }: { marketId: string }) => { + switch (marketId) { + case 'market-1': + return Promise.resolve(mockHistory1); + case 'market-2': + return Promise.reject(new Error('Failed to fetch market-2')); + case 'market-3': + return Promise.resolve(mockPriceHistory); + default: + return Promise.resolve([]); + } + }, + ); + + const { result } = renderHook( + () => + usePredictPriceHistory({ + marketIds: ['market-1', 'market-2', 'market-3'], + }), + { wrapper: Wrapper }, ); - await waitForNextUpdate(); + await waitFor(() => { + expect(result.current.isFetching).toBe(false); + }); expect(result.current.priceHistories).toEqual([ mockHistory1, @@ -203,106 +209,136 @@ describe('usePredictPriceHistory', () => { 'Failed to fetch market-2', null, ]); - expect(result.current.isFetching).toBe(false); }); }); describe('refetch functionality', () => { + it('does not refetch when disabled', async () => { + const { Wrapper } = createWrapper(); + const { result } = renderHook( + () => + usePredictPriceHistory({ + marketIds: ['market-1'], + enabled: false, + }), + { wrapper: Wrapper }, + ); + + expect(mockGetPriceHistory).not.toHaveBeenCalled(); + + await act(async () => { + await result.current.refetch(); + }); + + expect(mockGetPriceHistory).not.toHaveBeenCalled(); + }); + it('refetches data when refetch is called', async () => { - const { result, waitForNextUpdate } = renderHook(() => - usePredictPriceHistory({ - marketIds: ['market-1'], - }), + const { Wrapper } = createWrapper(); + const { result } = renderHook( + () => + usePredictPriceHistory({ + marketIds: ['market-1'], + }), + { wrapper: Wrapper }, ); - await waitForNextUpdate(); + await waitFor(() => { + expect(result.current.isFetching).toBe(false); + }); - expect( - Engine.context.PredictController.getPriceHistory, - ).toHaveBeenCalledTimes(1); + expect(mockGetPriceHistory).toHaveBeenCalledTimes(1); await act(async () => { await result.current.refetch(); }); - expect( - Engine.context.PredictController.getPriceHistory, - ).toHaveBeenCalledTimes(2); + expect(mockGetPriceHistory).toHaveBeenCalledTimes(2); expect(result.current.priceHistories).toEqual([mockPriceHistory]); }); }); describe('configuration options', () => { it('uses custom interval when provided', async () => { - const { waitForNextUpdate } = renderHook(() => - usePredictPriceHistory({ - marketIds: ['market-1'], + const { Wrapper } = createWrapper(); + renderHook( + () => + usePredictPriceHistory({ + marketIds: ['market-1'], + interval: PredictPriceHistoryInterval.ONE_WEEK, + }), + { wrapper: Wrapper }, + ); + + await waitFor(() => { + expect(mockGetPriceHistory).toHaveBeenCalled(); + }); + + expect(mockGetPriceHistory).toHaveBeenCalledWith( + expect.objectContaining({ + marketId: 'market-1', interval: PredictPriceHistoryInterval.ONE_WEEK, }), ); + }); - await waitForNextUpdate(); + it('uses custom fidelity when provided', async () => { + const { Wrapper } = createWrapper(); + renderHook( + () => + usePredictPriceHistory({ + marketIds: ['market-1'], + fidelity: 30, + }), + { wrapper: Wrapper }, + ); - expect( - Engine.context.PredictController.getPriceHistory, - ).toHaveBeenCalledWith({ - marketId: 'market-1', - interval: PredictPriceHistoryInterval.ONE_WEEK, - fidelity: undefined, + await waitFor(() => { + expect(mockGetPriceHistory).toHaveBeenCalled(); }); - }); - it('uses custom fidelity when provided', async () => { - const { waitForNextUpdate } = renderHook(() => - usePredictPriceHistory({ - marketIds: ['market-1'], + expect(mockGetPriceHistory).toHaveBeenCalledWith( + expect.objectContaining({ + marketId: 'market-1', + interval: PredictPriceHistoryInterval.ONE_DAY, fidelity: 30, }), ); - - await waitForNextUpdate(); - - expect( - Engine.context.PredictController.getPriceHistory, - ).toHaveBeenCalledWith({ - marketId: 'market-1', - interval: PredictPriceHistoryInterval.ONE_DAY, - fidelity: 30, - }); }); }); describe('reactivity', () => { it('refetches when marketIds change', async () => { - const { result, rerender, waitForNextUpdate } = renderHook( + const { Wrapper } = createWrapper(); + const { result, rerender } = renderHook( ({ marketIds }) => usePredictPriceHistory({ marketIds, }), { initialProps: { marketIds: ['market-1'] }, + wrapper: Wrapper, }, ); - await waitForNextUpdate(); + await waitFor(() => { + expect(result.current.isFetching).toBe(false); + }); expect(result.current.priceHistories).toEqual([mockPriceHistory]); rerender({ marketIds: ['market-2'] }); - await waitForNextUpdate(); - - expect( - Engine.context.PredictController.getPriceHistory, - ).toHaveBeenLastCalledWith({ - marketId: 'market-2', - interval: PredictPriceHistoryInterval.ONE_DAY, - fidelity: undefined, + await waitFor(() => { + expect(mockGetPriceHistory).toHaveBeenLastCalledWith( + expect.objectContaining({ marketId: 'market-2' }), + ); }); }); it('refetches when interval changes', async () => { - const { rerender, waitForNextUpdate } = renderHook( + const { Wrapper } = createWrapper(); + const { result, rerender } = renderHook( ({ interval }) => usePredictPriceHistory({ marketIds: ['market-1'], @@ -310,49 +346,43 @@ describe('usePredictPriceHistory', () => { }), { initialProps: { interval: PredictPriceHistoryInterval.ONE_DAY }, + wrapper: Wrapper, }, ); - await waitForNextUpdate(); + await waitFor(() => { + expect(result.current.isFetching).toBe(false); + }); rerender({ interval: PredictPriceHistoryInterval.ONE_WEEK }); - await waitForNextUpdate(); - - expect( - Engine.context.PredictController.getPriceHistory, - ).toHaveBeenLastCalledWith({ - marketId: 'market-1', - interval: PredictPriceHistoryInterval.ONE_WEEK, - fidelity: undefined, + await waitFor(() => { + expect(mockGetPriceHistory).toHaveBeenLastCalledWith( + expect.objectContaining({ + marketId: 'market-1', + interval: PredictPriceHistoryInterval.ONE_WEEK, + }), + ); }); }); - it('does not refetch when enabled changes from false to false', () => { - const { rerender } = renderHook( - ({ enabled }) => + it('does not fetch when disabled', () => { + const { Wrapper } = createWrapper(); + renderHook( + () => usePredictPriceHistory({ marketIds: ['market-1'], - enabled, + enabled: false, }), - { - initialProps: { enabled: false }, - }, + { wrapper: Wrapper }, ); - expect( - Engine.context.PredictController.getPriceHistory, - ).not.toHaveBeenCalled(); - - rerender({ enabled: false }); - - expect( - Engine.context.PredictController.getPriceHistory, - ).not.toHaveBeenCalled(); + expect(mockGetPriceHistory).not.toHaveBeenCalled(); }); it('fetches when enabled changes from false to true', async () => { - const { rerender, waitForNextUpdate } = renderHook( + const { Wrapper } = createWrapper(); + const { rerender } = renderHook( ({ enabled }) => usePredictPriceHistory({ marketIds: ['market-1'], @@ -360,104 +390,39 @@ describe('usePredictPriceHistory', () => { }), { initialProps: { enabled: false }, + wrapper: Wrapper, }, ); - expect( - Engine.context.PredictController.getPriceHistory, - ).not.toHaveBeenCalled(); + expect(mockGetPriceHistory).not.toHaveBeenCalled(); rerender({ enabled: true }); - await waitForNextUpdate(); - - expect( - Engine.context.PredictController.getPriceHistory, - ).toHaveBeenCalled(); + await waitFor(() => { + expect(mockGetPriceHistory).toHaveBeenCalled(); + }); }); }); describe('error handling', () => { - it('handles Engine not initialized error', async () => { - // Save the original Engine.context - const originalContext = Engine.context; - - // Set Engine.context to null - (Engine as unknown as { context: null }).context = null; + it('handles non-Error exceptions', async () => { + const { Wrapper } = createWrapper(); + mockGetPriceHistory.mockRejectedValue('String error'); - const { result, waitFor } = renderHook(() => - usePredictPriceHistory({ - marketIds: ['market-1'], - }), - ); - - // Wait for the error to be handled - await waitFor(() => result.current.isFetching === false); - - // Should have handled the error gracefully - expect(result.current.priceHistories).toEqual([[]]); - expect(result.current.errors).toEqual(['Engine not initialized']); - expect(result.current.isFetching).toBe(false); - expect(DevLogger.log).toHaveBeenCalled(); - - // Restore the original Engine.context - (Engine as unknown as { context: typeof originalContext }).context = - originalContext; - }); - - it('handles non-Error exceptions in individual market fetches', async () => { - // The Engine context is already mocked at the module level - ( - Engine.context.PredictController.getPriceHistory as jest.Mock - ).mockRejectedValueOnce('String error'); - - const { result, waitForNextUpdate } = renderHook(() => - usePredictPriceHistory({ - marketIds: ['market-1'], - }), - ); - - await waitForNextUpdate(); - - expect(result.current.priceHistories).toEqual([[]]); - expect(result.current.errors).toEqual(['Failed to fetch price history']); - expect(DevLogger.log).toHaveBeenCalled(); - }); - }); - - describe('cleanup', () => { - it('does not update state after unmount', async () => { - jest.useFakeTimers(); - - // Mock a delayed response - ( - Engine.context.PredictController.getPriceHistory as jest.Mock - ).mockImplementation( + const { result } = renderHook( () => - new Promise((resolve) => { - setTimeout(() => resolve(mockPriceHistory), 100); + usePredictPriceHistory({ + marketIds: ['market-1'], }), + { wrapper: Wrapper }, ); - const { unmount, result } = renderHook(() => - usePredictPriceHistory({ - marketIds: ['market-1'], - }), - ); - - // Initial state should be fetching - expect(result.current.isFetching).toBe(true); - - // Unmount immediately - unmount(); - - // Fast-forward timers - jest.runAllTimers(); - - // No errors should be thrown - expect(true).toBe(true); + await waitFor(() => { + expect(result.current.isFetching).toBe(false); + }); - jest.useRealTimers(); + expect(result.current.priceHistories).toEqual([[]]); + expect(result.current.errors).toEqual(['Failed to fetch price history']); }); }); @@ -473,22 +438,26 @@ describe('usePredictPriceHistory', () => { intervals.forEach((interval) => { it(`handles ${interval} interval correctly`, async () => { - const { result, waitForNextUpdate } = renderHook(() => - usePredictPriceHistory({ - marketIds: ['market-1'], - interval, - }), + const { Wrapper } = createWrapper(); + const { result } = renderHook( + () => + usePredictPriceHistory({ + marketIds: ['market-1'], + interval, + }), + { wrapper: Wrapper }, ); - await waitForNextUpdate(); - - expect( - Engine.context.PredictController.getPriceHistory, - ).toHaveBeenCalledWith({ - marketId: 'market-1', - interval, - fidelity: undefined, + await waitFor(() => { + expect(result.current.isFetching).toBe(false); }); + + expect(mockGetPriceHistory).toHaveBeenCalledWith( + expect.objectContaining({ + marketId: 'market-1', + interval, + }), + ); expect(result.current.priceHistories).toEqual([mockPriceHistory]); }); }); diff --git a/app/components/UI/Predict/hooks/usePredictPriceHistory.tsx b/app/components/UI/Predict/hooks/usePredictPriceHistory.tsx index 4420a88ef8d..3303b86def7 100644 --- a/app/components/UI/Predict/hooks/usePredictPriceHistory.tsx +++ b/app/components/UI/Predict/hooks/usePredictPriceHistory.tsx @@ -1,7 +1,7 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import Engine from '../../../../core/Engine'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useQueries, UseQueryResult } from '@tanstack/react-query'; +import { predictQueries } from '../queries'; import Logger from '../../../../util/Logger'; -import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger'; import { PREDICT_CONSTANTS } from '../constants/errors'; import { ensureError } from '../utils/predictErrorHandler'; import { @@ -26,7 +26,9 @@ export interface UsePredictPriceHistoryResult { } /** - * Hook to fetch and manage price history data for multiple markets + * Hook to fetch and manage price history data for multiple markets. + * Returns a curated interface (flattened histories, consolidated isFetching, + * batched refetch) shared by useChartData and PredictGameChart. */ export const usePredictPriceHistory = ( options: UsePredictPriceHistoryOptions, @@ -40,169 +42,105 @@ export const usePredictPriceHistory = ( enabled = true, } = options; - const [priceHistories, setPriceHistories] = useState< - PredictPriceHistoryPoint[][] - >([]); - const [isFetching, setIsFetching] = useState(false); - const [errors, setErrors] = useState<(string | null)[]>([]); - - const isMountedRef = useRef(true); - useEffect( - () => () => { - isMountedRef.current = false; - }, - [], + const queries = useQueries({ + queries: marketIds.map((marketId) => ({ + ...predictQueries.priceHistory.options({ + marketId, + interval, + fidelity, + startTs, + endTs, + }), + enabled, + })), + }); + + const marketIdsKey = marketIds.join(','); + const dataUpdatedAtKey = queries.map((q) => q.dataUpdatedAt).join(','); + const queryErrorKey = queries.map((q) => q.error).join(','); + + const priceHistories = useMemo( + () => queries.map((q) => q.data ?? []), + // eslint-disable-next-line react-hooks/exhaustive-deps + [dataUpdatedAtKey, marketIdsKey], + ); + const isFetching = queries.some((q) => q.isFetching); + const errors = useMemo( + () => + queries.map((q) => { + if (!q.error) return null; + return q.error instanceof Error + ? q.error.message + : 'Failed to fetch price history'; + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [queryErrorKey, marketIdsKey], ); - useEffect(() => { - if (!enabled && isMountedRef.current) { - setPriceHistories([]); - setErrors([]); - setIsFetching(false); - } - }, [enabled]); - - // Create a stable string representation for the dependency array - const marketIdsKey = marketIds?.join(',') ?? ''; + // Track which market errors have already been reported to Sentry + const reportedErrorsRef = useRef>(new Set()); - const fetchPriceHistories = useCallback(async () => { - if (!enabled) { - return; - } + // Capture query params in a ref so the error-reporting effect only fires + // when errors actually change, not when interval/fidelity/timestamps shift. + const queryParamsRef = useRef({ interval, fidelity, startTs, endTs }); + queryParamsRef.current = { interval, fidelity, startTs, endTs }; - if (!marketIds?.length) { - if (isMountedRef.current) { - setPriceHistories([]); - setErrors([]); - setIsFetching(false); + useEffect(() => { + // Clean up reported errors for market IDs that are no longer in the list + const currentMarketIds = new Set(marketIds); + for (const id of reportedErrorsRef.current) { + if (!currentMarketIds.has(id)) { + reportedErrorsRef.current.delete(id); } - return; - } - - if (isMountedRef.current) { - setIsFetching(true); - setErrors(new Array(marketIds.length).fill(null)); } - try { - if (!Engine || !Engine.context) { - throw new Error('Engine not initialized'); - } - - const controller = Engine.context.PredictController; - if (!controller) { - throw new Error('Predict controller not available'); - } - - // Fetch all price histories in parallel - const promises = marketIds.map(async (marketId, index) => { - try { - const history = await controller.getPriceHistory({ - marketId, - fidelity, - interval, - startTs, - endTs, - }); - return { index, data: history ?? [], error: null }; - } catch (err) { - const errorMessage = - err instanceof Error - ? err.message - : 'Failed to fetch price history'; - DevLogger.log( - `usePredictPriceHistory: Error fetching price history for market ${marketId}`, - err, - ); - - // Capture exception with price history loading context (single market) - Logger.error(ensureError(err), { - tags: { - feature: PREDICT_CONSTANTS.FEATURE_NAME, - component: 'usePredictPriceHistory', - }, - context: { - name: 'usePredictPriceHistory', - data: { - method: 'loadPriceHistory', - action: 'price_history_load_single', - operation: 'data_fetching', - marketId, - interval, - startTs, - endTs, - fidelity, - }, + queries.forEach((q, i) => { + const marketId = marketIds[i]; + if (q.error && marketId && !reportedErrorsRef.current.has(marketId)) { + reportedErrorsRef.current.add(marketId); + Logger.error(ensureError(q.error), { + tags: { + feature: PREDICT_CONSTANTS.FEATURE_NAME, + component: 'usePredictPriceHistory', + }, + context: { + name: 'usePredictPriceHistory', + data: { + method: 'loadPriceHistory', + action: 'price_history_load_single', + operation: 'data_fetching', + marketId, + ...queryParamsRef.current, }, - }); - - return { index, data: [], error: errorMessage }; - } - }); - - const results = await Promise.all(promises); - - if (isMountedRef.current) { - const histories = new Array(marketIds.length).fill([]); - const errorList = new Array(marketIds.length).fill(null); - - results.forEach(({ index, data, error }) => { - histories[index] = data; - errorList[index] = error; + }, }); - - setPriceHistories(histories); - setErrors(errorList); + } else if (!q.error && marketId) { + reportedErrorsRef.current.delete(marketId); } - } catch (err) { - const errorMessage = - err instanceof Error ? err.message : 'Failed to fetch price histories'; - - DevLogger.log('usePredictPriceHistory: Error in batch fetching', err); + }); + // Stable string that only changes when actual errors change, avoiding + // re-runs caused by useQueries returning a new array reference each render. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [queryErrorKey, marketIdsKey]); - // Capture exception with price history batch loading context - Logger.error(ensureError(err), { - tags: { - feature: PREDICT_CONSTANTS.FEATURE_NAME, - component: 'usePredictPriceHistory', - }, - context: { - name: 'usePredictPriceHistory', - data: { - method: 'loadPriceHistory', - action: 'price_history_load_batch', - operation: 'data_fetching', - marketCount: marketIds.length, - interval, - startTs, - endTs, - fidelity, - }, - }, - }); + // Use refs so refetch has a stable identity across renders + const queriesRef = + useRef[]>(queries); + queriesRef.current = queries; - if (isMountedRef.current) { - setErrors(new Array(marketIds.length).fill(errorMessage)); - setPriceHistories(new Array(marketIds.length).fill([])); - } - } finally { - if (isMountedRef.current) { - setIsFetching(false); - } - } - // eslint-disable-next-line react-compiler/react-compiler - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [enabled, marketIdsKey, fidelity, interval, startTs, endTs]); + const enabledRef = useRef(enabled); + enabledRef.current = enabled; - useEffect(() => { - fetchPriceHistories(); - }, [fetchPriceHistories]); + const refetch = useCallback(async () => { + if (!enabledRef.current) return; + await Promise.all(queriesRef.current.map((q) => q.refetch())); + }, []); return { - priceHistories, - isFetching, - errors, - refetch: fetchPriceHistories, + priceHistories: enabled ? priceHistories : [], + isFetching: enabled ? isFetching : false, + errors: enabled ? errors : [], + refetch, }; }; diff --git a/app/components/UI/Predict/queries/index.ts b/app/components/UI/Predict/queries/index.ts index 784e54b7b0d..07d3fc4f208 100644 --- a/app/components/UI/Predict/queries/index.ts +++ b/app/components/UI/Predict/queries/index.ts @@ -10,6 +10,10 @@ import { predictOrderPreviewOptions, } from './orderPreview'; import { predictPositionsKeys, predictPositionsOptions } from './positions'; +import { + predictPriceHistoryKeys, + predictPriceHistoryOptions, +} from './priceHistory'; import { predictUnrealizedPnLKeys, predictUnrealizedPnLOptions, @@ -40,6 +44,10 @@ export const predictQueries = { keys: predictPositionsKeys, options: predictPositionsOptions, }, + priceHistory: { + keys: predictPriceHistoryKeys, + options: predictPriceHistoryOptions, + }, unrealizedPnL: { keys: predictUnrealizedPnLKeys, options: predictUnrealizedPnLOptions, diff --git a/app/components/UI/Predict/queries/priceHistory.ts b/app/components/UI/Predict/queries/priceHistory.ts new file mode 100644 index 00000000000..6f694ce80a6 --- /dev/null +++ b/app/components/UI/Predict/queries/priceHistory.ts @@ -0,0 +1,59 @@ +import { queryOptions } from '@tanstack/react-query'; +import Engine from '../../../../core/Engine'; +import { + PredictPriceHistoryInterval, + PredictPriceHistoryPoint, +} from '../types'; + +export const predictPriceHistoryKeys = { + all: () => ['predict', 'priceHistory'] as const, + detail: ( + marketId: string, + interval: PredictPriceHistoryInterval, + fidelity?: number, + startTs?: number, + endTs?: number, + ) => + [ + ...predictPriceHistoryKeys.all(), + marketId, + interval, + fidelity, + startTs, + endTs, + ] as const, +}; + +export const predictPriceHistoryOptions = ({ + marketId, + interval = PredictPriceHistoryInterval.ONE_DAY, + fidelity, + startTs, + endTs, +}: { + marketId: string; + interval?: PredictPriceHistoryInterval; + fidelity?: number; + startTs?: number; + endTs?: number; +}) => + queryOptions({ + queryKey: predictPriceHistoryKeys.detail( + marketId, + interval, + fidelity, + startTs, + endTs, + ), + queryFn: async (): Promise => { + const history = await Engine.context.PredictController.getPriceHistory({ + marketId, + fidelity, + interval, + startTs, + endTs, + }); + return history ?? []; + }, + staleTime: 5_000, + }); From 385cd7118340e326ac78ee9f6bed93325c6bb85d Mon Sep 17 00:00:00 2001 From: George Gkasdrogkas Date: Mon, 16 Mar 2026 22:42:59 +0200 Subject: [PATCH 039/206] feat: retain expired swaps quote in ui (#27340) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Previously, when a bridge/swap quote expired the app would navigate to a separate `QuoteExpiredModal` bottom sheet. This modal required its own navigation route, two dedicated hooks (`useRenderQuoteExpireModal`, `useModalCloseOnQuoteExpiry`) and a separate `QuoteExpiredModal` component to manage that flow. The approach had several edge cases — overlapping bottom-sheet overlays, race conditions between the modal and re-fetching, and an "escape-hatch" workaround in `SwapsConfirmButton` that leaked expiry logic outside `useBridgeQuoteData`. This PR replaces the modal approach with an inline UX: - **Retained quotes**: When a quote expires and a new fetch has not yet started, `useBridgeQuoteData` now keeps showing the last cached Redux quote (`isShowingCachedQuote`). The user sees the stale-but-visible quote details instead of a blank screen. - **Inline "Get New Quote" button**: A new `needsNewQuote` flag (computed once in `useBridgeQuoteData` and consumed by all callers) drives a `SwapsConfirmButton` in place of the usual confirm button, prompting the user to refresh. - **`BridgeViewFooter` component**: The bottom-content render logic is extracted from `BridgeView` into a dedicated `BridgeViewFooter` component, making both files easier to read and test in isolation. - **Deleted code**: `QuoteExpiredModal`, `useRenderQuoteExpireModal`, `useModalCloseOnQuoteExpiry`, and the `QUOTE_EXPIRED_MODAL` route are all removed, eliminating ~600 lines of navigation-heavy glue code. ## **Changelog** CHANGELOG entry: Improved bridge/swap quote expiry experience; expired quotes now remain visible inline with a prompt to refresh, replacing a separate modal flow. ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/SWAPS-4235, https://consensyssoftware.atlassian.net/browse/SWAPS-4236 ## **Manual testing steps** ```gherkin Feature: Bridge/Swap quote expiry inline UX Scenario: user sees retained quote and gets a new one after expiry Given the user has opened the Bridge/Swap view And a valid source token, destination token, and amount are entered And a quote has been successfully fetched and is displayed When the quote timer reaches zero (quote expires) Then the quote details remain visible (not replaced by a blank screen or modal) And a "Get New Quote" button is displayed in the footer in place of the confirm button When the user taps "Get New Quote" Then a new quote fetch is triggered And the loading indicator is shown while the new quote loads And once loaded the new quote is displayed normally Scenario: user ignores expiry and waits Given the quote has expired and the inline "Get New Quote" button is shown When the user does nothing Then no modal or bottom sheet is pushed on top of BridgeView And the app remains responsive Scenario: quote expires while a destination-token selector is open Given the user opened the token selector sheet from BridgeView When the quote expires in the background Then no extra navigation happens And closing the token selector returns the user to BridgeView with the expired-quote inline UI ``` ## **Screenshots/Recordings** ### **Before** ### **After** εικόνα ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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 quote-expiry behavior across the bridge flow by retaining cached quotes and altering navigation/CTA logic, which could affect user ability to refresh quotes and submit swaps in edge cases. > > **Overview** > **Quote expiry UX is redesigned to retain the last fetched quote in the UI instead of forcing an expired-quote modal.** `useBridgeQuoteData` now serves cached Redux quotes when expired (and not refreshing/submitting) and exposes a new `needsNewQuote` flag to drive the "Get new quote" CTA. > > Bridge screens are updated to use `needsNewQuote` for loading/visibility decisions: `BridgeViewFooter` renders `SwapsConfirmButton` even when there’s no active quote but a refresh is needed, and `QuoteSelectorView` no longer auto-dismisses on expiry. > > Removes the `QuoteExpiredModal`, its route/constants/types, and the quote-expiry modal hooks (`useRenderQuoteExpireModal`, `useModalCloseOnQuoteExpiry`), with tests updated accordingly. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 8936dfb8859056f8e58ece0663d4d275297c6d89. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../Views/BridgeView/BridgeView.test.tsx | 48 ++- .../Views/BridgeView/BridgeViewFooter.tsx | 24 +- .../UI/Bridge/Views/BridgeView/index.tsx | 11 +- .../Bridge/_mocks_/useBridgeQuoteData.mock.ts | 1 + .../PriceImpactModal/index.test.tsx | 23 - .../components/PriceImpactModal/index.tsx | 3 - .../QuoteExpiredModal.styles.ts | 15 - .../QuoteExpiredModal.test.tsx | 80 ---- .../QuoteExpiredModal/QuoteExpiredModal.tsx | 94 ----- .../QuoteExpiredModal.test.tsx.snap | 351 ---------------- .../components/QuoteExpiredModal/index.ts | 1 - .../QuoteSelectorView/index.test.tsx | 6 +- .../components/QuoteSelectorView/index.tsx | 15 +- .../CustomSlippageModal.test.tsx | 24 +- .../SlippageModal/CustomSlippageModal.tsx | 2 - .../DefaultSlippageModal.test.tsx | 23 - .../SlippageModal/DefaultSlippageModal.tsx | 2 - .../SwapsConfirmButton.test.tsx | 18 +- .../components/SwapsConfirmButton/index.tsx | 9 +- .../Bridge/hooks/useBridgeQuoteData/index.ts | 34 +- .../useBridgeQuoteData.test.ts | 25 +- .../useModalCloseOnQuoteExpiry/index.test.ts | 137 ------ .../hooks/useModalCloseOnQuoteExpiry/index.ts | 28 -- .../hooks/useRenderQuoteExpireModal/index.ts | 73 ---- .../useRenderQuoteExpireModal.test.ts | 393 ------------------ app/components/UI/Bridge/routes.tsx | 5 - app/constants/navigation/Routes.ts | 1 - app/core/NavigationService/types.ts | 1 - 28 files changed, 123 insertions(+), 1324 deletions(-) delete mode 100644 app/components/UI/Bridge/components/QuoteExpiredModal/QuoteExpiredModal.styles.ts delete mode 100644 app/components/UI/Bridge/components/QuoteExpiredModal/QuoteExpiredModal.test.tsx delete mode 100644 app/components/UI/Bridge/components/QuoteExpiredModal/QuoteExpiredModal.tsx delete mode 100644 app/components/UI/Bridge/components/QuoteExpiredModal/__snapshots__/QuoteExpiredModal.test.tsx.snap delete mode 100644 app/components/UI/Bridge/components/QuoteExpiredModal/index.ts delete mode 100644 app/components/UI/Bridge/hooks/useModalCloseOnQuoteExpiry/index.test.ts delete mode 100644 app/components/UI/Bridge/hooks/useModalCloseOnQuoteExpiry/index.ts delete mode 100644 app/components/UI/Bridge/hooks/useRenderQuoteExpireModal/index.ts delete mode 100644 app/components/UI/Bridge/hooks/useRenderQuoteExpireModal/useRenderQuoteExpireModal.test.ts diff --git a/app/components/UI/Bridge/Views/BridgeView/BridgeView.test.tsx b/app/components/UI/Bridge/Views/BridgeView/BridgeView.test.tsx index 100221c0170..3270963ef80 100644 --- a/app/components/UI/Bridge/Views/BridgeView/BridgeView.test.tsx +++ b/app/components/UI/Bridge/Views/BridgeView/BridgeView.test.tsx @@ -1030,14 +1030,17 @@ describe('BridgeView', () => { expect(queryByTestId('edit-slippage-button')).toBeNull(); }); - it('navigates to QuoteExpiredModal when quote expires without refresh', async () => { + it('does not navigate to QuoteExpiredModal when quote expires without refresh', async () => { + // useRenderQuoteExpireModal was removed; the expired-quote modal no longer + // exists. Instead, the cached quote stays visible and "Get new quote" + // appears in the footer. jest .mocked(useBridgeQuoteData as unknown as jest.Mock) .mockImplementation(() => ({ ...mockUseBridgeQuoteData, isExpired: true, willRefresh: false, - activeQuote: undefined, // activeQuote is undefined when quote expires without refresh + needsNewQuote: true, })); renderScreen( @@ -1049,9 +1052,12 @@ describe('BridgeView', () => { ); await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith(Routes.BRIDGE.MODALS.ROOT, { - screen: Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL, - }); + expect(mockNavigate).not.toHaveBeenCalledWith( + Routes.BRIDGE.MODALS.ROOT, + { + screen: Routes.BRIDGE.BRIDGE_VIEW, + }, + ); }); }); @@ -1076,7 +1082,7 @@ describe('BridgeView', () => { expect(mockNavigate).not.toHaveBeenCalledWith( Routes.BRIDGE.MODALS.ROOT, { - screen: Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL, + screen: Routes.BRIDGE.BRIDGE_VIEW, }, ); }); @@ -1103,7 +1109,7 @@ describe('BridgeView', () => { expect(mockNavigate).not.toHaveBeenCalledWith( Routes.BRIDGE.MODALS.ROOT, { - screen: Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL, + screen: Routes.BRIDGE.BRIDGE_VIEW, }, ); }); @@ -1139,13 +1145,15 @@ describe('BridgeView', () => { expect(mockNavigate).not.toHaveBeenCalledWith( Routes.BRIDGE.MODALS.ROOT, { - screen: Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL, + screen: Routes.BRIDGE.BRIDGE_VIEW, }, ); }); }); - it('navigates to QuoteExpiredModal when quote expires and leaves quote content hidden', async () => { + it('shows cached quote content when quote expires', async () => { + // When quotes expire the cached quote (still in Redux) is shown in the + // QuoteDetailsCard. The slippage button must remain visible. jest .mocked(useBridgeQuoteData as unknown as jest.Mock) .mockImplementation(() => ({ @@ -1153,23 +1161,33 @@ describe('BridgeView', () => { isExpired: true, willRefresh: false, isLoading: false, - activeQuote: undefined, // activeQuote is undefined when quote expires without refresh + needsNewQuote: true, + // activeQuote remains the cached quote — not cleared on expiry })); + // createBridgeTestState provides source/dest tokens so QuoteDetailsCard + // passes its early-return guard and renders the slippage button. + const testState = createBridgeTestState({ + bridgeReducerOverrides: { sourceAmount: '1.0' }, + }); + const { queryByTestId } = renderScreen( BridgeView, { name: Routes.BRIDGE.ROOT, }, - { state: mockState }, + { state: testState }, ); await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith(Routes.BRIDGE.MODALS.ROOT, { - screen: Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL, - }); + expect(mockNavigate).not.toHaveBeenCalledWith( + Routes.BRIDGE.MODALS.ROOT, + { + screen: Routes.BRIDGE.BRIDGE_VIEW, + }, + ); }); - expect(queryByTestId('edit-slippage-button')).toBeNull(); + expect(queryByTestId('edit-slippage-button')).not.toBeNull(); }); }); diff --git a/app/components/UI/Bridge/Views/BridgeView/BridgeViewFooter.tsx b/app/components/UI/Bridge/Views/BridgeView/BridgeViewFooter.tsx index 4594b95d0ce..b6d27e1cd2e 100644 --- a/app/components/UI/Bridge/Views/BridgeView/BridgeViewFooter.tsx +++ b/app/components/UI/Bridge/Views/BridgeView/BridgeViewFooter.tsx @@ -48,9 +48,10 @@ export const BridgeViewFooter = ({ latestSourceBalance, location }: Props) => { ); const isSolanaSourced = useSelector(selectIsSolanaSourced); - const { activeQuote, isLoading, blockaidError } = useBridgeQuoteData({ - latestSourceAtomicBalance: latestSourceBalance?.atomicBalance, - }); + const { activeQuote, isLoading, blockaidError, needsNewQuote } = + useBridgeQuoteData({ + latestSourceAtomicBalance: latestSourceBalance?.atomicBalance, + }); const isValidSourceAmount = sourceAmount !== undefined && sourceAmount !== '.' && sourceToken?.decimals; @@ -59,14 +60,21 @@ export const BridgeViewFooter = ({ latestSourceBalance, location }: Props) => { ? !!isHardwareAccount(selectedAddress) : false; - if (isLoading && !activeQuote) { + if (isLoading && !activeQuote && !needsNewQuote) { return null; } - // Prevent bottom section from rendering when no active - // quotes exist and none are being fetching. - // This resolves edge cases when users are redirected back from - // Select Quote page due to quotes expiry. + if (needsNewQuote) { + return ( + + + + ); + } + if (!activeQuote) { return null; } diff --git a/app/components/UI/Bridge/Views/BridgeView/index.tsx b/app/components/UI/Bridge/Views/BridgeView/index.tsx index 55be4a3db6f..61945c66f4a 100644 --- a/app/components/UI/Bridge/Views/BridgeView/index.tsx +++ b/app/components/UI/Bridge/Views/BridgeView/index.tsx @@ -85,7 +85,6 @@ import { SwapsKeypadRef } from '../../components/SwapsKeypad/types.ts'; import { GaslessQuickPickOptions } from '../../components/GaslessQuickPickOptions/index.tsx'; import { SwapsConfirmButton } from '../../components/SwapsConfirmButton/index.tsx'; import { useBridgeViewOnFocus } from '../../hooks/useBridgeViewOnFocus/index.ts'; -import { useRenderQuoteExpireModal } from '../../hooks/useRenderQuoteExpireModal/index.ts'; import { type BridgeRouteParams } from '../../hooks/useSwapBridgeNavigation/index.ts'; import BridgeTrendingTokensSection from '../../components/BridgeTrendingTokensSection/BridgeTrendingTokensSection'; import { selectRemoteFeatureFlags } from '../../../../../selectors/featureFlagController'; @@ -202,6 +201,7 @@ const BridgeView = () => { isNoQuotesAvailable, blockaidError, shouldShowPriceImpactWarning, + needsNewQuote, } = useBridgeQuoteData({ latestSourceAtomicBalance: latestSourceBalance?.atomicBalance, }); @@ -262,8 +262,6 @@ const BridgeView = () => { // Compute error state directly from dependencies const isError = isNoQuotesAvailable || quoteFetchError; - // Always show quote details when there's an active quote - const shouldDisplayQuoteDetails = !!activeQuote; const isZeroState = !sourceAmount || !(Number(sourceAmount) > 0); // Update quote parameters when relevant state changes @@ -333,8 +331,6 @@ const BridgeView = () => { type: 'dest', }); - useRenderQuoteExpireModal({ inputRef, latestSourceBalance }); - const isRWATokenSelected = useMemo( () => (sourceToken && isStockToken(sourceToken as BridgeToken)) || @@ -346,11 +342,10 @@ const BridgeView = () => { : strings('bridge.error_banner_description'); const getContentMode = () => { - if (isLoading && !activeQuote) return 'loading'; + if (isLoading && !activeQuote && !needsNewQuote) return 'loading'; if (isError && isErrorBannerVisible) return 'error'; - if (shouldDisplayQuoteDetails) return 'quote'; if (isZeroState) return 'zero'; - return 'none'; + return 'quote'; }; const contentMode = getContentMode(); diff --git a/app/components/UI/Bridge/_mocks_/useBridgeQuoteData.mock.ts b/app/components/UI/Bridge/_mocks_/useBridgeQuoteData.mock.ts index d5191e93575..aec767f33de 100644 --- a/app/components/UI/Bridge/_mocks_/useBridgeQuoteData.mock.ts +++ b/app/components/UI/Bridge/_mocks_/useBridgeQuoteData.mock.ts @@ -8,6 +8,7 @@ export const mockUseBridgeQuoteData = { quoteFetchError: null, isNoQuotesAvailable: false, isExpired: false, + needsNewQuote: false, willRefresh: false, formattedQuoteData: { networkFee: '0', diff --git a/app/components/UI/Bridge/components/PriceImpactModal/index.test.tsx b/app/components/UI/Bridge/components/PriceImpactModal/index.test.tsx index 99324300f1e..72f86f765f9 100644 --- a/app/components/UI/Bridge/components/PriceImpactModal/index.test.tsx +++ b/app/components/UI/Bridge/components/PriceImpactModal/index.test.tsx @@ -134,10 +134,6 @@ jest.mock('../../hooks/useBridgeQuoteData', () => ({ useBridgeQuoteData: jest.fn(), })); -jest.mock('../../hooks/useModalCloseOnQuoteExpiry', () => ({ - useModalCloseOnQuoteExpiry: jest.fn(), -})); - jest.mock('../../hooks/usePriceImpactViewData', () => ({ usePriceImpactViewData: jest.fn(), })); @@ -146,7 +142,6 @@ import { useParams } from '../../../../../util/navigation/navUtils'; import { useLatestBalance } from '../../hooks/useLatestBalance'; import { useBridgeConfirm } from '../../hooks/useBridgeConfirm'; import { useBridgeQuoteData } from '../../hooks/useBridgeQuoteData'; -import { useModalCloseOnQuoteExpiry } from '../../hooks/useModalCloseOnQuoteExpiry'; import { usePriceImpactViewData } from '../../hooks/usePriceImpactViewData'; import { PriceImpactHeader } from './PriceImpactHeader'; import { PriceImpactDescription } from './PriceImpactDescription'; @@ -162,10 +157,6 @@ const mockUseBridgeConfirm = useBridgeConfirm as jest.MockedFunction< const mockUseBridgeQuoteData = useBridgeQuoteData as jest.MockedFunction< typeof useBridgeQuoteData >; -const mockUseModalCloseOnQuoteExpiry = - useModalCloseOnQuoteExpiry as jest.MockedFunction< - typeof useModalCloseOnQuoteExpiry - >; const mockUsePriceImpactViewData = usePriceImpactViewData as jest.MockedFunction; const mockPriceImpactHeader = PriceImpactHeader as jest.MockedFunction< @@ -218,20 +209,6 @@ describe('PriceImpactModal', () => { jest.clearAllMocks(); }); - describe('useModalCloseOnQuoteExpiry', () => { - it('calls useModalCloseOnQuoteExpiry on render', () => { - render(); - - expect(mockUseModalCloseOnQuoteExpiry).toHaveBeenCalled(); - }); - - it('calls useModalCloseOnQuoteExpiry exactly once per render', () => { - render(); - - expect(mockUseModalCloseOnQuoteExpiry).toHaveBeenCalledTimes(1); - }); - }); - describe('component structure', () => { it('renders PriceImpactHeader', () => { const { getByTestId } = render(); diff --git a/app/components/UI/Bridge/components/PriceImpactModal/index.tsx b/app/components/UI/Bridge/components/PriceImpactModal/index.tsx index 1a154f71670..f9c915ae963 100644 --- a/app/components/UI/Bridge/components/PriceImpactModal/index.tsx +++ b/app/components/UI/Bridge/components/PriceImpactModal/index.tsx @@ -10,7 +10,6 @@ import { PriceImpactDescription } from './PriceImpactDescription'; import { PriceImpactFooter } from './PriceImpactFooter'; import { useLatestBalance } from '../../hooks/useLatestBalance'; import { useBridgeConfirm } from '../../hooks/useBridgeConfirm'; -import { useModalCloseOnQuoteExpiry } from '../../hooks/useModalCloseOnQuoteExpiry'; import { usePriceImpactViewData } from '../../hooks/usePriceImpactViewData'; export const PriceImpactModal = () => { @@ -42,8 +41,6 @@ export const PriceImpactModal = () => { await confirmBridge(); }, [confirmBridge]); - useModalCloseOnQuoteExpiry(); - return ( - StyleSheet.create({ - container: { - paddingHorizontal: 16, - }, - footer: { - paddingHorizontal: 16, - paddingTop: 24, - paddingBottom: 16, - }, - }); - -export default createStyles; diff --git a/app/components/UI/Bridge/components/QuoteExpiredModal/QuoteExpiredModal.test.tsx b/app/components/UI/Bridge/components/QuoteExpiredModal/QuoteExpiredModal.test.tsx deleted file mode 100644 index 1ce4c085404..00000000000 --- a/app/components/UI/Bridge/components/QuoteExpiredModal/QuoteExpiredModal.test.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import '../../_mocks_/initialState'; -import React from 'react'; -import { SafeAreaProvider } from 'react-native-safe-area-context'; -import renderWithProvider from '../../../../../util/test/renderWithProvider'; -import QuoteExpiredModal from './QuoteExpiredModal'; -import Engine from '../../../../../core/Engine'; -import { fireEvent } from '@testing-library/react-native'; - -const mockNavigate = jest.fn(); -const mockGoBack = jest.fn(); -const mockUpdateQuoteParams = jest.fn(); - -jest.mock('@react-navigation/native', () => { - const actualReactNavigation = jest.requireActual('@react-navigation/native'); - return { - ...actualReactNavigation, - useNavigation: jest.fn(() => ({ - navigate: mockNavigate, - goBack: mockGoBack, - })), - }; -}); - -jest.mock('../../hooks/useBridgeQuoteRequest', () => ({ - useBridgeQuoteRequest: () => mockUpdateQuoteParams, -})); - -jest.mock('../../utils/quoteUtils', () => ({ - getQuoteRefreshRate: jest.fn(() => 15000), -})); - -const initialMetrics = { - frame: { x: 0, y: 0, width: 320, height: 640 }, - insets: { top: 0, left: 0, right: 0, bottom: 0 }, -}; - -const renderQuoteExpiredModal = () => - renderWithProvider( - - - , - ); - -describe('QuoteExpiredModal', () => { - beforeEach(() => { - jest.clearAllMocks(); - // Mock BridgeController with minimal required properties - Engine.context.BridgeController = { - resetState: jest.fn(), - } as unknown as typeof Engine.context.BridgeController; - }); - - it('renders correctly', () => { - const { toJSON } = renderQuoteExpiredModal(); - expect(toJSON()).toMatchSnapshot(); - }); - - it('resets BridgeController state, updates quote params, and closes modal when get new quote button is pressed', () => { - const { getByTestId } = renderQuoteExpiredModal(); - const getNewQuoteButton = getByTestId('bottomsheetfooter-button'); - fireEvent.press(getNewQuoteButton); - - expect(Engine.context.BridgeController.resetState).toHaveBeenCalled(); - expect(mockUpdateQuoteParams).toHaveBeenCalled(); - expect(mockGoBack).toHaveBeenCalled(); - }); - - it('handles missing BridgeController gracefully', () => { - // Remove BridgeController mock - Engine.context.BridgeController = - undefined as unknown as typeof Engine.context.BridgeController; - - const { getByTestId } = renderQuoteExpiredModal(); - const getNewQuoteButton = getByTestId('bottomsheetfooter-button'); - fireEvent.press(getNewQuoteButton); - - expect(mockUpdateQuoteParams).toHaveBeenCalled(); - expect(mockGoBack).toHaveBeenCalled(); - }); -}); diff --git a/app/components/UI/Bridge/components/QuoteExpiredModal/QuoteExpiredModal.tsx b/app/components/UI/Bridge/components/QuoteExpiredModal/QuoteExpiredModal.tsx deleted file mode 100644 index 494904e1622..00000000000 --- a/app/components/UI/Bridge/components/QuoteExpiredModal/QuoteExpiredModal.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import React, { useEffect, useRef } from 'react'; -import { View } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; -import { strings } from '../../../../../../locales/i18n'; -import BottomSheet, { - BottomSheetRef, -} from '../../../../../component-library/components/BottomSheets/BottomSheet'; -import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; -import BottomSheetFooter from '../../../../../component-library/components/BottomSheets/BottomSheetFooter'; -import Text, { - TextVariant, - TextColor, -} from '../../../../../component-library/components/Texts/Text'; -import { - ButtonSize, - ButtonVariants, -} from '../../../../../component-library/components/Buttons/Button'; -import { useStyles } from '../../../../../component-library/hooks'; -import createStyles from './QuoteExpiredModal.styles'; -import { useBridgeQuoteRequest } from '../../hooks/useBridgeQuoteRequest'; -import Engine from '../../../../../core/Engine'; -import { - selectBridgeFeatureFlags, - selectSourceToken, - setIsSubmittingTx, -} from '../../../../../core/redux/slices/bridge'; -import { useDispatch, useSelector } from 'react-redux'; -import { getQuoteRefreshRate } from '../../utils/quoteUtils'; - -const QuoteExpiredModal = () => { - const navigation = useNavigation(); - const sheetRef = useRef(null); - const { styles } = useStyles(createStyles, {}); - const updateQuoteParams = useBridgeQuoteRequest(); - const dispatch = useDispatch(); - const sourceToken = useSelector(selectSourceToken); - const bridgeFeatureFlags = useSelector(selectBridgeFeatureFlags); - const refreshRate = - getQuoteRefreshRate(bridgeFeatureFlags, sourceToken) / 1000; - - const handleClose = () => { - navigation.goBack(); - }; - - const handleGetNewQuote = () => { - dispatch(setIsSubmittingTx(false)); - // Reset bridge controller state - if (Engine.context.BridgeController?.resetState) { - Engine.context.BridgeController.resetState(); - } - // Update quote params to fetch new quote - updateQuoteParams(); - // Close the modal - navigation.goBack(); - }; - - useEffect(() => { - // Stop polling when modal opens - if (Engine.context.BridgeController?.stopAllPolling) { - Engine.context.BridgeController.stopAllPolling(); - } - }, []); - - const footerButtonProps = [ - { - label: strings('quote_expired_modal.get_new_quote'), - variant: ButtonVariants.Primary, - size: ButtonSize.Lg, - onPress: handleGetNewQuote, - }, - ]; - - return ( - - - - - {strings('quote_expired_modal.description', { - refreshRate, - })} - - - - - ); -}; - -export default QuoteExpiredModal; diff --git a/app/components/UI/Bridge/components/QuoteExpiredModal/__snapshots__/QuoteExpiredModal.test.tsx.snap b/app/components/UI/Bridge/components/QuoteExpiredModal/__snapshots__/QuoteExpiredModal.test.tsx.snap deleted file mode 100644 index 2944fd3a077..00000000000 --- a/app/components/UI/Bridge/components/QuoteExpiredModal/__snapshots__/QuoteExpiredModal.test.tsx.snap +++ /dev/null @@ -1,351 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`QuoteExpiredModal renders correctly 1`] = ` - - - - - - - - - - - - - - - - - - New quotes are available - - - - - - - - - - - - - - Rates update every 15 seconds, so tap Get new quote when you're ready. - - - - - - Get new quote - - - - - - - -`; diff --git a/app/components/UI/Bridge/components/QuoteExpiredModal/index.ts b/app/components/UI/Bridge/components/QuoteExpiredModal/index.ts deleted file mode 100644 index 853829df5a0..00000000000 --- a/app/components/UI/Bridge/components/QuoteExpiredModal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './QuoteExpiredModal'; diff --git a/app/components/UI/Bridge/components/QuoteSelectorView/index.test.tsx b/app/components/UI/Bridge/components/QuoteSelectorView/index.test.tsx index c7df7f9dd6e..0b9a494c156 100644 --- a/app/components/UI/Bridge/components/QuoteSelectorView/index.test.tsx +++ b/app/components/UI/Bridge/components/QuoteSelectorView/index.test.tsx @@ -597,7 +597,9 @@ describe('QuoteSelectorView', () => { expect(mockGoBack).toHaveBeenCalled(); }); - it('navigates back when quotes are expired and not loading', () => { + it('does not navigate back when quotes are expired and not loading', () => { + // When quotes expire the view keeps showing cached data (the Redux quotes + // are still present) so there is no reason to dismiss the selector. mockUseBridgeQuoteData.mockReturnValue({ validQuotes: [], bestQuote: null, @@ -609,7 +611,7 @@ describe('QuoteSelectorView', () => { render(); - expect(mockGoBack).toHaveBeenCalled(); + expect(mockGoBack).not.toHaveBeenCalled(); }); it('navigates back when loading and error exists', () => { diff --git a/app/components/UI/Bridge/components/QuoteSelectorView/index.tsx b/app/components/UI/Bridge/components/QuoteSelectorView/index.tsx index a416c12c30d..1813e8d0a54 100644 --- a/app/components/UI/Bridge/components/QuoteSelectorView/index.tsx +++ b/app/components/UI/Bridge/components/QuoteSelectorView/index.tsx @@ -34,15 +34,8 @@ export const QuoteSelectorView = () => { const dispatch = useDispatch(); const selectedQuoteRequestId = useSelector(selectSelectedQuoteRequestId); const currency = useSelector(selectCurrentCurrency); - const { - validQuotes, - bestQuote, - isLoading, - blockaidError, - quoteFetchError, - isExpired, - willRefresh, - } = useBridgeQuoteData(); + const { validQuotes, bestQuote, isLoading, blockaidError, quoteFetchError } = + useBridgeQuoteData(); const sourceToken = useSelector(selectSourceToken); const destToken = useSelector(selectDestToken); const latestSourceBalance = useLatestBalance({ @@ -131,10 +124,10 @@ export const QuoteSelectorView = () => { // Go back to bridge view only if there's an error or quotes are expired useEffect(() => { - if (quoteFetchError || blockaidError || (isExpired && !willRefresh)) { + if (quoteFetchError || blockaidError) { navigation.goBack(); } - }, [quoteFetchError, blockaidError, isExpired, navigation, willRefresh]); + }, [quoteFetchError, blockaidError, navigation]); return ( diff --git a/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.test.tsx b/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.test.tsx index 6780302c682..15f5f0cceb7 100644 --- a/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.test.tsx +++ b/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.test.tsx @@ -140,10 +140,6 @@ jest.mock('../../hooks/useSlippageConfig', () => ({ useSlippageConfig: jest.fn(), })); -jest.mock('../../hooks/useModalCloseOnQuoteExpiry', () => ({ - useModalCloseOnQuoteExpiry: jest.fn(), -})); - jest.mock('../../hooks/useShouldDisableCustomSlippageConfirm', () => ({ useShouldDisableCustomSlippageConfirm: jest.fn(), })); @@ -184,15 +180,11 @@ import { useSlippageStepperDescription } from '../../hooks/useSlippageStepperDes import { useParams } from '../../../../../util/navigation/navUtils'; import { InputStepper } from '../InputStepper'; import Keypad from '../../../../Base/Keypad'; -import { useModalCloseOnQuoteExpiry } from '../../hooks/useModalCloseOnQuoteExpiry'; const mockUseSlippageConfig = useSlippageConfig as jest.MockedFunction< typeof useSlippageConfig >; -const mockUseModalCloseOnQuoteExpiry = - useModalCloseOnQuoteExpiry as jest.MockedFunction< - typeof useModalCloseOnQuoteExpiry - >; + const mockUseShouldDisableCustomSlippageConfirm = useShouldDisableCustomSlippageConfirm as jest.MockedFunction< typeof useShouldDisableCustomSlippageConfirm @@ -955,20 +947,6 @@ describe('CustomSlippageModal', () => { }); }); - describe('useModalCloseOnQuoteExpiry', () => { - it('calls useModalCloseOnQuoteExpiry on render', () => { - render(); - - expect(mockUseModalCloseOnQuoteExpiry).toHaveBeenCalled(); - }); - - it('calls useModalCloseOnQuoteExpiry exactly once per render', () => { - render(); - - expect(mockUseModalCloseOnQuoteExpiry).toHaveBeenCalledTimes(1); - }); - }); - describe('handleClose functionality', () => { it('closes modal via header close button', () => { const { getByLabelText } = render(); diff --git a/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.tsx b/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.tsx index 243b1aa1849..b94daaa162a 100644 --- a/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.tsx +++ b/app/components/UI/Bridge/components/SlippageModal/CustomSlippageModal.tsx @@ -23,11 +23,9 @@ import { import { useDispatch, useSelector } from 'react-redux'; import { useSlippageStepperDescription } from '../../hooks/useSlippageStepperDescription'; import { useShouldDisableCustomSlippageConfirm } from '../../hooks/useShouldDisableCustomSlippageConfirm'; -import { useModalCloseOnQuoteExpiry } from '../../hooks/useModalCloseOnQuoteExpiry'; export const CustomSlippageModal = () => { const dispatch = useDispatch(); - useModalCloseOnQuoteExpiry(); const sheetRef = useRef(null); const { sourceChainId, destChainId } = useParams(); diff --git a/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.test.tsx b/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.test.tsx index 774e5839ded..fc5ba455bef 100644 --- a/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.test.tsx +++ b/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.test.tsx @@ -74,10 +74,6 @@ jest.mock('../../hooks/useSlippageConfig', () => ({ useSlippageConfig: jest.fn(), })); -jest.mock('../../hooks/useModalCloseOnQuoteExpiry', () => ({ - useModalCloseOnQuoteExpiry: jest.fn(), -})); - jest.mock('../../../../../util/navigation/navUtils', () => ({ useParams: jest.fn(), })); @@ -119,7 +115,6 @@ import { useGetSlippageOptions } from '../../hooks/useGetSlippageOptions'; import { useSlippageConfig } from '../../hooks/useSlippageConfig'; import { useParams } from '../../../../../util/navigation/navUtils'; import { AUTO_SLIPPAGE_VALUE } from './constants'; -import { useModalCloseOnQuoteExpiry } from '../../hooks/useModalCloseOnQuoteExpiry'; const mockUseGetSlippageOptions = useGetSlippageOptions as jest.MockedFunction< typeof useGetSlippageOptions @@ -128,10 +123,6 @@ const mockUseSlippageConfig = useSlippageConfig as jest.MockedFunction< typeof useSlippageConfig >; const mockUseParams = useParams as jest.MockedFunction; -const mockUseModalCloseOnQuoteExpiry = - useModalCloseOnQuoteExpiry as jest.MockedFunction< - typeof useModalCloseOnQuoteExpiry - >; describe('DefaultSlippageModal', () => { const mockSlippageConfig = { @@ -644,20 +635,6 @@ describe('DefaultSlippageModal', () => { }); }); - describe('useModalCloseOnQuoteExpiry', () => { - it('calls useModalCloseOnQuoteExpiry on render', () => { - render(); - - expect(mockUseModalCloseOnQuoteExpiry).toHaveBeenCalled(); - }); - - it('calls useModalCloseOnQuoteExpiry exactly once per render', () => { - render(); - - expect(mockUseModalCloseOnQuoteExpiry).toHaveBeenCalledTimes(1); - }); - }); - describe('auto slippage behavior', () => { it('dispatches undefined for auto slippage on submit', () => { mockSelector.mockReturnValue(undefined); diff --git a/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.tsx b/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.tsx index 9dda2b94e51..2d34b5caa77 100644 --- a/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.tsx +++ b/app/components/UI/Bridge/components/SlippageModal/DefaultSlippageModal.tsx @@ -26,12 +26,10 @@ import { DefaultSlippageModalParams } from './types'; import { useParams } from '../../../../../util/navigation/navUtils'; import { useSlippageConfig } from '../../hooks/useSlippageConfig'; import { SlippageType } from '../../types'; -import { useModalCloseOnQuoteExpiry } from '../../hooks/useModalCloseOnQuoteExpiry'; export const DefaultSlippageModal = () => { const navigation = useNavigation(); const dispatch = useDispatch(); - useModalCloseOnQuoteExpiry(); const sheetRef = useRef(null); const slippage = useSelector(selectSlippage); const [selectedSlippage, setSelectedSlippage] = useState( diff --git a/app/components/UI/Bridge/components/SwapsConfirmButton/SwapsConfirmButton.test.tsx b/app/components/UI/Bridge/components/SwapsConfirmButton/SwapsConfirmButton.test.tsx index 10f3774e5f1..8235fb5ee25 100644 --- a/app/components/UI/Bridge/components/SwapsConfirmButton/SwapsConfirmButton.test.tsx +++ b/app/components/UI/Bridge/components/SwapsConfirmButton/SwapsConfirmButton.test.tsx @@ -776,7 +776,7 @@ describe('SwapsConfirmButton', () => { .mocked(useBridgeQuoteData as unknown as jest.Mock) .mockImplementation(() => ({ ...mockUseBridgeQuoteData, - isExpired: true, + needsNewQuote: true, isLoading: false, })); @@ -800,7 +800,7 @@ describe('SwapsConfirmButton', () => { .mocked(useBridgeQuoteData as unknown as jest.Mock) .mockImplementation(() => ({ ...mockUseBridgeQuoteData, - isExpired: true, + needsNewQuote: true, isLoading: false, })); @@ -823,7 +823,7 @@ describe('SwapsConfirmButton', () => { .mocked(useBridgeQuoteData as unknown as jest.Mock) .mockImplementation(() => ({ ...mockUseBridgeQuoteData, - isExpired: true, + needsNewQuote: true, isLoading: false, })); @@ -853,7 +853,7 @@ describe('SwapsConfirmButton', () => { .mocked(useBridgeQuoteData as unknown as jest.Mock) .mockImplementation(() => ({ ...mockUseBridgeQuoteData, - isExpired: true, + needsNewQuote: true, isLoading: true, activeQuote: null, })); @@ -868,7 +868,7 @@ describe('SwapsConfirmButton', () => { }, ); - // needsNewQuote is true because there is no active quote + // needsNewQuote is true because the hook computed it from isExpired=true with no activeQuote expect( getByText(strings('quote_expired_modal.get_new_quote')), ).toBeTruthy(); @@ -882,7 +882,7 @@ describe('SwapsConfirmButton', () => { .mocked(useBridgeQuoteData as unknown as jest.Mock) .mockImplementation(() => ({ ...mockUseBridgeQuoteData, - isExpired: true, + needsNewQuote: false, isLoading: true, activeQuote: mockActiveQuote, })); @@ -897,7 +897,7 @@ describe('SwapsConfirmButton', () => { }, ); - // needsNewQuote is false because activeQuote exists and isLoading is true + // needsNewQuote is false because hook suppresses it when activeQuote exists and isLoading is true expect( queryByText(strings('quote_expired_modal.get_new_quote')), ).toBeNull(); @@ -908,7 +908,7 @@ describe('SwapsConfirmButton', () => { .mocked(useBridgeQuoteData as unknown as jest.Mock) .mockImplementation(() => ({ ...mockUseBridgeQuoteData, - isExpired: true, + needsNewQuote: false, isLoading: false, })); @@ -930,7 +930,7 @@ describe('SwapsConfirmButton', () => { }, ); - // needsNewQuote is false because isSubmittingTx is true + // needsNewQuote is false because the hook suppresses it when isSubmittingTx is true expect( queryByText(strings('quote_expired_modal.get_new_quote')), ).toBeNull(); diff --git a/app/components/UI/Bridge/components/SwapsConfirmButton/index.tsx b/app/components/UI/Bridge/components/SwapsConfirmButton/index.tsx index 904a7179eb3..93e55555a97 100644 --- a/app/components/UI/Bridge/components/SwapsConfirmButton/index.tsx +++ b/app/components/UI/Bridge/components/SwapsConfirmButton/index.tsx @@ -72,7 +72,7 @@ export const SwapsConfirmButton = ({ const { activeQuote, isLoading, - isExpired, + needsNewQuote, blockaidError, quoteFetchError, isNoQuotesAvailable, @@ -82,13 +82,6 @@ export const SwapsConfirmButton = ({ const hasSufficientGas = useHasSufficientGas({ quote: activeQuote }); - // The quote expired and no fetch is in progress — offer to get a new one. - // Also treat the edge-case where a fetch IS running but there is no active - // quote to fall back on — the user would otherwise be stuck on a spinner - // with no way to retry ("escape hatch"). - const needsNewQuote = - isExpired && !isSubmittingTx && (!isLoading || !activeQuote); - // Check both the display amount and the atomic amount are non-zero. // An amount like 0.000000001 BTC (8 decimals) is non-zero as a number but // resolves to 0 satoshis, meaning no quote will be fetched. diff --git a/app/components/UI/Bridge/hooks/useBridgeQuoteData/index.ts b/app/components/UI/Bridge/hooks/useBridgeQuoteData/index.ts index f3c896bc51a..57d4dd7a818 100644 --- a/app/components/UI/Bridge/hooks/useBridgeQuoteData/index.ts +++ b/app/components/UI/Bridge/hooks/useBridgeQuoteData/index.ts @@ -99,11 +99,28 @@ export const useBridgeQuoteData = ({ ) : undefined; - const activeQuote = + const rawActiveQuote = isExpired && !willRefresh && !isSubmittingTx ? undefined : (manuallySelectedQuote ?? bestQuote); + // When quotes are expired but the user hasn't yet triggered a new fetch, + // keep showing the last quotes that are still present in Redux. They are NOT + // cleared from the store on expiry — only BridgeController.resetState() + // (called on "Get new quote") removes them. Reading from Redux directly means + // every consumer of this hook (BridgeView, QuoteSelectorView, …) sees the + // same cached data without needing per-instance refs. + const isShowingCachedQuote = + isExpired && + !willRefresh && + !isSubmittingTx && + quotesLoadingStatus !== RequestStatus.LOADING && + !!(manuallySelectedQuote ?? bestQuote); + + const activeQuote = isShowingCachedQuote + ? (manuallySelectedQuote ?? bestQuote) + : rawActiveQuote; + // Validate that the quote's source asset matches the selected source token // This prevents showing stale quote data when user changes source token on the same chain const isQuoteSourceTokenMatch = useMemo(() => { @@ -144,16 +161,19 @@ export const useBridgeQuoteData = ({ const isQuoteDestTokenMatch = isQuoteDestTokenMatchForQuote(activeQuote); - // Filter all quotes to only include valid ones (not expired and matching dest token) + // Filter all quotes to only include valid ones (not expired and matching dest token). + // When showing cached data the expiry guard is bypassed so the Redux quotes + // that are still in the store remain visible until the user requests new ones. const validQuotes = useMemo( () => - isExpired && !willRefresh && !isSubmittingTx + isExpired && !willRefresh && !isSubmittingTx && !isShowingCachedQuote ? [] : allQuotes.filter((quote) => isQuoteDestTokenMatchForQuote(quote)), [ isExpired, willRefresh, isSubmittingTx, + isShowingCachedQuote, allQuotes, isQuoteDestTokenMatchForQuote, ], @@ -229,6 +249,13 @@ export const useBridgeQuoteData = ({ !bestQuote && quotesLastFetched && !isLoading, ); + // The quote expired and no fetch is in progress — offer to get a new one. + // Also treat the edge-case where a fetch IS running but there is no active + // quote to fall back on — the user would otherwise be stuck on a spinner + // with no way to retry ("escape hatch"). + const needsNewQuote = + isExpired && !isSubmittingTx && (!isLoading || !activeQuote); + const shouldShowPriceImpactWarning = Boolean( activeQuote?.quote.priceData?.priceImpact !== undefined && bridgeFeatureFlags?.priceImpactThreshold && @@ -319,5 +346,6 @@ export const useBridgeQuoteData = ({ blockaidError, shouldShowPriceImpactWarning, validQuotes, + needsNewQuote, }; }; diff --git a/app/components/UI/Bridge/hooks/useBridgeQuoteData/useBridgeQuoteData.test.ts b/app/components/UI/Bridge/hooks/useBridgeQuoteData/useBridgeQuoteData.test.ts index 52488512b3e..fe4922ecb97 100644 --- a/app/components/UI/Bridge/hooks/useBridgeQuoteData/useBridgeQuoteData.test.ts +++ b/app/components/UI/Bridge/hooks/useBridgeQuoteData/useBridgeQuoteData.test.ts @@ -141,6 +141,7 @@ describe('useBridgeQuoteData', () => { quoteFetchError: null, isNoQuotesAvailable: false, isExpired: false, + needsNewQuote: false, shouldShowPriceImpactWarning: false, willRefresh: false, blockaidError: null, @@ -296,6 +297,7 @@ describe('useBridgeQuoteData', () => { quoteFetchError: null, isNoQuotesAvailable: true, isExpired: false, + needsNewQuote: false, willRefresh: false, blockaidError: null, shouldShowPriceImpactWarning: false, @@ -366,16 +368,26 @@ describe('useBridgeQuoteData', () => { state: testState, }); + // When expired but not loading, the hook serves the last known Redux quotes + // as a cache so the UI can keep displaying them until the user requests a + // fresh fetch via "Get new quote". expect(result.current).toEqual({ - activeQuote: undefined, + activeQuote: mockQuoteWithMetadata, bestQuote: mockQuoteWithMetadata, destTokenAmount: undefined, - formattedQuoteData: undefined, + formattedQuoteData: { + estimatedTime: '5 seconds', + networkFee: '-', + priceImpact: '-0.20%', + rate: '--', + slippage: '0.5%', + }, isLoading: false, quoteFetchError: null, isNoQuotesAvailable: false, shouldShowPriceImpactWarning: false, isExpired: true, + needsNewQuote: true, willRefresh: false, blockaidError: null, quotesLoadingStatus: null, @@ -411,6 +423,7 @@ describe('useBridgeQuoteData', () => { quoteFetchError: null, isNoQuotesAvailable: false, isExpired: false, + needsNewQuote: false, shouldShowPriceImpactWarning: false, willRefresh: false, blockaidError: null, @@ -449,6 +462,7 @@ describe('useBridgeQuoteData', () => { quoteFetchError: error, isNoQuotesAvailable: false, isExpired: false, + needsNewQuote: false, willRefresh: false, blockaidError: null, quotesLoadingStatus: null, @@ -1462,7 +1476,7 @@ describe('useBridgeQuoteData', () => { }); }); - it('does not override activeQuote with manually selected when expired and not refreshing', () => { + it('keeps showing manually selected quote as activeQuote when expired and not refreshing', () => { const manuallySelectedQuote = { ...mockQuoteWithMetadata, quote: { @@ -1501,8 +1515,9 @@ describe('useBridgeQuoteData', () => { state: testState, }); - // When expired and not refreshing and not submitting, activeQuote should be undefined - expect(result.current.activeQuote).toBeUndefined(); + // When expired but not loading, the last known Redux quotes are served as + // a cache. The manually-selected quote is still shown (not cleared). + expect(result.current.activeQuote).toEqual(manuallySelectedQuote); expect(result.current.isExpired).toBe(true); }); diff --git a/app/components/UI/Bridge/hooks/useModalCloseOnQuoteExpiry/index.test.ts b/app/components/UI/Bridge/hooks/useModalCloseOnQuoteExpiry/index.test.ts deleted file mode 100644 index 271db4145ca..00000000000 --- a/app/components/UI/Bridge/hooks/useModalCloseOnQuoteExpiry/index.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; -import { useModalCloseOnQuoteExpiry } from './index'; -import { useBridgeQuoteData } from '../useBridgeQuoteData'; -import Routes from '../../../../../constants/navigation/Routes'; -import { CommonActions } from '@react-navigation/native'; - -jest.mock('../useBridgeQuoteData', () => ({ - useBridgeQuoteData: jest.fn(), -})); - -const mockDispatch = jest.fn(); -jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual('@react-navigation/native'), - useNavigation: () => ({ - dispatch: mockDispatch, - }), -})); - -const mockUseBridgeQuoteData = { - isExpired: false, - willRefresh: false, -}; - -describe('useModalCloseOnQuoteExpiry', () => { - beforeEach(() => { - jest.clearAllMocks(); - jest - .mocked(useBridgeQuoteData) - .mockReturnValue( - mockUseBridgeQuoteData as ReturnType, - ); - }); - - it('dispatches a reset to QuoteExpiredModal when quote is expired and will not refresh', () => { - // Arrange - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: true, - willRefresh: false, - } as ReturnType); - - // Act - renderHookWithProvider(() => useModalCloseOnQuoteExpiry()); - - // Assert - expect(mockDispatch).toHaveBeenCalledWith( - CommonActions.reset({ - index: 0, - routes: [{ name: Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL }], - }), - ); - }); - - it('does not dispatch when quote is not expired', () => { - // Arrange - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: false, - willRefresh: false, - } as ReturnType); - - // Act - renderHookWithProvider(() => useModalCloseOnQuoteExpiry()); - - // Assert - expect(mockDispatch).not.toHaveBeenCalled(); - }); - - it('does not dispatch when quote is expired but will refresh', () => { - // Arrange - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: true, - willRefresh: true, - } as ReturnType); - - // Act - renderHookWithProvider(() => useModalCloseOnQuoteExpiry()); - - // Assert - expect(mockDispatch).not.toHaveBeenCalled(); - }); - - it('dispatches again when quote transitions from not-expired to expired', () => { - // Arrange – start with not expired - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: false, - willRefresh: false, - } as ReturnType); - - const { rerender } = renderHookWithProvider(() => - useModalCloseOnQuoteExpiry(), - ); - - expect(mockDispatch).not.toHaveBeenCalled(); - - // Quote expires - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: true, - willRefresh: false, - } as ReturnType); - - // Act - rerender({}); - - // Assert - expect(mockDispatch).toHaveBeenCalledTimes(1); - expect(mockDispatch).toHaveBeenCalledWith( - CommonActions.reset({ - index: 0, - routes: [{ name: Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL }], - }), - ); - }); - - it('dispatches reset with index 0 so QuoteExpiredModal is the only route', () => { - // Arrange - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: true, - willRefresh: false, - } as ReturnType); - - // Act - renderHookWithProvider(() => useModalCloseOnQuoteExpiry()); - - // Assert - const dispatchedAction = mockDispatch.mock.calls[0][0]; - expect(dispatchedAction.payload.index).toBe(0); - expect(dispatchedAction.payload.routes).toHaveLength(1); - expect(dispatchedAction.payload.routes[0].name).toBe( - Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL, - ); - }); -}); diff --git a/app/components/UI/Bridge/hooks/useModalCloseOnQuoteExpiry/index.ts b/app/components/UI/Bridge/hooks/useModalCloseOnQuoteExpiry/index.ts deleted file mode 100644 index a5f4b7394c9..00000000000 --- a/app/components/UI/Bridge/hooks/useModalCloseOnQuoteExpiry/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { useEffect } from 'react'; -import { CommonActions, useNavigation } from '@react-navigation/native'; -import { useBridgeQuoteData } from '../useBridgeQuoteData'; -import Routes from '../../../../../constants/navigation/Routes'; - -/** - * Resets the BridgeModalStack to show only QuoteExpiredModal when quotes expire. - * - * Must be called from a screen that lives inside BridgeModalStack so that - * CommonActions.reset targets BridgeModalStack (not the root navigator). - * This prevents the previous modal's BottomSheetOverlay from remaining - * visible behind QuoteExpiredModal. - */ -export const useModalCloseOnQuoteExpiry = () => { - const navigation = useNavigation(); - const { isExpired, willRefresh } = useBridgeQuoteData(); - - useEffect(() => { - if (isExpired && !willRefresh) { - navigation.dispatch( - CommonActions.reset({ - index: 0, - routes: [{ name: Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL }], - }), - ); - } - }, [isExpired, willRefresh, navigation]); -}; diff --git a/app/components/UI/Bridge/hooks/useRenderQuoteExpireModal/index.ts b/app/components/UI/Bridge/hooks/useRenderQuoteExpireModal/index.ts deleted file mode 100644 index c190735edf7..00000000000 --- a/app/components/UI/Bridge/hooks/useRenderQuoteExpireModal/index.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { RefObject, useEffect, useRef } from 'react'; -import { TokenInputAreaRef } from '../../components/TokenInputArea'; -import { useBridgeQuoteData } from '../useBridgeQuoteData'; -import { useLatestBalance } from '../useLatestBalance'; -import { useSelector } from 'react-redux'; -import { - selectIsSelectingRecipient, - selectIsSelectingToken, - selectIsSubmittingTx, -} from '../../../../../core/redux/slices/bridge'; -import { useIsFocused, useNavigation } from '@react-navigation/native'; -import Routes from '../../../../../constants/navigation/Routes'; - -interface Params { - inputRef: RefObject; - latestSourceBalance: ReturnType; -} - -export const useRenderQuoteExpireModal = ({ - inputRef, - latestSourceBalance, -}: Params) => { - const navigation = useNavigation(); - const isBridgeViewFocused = useIsFocused(); - const isSelectingRecipient = useSelector(selectIsSelectingRecipient); - const isSelectingToken = useSelector(selectIsSelectingToken); - const isSubmittingTx = useSelector(selectIsSubmittingTx); - - const { isExpired, willRefresh } = useBridgeQuoteData({ - latestSourceAtomicBalance: latestSourceBalance?.atomicBalance, - }); - - // Track whether the expired-quote modal has already been shown for the - // current expiry cycle, so it doesn't re-trigger when unrelated deps - // (e.g. isSelectingToken) flip back after navigation. - const hasShownExpiredModal = useRef(false); - - // Reset the flag whenever the quote is no longer expired - // (i.e. a new quote was fetched or is loading). - useEffect(() => { - if (!isExpired) { - hasShownExpiredModal.current = false; - } - }, [isExpired]); - - useEffect(() => { - if ( - isExpired && - !willRefresh && - isBridgeViewFocused && - !isSelectingRecipient && - !isSelectingToken && - !isSubmittingTx && - !hasShownExpiredModal.current - ) { - hasShownExpiredModal.current = true; - inputRef.current?.blur(); - // open the quote tooltip modal - navigation.navigate(Routes.BRIDGE.MODALS.ROOT, { - screen: Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL, - }); - } - }, [ - isExpired, - willRefresh, - navigation, - isBridgeViewFocused, - isSelectingRecipient, - isSelectingToken, - isSubmittingTx, - inputRef, - ]); -}; diff --git a/app/components/UI/Bridge/hooks/useRenderQuoteExpireModal/useRenderQuoteExpireModal.test.ts b/app/components/UI/Bridge/hooks/useRenderQuoteExpireModal/useRenderQuoteExpireModal.test.ts deleted file mode 100644 index f595d5ffb7e..00000000000 --- a/app/components/UI/Bridge/hooks/useRenderQuoteExpireModal/useRenderQuoteExpireModal.test.ts +++ /dev/null @@ -1,393 +0,0 @@ -import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; -import { useRenderQuoteExpireModal } from './index'; -import { useBridgeQuoteData } from '../useBridgeQuoteData'; -import Routes from '../../../../../constants/navigation/Routes'; -import { BigNumber } from 'ethers'; - -// Mock useBridgeQuoteData -const mockUseBridgeQuoteData = { - isExpired: false, - willRefresh: false, -}; -jest.mock('../useBridgeQuoteData', () => ({ - useBridgeQuoteData: jest.fn(), -})); - -// Mock redux selectors -jest.mock('../../../../../core/redux/slices/bridge', () => ({ - ...jest.requireActual('../../../../../core/redux/slices/bridge'), - selectIsSelectingRecipient: jest.fn().mockReturnValue(false), - selectIsSelectingToken: jest.fn().mockReturnValue(false), - selectIsSubmittingTx: jest.fn().mockReturnValue(false), -})); - -import { - selectIsSelectingRecipient, - selectIsSelectingToken, - selectIsSubmittingTx, -} from '../../../../../core/redux/slices/bridge'; - -// Mock navigation -const mockNavigate = jest.fn(); -let mockIsFocused = true; -jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual('@react-navigation/native'), - useNavigation: () => ({ - navigate: mockNavigate, - setOptions: jest.fn(), - }), - useIsFocused: () => mockIsFocused, -})); - -const createMockInputRef = () => ({ - current: { - blur: jest.fn(), - focus: jest.fn(), - isFocused: jest.fn().mockReturnValue(false), - }, -}); - -const mockLatestSourceBalance = { - displayBalance: '2.0', - atomicBalance: BigNumber.from('2000000000000000000'), -}; - -describe('useRenderQuoteExpireModal', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockIsFocused = true; - jest - .mocked(useBridgeQuoteData) - .mockReturnValue( - mockUseBridgeQuoteData as ReturnType, - ); - jest.mocked(selectIsSelectingRecipient).mockReturnValue(false); - jest.mocked(selectIsSelectingToken).mockReturnValue(false); - jest.mocked(selectIsSubmittingTx).mockReturnValue(false); - }); - - it('navigates to quote expired modal when quote is expired and will not refresh', () => { - // Arrange - const inputRef = createMockInputRef(); - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: true, - willRefresh: false, - } as ReturnType); - - // Act - renderHookWithProvider(() => - useRenderQuoteExpireModal({ - inputRef, - latestSourceBalance: mockLatestSourceBalance, - }), - ); - - // Assert - expect(inputRef.current.blur).toHaveBeenCalled(); - expect(mockNavigate).toHaveBeenCalledWith(Routes.BRIDGE.MODALS.ROOT, { - screen: Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL, - }); - }); - - it('does not navigate when quote is not expired', () => { - // Arrange - const inputRef = createMockInputRef(); - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: false, - willRefresh: false, - } as ReturnType); - - // Act - renderHookWithProvider(() => - useRenderQuoteExpireModal({ - inputRef, - latestSourceBalance: mockLatestSourceBalance, - }), - ); - - // Assert - expect(inputRef.current.blur).not.toHaveBeenCalled(); - expect(mockNavigate).not.toHaveBeenCalled(); - }); - - it('does not navigate when quote is expired but will refresh', () => { - // Arrange - const inputRef = createMockInputRef(); - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: true, - willRefresh: true, - } as ReturnType); - - // Act - renderHookWithProvider(() => - useRenderQuoteExpireModal({ - inputRef, - latestSourceBalance: mockLatestSourceBalance, - }), - ); - - // Assert - expect(inputRef.current.blur).not.toHaveBeenCalled(); - expect(mockNavigate).not.toHaveBeenCalled(); - }); - - it('does not navigate when user is selecting a recipient', () => { - // Arrange - const inputRef = createMockInputRef(); - jest.mocked(selectIsSelectingRecipient).mockReturnValue(true); - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: true, - willRefresh: false, - } as ReturnType); - - // Act - renderHookWithProvider(() => - useRenderQuoteExpireModal({ - inputRef, - latestSourceBalance: mockLatestSourceBalance, - }), - ); - - // Assert - expect(mockNavigate).not.toHaveBeenCalled(); - }); - - it('does not navigate when user is selecting a token', () => { - // Arrange - const inputRef = createMockInputRef(); - jest.mocked(selectIsSelectingToken).mockReturnValue(true); - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: true, - willRefresh: false, - } as ReturnType); - - // Act - renderHookWithProvider(() => - useRenderQuoteExpireModal({ - inputRef, - latestSourceBalance: mockLatestSourceBalance, - }), - ); - - // Assert - expect(mockNavigate).not.toHaveBeenCalled(); - }); - - it('does not navigate when bridge view is not focused', () => { - // Arrange - const inputRef = createMockInputRef(); - mockIsFocused = false; - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: true, - willRefresh: false, - } as ReturnType); - - // Act - renderHookWithProvider(() => - useRenderQuoteExpireModal({ - inputRef, - latestSourceBalance: mockLatestSourceBalance, - }), - ); - - // Assert - expect(inputRef.current.blur).not.toHaveBeenCalled(); - expect(mockNavigate).not.toHaveBeenCalled(); - }); - - it('does not navigate when a transaction is being submitted', () => { - // Arrange - const inputRef = createMockInputRef(); - jest.mocked(selectIsSubmittingTx).mockReturnValue(true); - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: true, - willRefresh: false, - } as ReturnType); - - // Act - renderHookWithProvider(() => - useRenderQuoteExpireModal({ - inputRef, - latestSourceBalance: mockLatestSourceBalance, - }), - ); - - // Assert - expect(mockNavigate).not.toHaveBeenCalled(); - }); - - it('does not show modal twice for the same expiry cycle', () => { - // Arrange - const inputRef = createMockInputRef(); - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: true, - willRefresh: false, - } as ReturnType); - - // Act – render once (modal shown), then rerender - const { rerender } = renderHookWithProvider(() => - useRenderQuoteExpireModal({ - inputRef, - latestSourceBalance: mockLatestSourceBalance, - }), - ); - - expect(mockNavigate).toHaveBeenCalledTimes(1); - - // Trigger a rerender with same expired state - rerender({}); - - // Assert – should still only have been called once - expect(mockNavigate).toHaveBeenCalledTimes(1); - }); - - it('shows modal after bridge view regains focus while quote remains expired', () => { - // Arrange - const inputRef = createMockInputRef(); - mockIsFocused = false; - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: true, - willRefresh: false, - } as ReturnType); - - // Act – render out of focus first - const { rerender } = renderHookWithProvider(() => - useRenderQuoteExpireModal({ - inputRef, - latestSourceBalance: mockLatestSourceBalance, - }), - ); - - expect(mockNavigate).not.toHaveBeenCalled(); - - // Regain focus while quote is still expired - mockIsFocused = true; - rerender({}); - - // Assert - expect(inputRef.current.blur).toHaveBeenCalledTimes(1); - expect(mockNavigate).toHaveBeenCalledTimes(1); - expect(mockNavigate).toHaveBeenCalledWith(Routes.BRIDGE.MODALS.ROOT, { - screen: Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL, - }); - }); - - it('resets and shows modal again after quote recovers then expires again', () => { - // Arrange - const inputRef = createMockInputRef(); - - // First render: expired - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: true, - willRefresh: false, - } as ReturnType); - - const { rerender } = renderHookWithProvider(() => - useRenderQuoteExpireModal({ - inputRef, - latestSourceBalance: mockLatestSourceBalance, - }), - ); - - expect(mockNavigate).toHaveBeenCalledTimes(1); - - // Quote recovers (not expired anymore) - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: false, - willRefresh: false, - } as ReturnType); - - rerender({}); - - // Quote expires again - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: true, - willRefresh: false, - } as ReturnType); - - rerender({}); - - // Assert – modal shown a second time - expect(mockNavigate).toHaveBeenCalledTimes(2); - }); - - it('blurs the input ref before navigating', () => { - // Arrange - const inputRef = createMockInputRef(); - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: true, - willRefresh: false, - } as ReturnType); - - // Act - renderHookWithProvider(() => - useRenderQuoteExpireModal({ - inputRef, - latestSourceBalance: mockLatestSourceBalance, - }), - ); - - // Assert - expect(inputRef.current.blur).toHaveBeenCalledTimes(1); - }); - - it('handles null inputRef.current gracefully', () => { - // Arrange - const inputRef = { current: null }; - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: true, - willRefresh: false, - } as ReturnType); - - // Act & Assert – should not throw - expect(() => - renderHookWithProvider(() => - useRenderQuoteExpireModal({ - inputRef, - latestSourceBalance: mockLatestSourceBalance, - }), - ), - ).not.toThrow(); - - expect(mockNavigate).toHaveBeenCalledWith(Routes.BRIDGE.MODALS.ROOT, { - screen: Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL, - }); - }); - - it('handles undefined latestSourceBalance', () => { - // Arrange - const inputRef = createMockInputRef(); - jest.mocked(useBridgeQuoteData).mockReturnValue({ - ...mockUseBridgeQuoteData, - isExpired: true, - willRefresh: false, - } as ReturnType); - - // Act - renderHookWithProvider(() => - useRenderQuoteExpireModal({ - inputRef, - latestSourceBalance: undefined, - }), - ); - - // Assert – should still show the modal - expect(mockNavigate).toHaveBeenCalledWith(Routes.BRIDGE.MODALS.ROOT, { - screen: Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL, - }); - }); -}); diff --git a/app/components/UI/Bridge/routes.tsx b/app/components/UI/Bridge/routes.tsx index 163f44e9d3c..7cdcaac9460 100644 --- a/app/components/UI/Bridge/routes.tsx +++ b/app/components/UI/Bridge/routes.tsx @@ -4,7 +4,6 @@ import Routes from '../../../constants/navigation/Routes'; import { BridgeTokenSelector } from './components/BridgeTokenSelector'; import BridgeView from './Views/BridgeView'; import BlockExplorersModal from './components/TransactionDetails/BlockExplorersModal'; -import QuoteExpiredModal from './components/QuoteExpiredModal'; import BlockaidModal from './components/BlockaidModal'; import RecipientSelectorModal from './components/RecipientSelectorModal'; import MarketClosedBottomSheet from './components/MarketClosedBottomSheets/MarketClosedBottomSheet'; @@ -66,10 +65,6 @@ export const BridgeModalStack = () => ( name={Routes.BRIDGE.MODALS.TRANSACTION_DETAILS_BLOCK_EXPLORER} component={BlockExplorersModal} /> - Date: Tue, 17 Mar 2026 07:19:39 +0900 Subject: [PATCH 040/206] fix(ramp): Correct ramp v2 order detail's crypto amount formatting (#27469) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Fixes excessive decimal places on the Order Details screen for ramp purchases. When buying crypto (e.g. ETH via Mercuryo), the `cryptoAmount` was displayed raw with up to 17 decimal digits (`0.01588973776561068`), breaking the layout. Now the amount is formatted consistently with the home screen: - **Normal amounts** are capped at 5 decimal places using `formatWithThreshold` (e.g. `0.01589 ETH`) - **Very small amounts** (< 0.0001) use subscript notation via `formatSubscriptNotation` (e.g. `0.0₅4567 BTC`) - **Missing amounts** show `...` ## **Changelog** CHANGELOG entry: Fixed Order Details screen displaying excessive decimal places for crypto amounts after ramp purchases. ## **Related issues** Refs: ## **Manual testing steps** ```gherkin Feature: Order Details crypto amount formatting Scenario: Normal crypto amount is truncated to 5 decimal places Given the user has completed a buy order (e.g. ETH via Mercuryo) And the order cryptoAmount has many decimal places (e.g. 0.01588973776561068) When the Order Details screen is displayed Then the crypto amount is shown as "0.01589 ETH" And the layout is not broken by excessive decimals Scenario: Very small crypto amount uses subscript notation Given the user has completed a buy order with a very small amount (e.g. $5 of BTC) And the order cryptoAmount is less than 0.0001 (e.g. 0.00000456789) When the Order Details screen is displayed Then the crypto amount is shown with subscript notation (e.g. "0.0₅4567 BTC") ``` ## **Screenshots/Recordings** ### **Before** Screenshot 2026-03-13 180811 ### **After** Screenshot 2026-03-13 175207 Screenshot 2026-03-13 175321 ## **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 - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Low risk UI-only change that adjusts number formatting on the ramp Order Details screen and adds unit tests for edge cases (tiny, long-decimal, missing, and zero amounts). > > **Overview** > Fixes ramp v2 Order Details to **format `cryptoAmount` instead of rendering the raw number**, preventing long decimals from breaking layout. > > Amounts now prefer `formatSubscriptNotation` for very small values, otherwise use `formatWithThreshold` with a 5-decimal cap and current `I18n.locale`; missing amounts render as `...`. Adds tests covering long decimals, subscript formatting, missing values, and zero. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 188a75430d3ed7638e63537e37e0442a23902592. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../Views/OrderDetails/OrderContent.test.tsx | 42 +++++++++++++++++++ .../Ramp/Views/OrderDetails/OrderContent.tsx | 20 ++++++++- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/app/components/UI/Ramp/Views/OrderDetails/OrderContent.test.tsx b/app/components/UI/Ramp/Views/OrderDetails/OrderContent.test.tsx index 099eedd049b..dd8acc74a21 100644 --- a/app/components/UI/Ramp/Views/OrderDetails/OrderContent.test.tsx +++ b/app/components/UI/Ramp/Views/OrderDetails/OrderContent.test.tsx @@ -166,6 +166,48 @@ describe('OrderContent', () => { ).toBeOnTheScreen(); }); + it('truncates long crypto amounts to 5 decimal places', () => { + const longDecimalOrder: RampsOrder = { + ...mockOrder, + cryptoAmount: 0.01588973776561068, + }; + renderOrder(longDecimalOrder); + const tokenAmount = screen.getByTestId('ramps-order-details-token-amount'); + expect(tokenAmount.props.children).not.toContain('0.01588973776561068'); + expect(tokenAmount).toHaveTextContent('0.01589 ETH'); + }); + + it('uses subscript notation for very small crypto amounts', () => { + const tinyAmountOrder: RampsOrder = { + ...mockOrder, + cryptoAmount: 0.00000614, + }; + renderOrder(tinyAmountOrder); + const tokenAmount = screen.getByTestId('ramps-order-details-token-amount'); + // 0.00000614 has 5 leading zeros → "0.0₅614" + expect(tokenAmount).toHaveTextContent('0.0₅614 ETH'); + }); + + it('shows "..." when cryptoAmount is missing', () => { + const noAmountOrder: RampsOrder = { + ...mockOrder, + cryptoAmount: undefined as unknown as number, + }; + renderOrder(noAmountOrder); + const tokenAmount = screen.getByTestId('ramps-order-details-token-amount'); + expect(tokenAmount).toHaveTextContent('... ETH'); + }); + + it('renders "0" when cryptoAmount is zero', () => { + const zeroAmountOrder: RampsOrder = { + ...mockOrder, + cryptoAmount: 0, + }; + renderOrder(zeroAmountOrder); + const tokenAmount = screen.getByTestId('ramps-order-details-token-amount'); + expect(tokenAmount).toHaveTextContent('0 ETH'); + }); + it('does not render info row when statusDescription is absent', () => { const orderWithoutDescription: RampsOrder = { ...mockOrder, diff --git a/app/components/UI/Ramp/Views/OrderDetails/OrderContent.tsx b/app/components/UI/Ramp/Views/OrderDetails/OrderContent.tsx index c73658759dd..ed6c21707a0 100644 --- a/app/components/UI/Ramp/Views/OrderDetails/OrderContent.tsx +++ b/app/components/UI/Ramp/Views/OrderDetails/OrderContent.tsx @@ -24,9 +24,11 @@ import BadgeWrapper, { BadgePosition, } from '../../../../../component-library/components/Badges/BadgeWrapper'; import { AvatarSize } from '../../../../../component-library/components/Avatars/Avatar'; -import { strings } from '../../../../../../locales/i18n'; +import I18n, { strings } from '../../../../../../locales/i18n'; import { toDateFormat } from '../../../../../util/date'; import { renderFiat } from '../../../../../util/number'; +import { formatSubscriptNotation } from '../../../../../util/number/subscriptNotation'; +import { formatWithThreshold } from '../../../../../util/assets'; import { getNetworkImageSource } from '../../../../../util/networks'; import Logger from '../../../../../util/Logger'; import Button, { @@ -318,7 +320,21 @@ const OrderContent: React.FC = ({ fontWeight={FontWeight.Bold} twClassName="mt-6 text-center" > - {order.cryptoAmount} {cryptoSymbol} + {order.cryptoAmount != null + ? (formatSubscriptNotation( + parseFloat(String(order.cryptoAmount)), + ) ?? + formatWithThreshold( + parseFloat(String(order.cryptoAmount)), + 0.00001, + I18n.locale, + { + minimumFractionDigits: 0, + maximumFractionDigits: 5, + }, + )) + : '...'}{' '} + {cryptoSymbol}
From 62782c9aea6a87a5397d16f12690fe5e1a48238a Mon Sep 17 00:00:00 2001 From: Wei Sun Date: Mon, 16 Mar 2026 19:54:35 -0700 Subject: [PATCH 041/206] chore: default RAMP_INTERNAL_BUILD to false (#27507) ## **Description** Set RAMP_INTERNAL_BUILD default to false ## **Changelog** CHANGELOG entry: fixed RAMP_INTERNAL_BUILD default for OTA push ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Low code complexity, but it changes the build-time environment for the `push-eas-update` workflow, which could alter what gets included in published OTA updates if downstream logic depends on `RAMP_INTERNAL_BUILD`. Review impact on feature gating/config for release channels. > > **Overview** > Updates the `Push OTA Update` GitHub Actions workflow to set `RAMP_INTERNAL_BUILD` to `false` when publishing EAS OTA updates, changing the default build configuration used during `push-update`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 6aa5d92fa9d066c4617beeef567c081ea0dab90c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .github/workflows/push-eas-update.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/push-eas-update.yml b/.github/workflows/push-eas-update.yml index 2f780f98d38..6a01cc245f8 100644 --- a/.github/workflows/push-eas-update.yml +++ b/.github/workflows/push-eas-update.yml @@ -277,7 +277,7 @@ jobs: EXPO_PROJECT_ID: ${{ secrets.EXPO_PROJECT_ID }} EXPO_CHANNEL: ${{ vars.EXPO_CHANNEL }} GIT_BRANCH: ${{ github.ref_name }} - RAMP_INTERNAL_BUILD: 'true' + RAMP_INTERNAL_BUILD: 'false' MM_MUSD_CONVERSION_FLOW_ENABLED: 'false' MM_NETWORK_UI_REDESIGN_ENABLED: 'false' MM_NOTIFICATIONS_UI_ENABLED: 'true' From 4e1ff6b4528faebfc04da9f562f77945a2c878d8 Mon Sep 17 00:00:00 2001 From: Nico MASSART Date: Tue, 17 Mar 2026 11:26:16 +0100 Subject: [PATCH 042/206] refactor(analytics): PR A2 split types from MetaMetrics (#26988) ## **Description** Part of the analytics cleanup workstream (#26686). - Splits types from `MetaMetrics.types.ts` into: - `app/util/analytics/analyticsDataDeletion.types.ts` (data deletion) - `app/util/analytics/analytics.types.ts` (event/transitional + domain types). - Keeps `ISegmentClient` and `IMetaMetrics` in the old file and re-exports all moved types from the new locations so consumers remain unchanged. - Updates imports only in staying, not code-owned files. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: #26811 ## **Manual testing steps** N/A ## **Screenshots/Recordings** 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. ## **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** > Primarily a type-only refactor that moves analytics-related TypeScript types and updates imports, with minimal runtime impact beyond a small exported type guard. Risk is limited to potential compile-time/import path regressions in analytics and transaction-metrics call sites. > > **Overview** > Refactors analytics type ownership by moving legacy MetaMetrics event and JSON property types into `app/util/analytics/analytics.types.ts`, and extracting Segment data-deletion/GDPR types into the new `app/util/analytics/analyticsDataDeletion.types.ts`. > > Updates affected modules (including `useAnalytics`, `AnalyticsEventBuilder`, and transaction-controller metrics builders) to import these types from the new util locations, and adds a focused unit test for the new `isTrackingEvent` type guard. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 10086c1fc8c47b6d6762a55cba795875dbb42a3a. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../hooks/useAnalytics/useAnalytics.test.tsx | 2 +- .../hooks/useAnalytics/useAnalytics.types.ts | 14 ++-- .../event-handlers/metrics.ts | 2 +- .../metrics_properties/batch.ts | 2 +- .../metrics_properties/metamask-pay.ts | 2 +- .../security-alert-response.ts | 2 +- .../metrics_properties/simulation-values.ts | 2 +- .../transaction-controller/types.ts | 2 +- .../transaction-controller/utils.ts | 2 +- app/core/Engine/utils/analytics.ts | 4 +- .../analytics/AnalyticsEventBuilder.test.ts | 5 +- app/util/analytics/AnalyticsEventBuilder.ts | 4 +- .../analytics/actionButtonTracking.test.ts | 2 +- app/util/analytics/actionButtonTracking.ts | 5 +- app/util/analytics/analytics.types.test.ts | 32 ++++++++ app/util/analytics/analytics.types.ts | 76 +++++++++++++++++++ .../analytics/analyticsDataDeletion.test.ts | 2 +- app/util/analytics/analyticsDataDeletion.ts | 2 +- .../analytics/analyticsDataDeletion.types.ts | 46 +++++++++++ 19 files changed, 179 insertions(+), 29 deletions(-) create mode 100644 app/util/analytics/analytics.types.test.ts create mode 100644 app/util/analytics/analyticsDataDeletion.types.ts diff --git a/app/components/hooks/useAnalytics/useAnalytics.test.tsx b/app/components/hooks/useAnalytics/useAnalytics.test.tsx index 39d7c8b9446..2392d04b11a 100644 --- a/app/components/hooks/useAnalytics/useAnalytics.test.tsx +++ b/app/components/hooks/useAnalytics/useAnalytics.test.tsx @@ -4,7 +4,7 @@ import { DataDeleteStatus, type IDeleteRegulationResponse, type IDeleteRegulationStatus, -} from '../../../core/Analytics/MetaMetrics.types'; +} from '../../../util/analytics/analyticsDataDeletion.types'; import { AnalyticsEventBuilder, type AnalyticsTrackingEvent, diff --git a/app/components/hooks/useAnalytics/useAnalytics.types.ts b/app/components/hooks/useAnalytics/useAnalytics.types.ts index bb388e7f23c..9290757bf47 100644 --- a/app/components/hooks/useAnalytics/useAnalytics.types.ts +++ b/app/components/hooks/useAnalytics/useAnalytics.types.ts @@ -1,10 +1,12 @@ import { - DataDeleteDate, - IDeleteRegulationResponse, - IDeleteRegulationStatus, - type IMetaMetricsEvent, - type ITrackingEvent, -} from '../../../core/Analytics/MetaMetrics.types'; + type DataDeleteDate, + type IDeleteRegulationResponse, + type IDeleteRegulationStatus, +} from '../../../util/analytics/analyticsDataDeletion.types'; +import type { + IMetaMetricsEvent, + ITrackingEvent, +} from '../../../util/analytics/analytics.types'; import { AnalyticsEventBuilder, type AnalyticsTrackingEvent, diff --git a/app/core/Engine/controllers/transaction-controller/event-handlers/metrics.ts b/app/core/Engine/controllers/transaction-controller/event-handlers/metrics.ts index b8008d651b7..0ddf9ccfb82 100644 --- a/app/core/Engine/controllers/transaction-controller/event-handlers/metrics.ts +++ b/app/core/Engine/controllers/transaction-controller/event-handlers/metrics.ts @@ -3,7 +3,7 @@ import { merge } from 'lodash'; import { createProjectLogger } from '@metamask/utils'; import { TRANSACTION_EVENTS } from '../../../../Analytics/events/confirmations'; -import { IMetaMetricsEvent } from '../../../../Analytics/MetaMetrics.types'; +import { IMetaMetricsEvent } from '../../../../../util/analytics/analytics.types'; import { AnalyticsEventBuilder } from '../../../../../util/analytics/AnalyticsEventBuilder'; import { generateEvent, retryIfEngineNotInitialized } from '../utils'; import type { diff --git a/app/core/Engine/controllers/transaction-controller/metrics_properties/batch.ts b/app/core/Engine/controllers/transaction-controller/metrics_properties/batch.ts index f35f2a7c2b8..c54fdaba420 100644 --- a/app/core/Engine/controllers/transaction-controller/metrics_properties/batch.ts +++ b/app/core/Engine/controllers/transaction-controller/metrics_properties/batch.ts @@ -10,7 +10,7 @@ import type { TransactionMetrics, TransactionMetricsBuilderRequest, } from '../types'; -import { JsonMap } from '../../../../Analytics/MetaMetrics.types'; +import { JsonMap } from '../../../../../util/analytics/analytics.types'; import { getMethodData } from '../../../../../util/transactions'; import { EIP5792ErrorCode } from '../../../../../constants/transaction'; diff --git a/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.ts b/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.ts index a8dda5facb1..b537086fee0 100644 --- a/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.ts +++ b/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.ts @@ -3,7 +3,7 @@ import { TransactionType, } from '@metamask/transaction-controller'; import { TransactionMetricsBuilder } from '../types'; -import { JsonMap } from '../../../../Analytics/MetaMetrics.types'; +import { JsonMap } from '../../../../../util/analytics/analytics.types'; import { orderBy } from 'lodash'; import { NATIVE_TOKEN_ADDRESS } from '../../../../../components/Views/confirmations/constants/tokens'; import { hasTransactionType } from '../../../../../components/Views/confirmations/utils/transaction'; diff --git a/app/core/Engine/controllers/transaction-controller/metrics_properties/security-alert-response.ts b/app/core/Engine/controllers/transaction-controller/metrics_properties/security-alert-response.ts index eea96c67eed..b2efe11be28 100644 --- a/app/core/Engine/controllers/transaction-controller/metrics_properties/security-alert-response.ts +++ b/app/core/Engine/controllers/transaction-controller/metrics_properties/security-alert-response.ts @@ -1,7 +1,7 @@ import type { SecurityAlertResponse } from '@metamask/transaction-controller'; import { ResultType } from '../../../../../components/Views/confirmations/constants/signatures'; -import type { JsonMap } from '../../../../Analytics/MetaMetrics.types'; +import type { JsonMap } from '../../../../../util/analytics/analytics.types'; import type { TransactionMetrics, TransactionMetricsBuilderRequest, diff --git a/app/core/Engine/controllers/transaction-controller/metrics_properties/simulation-values.ts b/app/core/Engine/controllers/transaction-controller/metrics_properties/simulation-values.ts index 8332a1b4a56..1746f106ce1 100644 --- a/app/core/Engine/controllers/transaction-controller/metrics_properties/simulation-values.ts +++ b/app/core/Engine/controllers/transaction-controller/metrics_properties/simulation-values.ts @@ -1,5 +1,5 @@ import { TransactionMetricsBuilder } from '../types'; -import { JsonMap } from '../../../../Analytics/MetaMetrics.types'; +import { JsonMap } from '../../../../../util/analytics/analytics.types'; /** * Gets simulation asset fiat values for transaction metrics from TransactionMeta.assetsFiatValues. diff --git a/app/core/Engine/controllers/transaction-controller/types.ts b/app/core/Engine/controllers/transaction-controller/types.ts index 2ca76ee5136..1fb284e3474 100644 --- a/app/core/Engine/controllers/transaction-controller/types.ts +++ b/app/core/Engine/controllers/transaction-controller/types.ts @@ -1,7 +1,7 @@ import { JsonMap, IMetaMetricsEvent, -} from '../../../Analytics/MetaMetrics.types'; +} from '../../../../util/analytics/analytics.types'; import { SmartTransactionsController } from '@metamask/smart-transactions-controller'; import type { RootState } from '../../../../reducers'; import { TransactionControllerInitMessenger } from '../../messengers/transaction-controller-messenger'; diff --git a/app/core/Engine/controllers/transaction-controller/utils.ts b/app/core/Engine/controllers/transaction-controller/utils.ts index a63fafce2ef..450e347e036 100644 --- a/app/core/Engine/controllers/transaction-controller/utils.ts +++ b/app/core/Engine/controllers/transaction-controller/utils.ts @@ -4,7 +4,7 @@ import { MetricsEventBuilder } from '../../../Analytics/MetricsEventBuilder'; import { JsonMap, IMetaMetricsEvent, -} from '../../../Analytics/MetaMetrics.types'; +} from '../../../../util/analytics/analytics.types'; import { TRANSACTION_EVENTS } from '../../../Analytics/events/confirmations'; import type { TransactionEventHandlerRequest, diff --git a/app/core/Engine/utils/analytics.ts b/app/core/Engine/utils/analytics.ts index 1ba4fb991d6..86c50996567 100644 --- a/app/core/Engine/utils/analytics.ts +++ b/app/core/Engine/utils/analytics.ts @@ -1,10 +1,10 @@ import type { ControllerMessenger } from '../types'; import type { AnalyticsTrackingEvent } from '@metamask/analytics-controller'; import type { + AnalyticsUnfilteredProperties, IMetaMetricsEvent, ITrackingEvent, -} from '../../../core/Analytics/MetaMetrics.types'; -import type { AnalyticsUnfilteredProperties } from '../../../util/analytics/analytics.types'; +} from '../../../util/analytics/analytics.types'; import Logger from '../../../util/Logger'; import { AnalyticsEventBuilder } from '../../../util/analytics/AnalyticsEventBuilder'; diff --git a/app/util/analytics/AnalyticsEventBuilder.test.ts b/app/util/analytics/AnalyticsEventBuilder.test.ts index ad8c7d8de7a..a9df01400f2 100644 --- a/app/util/analytics/AnalyticsEventBuilder.test.ts +++ b/app/util/analytics/AnalyticsEventBuilder.test.ts @@ -2,10 +2,7 @@ import { AnalyticsEventBuilder, type AnalyticsTrackingEvent, } from './AnalyticsEventBuilder'; -import type { - IMetaMetricsEvent, - ITrackingEvent, -} from '../../core/Analytics/MetaMetrics.types'; +import type { IMetaMetricsEvent, ITrackingEvent } from './analytics.types'; import type { AnalyticsEventProperties } from '@metamask/analytics-controller'; describe('AnalyticsEventBuilder', () => { diff --git a/app/util/analytics/AnalyticsEventBuilder.ts b/app/util/analytics/AnalyticsEventBuilder.ts index c88ab530cd7..19ccf7efca3 100644 --- a/app/util/analytics/AnalyticsEventBuilder.ts +++ b/app/util/analytics/AnalyticsEventBuilder.ts @@ -1,10 +1,10 @@ import type { AnalyticsEventProperties } from '@metamask/analytics-controller'; import type { + AnalyticsUnfilteredProperties, IMetaMetricsEvent, ITrackingEvent, -} from '../../core/Analytics/MetaMetrics.types'; +} from './analytics.types'; import { filterUndefinedValues } from './filterUndefinedValues'; -import type { AnalyticsUnfilteredProperties } from './analytics.types'; /** * Analytics tracking event structure for AnalyticsController diff --git a/app/util/analytics/actionButtonTracking.test.ts b/app/util/analytics/actionButtonTracking.test.ts index 5b896e25e04..79e1ed829f6 100644 --- a/app/util/analytics/actionButtonTracking.test.ts +++ b/app/util/analytics/actionButtonTracking.test.ts @@ -7,7 +7,7 @@ import { } from './actionButtonTracking'; import { MetaMetricsEvents } from '../../core/Analytics'; import { MetricsEventBuilder } from '../../core/Analytics/MetricsEventBuilder'; -import { ITrackingEvent } from '../../core/Analytics/MetaMetrics.types'; +import { ITrackingEvent } from './analytics.types'; // Mock dependencies jest.mock('../../core/Analytics'); diff --git a/app/util/analytics/actionButtonTracking.ts b/app/util/analytics/actionButtonTracking.ts index fe2a43b8715..a2be268bcde 100644 --- a/app/util/analytics/actionButtonTracking.ts +++ b/app/util/analytics/actionButtonTracking.ts @@ -1,8 +1,5 @@ import { MetaMetricsEvents } from '../../core/Analytics'; -import { - IMetaMetricsEvent, - JsonMap, -} from '../../core/Analytics/MetaMetrics.types'; +import { IMetaMetricsEvent, JsonMap } from './analytics.types'; export enum ActionLocation { HOME = 'home', diff --git a/app/util/analytics/analytics.types.test.ts b/app/util/analytics/analytics.types.test.ts new file mode 100644 index 00000000000..145a84f3036 --- /dev/null +++ b/app/util/analytics/analytics.types.test.ts @@ -0,0 +1,32 @@ +import { + isTrackingEvent, + type IMetaMetricsEvent, + type ITrackingEvent, +} from './analytics.types'; + +describe('isTrackingEvent', () => { + it('returns true for an ITrackingEvent', () => { + const event: ITrackingEvent = { + name: 'test_event', + properties: {}, + sensitiveProperties: {}, + saveDataRecording: true, + get isAnonymous() { + return false; + }, + get hasProperties() { + return false; + }, + }; + + expect(isTrackingEvent(event)).toBe(true); + }); + + it('returns false for an IMetaMetricsEvent', () => { + const event: IMetaMetricsEvent = { + category: 'test_category', + }; + + expect(isTrackingEvent(event)).toBe(false); + }); +}); diff --git a/app/util/analytics/analytics.types.ts b/app/util/analytics/analytics.types.ts index 0f5acdde7f3..517b385ce55 100644 --- a/app/util/analytics/analytics.types.ts +++ b/app/util/analytics/analytics.types.ts @@ -89,3 +89,79 @@ export interface AnalyticsDefaults { analyticsId: string; optedIn: boolean; } + +// --- Transitional / legacy event types (from MetaMetrics.types.ts) --- + +/** + * Values that can be passed as properties to the event tracking function. + * Proxy type to decouple the app from Segment SDK JsonValue. + */ +export type JsonValue = + | boolean + | number + | string + | null + | JsonValue[] + | JsonMap + | undefined; + +/** + * Map object used to pass properties to the event tracking function. + * Proxy type to decouple the app from Segment SDK JsonMap. + */ +export interface JsonMap { + [key: string]: JsonValue; + [index: number]: JsonValue; +} + +/** + * Legacy MetaMetrics event interface. + */ +export interface IMetaMetricsEvent { + category: string; + properties?: { + name?: string; + action?: string; + }; +} + +/** + * New event properties structure with two distinct properties lists. + */ +export interface ITrackingEvent { + readonly name: string; + properties: JsonMap; + sensitiveProperties: JsonMap; + saveDataRecording: boolean; + get isAnonymous(): boolean; + get hasProperties(): boolean; +} + +/** + * Type guard to check if the event is a new ITrackingEvent. + */ +export const isTrackingEvent = ( + event: IMetaMetricsEvent | ITrackingEvent, +): event is ITrackingEvent => + (event as ITrackingEvent).saveDataRecording !== undefined; + +/** + * Monetized primitives associated with a transaction. + * Only propagated when the transaction involves a monetized primitive. + */ +export enum MonetizedPrimitive { + Swaps = 'swaps', + Perps = 'perps', + Ramps = 'ramps', + Predict = 'predict', + MmPay = 'mm_pay', +} + +/** + * The API type used to perform a request to MetaMask Mobile. + * Indicates whether the request came through the Ethereum Provider API or the Multichain API. + */ +export enum MetaMetricsRequestedThrough { + EthereumProvider = 'ethereum_provider', + MultichainApi = 'multichain_api', +} diff --git a/app/util/analytics/analyticsDataDeletion.test.ts b/app/util/analytics/analyticsDataDeletion.test.ts index 72fe0696c88..c7b7ee57cb3 100644 --- a/app/util/analytics/analyticsDataDeletion.test.ts +++ b/app/util/analytics/analyticsDataDeletion.test.ts @@ -1,7 +1,7 @@ import { DataDeleteResponseStatus, DataDeleteStatus, -} from '../../core/Analytics/MetaMetrics.types'; +} from './analyticsDataDeletion.types'; import { __resetCacheForTests, createDataDeletionTask, diff --git a/app/util/analytics/analyticsDataDeletion.ts b/app/util/analytics/analyticsDataDeletion.ts index fb4d7e57e2d..491b3e55e82 100644 --- a/app/util/analytics/analyticsDataDeletion.ts +++ b/app/util/analytics/analyticsDataDeletion.ts @@ -13,7 +13,7 @@ import { type IDeleteRegulationResponse, type IDeleteRegulationStatus, type IDeleteRegulationStatusResponse, -} from '../../core/Analytics/MetaMetrics.types'; +} from './analyticsDataDeletion.types'; import { analytics } from './analytics'; const SEGMENT_HEADERS = { diff --git a/app/util/analytics/analyticsDataDeletion.types.ts b/app/util/analytics/analyticsDataDeletion.types.ts new file mode 100644 index 00000000000..3fd743c0371 --- /dev/null +++ b/app/util/analytics/analyticsDataDeletion.types.ts @@ -0,0 +1,46 @@ +/** + * Types for analytics data deletion (Segment regulation / GDPR-style flows). + * Extracted from MetaMetrics.types.ts for use by app/util/analytics and useAnalytics. + */ + +/** + * Deletion task possible status. + * @see https://docs.segmentapis.com/tag/Deletion-and-Suppression#operation/getRegulation + */ +export enum DataDeleteStatus { + failed = 'FAILED', + finished = 'FINISHED', + initialized = 'INITIALIZED', + invalid = 'INVALID', + notSupported = 'NOT_SUPPORTED', + partialSuccess = 'PARTIAL_SUCCESS', + running = 'RUNNING', + unknown = 'UNKNOWN', +} + +/** + * Deletion task possible response status. + */ +export enum DataDeleteResponseStatus { + ok = 'ok', + error = 'error', +} + +export interface IDeleteRegulationResponse { + status: DataDeleteResponseStatus; + error?: string; +} + +export interface IDeleteRegulationStatusResponse { + status: DataDeleteResponseStatus; + dataDeleteStatus: DataDeleteStatus; +} + +export type DataDeleteDate = string | undefined; +export type DataDeleteRegulationId = string | undefined; + +export interface IDeleteRegulationStatus { + deletionRequestDate?: DataDeleteDate; + hasCollectedDataSinceDeletionRequest: boolean; + dataDeletionRequestStatus: DataDeleteStatus; +} From 5336c7c70fb5eddb3d78b0ba300479dad2f814be Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Tue, 17 Mar 2026 13:49:08 +0100 Subject: [PATCH 043/206] feat: add security data section in token details page (#27073) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Implements the Security & Trust feature on the Token Details page, based on the [Token Security & Trust API to Design Mapping](https://www.notion.so/metamask-consensys/Token-Security-Trust-API-to-Design-Mapping-313f86d67d6880ec91b0c437eb13f367). **What changed:** - Added `includeTokenSecurityData: true` to `getTrendingTokens` and `searchTokens` API calls so security data is proactively fetched for trending and search flows - Extended `TokenDetailsRouteParams` to carry `securityData` through navigation, avoiding redundant fetches for trending/search entry points - Added `useTokenSecurityData` hook that returns prefetched data immediately or fetches on-demand (with 60s refresh) for other entry flows (e.g. wallet home, swaps) - Added `SecurityTrustEntryCard` — a flat summary section on the Token Details page showing risk level, feature tags (Verified Contract, High Reputation, Listed on CEX, 0% Tax), and a timestamp - Added `SecurityTrustScreen` — a full-page view navigated to from the entry card, with sections for: Security Score, Risk Factors, Contract Security, Honeypot Analysis, Buy/Sell Tax, Token Distribution, Liquidity, Audits & Reviews, Official Links, Token Info, On-chain Activity - Registered `SecurityTrust` route in `MainNavigator` ## **Changelog** CHANGELOG entry: Added Security & Trust section to Token Details page showing risk level, contract security features, buy/sell tax, token distribution, and official links powered by Blockaid. ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/508b7910-341b-46d2-a514-825b51042fbf ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Adds new token-security data fetching and multiple new UI navigation surfaces (screen + bottom sheets) that gate trading actions, which could affect token details behavior and analytics if data is missing or mislabeled. > > **Overview** > Adds a **Security & Trust** experience to Token Details, including an inline security badge (with malicious warning banner), a summary entry card, and a new `SecurityTrust` details screen. > > Introduces `useTokenSecurityData` and propagates `securityData` through `TokenDetailsRouteParams`, while updating trending/search requests to proactively fetch security data (`includeTokenSecurityData: true`). Adds a new `SecurityBadgeBottomSheet` used both from the badge and as a *proceed/cancel* interstitial for Buy/Swap on risky tokens, along with new MetaMetrics events. > > Separately tweaks Token Details layout (new sticky footer component, simplified inline header, hide empty transactions state) and adjusts price UI/tests and chart coloring in dark mode. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 9c82be28b4ff4c36aaa84556cc8c1039b8768547. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/components/Nav/App/App.tsx | 5 + app/components/Nav/Main/MainNavigator.js | 5 + .../UI/AssetOverview/Price/Price.styles.tsx | 10 +- .../UI/AssetOverview/Price/Price.test.tsx | 70 -- .../UI/AssetOverview/Price/Price.tsx | 44 -- .../AssetOverview/PriceChart/PriceChart.tsx | 8 +- .../Views/SecurityTrustScreen.test.tsx | 251 +++++++ .../Views/SecurityTrustScreen.tsx | 626 ++++++++++++++++++ .../SecurityTrustEntryCard.test.tsx | 254 +++++++ .../SecurityTrustEntryCard.tsx | 160 +++++ app/components/UI/SecurityTrust/types.ts | 13 + .../SecurityTrust/utils/securityUtils.test.ts | 366 ++++++++++ .../UI/SecurityTrust/utils/securityUtils.ts | 284 ++++++++ .../UI/TokenDetails/Views/TokenDetails.tsx | 170 ++--- .../components/AssetOverviewContent.test.tsx | 295 +++++++++ .../components/AssetOverviewContent.tsx | 311 ++++++++- .../SecurityBadgeBottomSheet.test.tsx | 149 +++++ .../components/SecurityBadgeBottomSheet.tsx | 211 ++++++ .../TokenDetailsInlineHeader.test.tsx | 61 +- .../components/TokenDetailsInlineHeader.tsx | 30 +- .../components/TokenDetailsStickyFooter.tsx | 170 +++++ .../UI/TokenDetails/constants/constants.ts | 2 + .../hooks/useTokenSecurityData.test.ts | 240 +++++++ .../hooks/useTokenSecurityData.ts | 75 +++ app/components/UI/Transactions/index.js | 8 + .../TrendingTokenRowItem.tsx | 1 + .../hooks/useRwaTokens/useRwaTokens.ts | 1 + .../useSearchRequest/useSearchRequest.ts | 8 +- .../useTrendingRequest/useTrendingRequest.ts | 1 + .../useTrendingSearch/useTrendingSearch.ts | 1 + app/constants/navigation/Routes.ts | 2 + app/core/Analytics/MetaMetrics.events.ts | 8 + locales/languages/en.json | 47 ++ .../mock-responses/defaults/token-apis.ts | 8 + 34 files changed, 3600 insertions(+), 295 deletions(-) create mode 100644 app/components/UI/SecurityTrust/Views/SecurityTrustScreen.test.tsx create mode 100644 app/components/UI/SecurityTrust/Views/SecurityTrustScreen.tsx create mode 100644 app/components/UI/SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard.test.tsx create mode 100644 app/components/UI/SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard.tsx create mode 100644 app/components/UI/SecurityTrust/types.ts create mode 100644 app/components/UI/SecurityTrust/utils/securityUtils.test.ts create mode 100644 app/components/UI/SecurityTrust/utils/securityUtils.ts create mode 100644 app/components/UI/TokenDetails/components/SecurityBadgeBottomSheet.test.tsx create mode 100644 app/components/UI/TokenDetails/components/SecurityBadgeBottomSheet.tsx create mode 100644 app/components/UI/TokenDetails/components/TokenDetailsStickyFooter.tsx create mode 100644 app/components/UI/TokenDetails/hooks/useTokenSecurityData.test.ts create mode 100644 app/components/UI/TokenDetails/hooks/useTokenSecurityData.ts diff --git a/app/components/Nav/App/App.tsx b/app/components/Nav/App/App.tsx index 690420af7e9..7b13a9c52a0 100644 --- a/app/components/Nav/App/App.tsx +++ b/app/components/Nav/App/App.tsx @@ -71,6 +71,7 @@ import FiatOnTestnetsFriction from '../../../components/Views/Settings/AdvancedS import WalletActions from '../../Views/WalletActions'; import FundActionMenu from '../../UI/FundActionMenu'; import MoreTokenActionsMenu from '../../UI/TokenDetails/components/MoreTokenActionsMenu'; +import SecurityBadgeBottomSheet from '../../UI/TokenDetails/components/SecurityBadgeBottomSheet'; import NetworkSelector from '../../../components/Views/NetworkSelector'; import ReturnToAppNotification from '../../Views/ReturnToAppNotification'; import EditAccountName from '../../Views/EditAccountName/EditAccountName'; @@ -380,6 +381,10 @@ const RootModalFlow = (props: RootModalFlowProps) => ( name={Routes.MODAL.MORE_TOKEN_ACTIONS_MENU} component={MoreTokenActionsMenu} /> + ( component={AssetDetails} initialParams={{ address: props.route.params?.address }} /> + ({ default: jest.fn().mockImplementation(() => null), })); -jest.mock('../../Bridge/hooks/useRWAToken', () => ({ - useRWAToken: () => ({ - isStockToken: jest.fn().mockReturnValue(false), - isTokenTradingOpen: jest.fn().mockResolvedValue(true), - }), -})); - -const mockAsset: TokenI = { - name: 'Ethereum', - ticker: 'ETH', - symbol: 'Ethereum', - address: '0x0', - aggregators: [], - decimals: 18, - image: '', - balance: '100', - balanceFiat: '$100', - logo: '', - isETH: true, - isNative: true, -}; - const mockPrices: TokenPrice[] = [ ['1736761237983', 100], ['1736761237986', 105], ]; const mockProps: { - asset: TokenI; prices: TokenPrice[]; priceDiff: number; currentPrice: number; @@ -54,7 +30,6 @@ const mockProps: { isLoading: boolean; timePeriod: TimePeriod; } = { - asset: mockAsset, prices: mockPrices, priceDiff: 5, currentPrice: 105, @@ -65,51 +40,6 @@ const mockProps: { }; describe('Price Component', () => { - describe('Header', () => { - it('renders header correctly when asset name and symbol are provided', () => { - const props = { - ...mockProps, - asset: { - ...mockProps.asset, - ticker: '', - }, - }; - - const { getByText } = render(); - - // Name and symbol are rendered together when ticker is not provided - // Format: "name (symbol)" - expect( - getByText(`${mockProps.asset.name} (${mockProps.asset.symbol})`), - ).toBeTruthy(); - }); - - it('renders header correctly when name not provided and symbol is provided', () => { - const props = { - ...mockProps, - asset: { - ...mockProps.asset, - name: '', - ticker: '', - }, - }; - - const { getByText } = render(); - - expect(getByText(`${mockProps.asset.symbol}`)).toBeTruthy(); - }); - - it('renders header correctly when name and ticker are provided', () => { - const { getByText } = render(); - - // Name and ticker are rendered together - // Format: "name (ticker)" - expect( - getByText(`${mockProps.asset.name} (${mockProps.asset.ticker})`), - ).toBeTruthy(); - }); - }); - it('shows loading state when isLoading is true', () => { const { getByTestId } = render( , diff --git a/app/components/UI/AssetOverview/Price/Price.tsx b/app/components/UI/AssetOverview/Price/Price.tsx index 000398ed09d..451adfadedb 100644 --- a/app/components/UI/AssetOverview/Price/Price.tsx +++ b/app/components/UI/AssetOverview/Price/Price.tsx @@ -19,13 +19,8 @@ import PriceChart from '../PriceChart/PriceChart'; import { distributeDataPoints } from '../PriceChart/utils'; import styleSheet from './Price.styles'; import { TokenOverviewSelectorsIDs } from '../TokenOverview.testIds'; -import { TokenI } from '../../Tokens/types'; -import StockBadge from '../../shared/StockBadge/StockBadge'; -import { BridgeToken } from '../../Bridge/types'; -import { useRWAToken } from '../../Bridge/hooks/useRWAToken'; interface PriceProps { - asset: TokenI; prices: TokenPrice[]; priceDiff: number; currentPrice: number; @@ -36,7 +31,6 @@ interface PriceProps { } const Price = ({ - asset, prices, priceDiff, currentPrice, @@ -46,7 +40,6 @@ const Price = ({ timePeriod, }: PriceProps) => { const [activeChartIndex, setActiveChartIndex] = useState(-1); - const { isStockToken } = useRWAToken(); const distributedPriceData = useMemo(() => { if (prices.length > 0) { @@ -89,47 +82,10 @@ const Price = ({ : priceDiff; const { styles, theme } = useStyles(styleSheet, { priceDiff: diff }); - const ticker = asset.ticker || asset.symbol; - const stockTokenBadge = isStockToken(asset as BridgeToken) && ( - - ); return ( <> - {asset.name ? ( - stockTokenBadge ? ( - - - {asset.name} - - - - {ticker} - - {stockTokenBadge} - - - ) : ( - - {asset.name} ({ticker}) - - ) - ) : ( - - {ticker} - {stockTokenBadge} - - )} {!isNaN(price) && ( 0 - ? theme.colors.primary.default + ? theme.themeAppearance === 'dark' + ? theme.brandColors.blue300 + : theme.colors.primary.default : priceDiff < 0 - ? theme.colors.primary.default + ? theme.themeAppearance === 'dark' + ? theme.brandColors.blue300 + : theme.colors.primary.default : theme.colors.text.alternative; const apx = (size = 0) => { diff --git a/app/components/UI/SecurityTrust/Views/SecurityTrustScreen.test.tsx b/app/components/UI/SecurityTrust/Views/SecurityTrustScreen.test.tsx new file mode 100644 index 00000000000..7a6454e3cad --- /dev/null +++ b/app/components/UI/SecurityTrust/Views/SecurityTrustScreen.test.tsx @@ -0,0 +1,251 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { Linking, useColorScheme } from 'react-native'; +import SecurityTrustScreen from './SecurityTrustScreen'; +import { strings } from '../../../../../locales/i18n'; + +const mockNavigate = jest.fn(); +const mockGoBack = jest.fn(); + +jest.mock('react-native', () => { + const actual = jest.requireActual('react-native'); + return { + ...actual, + Linking: { + openURL: jest.fn(() => Promise.resolve()), + }, + useColorScheme: jest.fn(() => 'light'), + }; +}); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ + navigate: mockNavigate, + goBack: mockGoBack, + }), + useRoute: () => ({ + params: { + address: '0x1234567890abcdef', + chainId: '0x1', + symbol: 'TEST', + decimals: 18, + name: 'Test Token', + isNative: false, + securityData: { + resultType: 'Verified', + maliciousScore: '0', + features: [ + { + featureId: 'VERIFIED_CONTRACT', + type: 'Info', + description: 'Contract is verified', + }, + { + featureId: 'HIGH_REPUTATION_TOKEN', + type: 'Benign', + description: 'Token has high reputation', + }, + ], + fees: { + buy: 1, + sell: 2, + transfer: 0, + transferFeeMaxAmount: null, + }, + financialStats: { + supply: 1000000000000000000000000, + holdersCount: 5000, + topHolders: [ + { + label: 'Holder 1', + name: null, + address: '0xholder1', + holdingPercentage: 15, + }, + { + label: 'Holder 2', + name: null, + address: '0xholder2', + holdingPercentage: 10, + }, + ], + tradeVolume24h: 1000000, + lockedLiquidityPct: 80, + markets: [], + }, + metadata: { + externalLinks: { + homepage: 'https://example.com', + twitterPage: 'testtoken', + telegramChannelId: 'testtoken', + }, + }, + created: '2023-01-01T00:00:00Z', + }, + }, + }), +})); + +jest.mock('../../../Views/confirmations/hooks/useNetworkName', () => ({ + useNetworkName: () => 'Ethereum Mainnet', +})); + +jest.mock('../../../hooks/useBlockExplorer', () => ({ + __esModule: true, + default: () => ({ + getBlockExplorerTokenUrl: (address: string) => + `https://etherscan.io/address/${address}`, + getBlockExplorerName: () => 'Etherscan', + }), +})); + +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ + top: 0, + bottom: 0, + left: 0, + right: 0, + }), +})); + +jest.mock('../../TokenDetails/components/TokenDetailsStickyFooter', () => ({ + __esModule: true, + default: () => null, +})); + +jest.mock('../../TokenDetails/hooks/useTokenActions', () => ({ + useTokenActions: jest.fn(() => ({ + onBuy: jest.fn(), + goToSwaps: jest.fn(), + hasEligibleSwapTokens: true, + networkModal: null, + })), +})); + +describe('SecurityTrustScreen', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders without crashing', () => { + const { getByText } = render(); + expect(getByText(strings('security_trust.verified'))).toBeTruthy(); + }); + + it('displays token security result label', () => { + const { getByText } = render(); + expect(getByText(strings('security_trust.verified'))).toBeTruthy(); + }); + + it('displays token distribution section', () => { + const { getByText } = render(); + expect( + getByText(strings('security_trust.token_distribution')), + ).toBeTruthy(); + expect(getByText(strings('security_trust.total_supply'))).toBeTruthy(); + }); + + it('displays token info section', () => { + const { getByText } = render(); + expect(getByText(strings('security_trust.token_info'))).toBeTruthy(); + expect(getByText('Type')).toBeTruthy(); + expect(getByText('Network')).toBeTruthy(); + expect(getByText('ERC-20')).toBeTruthy(); + expect(getByText('Ethereum Mainnet')).toBeTruthy(); + }); + + it('displays buy and sell tax section', () => { + const { getByText } = render(); + expect(getByText('Buy/Sell Tax')).toBeTruthy(); + expect(getByText('Buy tax')).toBeTruthy(); + expect(getByText('Sell tax')).toBeTruthy(); + }); + + it('displays official links section when metadata is available', () => { + const { getByText } = render(); + expect(getByText(strings('security_trust.official_links'))).toBeTruthy(); + expect(getByText(strings('security_trust.website'))).toBeTruthy(); + expect(getByText('@testtoken')).toBeTruthy(); + }); + + it('displays disclaimer at the bottom', () => { + const { getByText } = render(); + expect( + getByText(strings('security_trust.evaluation_disclaimer')), + ).toBeTruthy(); + }); + + it('calls navigation.goBack when back button is pressed', () => { + const { getByTestId } = render(); + + const backButton = getByTestId('security-trust-back-button'); + fireEvent.press(backButton); + + expect(mockGoBack).toHaveBeenCalled(); + }); + + it('opens external link when link is pressed', () => { + const mockOpenURL = Linking.openURL as jest.Mock; + mockOpenURL.mockReturnValue(Promise.resolve()); + + const { getByText } = render(); + + const websiteLink = getByText(strings('security_trust.website')); + fireEvent.press(websiteLink); + + expect(mockOpenURL).toHaveBeenCalledWith('https://example.com'); + }); + + it('handles link opening errors gracefully', async () => { + const mockOpenURL = Linking.openURL as jest.Mock; + mockOpenURL.mockReturnValue(Promise.reject(new Error('Failed to open'))); + + const { getByText } = render(); + + const websiteLink = getByText(strings('security_trust.website')); + fireEvent.press(websiteLink); + + expect(mockOpenURL).toHaveBeenCalledWith('https://example.com'); + }); + + it('applies dark mode color scheme to progress bar', () => { + const mockUseColorScheme = useColorScheme as jest.Mock; + mockUseColorScheme.mockReturnValue('dark'); + + render(); + + expect(mockUseColorScheme).toHaveBeenCalled(); + }); + + it('applies light mode color scheme to progress bar', () => { + const mockUseColorScheme = useColorScheme as jest.Mock; + mockUseColorScheme.mockReturnValue('light'); + + render(); + + expect(mockUseColorScheme).toHaveBeenCalled(); + }); + + it('displays correct fee values from mock data', () => { + const { getByText } = render(); + + expect(getByText('1.0%')).toBeTruthy(); + expect(getByText('2.0%')).toBeTruthy(); + expect(getByText('0.0%')).toBeTruthy(); + }); + + it('displays correct holder distribution from topHolders array', () => { + const { getByText } = render(); + + expect(getByText('25.0%')).toBeTruthy(); + expect(getByText('75.0%')).toBeTruthy(); + }); + + it('renders feature tags from TokenSecurityFeature objects', () => { + const { getByText } = render(); + + expect(getByText('Published contract')).toBeTruthy(); + expect(getByText('Established reputation')).toBeTruthy(); + }); +}); diff --git a/app/components/UI/SecurityTrust/Views/SecurityTrustScreen.tsx b/app/components/UI/SecurityTrust/Views/SecurityTrustScreen.tsx new file mode 100644 index 00000000000..22e18821e9b --- /dev/null +++ b/app/components/UI/SecurityTrust/Views/SecurityTrustScreen.tsx @@ -0,0 +1,626 @@ +import React, { useCallback } from 'react'; +import { + ScrollView, + View, + Linking, + TouchableOpacity, + useColorScheme, +} from 'react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + Text, + TextVariant, + TextColor, + Icon, + IconName, + IconSize, + IconColor, + BoxFlexDirection, + BoxAlignItems, + BoxJustifyContent, + FontWeight, + ButtonBase, + ButtonBaseSize, +} from '@metamask/design-system-react-native'; +import { useNavigation, useRoute } from '@react-navigation/native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import type { Hex } from '@metamask/utils'; +import { strings } from '../../../../../locales/i18n'; +import { useNetworkName } from '../../../Views/confirmations/hooks/useNetworkName'; +import type { TokenDetailsRouteParams } from '../../TokenDetails/constants/constants'; +import { + getFeatureTags, + formatFeePercent, + getTop10HoldingPct, + formatCompactSupply, + getResultTypeConfig, +} from '../utils/securityUtils'; +import TokenDetailsStickyFooter from '../../TokenDetails/components/TokenDetailsStickyFooter'; +import useBlockExplorer from '../../../hooks/useBlockExplorer'; +import { useTokenActions } from '../../TokenDetails/hooks/useTokenActions'; + +const SectionHeader: React.FC<{ title: string }> = ({ title }) => ( + + {title} + +); + +const SecurityTrustScreen: React.FC = () => { + const tw = useTailwind(); + const colorScheme = useColorScheme(); + const navigation = useNavigation(); + const route = useRoute(); + const insets = useSafeAreaInsets(); + + const params = route.params as TokenDetailsRouteParams; + const securityData = params?.securityData ?? null; + const explorer = useBlockExplorer(params?.chainId); + const networkName = useNetworkName(params?.chainId as Hex); + + // Get action handlers from hook (single source of truth) + const { onBuy, goToSwaps, hasEligibleSwapTokens, networkModal } = + useTokenActions({ + token: params, + networkName, + }); + + const fees = securityData?.fees ?? null; + const features = securityData?.features ?? []; + const { tags: featureTags } = getFeatureTags( + features, + securityData?.resultType, + true, + ); + + const { + label: resultLabel, + textColor: resultTextColor, + subtitle: resultSubtitle, + icon: tagIcon, + iconColor: tagIconColor, + } = getResultTypeConfig(securityData?.resultType); + const financialStats = securityData?.financialStats ?? null; + const metadata = securityData?.metadata ?? null; + + const top10Pct = getTop10HoldingPct(financialStats); + const otherPct = top10Pct !== null ? Math.max(0, 100 - top10Pct) : null; + const barFillStyle = React.useMemo( + () => ({ width: `${top10Pct ?? 0}%` as `${number}%` }), + [top10Pct], + ); + const formattedCreatedDate = React.useMemo(() => { + const raw = securityData?.created; + if (!raw) return strings('security_trust.na'); + try { + return new Date(raw).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + } catch { + return raw; + } + }, [securityData?.created]); + + const tokenAgeDisplay = React.useMemo(() => { + const raw = securityData?.created; + if (!raw) return strings('security_trust.na'); + try { + const diffMs = Date.now() - new Date(raw).getTime(); + const days = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + if (days < 30) return `${days}d`; + if (days < 365) return `${Math.floor(days / 30)}mo`; + return `${Math.floor(days / 365)}yr`; + } catch { + return strings('security_trust.na'); + } + }, [securityData?.created]); + + const tokenType = params?.isNative ? 'Native' : 'ERC-20'; + + const openLink = useCallback((url: string) => { + Linking.openURL(url).catch(() => null); + }, []); + + const scrollContentStyle = React.useMemo( + () => ({ + paddingTop: 16, + paddingBottom: insets.bottom + 24, + paddingLeft: 16, + paddingRight: 16, + }), + [insets.bottom], + ); + + return ( + + {networkModal} + + navigation.goBack()} + hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }} + testID="security-trust-back-button" + > + + + + + {strings('security_trust.title')} + + + + + + + {/* ══ Section 1: Security Score header ════════════════════════════════ */} + + + {resultLabel} + + + {resultSubtitle} + + {featureTags.length > 0 && ( + + {featureTags.map((tag) => ( + + {tagIcon && tagIconColor && ( + + )} + + {tag.label} + + + ))} + + )} + + + + + + + {/* ══ Section 2: Token Distribution ═══════════════════════════════════ */} + + + + + + {strings('security_trust.total_supply')} + + + {formatCompactSupply(financialStats?.supply, params?.decimals)}{' '} + {params?.symbol ?? ''} + + + + + {top10Pct !== null && ( + + + + + + )} + + + + + + + {strings('security_trust.top_10_holders')} + + + + {top10Pct !== null + ? `${top10Pct.toFixed(1)}%` + : strings('security_trust.na')} + + + + + + + + {strings('security_trust.other')} + + + + {otherPct !== null + ? `${otherPct.toFixed(1)}%` + : strings('security_trust.na')} + + + + + + + + + {/* ══ Section 8: Buy/Sell Tax ══════════════════════════════════════════ */} + + + + {( + [ + { label: strings('security_trust.buy_tax'), value: fees?.buy }, + { + label: strings('security_trust.sell_tax'), + value: fees?.sell, + }, + { + label: strings('security_trust.transfer'), + value: fees?.transfer, + }, + ] as const + ).map(({ label, value }) => ( + + + {formatFeePercent(value)} + + + {label} + + + ))} + + {fees !== null && + fees.transfer === 0 && + fees.buy === 0 && + fees.sell === 0 && ( + + + + {strings('security_trust.no_hidden_fees_detected')} + + + )} + + + + + + + {/* ══ Section 9: Token Info ════════════════════════════════════════════ */} + + + + + + {strings('security_trust.created')} + + + {formattedCreatedDate} + + + + + {strings('security_trust.token_age')} + + + {tokenAgeDisplay} + + + + + + + {strings('security_trust.network')} + + + {networkName ?? strings('security_trust.na')} + + + + + {strings('security_trust.type')} + + + {tokenType} + + + + + + + + + + {/* ══ Section 11: Official Links ═══════════════════════════════════════ */} + {metadata?.externalLinks && ( + <> + + + {metadata.externalLinks.homepage && ( + + openLink(metadata.externalLinks.homepage || '') + } + size={ButtonBaseSize.Md} + twClassName={(pressed) => + `rounded-lg bg-muted px-3 ${pressed ? 'opacity-70' : ''}` + } + startIconName={IconName.Global} + startIconProps={{ + color: IconColor.IconDefault, + size: IconSize.Sm, + }} + > + + {strings('security_trust.website')} + + + )} + {metadata.externalLinks.twitterPage && ( + + openLink( + `https://x.com/${metadata.externalLinks.twitterPage}`, + ) + } + size={ButtonBaseSize.Md} + twClassName={(pressed) => + `rounded-lg bg-muted px-3 ${pressed ? 'opacity-70' : ''}` + } + startIconName={IconName.X} + startIconProps={{ + color: IconColor.IconDefault, + size: IconSize.Sm, + }} + > + + {`@${metadata.externalLinks.twitterPage}`} + + + )} + {metadata.externalLinks.telegramChannelId && ( + + openLink( + `https://t.me/${metadata.externalLinks.telegramChannelId}`, + ) + } + size={ButtonBaseSize.Md} + twClassName={(pressed) => + `rounded-lg bg-muted px-3 ${pressed ? 'opacity-70' : ''}` + } + startIconName={IconName.Global} + startIconProps={{ + color: IconColor.IconDefault, + size: IconSize.Sm, + }} + > + + {strings('security_trust.telegram')} + + + )} + {Boolean(params?.address && !params.isNative) && + (() => { + const blockExplorerUrl = explorer.getBlockExplorerTokenUrl( + params.address, + params.chainId, + ); + const blockExplorerName = explorer.getBlockExplorerName( + params.chainId, + ); + + return blockExplorerUrl ? ( + openLink(blockExplorerUrl)} + size={ButtonBaseSize.Md} + twClassName={(pressed) => + `rounded-lg bg-muted px-3 ${pressed ? 'opacity-70' : ''}` + } + startIconName={IconName.Global} + startIconProps={{ + color: IconColor.IconDefault, + size: IconSize.Sm, + }} + > + + {blockExplorerName || + strings('security_trust.etherscan')} + + + ) : null; + })()} + + + )} + + + + + + {strings('security_trust.evaluation_disclaimer')} + + + + + + ); +}; + +export default SecurityTrustScreen; diff --git a/app/components/UI/SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard.test.tsx b/app/components/UI/SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard.test.tsx new file mode 100644 index 00000000000..93541e833ec --- /dev/null +++ b/app/components/UI/SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard.test.tsx @@ -0,0 +1,254 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import SecurityTrustEntryCard from './SecurityTrustEntryCard'; +import { strings } from '../../../../../../locales/i18n'; +import type { TokenSecurityData } from '../../types'; +import type { TokenDetailsRouteParams } from '../../../TokenDetails/constants/constants'; +import Routes from '../../../../../constants/navigation/Routes'; + +const mockNavigate = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ + navigate: mockNavigate, + }), +})); + +const mockToken: TokenDetailsRouteParams = { + address: '0x1234567890abcdef', + chainId: '0x1', + symbol: 'TEST', + decimals: 18, + name: 'Test Token', + isNative: false, + image: 'https://example.com/token.png', + balance: '1000000000000000000', + logo: 'https://example.com/logo.png', + isETH: false, +}; + +const mockSecurityData: TokenSecurityData = { + resultType: 'Verified', + maliciousScore: '0', + features: [ + { + featureId: 'liquidity_pools', + type: 'info', + description: 'Has liquidity pools', + }, + { + featureId: 'verified_contract', + type: 'info', + description: 'Contract is verified', + }, + ], + fees: { + transfer: 0, + transferFeeMaxAmount: null, + buy: 0.01, + sell: 0.02, + }, + financialStats: { + supply: 1000000000000000000000000, + topHolders: [], + holdersCount: 5000, + tradeVolume24h: null, + lockedLiquidityPct: null, + markets: [], + }, + metadata: { + externalLinks: { + homepage: 'https://example.com', + twitterPage: 'testtoken', + telegramChannelId: null, + }, + }, + created: '2023-01-01T00:00:00Z', +}; + +describe('SecurityTrustEntryCard', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders loading state with skeletons', () => { + const { queryByText, getByTestId } = render( + , + ); + + expect(queryByText(strings('security_trust.title'))).toBeNull(); + expect(getByTestId('security-trust-entry-card')).toBeTruthy(); + }); + + it('renders security data with title and result label', () => { + const { getByText } = render( + , + ); + + expect(getByText(strings('security_trust.title'))).toBeTruthy(); + expect(getByText(strings('security_trust.verified'))).toBeTruthy(); + }); + + it('displays arrow icon when details are available', () => { + const { getByText } = render( + , + ); + + expect(getByText(strings('security_trust.title'))).toBeTruthy(); + expect(getByText(strings('security_trust.verified'))).toBeTruthy(); + }); + + it('navigates to security trust screen when pressed with details', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId('security-trust-entry-card')); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.SECURITY_TRUST, { + ...mockToken, + securityData: mockSecurityData, + }); + }); + + it('does not navigate when pressed without details', () => { + const securityDataNoFeatures: TokenSecurityData = { + resultType: 'Verified', + maliciousScore: '0', + features: [], + fees: { + transfer: 0, + transferFeeMaxAmount: null, + buy: 0, + sell: null, + }, + financialStats: { + supply: 0, + topHolders: [], + holdersCount: 0, + tradeVolume24h: null, + lockedLiquidityPct: null, + markets: [], + }, + metadata: { + externalLinks: { + homepage: null, + twitterPage: null, + telegramChannelId: null, + }, + }, + created: '2023-01-01T00:00:00Z', + }; + + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId('security-trust-entry-card')); + + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('does not display arrow icon when no details available', () => { + const securityDataNoFeatures: TokenSecurityData = { + resultType: 'Verified', + maliciousScore: '0', + features: [], + fees: { + transfer: 0, + transferFeeMaxAmount: null, + buy: 0, + sell: null, + }, + financialStats: { + supply: 0, + topHolders: [], + holdersCount: 0, + tradeVolume24h: null, + lockedLiquidityPct: null, + markets: [], + }, + metadata: { + externalLinks: { + homepage: null, + twitterPage: null, + telegramChannelId: null, + }, + }, + created: '2023-01-01T00:00:00Z', + }; + + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('icon-arrow-right')).toBeNull(); + }); + + it('displays subtitle when no features but subtitle exists', () => { + const securityDataNoFeatures: TokenSecurityData = { + resultType: 'NotEnoughData', + maliciousScore: '0', + features: [], + fees: { + transfer: 0, + transferFeeMaxAmount: null, + buy: 0, + sell: null, + }, + financialStats: { + supply: 0, + topHolders: [], + holdersCount: 0, + tradeVolume24h: null, + lockedLiquidityPct: null, + markets: [], + }, + metadata: { + externalLinks: { + homepage: null, + twitterPage: null, + telegramChannelId: null, + }, + }, + created: '2023-01-01T00:00:00Z', + }; + + const { getByText } = render( + , + ); + + expect( + getByText('Security analysis could not be loaded for this token.'), + ).toBeTruthy(); + }); +}); diff --git a/app/components/UI/SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard.tsx b/app/components/UI/SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard.tsx new file mode 100644 index 00000000000..cd5a30f182f --- /dev/null +++ b/app/components/UI/SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard.tsx @@ -0,0 +1,160 @@ +import React from 'react'; +import { Pressable } from 'react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import Skeleton from '../../../../../component-library/components-temp/Skeleton/Skeleton'; +import { + Box, + Text, + TextVariant, + TextColor, + Icon, + IconName, + IconSize, + IconColor, + BoxFlexDirection, + BoxAlignItems, + FontWeight, +} from '@metamask/design-system-react-native'; +import { useNavigation } from '@react-navigation/native'; +import type { TokenSecurityData } from '../../types'; +import { getFeatureTags, getResultTypeConfig } from '../../utils/securityUtils'; +import type { TokenDetailsRouteParams } from '../../../TokenDetails/constants/constants'; +import Routes from '../../../../../constants/navigation/Routes'; +import { strings } from '../../../../../../locales/i18n'; + +interface SecurityTrustEntryCardProps { + securityData: TokenSecurityData | null; + isLoading: boolean; + token: TokenDetailsRouteParams; +} + +const SecurityTrustEntryCard: React.FC = ({ + securityData, + isLoading, + token, +}) => { + const tw = useTailwind(); + const navigation = useNavigation(); + + const config = getResultTypeConfig(securityData?.resultType); + const tagIcon = config.icon; + const tagIconColor = config.iconColor; + const { tags: featureTags, remainingCount } = securityData + ? getFeatureTags(securityData.features ?? [], securityData.resultType) + : { tags: [], remainingCount: 0 }; + + const hasDetails = (securityData?.features?.length ?? 0) > 0; + + const handlePress = () => { + if (!hasDetails) return; + navigation.navigate( + Routes.SECURITY_TRUST as never, + { + ...token, + securityData, + } as never, + ); + }; + + const content = isLoading ? ( + + + + + + + + + ) : ( + + + + {strings('security_trust.title')} + + {hasDetails && ( + + )} + + + {config.label} + + {hasDetails ? ( + featureTags.length > 0 && ( + + {featureTags.map((tag) => ( + + {tagIcon && tagIconColor && ( + + )} + + {tag.label} + + + ))} + {remainingCount > 0 && ( + + + +{remainingCount} {strings('security_trust.more')} + + + )} + + ) + ) : config.subtitle ? ( + + {config.subtitle} + + ) : null} + + ); + + return ( + tw.style(hasDetails && pressed && 'opacity-70')} + testID="security-trust-entry-card" + > + {content} + + ); +}; + +export default SecurityTrustEntryCard; diff --git a/app/components/UI/SecurityTrust/types.ts b/app/components/UI/SecurityTrust/types.ts new file mode 100644 index 00000000000..4bc6113598b --- /dev/null +++ b/app/components/UI/SecurityTrust/types.ts @@ -0,0 +1,13 @@ +export type { + TokenSecurityData, + TokenSecurityFeature, + TokenSecurityFees, + TokenSecurityFinancialStats, + TokenSecurityHolder, + TokenSecurityMarket, + TokenSecurityMetadata, +} from '@metamask/assets-controllers'; + +export interface FeatureTag { + label: string; +} diff --git a/app/components/UI/SecurityTrust/utils/securityUtils.test.ts b/app/components/UI/SecurityTrust/utils/securityUtils.test.ts new file mode 100644 index 00000000000..3fde5998b99 --- /dev/null +++ b/app/components/UI/SecurityTrust/utils/securityUtils.test.ts @@ -0,0 +1,366 @@ +import type { + TokenSecurityFeature, + TokenSecurityFinancialStats, +} from '../types'; +import { + getFeatureTags, + formatFeePercent, + getTop10HoldingPct, + formatCompactSupply, + getResultTypeConfig, +} from './securityUtils'; +import { + TextColor, + IconName, + IconColor, +} from '@metamask/design-system-react-native'; +import { strings } from '../../../../../locales/i18n'; + +describe('securityUtils', () => { + describe('getResultTypeConfig', () => { + it('returns config for Verified result type', () => { + const config = getResultTypeConfig('Verified'); + + expect(config.label).toBe(strings('security_trust.verified')); + expect(config.textColor).toBe(TextColor.SuccessDefault); + expect(config.subtitle).toBe(strings('security_trust.subtitle_known')); + expect(config.icon).toBe(IconName.SecurityTick); + expect(config.iconColor).toBe(IconColor.SuccessDefault); + }); + + it('returns config for Benign result type', () => { + const config = getResultTypeConfig('Benign'); + + expect(config.label).toBe(strings('security_trust.no_issues')); + expect(config.textColor).toBe(TextColor.SuccessDefault); + expect(config.subtitle).toBe( + strings('security_trust.subtitle_no_issues'), + ); + expect(config.icon).toBe(IconName.SecurityTick); + expect(config.iconColor).toBe(IconColor.SuccessDefault); + }); + + it('returns config for Warning result type', () => { + const config = getResultTypeConfig('Warning'); + + expect(config.label).toBe(strings('security_trust.suspicious')); + expect(config.textColor).toBe(TextColor.WarningDefault); + expect(config.subtitle).toBe( + strings('security_trust.subtitle_suspicious'), + ); + expect(config.icon).toBe(IconName.Warning); + expect(config.iconColor).toBe(IconColor.WarningDefault); + }); + + it('returns config for Spam result type', () => { + const config = getResultTypeConfig('Spam'); + + expect(config.label).toBe(strings('security_trust.suspicious')); + expect(config.textColor).toBe(TextColor.WarningDefault); + expect(config.subtitle).toBe( + strings('security_trust.subtitle_suspicious'), + ); + expect(config.icon).toBe(IconName.Warning); + expect(config.iconColor).toBe(IconColor.WarningDefault); + }); + + it('returns config for Malicious result type', () => { + const config = getResultTypeConfig('Malicious'); + + expect(config.label).toBe(strings('security_trust.malicious_label')); + expect(config.textColor).toBe(TextColor.ErrorDefault); + expect(config.subtitle).toBe( + strings('security_trust.subtitle_malicious'), + ); + expect(config.icon).toBe(IconName.Danger); + expect(config.iconColor).toBe(IconColor.ErrorDefault); + }); + + it('returns default config for undefined result type', () => { + const config = getResultTypeConfig(undefined); + + expect(config.label).toBe(strings('security_trust.data_unavailable')); + expect(config.textColor).toBe(TextColor.TextAlternative); + expect(config.subtitle).toBe( + strings('security_trust.subtitle_unavailable'), + ); + expect(config.icon).toBeUndefined(); + expect(config.iconColor).toBeUndefined(); + }); + + it('returns default config for unknown result type', () => { + const config = getResultTypeConfig('UnknownType'); + + expect(config.label).toBe(strings('security_trust.data_unavailable')); + expect(config.textColor).toBe(TextColor.TextAlternative); + expect(config.subtitle).toBe( + strings('security_trust.subtitle_unavailable'), + ); + expect(config.icon).toBeUndefined(); + expect(config.iconColor).toBeUndefined(); + }); + }); + describe('getFeatureTags', () => { + const makeFeature = (featureId: string): TokenSecurityFeature => + ({ featureId }) as TokenSecurityFeature; + + describe('Low risk (Verified / Benign)', () => { + it('returns positive tags for known positive feature IDs', () => { + const features = [ + makeFeature('VERIFIED_CONTRACT'), + makeFeature('HIGH_REPUTATION_TOKEN'), + ]; + + const { tags, remainingCount } = getFeatureTags(features, 'Verified'); + + expect(tags).toEqual([ + { label: 'Published contract' }, + { label: 'Established reputation' }, + ]); + expect(remainingCount).toBe(0); + }); + + it('ignores negative feature IDs when resultType is Verified', () => { + const features = [ + makeFeature('RUGPULL'), + makeFeature('VERIFIED_CONTRACT'), + ]; + + const { tags } = getFeatureTags(features, 'Verified'); + + expect(tags).toEqual([{ label: 'Published contract' }]); + }); + + it('caps display at 4 positive tags with no remainingCount', () => { + const features = [ + makeFeature('HIGH_REPUTATION_TOKEN'), + makeFeature('LISTED_ON_CENTRALIZED_EXCHANGE'), + makeFeature('VERIFIED_CONTRACT'), + makeFeature('HIGH_TRADE_VOLUME'), + makeFeature('UNKNOWN_EXTRA'), + ].map((f) => f); + + const { tags, remainingCount } = getFeatureTags(features, 'Verified'); + + expect(tags.length).toBeLessThanOrEqual(4); + expect(remainingCount).toBe(0); + }); + + it('defaults to positive behaviour when resultType is undefined', () => { + const features = [makeFeature('HIGH_REPUTATION_TOKEN')]; + + const { tags } = getFeatureTags(features, undefined); + + expect(tags).toEqual([{ label: 'Established reputation' }]); + }); + }); + + describe('Medium risk (Warning / Spam)', () => { + it('returns Warning-type negative tags for Warning resultType', () => { + const features = [ + makeFeature('HONEYPOT'), + makeFeature('AIRDROP_PATTERN'), + ]; + + const { tags, remainingCount } = getFeatureTags(features, 'Warning'); + + expect(tags).toEqual([ + { label: 'Honeypot risk' }, + { label: 'Suspicious airdrop' }, + ]); + expect(remainingCount).toBe(0); + }); + + it('returns Spam-type negative tags for Spam resultType', () => { + const features = [makeFeature('IMPERSONATOR_HIGH_CONFIDENCE')]; + + const { tags } = getFeatureTags(features, 'Spam'); + + expect(tags).toEqual([{ label: 'Likely impersonator' }]); + }); + + it('ignores Malicious features when resultType is Warning', () => { + const features = [makeFeature('RUGPULL'), makeFeature('HONEYPOT')]; + + const { tags } = getFeatureTags(features, 'Warning'); + + expect(tags).toEqual([{ label: 'Honeypot risk' }]); + }); + + it('caps display at 3 and returns correct remainingCount', () => { + const features = [ + makeFeature('HONEYPOT'), + makeFeature('AIRDROP_PATTERN'), + makeFeature('INORGANIC_VOLUME'), + makeFeature('DYNAMIC_ANALYSIS'), + makeFeature('UNSTABLE_TOKEN_PRICE'), + ]; + + const { tags, remainingCount } = getFeatureTags(features, 'Warning'); + + expect(tags).toHaveLength(3); + expect(remainingCount).toBe(2); + }); + }); + + describe('High risk (Malicious)', () => { + it('returns Malicious-type negative tags', () => { + const features = [ + makeFeature('RUGPULL'), + makeFeature('KNOWN_MALICIOUS'), + ]; + + const { tags, remainingCount } = getFeatureTags(features, 'Malicious'); + + expect(tags).toEqual([ + { label: 'Rugpull risk' }, + { label: 'Known malicious' }, + ]); + expect(remainingCount).toBe(0); + }); + + it('ignores Warning features when resultType is Malicious', () => { + const features = [makeFeature('HONEYPOT'), makeFeature('RUGPULL')]; + + const { tags } = getFeatureTags(features, 'Malicious'); + + expect(tags).toEqual([{ label: 'Rugpull risk' }]); + }); + + it('caps display at 3 and returns correct remainingCount', () => { + const features = [ + makeFeature('RUGPULL'), + makeFeature('KNOWN_MALICIOUS'), + makeFeature('UNSELLABLE_TOKEN'), + makeFeature('SANCTIONED_CREATOR'), + makeFeature('POST_DUMP'), + makeFeature('TOKEN_BACKDOOR'), + ]; + + const { tags, remainingCount } = getFeatureTags(features, 'Malicious'); + + expect(tags).toHaveLength(3); + expect(remainingCount).toBe(3); + }); + }); + + it('ignores unknown feature IDs in all modes', () => { + const features = [makeFeature('UNKNOWN_FEATURE')]; + + expect(getFeatureTags(features, 'Verified').tags).toEqual([]); + expect(getFeatureTags(features, 'Malicious').tags).toEqual([]); + expect(getFeatureTags(features, 'Warning').tags).toEqual([]); + }); + }); + + describe('formatFeePercent', () => { + it('formats a number as a percentage with one decimal', () => { + expect(formatFeePercent(5)).toBe('5.0%'); + }); + + it('formats zero', () => { + expect(formatFeePercent(0)).toBe('0.0%'); + }); + + it('returns N/A for null', () => { + expect(formatFeePercent(null)).toBe('N/A'); + }); + + it('returns N/A for undefined', () => { + expect(formatFeePercent(undefined)).toBe('N/A'); + }); + }); + + describe('getTop10HoldingPct', () => { + it('sums holder percentages', () => { + const stats = { + topHolders: [ + { holdingPercentage: 10 }, + { holdingPercentage: 15 }, + { holdingPercentage: 5 }, + ], + } as TokenSecurityFinancialStats; + + expect(getTop10HoldingPct(stats)).toBe(30); + }); + + it('caps at 100', () => { + const stats = { + topHolders: [{ holdingPercentage: 60 }, { holdingPercentage: 50 }], + } as TokenSecurityFinancialStats; + + expect(getTop10HoldingPct(stats)).toBe(100); + }); + + it('treats missing holdingPercentage as 0', () => { + const stats = { + topHolders: [ + { holdingPercentage: 10 }, + { holdingPercentage: undefined }, + ], + } as unknown as TokenSecurityFinancialStats; + + expect(getTop10HoldingPct(stats)).toBe(10); + }); + + it('returns null when no topHolders', () => { + expect( + getTop10HoldingPct({ + topHolders: [], + } as unknown as TokenSecurityFinancialStats), + ).toBeNull(); + }); + + it('returns null for null stats', () => { + expect(getTop10HoldingPct(null)).toBeNull(); + }); + + it('returns null for undefined stats', () => { + expect(getTop10HoldingPct(undefined)).toBeNull(); + }); + }); + + describe('formatCompactSupply', () => { + it('returns N/A for null', () => { + expect(formatCompactSupply(null)).toBe('N/A'); + }); + + it('returns N/A for undefined', () => { + expect(formatCompactSupply(undefined)).toBe('N/A'); + }); + + it('formats quadrillions', () => { + expect(formatCompactSupply(2e15)).toBe('2.00Q'); + }); + + it('formats trillions', () => { + expect(formatCompactSupply(1.5e12)).toBe('1.50T'); + }); + + it('formats billions', () => { + expect(formatCompactSupply(10e9)).toBe('10.00B'); + }); + + it('formats millions', () => { + expect(formatCompactSupply(5_000_000)).toBe('5.00M'); + }); + + it('formats thousands', () => { + expect(formatCompactSupply(1_500)).toBe('1.50K'); + }); + + it('formats small values as integers', () => { + expect(formatCompactSupply(42)).toBe('42'); + }); + + it('adjusts by decimals when provided', () => { + const rawSupply = 1.6e25; + const result = formatCompactSupply(rawSupply, 18); + expect(result).toBe('16.00M'); + }); + + it('does not adjust when decimals is 0', () => { + expect(formatCompactSupply(5_000_000, 0)).toBe('5.00M'); + }); + }); +}); diff --git a/app/components/UI/SecurityTrust/utils/securityUtils.ts b/app/components/UI/SecurityTrust/utils/securityUtils.ts new file mode 100644 index 00000000000..adb4417f45f --- /dev/null +++ b/app/components/UI/SecurityTrust/utils/securityUtils.ts @@ -0,0 +1,284 @@ +import { + IconColor, + IconName, + TextColor, +} from '@metamask/design-system-react-native'; +import { + type FeatureTag, + type TokenSecurityData, + type TokenSecurityFeature, + type TokenSecurityFinancialStats, +} from '../types'; +import { strings } from '../../../../../locales/i18n'; + +export interface ResultTypeConfig { + label: string; + textColor: TextColor; + subtitle?: string; + icon?: IconName; + iconColor?: IconColor; +} + +export const getResultTypeConfig = ( + resultType: string | undefined, +): ResultTypeConfig => { + switch (resultType) { + case 'Verified': + return { + label: strings('security_trust.verified'), + textColor: TextColor.SuccessDefault, + subtitle: strings('security_trust.subtitle_known'), + icon: IconName.SecurityTick, + iconColor: IconColor.SuccessDefault, + }; + case 'Benign': + return { + label: strings('security_trust.no_issues'), + textColor: TextColor.SuccessDefault, + subtitle: strings('security_trust.subtitle_no_issues'), + icon: IconName.SecurityTick, + iconColor: IconColor.SuccessDefault, + }; + case 'Warning': + case 'Spam': + return { + label: strings('security_trust.suspicious'), + textColor: TextColor.WarningDefault, + subtitle: strings('security_trust.subtitle_suspicious'), + icon: IconName.Warning, + iconColor: IconColor.WarningDefault, + }; + case 'Malicious': + return { + label: strings('security_trust.malicious_label'), + textColor: TextColor.ErrorDefault, + subtitle: strings('security_trust.subtitle_malicious'), + icon: IconName.Danger, + iconColor: IconColor.ErrorDefault, + }; + default: + return { + label: strings('security_trust.data_unavailable'), + textColor: TextColor.TextAlternative, + subtitle: strings('security_trust.subtitle_unavailable'), + }; + } +}; + +/** Blockaid-assigned feature type, as documented in the Blockaid token-scan API. */ +export type BlockaidFeatureType = + | 'Benign' + | 'Info' + | 'Warning' + | 'Spam' + | 'Malicious'; + +interface FeatureDefinition { + label: string; + type: BlockaidFeatureType; +} + +/** Positive-signal features (Benign / Info) */ +const POSITIVE_FEATURE_LABELS: Record = { + HIGH_REPUTATION_TOKEN: { label: 'Established reputation', type: 'Benign' }, + LISTED_ON_CENTRALIZED_EXCHANGE: { + label: 'Listed on exchange', + type: 'Benign', + }, + VERIFIED_CONTRACT: { label: 'Published contract', type: 'Info' }, + HIGH_TRADE_VOLUME: { label: 'High trading volume', type: 'Info' }, +}; + +/** Negative-signal features (Malicious / Spam / Warning / risk-bearing Info) */ +const NEGATIVE_FEATURE_LABELS: Record = { + // Malicious + KNOWN_MALICIOUS: { label: 'Known malicious', type: 'Malicious' }, + METADATA: { label: 'Suspicious metadata', type: 'Malicious' }, + IMPERSONATOR_SENSITIVE_ASSET: { + label: 'Impersonates a sensitive asset', + type: 'Malicious', + }, + STATIC_CODE_SIGNATURE: { label: 'Suspicious code', type: 'Malicious' }, + RUGPULL: { label: 'Rugpull risk', type: 'Malicious' }, + HIGH_TRANSFER_FEE: { label: 'High transfer fee', type: 'Malicious' }, + HIGH_BUY_FEE: { label: 'High buy fee', type: 'Malicious' }, + HIGH_SELL_FEE: { label: 'High sell fee', type: 'Malicious' }, + UNSELLABLE_TOKEN: { label: 'Unsellable token', type: 'Malicious' }, + SANCTIONED_CREATOR: { label: 'Sanctioned creator', type: 'Malicious' }, + SIMILAR_MALICIOUS_CONTRACT: { + label: 'Resembles malicious contract', + type: 'Malicious', + }, + TOKEN_BACKDOOR: { label: 'Token backdoor', type: 'Malicious' }, + POST_DUMP: { label: 'Possible price manipulation', type: 'Malicious' }, + + // Spam + IMPERSONATOR_HIGH_CONFIDENCE: { label: 'Likely impersonator', type: 'Spam' }, + IMPERSONATOR_MEDIUM_CONFIDENCE: { + label: 'Possible impersonator', + type: 'Spam', + }, + + // Warning + AIRDROP_PATTERN: { label: 'Suspicious airdrop', type: 'Warning' }, + IMPERSONATOR: { label: 'Impersonator', type: 'Warning' }, + INORGANIC_VOLUME: { label: 'Artificial volume', type: 'Warning' }, + DYNAMIC_ANALYSIS: { label: 'Suspicious behavior', type: 'Warning' }, + UNSTABLE_TOKEN_PRICE: { label: 'Unstable price', type: 'Warning' }, + INAPPROPRIATE_CONTENT: { label: 'Inappropriate content', type: 'Warning' }, + HONEYPOT: { label: 'Honeypot risk', type: 'Warning' }, + SPAM_TEXT: { label: 'Spam text', type: 'Warning' }, + INSUFFICIENT_LOCKED_LIQUIDITY: { + label: 'Low locked liquidity', + type: 'Warning', + }, + CONCENTRATED_SUPPLY_DISTRIBUTION: { + label: 'Concentrated supply', + type: 'Warning', + }, + WASH_TRADING: { label: 'Wash trading', type: 'Warning' }, + FAKE_VOLUME: { label: 'Fake volume', type: 'Warning' }, + HIDDEN_SUPPLY_BY_KEY_HOLDER: { label: 'Undisclosed supply', type: 'Warning' }, + HEAVILY_SNIPED: { label: 'Heavy bot activity', type: 'Warning' }, + FAKE_TRADE_MAKER_COUNT: { label: 'Inflated trader count', type: 'Warning' }, + LOW_REPUTATION_CREATOR: { + label: 'Creator has low reputation', + type: 'Warning', + }, + SNIPE_AT_MINT: { label: 'Bot activity at launch', type: 'Warning' }, + + // Info – risk-bearing capabilities + IMPERSONATOR_LOW_CONFIDENCE: { + label: 'Unconfirmed impersonator', + type: 'Warning', + }, // used to be Info, but now it's Warning + IS_MINTABLE: { label: 'Mintable', type: 'Info' }, + CAN_BLACKLIST: { label: 'Can blacklist', type: 'Info' }, + CAN_WHITELIST: { label: 'Can whitelist', type: 'Info' }, + HAS_TRADING_COOLDOWN: { label: 'Trading cooldown', type: 'Info' }, + EXTERNAL_FUNCTIONS: { label: 'External calls', type: 'Info' }, + HIDDEN_OWNER: { label: 'Hidden owner', type: 'Info' }, + TRANSFER_PAUSEABLE: { label: 'Transfers pauseable', type: 'Info' }, + PROXY_CONTRACT: { label: 'Proxy contract', type: 'Info' }, + MODIFIABLE_TAXES: { label: 'Modifiable taxes', type: 'Info' }, + OWNER_CAN_CHANGE_BALANCE: { label: 'Owner can change balance', type: 'Info' }, + TRANSFER_FROM_REVERTS: { label: 'Transfer reversals enabled', type: 'Info' }, + TRANSFER_HOOK_ENABLED: { label: 'Transfer hook enabled', type: 'Info' }, + CONFIDENTIAL_TRANSFERS_ENABLED: { + label: 'Confidential transfers', + type: 'Info', + }, + NON_TRANSERABLE: { label: 'Non-transferable', type: 'Info' }, +}; + +export interface FeatureTagsResult { + tags: FeatureTag[]; + remainingCount: number; +} + +const FEATURE_TAG_DISPLAY_MAX = 3; +const POSITIVE_FEATURE_TAG_DISPLAY_MAX = 4; + +/** + * Returns up to 3 feature tags for the entry card, filtered by resultType, + * plus a count of additional matching features beyond the display limit. + * + * - Low (Verified/Benign): positive features only, no remainingCount. + * - Medium (Warning/Spam): Warning + Spam negative features, with overflow count. + * - High (Malicious): Malicious negative features, with overflow count. + */ +export const getFeatureTags = ( + features: TokenSecurityFeature[], + resultType?: TokenSecurityData['resultType'], + showAll = false, +): FeatureTagsResult => { + const tags: FeatureTag[] = []; + let totalMatching = 0; + + if (resultType === 'Malicious') { + for (const feature of features) { + const def = NEGATIVE_FEATURE_LABELS[feature.featureId]; + if (def?.type === 'Malicious') { + totalMatching++; + if (showAll || tags.length < FEATURE_TAG_DISPLAY_MAX) { + tags.push({ label: def.label }); + } + } + } + } else if (resultType === 'Warning' || resultType === 'Spam') { + for (const feature of features) { + const def = NEGATIVE_FEATURE_LABELS[feature.featureId]; + if (def?.type === 'Warning' || def?.type === 'Spam') { + totalMatching++; + if (showAll || tags.length < FEATURE_TAG_DISPLAY_MAX) { + tags.push({ label: def.label }); + } + } + } + } else { + // Low (Verified/Benign) or no resultType: positive features only + for (const feature of features) { + const def = POSITIVE_FEATURE_LABELS[feature.featureId]; + if (def) { + if (showAll || tags.length < POSITIVE_FEATURE_TAG_DISPLAY_MAX) { + tags.push({ label: def.label }); + } + } + } + return { tags, remainingCount: 0 }; + } + + return { + tags, + remainingCount: showAll + ? 0 + : Math.max(0, totalMatching - FEATURE_TAG_DISPLAY_MAX), + }; +}; + +/** + * Format a fee value (0-100 range) as a percentage string, or "N/A" if null. + */ +export const formatFeePercent = (fee: number | null | undefined): string => { + if (fee === null || fee === undefined) return 'N/A'; + return `${fee.toFixed(1)}%`; +}; + +/** + * Sum the holding percentages of top holders. + */ +export const getTop10HoldingPct = ( + financialStats: TokenSecurityFinancialStats | null | undefined, +): number | null => { + if (!financialStats?.topHolders?.length) return null; + const sum = financialStats.topHolders.reduce( + (acc, h) => acc + (h.holdingPercentage ?? 0), + 0, + ); + return Math.min(sum, 100); +}; + +/** + * Format a raw token supply number to a compact string with unit. + */ +export const formatCompactSupply = ( + supply: number | null | undefined, + decimals?: number, +): string => { + if (supply === null || supply === undefined) return 'N/A'; + const adjusted = + decimals != null && decimals > 0 ? supply / 10 ** decimals : supply; + const units: [number, string][] = [ + [1e15, 'Q'], + [1e12, 'T'], + [1e9, 'B'], + [1e6, 'M'], + [1e3, 'K'], + ]; + for (const [threshold, suffix] of units) { + if (adjusted >= threshold) { + return `${(adjusted / threshold).toFixed(2)}${suffix}`; + } + } + return adjusted.toFixed(0); +}; diff --git a/app/components/UI/TokenDetails/Views/TokenDetails.tsx b/app/components/UI/TokenDetails/Views/TokenDetails.tsx index 8eec9e9fa1c..65f2333fe60 100644 --- a/app/components/UI/TokenDetails/Views/TokenDetails.tsx +++ b/app/components/UI/TokenDetails/Views/TokenDetails.tsx @@ -1,7 +1,6 @@ -import React, { useCallback, useEffect, useRef } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { View, StyleSheet, ActivityIndicator } from 'react-native'; import { useSelector } from 'react-redux'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { selectTokenListLayoutV2Enabled } from '../../../../selectors/featureFlagController/tokenListLayout'; import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../core/Analytics'; @@ -16,6 +15,9 @@ import { RootState } from '../../../../reducers'; import { selectNetworkConfigurationByChainId } from '../../../../selectors/networkController'; import { useNavigation, useRoute } from '@react-navigation/native'; import Routes from '../../../../constants/navigation/Routes'; +import { useTokenSecurityData } from '../hooks/useTokenSecurityData'; +import { isCaipAssetType, type CaipAssetType } from '@metamask/utils'; +import { formatAddressToAssetId } from '@metamask/bridge-controller'; import { isMainnetByChainId } from '../../../../util/networks'; import useBlockExplorer from '../../../hooks/useBlockExplorer'; import { TokenDetailsInlineHeader } from '../components/TokenDetailsInlineHeader'; @@ -37,18 +39,8 @@ import ActivityHeader from '../../../Views/Asset/ActivityHeader'; import Transactions from '../../Transactions'; import MultichainTransactionsView from '../../../Views/MultichainTransactionsView/MultichainTransactionsView'; import { TransactionDetailLocation } from '../../../../core/Analytics/events/transactions'; -import BottomSheetFooter, { - ButtonsAlignment, -} from '../../../../component-library/components/BottomSheets/BottomSheetFooter'; -import { - ButtonSize, - ButtonVariants, -} from '../../../../component-library/components/Buttons/Button'; -import { strings } from '../../../../../locales/i18n'; import { useTokenDetailsABTest } from '../hooks/useTokenDetailsABTest'; -import { useRWAToken } from '../../Bridge/hooks/useRWAToken'; -import { BridgeToken } from '../../Bridge/types'; -import useTokenBuyability from '../../Ramp/hooks/useTokenBuyability'; +import TokenDetailsStickyFooter from '../components/TokenDetailsStickyFooter'; const styleSheet = (params: { theme: Theme }) => { const { theme } = params; @@ -64,11 +56,6 @@ const styleSheet = (params: { theme: Theme }) => { alignItems: 'center', justifyContent: 'center', }, - bottomSheetFooter: { - backgroundColor: colors.background.default, - paddingHorizontal: 16, - paddingTop: 16, - }, }); }; @@ -78,15 +65,38 @@ const styleSheet = (params: { theme: Theme }) => { */ const TokenDetails: React.FC<{ token: TokenDetailsRouteParams; - onMarketInsightsDisplayResolved?: (isDisplayed: boolean) => void; + onMarketInsightsDisplayResolved?: (params: { + isDisplayed: boolean; + severity: string | undefined; + }) => void; }> = ({ token, onMarketInsightsDisplayResolved }) => { const { styles } = useStyles(styleSheet, {}); const navigation = useNavigation(); - const insets = useSafeAreaInsets(); + + const caip19AssetId = useMemo((): CaipAssetType | null => { + try { + if (isCaipAssetType(token.address)) { + return token.address as CaipAssetType; + } + if (!token.chainId) return null; + return (formatAddressToAssetId(token.address, token.chainId) ?? + null) as CaipAssetType | null; + } catch { + return null; + } + }, [token.address, token.chainId]); + + const { + securityData, + isLoading: isSecurityDataLoading, + error: securityDataError, + } = useTokenSecurityData({ + assetId: caip19AssetId, + prefetchedData: token.securityData, + }); // A/B test hook for layout selection const { useNewLayout } = useTokenDetailsABTest(); - const { isTokenTradingOpen } = useRWAToken(); useEffect(() => { endTrace({ name: TraceName.AssetDetails }); @@ -144,19 +154,11 @@ const TokenDetails: React.FC<{ ///: END:ONLY_INCLUDE_IF } = useTokenBalance(token); - const { - onBuy, - onSend, - onReceive, - goToSwaps, - hasEligibleSwapTokens, - networkModal, - } = useTokenActions({ - token, - networkName, - }); - - const { isBuyable } = useTokenBuyability(token); + const { onBuy, onSend, onReceive, goToSwaps, hasEligibleSwapTokens } = + useTokenActions({ + token, + networkName, + }); const { transactions, @@ -170,6 +172,11 @@ const TokenDetails: React.FC<{ isNonEvmAsset: txIsNonEvmAsset, } = useTokenTransactions(token); + const hasTransactions = + transactions.length > 0 || + submittedTxs.length > 0 || + confirmedTxs.length > 0; + const isSwapsAssetAllowed = getIsSwapsAssetAllowed({ asset: { isETH: token.isETH ?? false, @@ -180,9 +187,6 @@ const TokenDetails: React.FC<{ }); const displaySwapsButton = isSwapsAssetAllowed && AppConstants.SWAPS.ACTIVE; - const showSwapButton = hasEligibleSwapTokens; - const showBuyButton = isBuyable || !hasEligibleSwapTokens; - const rampNetworks = useSelector(getRampNetworks); const chainIdForRamp = token.chainId ?? ''; @@ -213,7 +217,18 @@ const TokenDetails: React.FC<{ onSend={onSend} onReceive={onReceive} goToSwaps={goToSwaps} - onMarketInsightsDisplayResolved={onMarketInsightsDisplayResolved} + onMarketInsightsDisplayResolved={ + onMarketInsightsDisplayResolved + ? (isDisplayed: boolean) => + onMarketInsightsDisplayResolved({ + isDisplayed, + severity: securityData?.resultType, + }) + : undefined + } + securityData={securityData} + isSecurityDataLoading={isSecurityDataLoading} + hasSecurityDataError={Boolean(securityDataError)} ///: BEGIN:ONLY_INCLUDE_IF(tron) isTronNative={isTronNative} stakedTrxAsset={stakedTrxAsset} @@ -221,12 +236,14 @@ const TokenDetails: React.FC<{ readyForWithdrawalBalance={readyForWithdrawalBalance} ///: END:ONLY_INCLUDE_IF /> - + {(txLoading || hasTransactions) && ( + + )} ); @@ -238,8 +255,6 @@ const TokenDetails: React.FC<{ return ( navigation.goBack()} onOptionsPress={ shouldShowMoreOptionsInNavBar && !useNewLayout @@ -247,6 +262,7 @@ const TokenDetails: React.FC<{ : undefined } /> + {txLoading ? ( renderLoader() ) : txIsNonEvmAsset ? ( @@ -276,43 +292,19 @@ const TokenDetails: React.FC<{ headerHeight={280} tokenChainId={token.chainId} skipScrollOnClick + hideEmptyState location={TransactionDetailLocation.AssetDetails} /> )} - {networkModal} - {useNewLayout && - !txLoading && - isTokenTradingOpen(token as BridgeToken) && ( - goToSwaps(), - }, - ] - : []), - ...(showBuyButton - ? [ - { - variant: ButtonVariants.Primary, - label: strings('asset_overview.buy_button'), - size: ButtonSize.Lg, - onPress: onBuy, - }, - ] - : []), - ]} - buttonsAlignment={ButtonsAlignment.Horizontal} - /> - )} + {useNewLayout && !txLoading && ( + + )} ); }; @@ -329,7 +321,13 @@ const useTokenDetailsOpenedTracking = (params: TokenDetailsRouteParams) => { const lastTrackedTokenKeyRef = useRef(null); return useCallback( - ({ isMarketInsightsDisplayed }: { isMarketInsightsDisplayed: boolean }) => { + ({ + isMarketInsightsDisplayed, + severity, + }: { + isMarketInsightsDisplayed: boolean; + severity: string | undefined; + }) => { const source = params.source ?? TokenDetailsSource.Unknown; const tokenTrackingKey = `${params.chainId ?? ''}:${params.address ?? ''}:${params.symbol ?? ''}:${source}`; @@ -355,6 +353,7 @@ const useTokenDetailsOpenedTracking = (params: TokenDetailsRouteParams) => { token_name: params.name, has_balance: hasBalance, market_insights_displayed: isMarketInsightsDisplayed, + severity, // A/B test attribution — each experiment is independent ...((isTestActive || isFromTokenList) && { ab_tests: { @@ -402,9 +401,16 @@ export const TokenDetailsRouteWrapper: React.FC = () => { const trackTokenDetailsOpened = useTokenDetailsOpenedTracking(token); const handleMarketInsightsDisplayResolved = useCallback( - (isDisplayed: boolean) => { + ({ + isDisplayed, + severity, + }: { + isDisplayed: boolean; + severity: string | undefined; + }) => { trackTokenDetailsOpened({ isMarketInsightsDisplayed: isDisplayed, + severity, }); }, [trackTokenDetailsOpened], diff --git a/app/components/UI/TokenDetails/components/AssetOverviewContent.test.tsx b/app/components/UI/TokenDetails/components/AssetOverviewContent.test.tsx index 629324f679d..6a55ce5c07a 100644 --- a/app/components/UI/TokenDetails/components/AssetOverviewContent.test.tsx +++ b/app/components/UI/TokenDetails/components/AssetOverviewContent.test.tsx @@ -15,6 +15,8 @@ import { PERPS_EVENT_PROPERTY, PERPS_EVENT_VALUE, } from '@metamask/perps-controller'; +import { strings } from '../../../../../locales/i18n'; +import type { TokenSecurityData } from '@metamask/assets-controllers'; const mockHandlePerpsAction = jest.fn(); const mockTrack = jest.fn(); @@ -90,6 +92,14 @@ jest.mock('../../Perps/components/PerpsDiscoveryBanner', () => ({ jest.mock('../../AssetOverview/TokenDetails', () => () => null); +jest.mock( + '../../SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard', + () => ({ + __esModule: true, + default: ({ testID }: { testID?: string }) => , + }), +); + jest.mock('@react-navigation/native', () => { const actual = jest.requireActual('@react-navigation/native'); return { @@ -160,6 +170,36 @@ const defaultProps: AssetOverviewContentProps = { goToSwaps: jest.fn(), }; +const createMockSecurityData = ( + resultType: TokenSecurityData['resultType'], +): TokenSecurityData => ({ + resultType, + maliciousScore: '0', + fees: { + transfer: 0, + transferFeeMaxAmount: null, + buy: 0, + sell: 0, + }, + features: [], + financialStats: { + supply: 1000000, + topHolders: [], + holdersCount: 100, + tradeVolume24h: null, + lockedLiquidityPct: null, + markets: [], + }, + metadata: { + externalLinks: { + homepage: null, + twitterPage: null, + telegramChannelId: null, + }, + }, + created: '2023-01-01T00:00:00Z', +}); + const defaultMarketInsightsResult = { report: { asset: 'eth', @@ -481,4 +521,259 @@ describe('AssetOverviewContent', () => { ).toBeNull(); }); }); + + describe('Security Badge', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockBuild.mockReturnValue({ category: 'test-event' }); + mockAddProperties.mockReturnValue({ build: mockBuild }); + mockCreateEventBuilder.mockReturnValue({ + addProperties: mockAddProperties, + }); + mockSelectMarketInsightsEnabled.mockReturnValue(false); + mockUseMarketInsights.mockReturnValue({ + report: null, + isLoading: false, + error: null, + timeAgo: null, + }); + mockUsePerpsPositionForAsset.mockReturnValue(defaultPerpsPositionResult); + }); + + it('renders verified badge when securityData resultType is Verified', () => { + const { getByTestId } = renderWithProvider( + , + { state: createState(true) }, + ); + + const badge = getByTestId('security-badge-verified'); + expect(badge).toBeOnTheScreen(); + }); + + it('does not render badge when securityData resultType is Benign', () => { + const { queryByTestId } = renderWithProvider( + , + { state: createState(true) }, + ); + + expect(queryByTestId('security-badge-verified')).toBeNull(); + expect(queryByTestId('security-badge-warning')).toBeNull(); + expect(queryByTestId('security-badge-malicious')).toBeNull(); + }); + + it('renders warning badge when securityData resultType is Warning', () => { + const { getByTestId } = renderWithProvider( + , + { state: createState(true) }, + ); + + const badge = getByTestId('security-badge-warning'); + expect(badge).toBeOnTheScreen(); + }); + + it('renders warning badge when securityData resultType is Spam', () => { + const { getByTestId } = renderWithProvider( + , + { state: createState(true) }, + ); + + const badge = getByTestId('security-badge-warning'); + expect(badge).toBeOnTheScreen(); + }); + + it('renders malicious badge when securityData resultType is Malicious', () => { + const { getByTestId } = renderWithProvider( + , + { state: createState(true) }, + ); + + const badge = getByTestId('security-badge-malicious'); + expect(badge).toBeOnTheScreen(); + }); + + it('navigates to security badge bottom sheet when verified badge is pressed', () => { + const { getByTestId } = renderWithProvider( + , + { state: createState(true) }, + ); + + fireEvent.press(getByTestId('security-badge-verified')); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.MODAL.SECURITY_BADGE_BOTTOM_SHEET, + params: expect.objectContaining({ + title: expect.any(String), + description: expect.any(String), + source: 'badge', + severity: 'Verified', + tokenAddress: '0x123', + tokenSymbol: 'ETH', + chainId: '0x1', + }), + }); + }); + + it('navigates to security badge bottom sheet when warning badge is pressed', () => { + const { getByTestId } = renderWithProvider( + , + { state: createState(true) }, + ); + + fireEvent.press(getByTestId('security-badge-warning')); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.MODAL.SECURITY_BADGE_BOTTOM_SHEET, + params: expect.objectContaining({ + title: expect.any(String), + description: expect.any(String), + source: 'badge', + severity: 'Warning', + tokenAddress: '0x123', + tokenSymbol: 'ETH', + chainId: '0x1', + }), + }); + }); + + it('navigates to security badge bottom sheet when spam badge is pressed', () => { + const { getByTestId } = renderWithProvider( + , + { state: createState(true) }, + ); + + fireEvent.press(getByTestId('security-badge-warning')); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.MODAL.SECURITY_BADGE_BOTTOM_SHEET, + params: expect.objectContaining({ + title: expect.any(String), + description: expect.any(String), + source: 'badge', + severity: 'Spam', + tokenAddress: '0x123', + tokenSymbol: 'ETH', + chainId: '0x1', + }), + }); + }); + + it('navigates to security badge bottom sheet when malicious badge is pressed', () => { + const { getByTestId } = renderWithProvider( + , + { state: createState(true) }, + ); + + fireEvent.press(getByTestId('security-badge-malicious')); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.MODAL.SECURITY_BADGE_BOTTOM_SHEET, + params: expect.objectContaining({ + title: expect.any(String), + description: expect.any(String), + source: 'badge', + severity: 'Malicious', + tokenAddress: '0x123', + tokenSymbol: 'ETH', + chainId: '0x1', + }), + }); + }); + + it('does not navigate when benign badge is pressed', () => { + renderWithProvider( + , + { state: createState(true) }, + ); + + // Benign should not render any badge, so there's nothing to press + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('does not render badge when securityData is null', () => { + const { queryByTestId } = renderWithProvider( + , + { state: createState(true) }, + ); + + expect(queryByTestId('security-badge-verified')).toBeNull(); + expect(queryByTestId('security-badge-warning')).toBeNull(); + expect(queryByTestId('security-badge-malicious')).toBeNull(); + }); + + it('does not render badge when securityData is undefined', () => { + const { queryByTestId } = renderWithProvider( + , + { state: createState(true) }, + ); + + expect(queryByTestId('security-badge-verified')).toBeNull(); + expect(queryByTestId('security-badge-warning')).toBeNull(); + expect(queryByTestId('security-badge-malicious')).toBeNull(); + }); + + it('renders malicious warning banner when resultType is Malicious', () => { + const { getByText } = renderWithProvider( + , + { state: createState(true) }, + ); + + expect( + getByText(strings('security_trust.malicious_token_title')), + ).toBeOnTheScreen(); + expect( + getByText( + strings('security_trust.malicious_token_description', { + symbol: 'ETH', + }), + ), + ).toBeOnTheScreen(); + }); + + it('does not render malicious warning banner when resultType is not Malicious', () => { + const { queryByText } = renderWithProvider( + , + { state: createState(true) }, + ); + + expect( + queryByText(strings('security_trust.malicious_token_title')), + ).toBeNull(); + }); + }); }); diff --git a/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx b/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx index 75888306887..1f64f015633 100644 --- a/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx +++ b/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx @@ -58,8 +58,33 @@ import { useMarketInsights, selectMarketInsightsEnabled, } from '../../MarketInsights'; -import { isCaipAssetType } from '@metamask/utils'; +import { isCaipAssetType, type Hex } from '@metamask/utils'; import { formatAddressToAssetId } from '@metamask/bridge-controller'; +import type { TokenSecurityData } from '@metamask/assets-controllers'; +import SecurityTrustEntryCard from '../../SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard'; +import type { TokenDetailsRouteParams } from '../constants/constants'; +import { + Box, + Text as DSText, + TextVariant as DSTextVariant, + TextColor as DSTextColor, + BoxFlexDirection, + BoxAlignItems, + Icon, + IconName, + IconSize, + IconColor, + FontWeight, +} from '@metamask/design-system-react-native'; +import Badge, { + BadgeVariant, +} from '../../../../component-library/components/Badges/Badge'; +import { AvatarSize } from '../../../../component-library/components/Avatars/Avatar/Avatar.types'; +import BadgeWrapper, { + BadgePosition, +} from '../../../../component-library/components/Badges/BadgeWrapper'; +import AssetLogo from '../../Assets/components/AssetLogo/AssetLogo'; +import { NetworkBadgeSource } from '../../AssetOverview/Balance/Balance'; ///: BEGIN:ONLY_INCLUDE_IF(tron) import TronEnergyBandwidthDetail from '../../AssetOverview/TronEnergyBandwidthDetail/TronEnergyBandwidthDetail'; import TronUnstakingBanner from '../../Earn/components/Tron/TronUnstakingBanner/TronUnstakingBanner'; @@ -69,9 +94,10 @@ import TronStakingCta from '../../Earn/components/Tron/TronStakingCta/TronStakin import useTronStakeApy from '../../Earn/hooks/useTronStakeApy'; ///: END:ONLY_INCLUDE_IF import MarketClosedActionButton from '../../AssetOverview/MarketClosedActionButton'; -import { IconName } from '../../../../component-library/components/Icons/Icon'; +import { IconName as ComponentLibraryIconName } from '../../../../component-library/components/Icons/Icon'; import { useRWAToken } from '../../Bridge/hooks/useRWAToken'; import { BridgeToken } from '../../Bridge/types'; +import StockBadge from '../../shared/StockBadge/StockBadge'; import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; import { endTrace, @@ -120,6 +146,11 @@ const styleSheet = (params: { theme: Theme }) => { marketClosedActionButtonContainer: { marginBottom: 8, }, + securityTrustWrapper: { + marginTop: 20, + marginBottom: 20, + paddingHorizontal: 16, + } as ViewStyle, perpsPositionTitle: { marginBottom: 8, } as TextStyle, @@ -173,6 +204,14 @@ export interface AssetOverviewContentProps { inLockPeriodBalance?: string; readyForWithdrawalBalance?: string; onMarketInsightsDisplayResolved?: (isDisplayed: boolean) => void; + + // Security & Trust + /** Resolved security data owned by the parent (TokenDetails). */ + securityData?: TokenSecurityData | null; + /** Whether security data is still being fetched. */ + isSecurityDataLoading?: boolean; + /** Whether the security data fetch failed. Hides the card when true. */ + hasSecurityDataError?: boolean; } /** @@ -212,11 +251,14 @@ const AssetOverviewContent: React.FC = ({ inLockPeriodBalance, readyForWithdrawalBalance, onMarketInsightsDisplayResolved, + securityData, + isSecurityDataLoading = false, + hasSecurityDataError = false, }) => { const { styles } = useStyles(styleSheet, {}); const navigation = useNavigation(); const resetNavigationLockRef = useRef<(() => void) | null>(null); - const { isTokenTradingOpen } = useRWAToken(); + const { isTokenTradingOpen, isStockToken } = useRWAToken(); const { trackEvent, createEventBuilder } = useAnalytics(); // A/B test hook for layout selection (must be called before usePerpsActions to pass ab_tests) @@ -291,6 +333,115 @@ const AssetOverviewContent: React.FC = ({ !isPerpsPositionLoading; const isMarketInsightsEnabled = useSelector(selectMarketInsightsEnabled); + + const securityBadge = useMemo(() => { + switch (securityData?.resultType) { + case 'Verified': + return { + icon: IconName.VerifiedFilled, + iconColor: IconColor.IconDefault, + label: null, + bg: null, + textColor: undefined, + }; + case 'Benign': + return null; + case 'Warning': + case 'Spam': + return { + icon: IconName.Warning, + iconColor: IconColor.WarningDefault, + label: strings('security_trust.risky'), + bg: 'bg-warning-muted', + textColor: DSTextColor.WarningDefault, + }; + case 'Malicious': + return { + icon: IconName.Danger, + iconColor: IconColor.ErrorDefault, + label: strings('security_trust.malicious'), + bg: 'bg-error-muted', + textColor: DSTextColor.ErrorDefault, + }; + default: + return null; + } + }, [securityData?.resultType]); + + const handleSecurityBadgePress = useCallback(() => { + if (!securityData?.resultType || securityData.resultType === 'Benign') + return; + + const configMap: Record< + string, + { + icon: IconName; + iconColor: IconColor; + title: string; + description: string; + } + > = { + Verified: { + icon: IconName.VerifiedFilled, + iconColor: IconColor.IconDefault, + title: strings('security_trust.verified_token_title'), + description: strings('security_trust.verified_token_description', { + symbol: token.symbol, + }), + }, + Warning: { + icon: IconName.Warning, + iconColor: IconColor.WarningDefault, + title: strings('security_trust.risky_token_title'), + description: strings('security_trust.risky_token_description', { + symbol: token.symbol, + }), + }, + Spam: { + icon: IconName.Warning, + iconColor: IconColor.WarningDefault, + title: strings('security_trust.risky_token_title'), + description: strings('security_trust.risky_token_description', { + symbol: token.symbol, + }), + }, + Malicious: { + icon: IconName.Danger, + iconColor: IconColor.ErrorDefault, + title: strings('security_trust.malicious_token_title'), + description: strings( + 'security_trust.malicious_token_sheet_description', + { symbol: token.symbol }, + ), + }, + }; + + const config = configMap[securityData.resultType]; + if (config) { + navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.MODAL.SECURITY_BADGE_BOTTOM_SHEET, + params: { + ...config, + source: 'badge', + severity: securityData.resultType, + tokenAddress: token.address, + tokenSymbol: token.symbol, + chainId: token.chainId, + }, + }); + } + }, [ + securityData?.resultType, + token.symbol, + token.address, + token.chainId, + navigation, + ]); + + const networkBadgeSource = token.chainId + ? NetworkBadgeSource(token.chainId as Hex) + : undefined; + const marketInsightsCaip19Id = useMemo(() => { if (!isMarketInsightsEnabled) { return null; @@ -478,9 +629,149 @@ const AssetOverviewContent: React.FC = ({ renderWarning() ) : ( + {/* Token icon + name row */} + + + ) : undefined + } + > + + + + + + + {token.name || token.symbol} + + {securityBadge && securityBadge.label === null && ( + + + + )} + {securityBadge && securityBadge.label !== null && ( + + + + + {securityBadge.label} + + + + )} + {!token.name && isStockToken(token as BridgeToken) && ( + + )} + + {token.name ? ( + + + {token.ticker || token.symbol} + + {isStockToken(token as BridgeToken) && ( + + )} + + ) : null} + + + + {securityData?.resultType === 'Malicious' && ( + + + + + + + {strings('security_trust.malicious_token_title')} + + + {strings('security_trust.malicious_token_description', { + symbol: token.symbol, + })} + + + + )} + = ({ {!isTokenTradingOpen(token as BridgeToken) && ( @@ -637,6 +928,16 @@ const AssetOverviewContent: React.FC = ({ + {!hasSecurityDataError && + (isSecurityDataLoading || securityData?.resultType) && ( + + + + )} {isEligibilityModalVisible && ( ({ + addProperties: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnValue({}), +})); + +jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), + useSafeAreaFrame: () => ({ x: 0, y: 0, width: 390, height: 844 }), + SafeAreaProvider: ({ children }: { children: React.ReactNode }) => children, +})); + +const mockRouteParams = { + icon: IconName.SecurityTick, + iconColor: IconColor.SuccessDefault, + title: 'Test Title', + description: 'Test Description', + source: 'badge', + severity: 'Verified', + tokenAddress: '0x1234567890abcdef', + tokenSymbol: 'TEST', + chainId: '0x1', +}; + +let mockUseRouteImpl = jest.fn(() => ({ + params: mockRouteParams, +})); + +jest.mock('@react-navigation/native', () => { + const actual = jest.requireActual('@react-navigation/native'); + return { + ...actual, + useRoute: () => mockUseRouteImpl(), + useNavigation: () => ({ + navigate: jest.fn(), + goBack: jest.fn(), + isFocused: jest.fn(() => true), + }), + }; +}); + +describe('SecurityBadgeBottomSheet', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseRouteImpl = jest.fn(() => ({ + params: mockRouteParams, + })); + }); + + it('renders without crashing', () => { + const { getByText } = render(); + expect(getByText('Test Title')).toBeTruthy(); + expect(getByText('Test Description')).toBeTruthy(); + }); + + it('tracks bottom sheet opened event on mount', () => { + render(); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.SECURITY_TRUST_BOTTOM_SHEET_OPENED, + ); + expect(mockTrackEvent).toHaveBeenCalled(); + }); + + it('displays "Got it" button when onProceed is not provided', () => { + const { getByText, queryByText } = render(); + + expect(getByText(strings('security_trust.got_it'))).toBeTruthy(); + expect(queryByText(strings('security_trust.proceed'))).toBeNull(); + expect(queryByText(strings('security_trust.cancel'))).toBeNull(); + }); + + it('displays title and description from route params', () => { + const { getByText } = render(); + + expect(getByText('Test Title')).toBeTruthy(); + expect(getByText('Test Description')).toBeTruthy(); + }); + + it('displays proceed and cancel buttons when onProceed is provided', () => { + const mockOnProceed = jest.fn(); + + mockUseRouteImpl = jest.fn(() => ({ + params: { + ...mockRouteParams, + onProceed: mockOnProceed, + }, + })); + + const { getByText, queryByText } = render(); + + expect(getByText(strings('security_trust.proceed'))).toBeTruthy(); + expect(getByText(strings('security_trust.cancel'))).toBeTruthy(); + expect(queryByText(strings('security_trust.got_it'))).toBeNull(); + }); + + it('calls onProceed and tracks action when proceed button is pressed', () => { + const mockOnProceed = jest.fn(); + + mockUseRouteImpl = jest.fn(() => ({ + params: { + ...mockRouteParams, + onProceed: mockOnProceed, + }, + })); + + const { getByText } = render(); + + fireEvent.press(getByText(strings('security_trust.proceed'))); + + expect(mockOnProceed).toHaveBeenCalled(); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.SECURITY_TRUST_BOTTOM_SHEET_ACTION_TAKEN, + ); + }); + + it('tracks cancel action when cancel button is pressed', () => { + const mockOnProceed = jest.fn(); + + mockUseRouteImpl = jest.fn(() => ({ + params: { + ...mockRouteParams, + onProceed: mockOnProceed, + }, + })); + + const { getByText } = render(); + + fireEvent.press(getByText(strings('security_trust.cancel'))); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.SECURITY_TRUST_BOTTOM_SHEET_ACTION_TAKEN, + ); + }); +}); diff --git a/app/components/UI/TokenDetails/components/SecurityBadgeBottomSheet.tsx b/app/components/UI/TokenDetails/components/SecurityBadgeBottomSheet.tsx new file mode 100644 index 00000000000..da80f991917 --- /dev/null +++ b/app/components/UI/TokenDetails/components/SecurityBadgeBottomSheet.tsx @@ -0,0 +1,211 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import { useRoute, RouteProp } from '@react-navigation/native'; +import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import BottomSheet, { + BottomSheetRef, +} from '../../../../component-library/components/BottomSheets/BottomSheet'; +import { + Box, + Button, + ButtonVariant, + ButtonIconSize, + BottomSheetHeader, + FontWeight, + Text as DSText, + TextVariant as DSTextVariant, + TextColor as DSTextColor, + BoxFlexDirection, + BoxAlignItems, + Icon, + IconName, + IconSize, + IconColor, +} from '@metamask/design-system-react-native'; +import { strings } from '../../../../../locales/i18n'; + +export interface SecurityBadgeBottomSheetParams { + icon: IconName; + iconColor: IconColor; + title: string; + description: string; + onProceed?: () => void; + source: string; + severity?: string; + tokenAddress?: string; + tokenSymbol?: string; + chainId?: string; +} + +type SecurityBadgeBottomSheetRouteProp = RouteProp< + { SecurityBadgeBottomSheet: SecurityBadgeBottomSheetParams }, + 'SecurityBadgeBottomSheet' +>; + +const SecurityBadgeBottomSheet = () => { + const sheetRef = useRef(null); + const route = useRoute(); + const { trackEvent, createEventBuilder } = useAnalytics(); + + const { + icon, + iconColor, + title, + description, + onProceed, + source, + severity, + tokenAddress, + tokenSymbol, + chainId, + } = route.params; + + useEffect(() => { + trackEvent( + createEventBuilder(MetaMetricsEvents.SECURITY_TRUST_BOTTOM_SHEET_OPENED) + .addProperties({ + source, + severity, + token_address: tokenAddress, + token_symbol: tokenSymbol, + chain_id: chainId, + }) + .build(), + ); + }, [ + chainId, + createEventBuilder, + severity, + source, + tokenAddress, + tokenSymbol, + trackEvent, + ]); + + const trackAction = useCallback( + (action: 'proceed' | 'cancel') => { + if (!onProceed) return; + trackEvent( + createEventBuilder( + MetaMetricsEvents.SECURITY_TRUST_BOTTOM_SHEET_ACTION_TAKEN, + ) + .addProperties({ + action, + source, + severity, + token_address: tokenAddress, + token_symbol: tokenSymbol, + chain_id: chainId, + }) + .build(), + ); + }, + [ + chainId, + createEventBuilder, + onProceed, + severity, + source, + tokenAddress, + tokenSymbol, + trackEvent, + ], + ); + + const handleClose = useCallback(() => { + trackAction('cancel'); + sheetRef.current?.onCloseBottomSheet(); + }, [trackAction]); + + const handleProceed = useCallback(() => { + trackAction('proceed'); + sheetRef.current?.onCloseBottomSheet(); + onProceed?.(); + }, [onProceed, trackAction]); + + return ( + + + + + + + + {title} + + + {description} + + + + {onProceed ? ( + + + + + ) : ( + + )} + + + ); +}; + +SecurityBadgeBottomSheet.displayName = 'SecurityBadgeBottomSheet'; + +export default SecurityBadgeBottomSheet; diff --git a/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.test.tsx b/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.test.tsx index 19491de52f4..1a66240d845 100644 --- a/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.test.tsx +++ b/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.test.tsx @@ -7,8 +7,6 @@ describe('TokenDetailsInlineHeader', () => { const mockOnOptionsPress = jest.fn(); const defaultProps = { - title: 'ETH', - networkName: 'Ethereum Mainnet', onBackPress: mockOnBackPress, onOptionsPress: mockOnOptionsPress, }; @@ -18,30 +16,6 @@ describe('TokenDetailsInlineHeader', () => { }); describe('rendering', () => { - it('renders title text', () => { - const { getByText } = render( - , - ); - - expect(getByText('ETH')).toBeOnTheScreen(); - }); - - it('renders network name when provided', () => { - const { getByText } = render( - , - ); - - expect(getByText('Ethereum Mainnet')).toBeOnTheScreen(); - }); - - it('does not render network name when empty string', () => { - const { queryByText } = render( - , - ); - - expect(queryByText('Ethereum Mainnet')).not.toBeOnTheScreen(); - }); - it('renders back button with testID', () => { const { getByTestId } = render( , @@ -60,13 +34,11 @@ describe('TokenDetailsInlineHeader', () => { }); it('renders placeholder when onOptionsPress is falsy', () => { - const props = { - ...defaultProps, - onOptionsPress: undefined, - }; - const { getByTestId, queryByTestId } = render( - , + , ); expect(getByTestId('back-arrow-button')).toBeOnTheScreen(); @@ -95,29 +67,4 @@ describe('TokenDetailsInlineHeader', () => { expect(mockOnOptionsPress).toHaveBeenCalledTimes(1); }); }); - - describe('edge cases', () => { - it('handles long title text with truncation', () => { - const longTitle = 'VeryLongTokenSymbolName'; - const { getByText } = render( - , - ); - - const titleElement = getByText(longTitle); - expect(titleElement.props.numberOfLines).toBe(1); - }); - - it('handles long network name with truncation', () => { - const longNetworkName = 'Very Long Network Name That Should Be Truncated'; - const { getByText } = render( - , - ); - - const networkElement = getByText(longNetworkName); - expect(networkElement.props.numberOfLines).toBe(1); - }); - }); }); diff --git a/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.tsx b/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.tsx index 8b89ce855c5..9ef4505a863 100644 --- a/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.tsx +++ b/app/components/UI/TokenDetails/components/TokenDetailsInlineHeader.tsx @@ -1,9 +1,5 @@ import React from 'react'; import { View, StyleSheet } from 'react-native'; -import Text, { - TextVariant, - TextColor, -} from '../../../../component-library/components/Texts/Text'; import { Theme } from '@metamask/design-tokens'; import { useStyles } from '../../../hooks/useStyles'; import { @@ -34,10 +30,6 @@ const inlineHeaderStyles = (params: { leftButton: { marginLeft: 16, }, - titleWrapper: { - flex: 1, - alignItems: 'center', - }, rightButton: { marginRight: 16, }, @@ -45,17 +37,16 @@ const inlineHeaderStyles = (params: { marginRight: 16, width: 24, }, + spacer: { + flex: 1, + }, }); }; export const TokenDetailsInlineHeader = ({ - title, - networkName, onBackPress, onOptionsPress, }: { - title: string; - networkName: string; onBackPress: () => void; onOptionsPress: (() => void) | undefined; }) => { @@ -70,20 +61,7 @@ export const TokenDetailsInlineHeader = ({ iconName={IconName.ArrowLeft} testID="back-arrow-button" /> - - - {title} - - {networkName ? ( - - {networkName} - - ) : null} - + {onOptionsPress ? ( void; + goToSwaps: () => void; + hasEligibleSwapTokens: boolean; +} + +const TokenDetailsStickyFooter: React.FC = ({ + token, + securityData, + onBuy, + goToSwaps, + hasEligibleSwapTokens, +}) => { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const { colors } = useTheme(); + + const { isBuyable } = useTokenBuyability(token); + const { isTokenTradingOpen } = useRWAToken(); + + const showSwapButton = hasEligibleSwapTokens; + const showBuyButton = isBuyable || !hasEligibleSwapTokens; + + const handleFooterAction = useCallback( + (action: () => void, source: string) => { + const resultType = securityData?.resultType; + + const configMap: Record< + string, + { + icon: IconName; + iconColor: IconColor; + title: string; + description: string; + } + > = { + Warning: { + icon: IconName.Warning, + iconColor: IconColor.WarningDefault, + title: strings('security_trust.risky_token_title'), + description: strings('security_trust.risky_token_description', { + symbol: token.symbol, + }), + }, + Spam: { + icon: IconName.Warning, + iconColor: IconColor.WarningDefault, + title: strings('security_trust.risky_token_title'), + description: strings('security_trust.risky_token_description', { + symbol: token.symbol, + }), + }, + Malicious: { + icon: IconName.Danger, + iconColor: IconColor.ErrorDefault, + title: strings('security_trust.malicious_token_title'), + description: strings( + 'security_trust.malicious_token_sheet_description', + { + symbol: token.symbol, + }, + ), + }, + }; + + const config = resultType ? configMap[resultType] : undefined; + + if (!config) { + action(); + return; + } + + navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.MODAL.SECURITY_BADGE_BOTTOM_SHEET, + params: { + ...config, + onProceed: action, + source, + severity: resultType, + tokenAddress: token.address, + tokenSymbol: token.symbol, + chainId: token.chainId, + }, + }); + }, + [ + navigation, + securityData?.resultType, + token.symbol, + token.address, + token.chainId, + ], + ); + + const footerStyle = React.useMemo( + () => ({ + backgroundColor: colors.background.default, + paddingHorizontal: 16, + paddingTop: 16, + paddingBottom: insets.bottom + 6, + }), + [colors.background.default, insets.bottom], + ); + + return ( + <> + {isTokenTradingOpen(token as BridgeToken) && ( + + handleFooterAction( + () => goToSwaps(), + strings('asset_overview.swap'), + ), + }, + ] + : []), + ...(showBuyButton + ? [ + { + variant: ButtonVariants.Primary, + label: strings('asset_overview.buy_button'), + size: ButtonSize.Lg, + onPress: () => + handleFooterAction( + onBuy, + strings('asset_overview.buy_button'), + ), + }, + ] + : []), + ]} + buttonsAlignment={ButtonsAlignment.Horizontal} + /> + )} + + ); +}; + +export default TokenDetailsStickyFooter; diff --git a/app/components/UI/TokenDetails/constants/constants.ts b/app/components/UI/TokenDetails/constants/constants.ts index aae3fbf70ac..0e09238e3f1 100644 --- a/app/components/UI/TokenDetails/constants/constants.ts +++ b/app/components/UI/TokenDetails/constants/constants.ts @@ -1,4 +1,5 @@ import type { TokenI } from '../../Tokens/types'; +import type { TokenSecurityData } from '@metamask/assets-controllers'; /** * Source of navigation to Token Details page @@ -22,4 +23,5 @@ export enum TokenDetailsSource { */ export interface TokenDetailsRouteParams extends TokenI { source?: TokenDetailsSource; + securityData?: TokenSecurityData; } diff --git a/app/components/UI/TokenDetails/hooks/useTokenSecurityData.test.ts b/app/components/UI/TokenDetails/hooks/useTokenSecurityData.test.ts new file mode 100644 index 00000000000..34dab03dff2 --- /dev/null +++ b/app/components/UI/TokenDetails/hooks/useTokenSecurityData.test.ts @@ -0,0 +1,240 @@ +import { renderHook, waitFor } from '@testing-library/react-native'; +import { useTokenSecurityData } from './useTokenSecurityData'; +import { + fetchTokenAssets, + TokenSecurityData, +} from '@metamask/assets-controllers'; +import type { CaipAssetType } from '@metamask/utils'; + +jest.mock('@metamask/assets-controllers', () => ({ + fetchTokenAssets: jest.fn(), +})); + +const mockFetchTokenAssets = jest.mocked(fetchTokenAssets); + +const mockSecurityData: TokenSecurityData = { + resultType: 'Verified', + maliciousScore: '0', + features: [ + { + featureId: 'liquidity_pools', + type: 'info', + description: 'Has liquidity pools', + }, + ], + fees: { + transfer: 0, + transferFeeMaxAmount: null, + buy: 0, + sell: null, + }, + financialStats: { + supply: 1000000, + topHolders: [], + holdersCount: 100, + tradeVolume24h: null, + lockedLiquidityPct: null, + markets: [], + }, + metadata: { + externalLinks: { + homepage: null, + twitterPage: null, + telegramChannelId: null, + }, + }, + created: '2023-01-01T00:00:00Z', +}; + +describe('useTokenSecurityData', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns prefetched data immediately without fetching', () => { + const assetId = 'eip155:1/erc20:0x1234' as CaipAssetType; + + const { result } = renderHook(() => + useTokenSecurityData({ + assetId, + prefetchedData: mockSecurityData, + }), + ); + + expect(result.current.securityData).toBe(mockSecurityData); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + expect(mockFetchTokenAssets).not.toHaveBeenCalled(); + }); + + it('fetches security data when assetId is provided', async () => { + const assetId = 'eip155:1/erc20:0x1234' as CaipAssetType; + mockFetchTokenAssets.mockResolvedValue([ + { + assetId, + name: 'Test Token', + symbol: 'TEST', + decimals: 18, + securityData: mockSecurityData, + }, + ]); + + const { result } = renderHook(() => useTokenSecurityData({ assetId })); + + expect(result.current.isLoading).toBe(true); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(mockFetchTokenAssets).toHaveBeenCalledWith([assetId], { + includeTokenSecurityData: true, + }); + expect(result.current.securityData).toBe(mockSecurityData); + expect(result.current.error).toBeNull(); + }); + + it('sets error when fetch fails', async () => { + const assetId = 'eip155:1/erc20:0x1234' as CaipAssetType; + const mockError = new Error('Fetch failed'); + mockFetchTokenAssets.mockRejectedValue(mockError); + + const { result } = renderHook(() => useTokenSecurityData({ assetId })); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.error).toBe(mockError); + expect(result.current.securityData).toBeNull(); + }); + + it('does not fetch when assetId is null', () => { + const { result } = renderHook(() => + useTokenSecurityData({ assetId: null }), + ); + + expect(result.current.isLoading).toBe(false); + expect(result.current.securityData).toBeNull(); + expect(result.current.error).toBeNull(); + expect(mockFetchTokenAssets).not.toHaveBeenCalled(); + }); + + it('handles empty security data from API', async () => { + const assetId = 'eip155:1/erc20:0x1234' as CaipAssetType; + mockFetchTokenAssets.mockResolvedValue([ + { + assetId, + name: 'Test Token', + symbol: 'TEST', + decimals: 18, + }, + ]); + + const { result } = renderHook(() => useTokenSecurityData({ assetId })); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.securityData).toBeNull(); + expect(result.current.error).toBeNull(); + }); + + it('refetches when assetId changes', async () => { + const assetId1 = 'eip155:1/erc20:0x1111' as CaipAssetType; + const assetId2 = 'eip155:1/erc20:0x2222' as CaipAssetType; + + mockFetchTokenAssets.mockResolvedValue([ + { + assetId: assetId1, + name: 'Test Token', + symbol: 'TEST', + decimals: 18, + securityData: mockSecurityData, + }, + ]); + + const { result, rerender } = renderHook( + ({ assetId }) => useTokenSecurityData({ assetId }), + { initialProps: { assetId: assetId1 } }, + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(mockFetchTokenAssets).toHaveBeenCalledTimes(1); + expect(mockFetchTokenAssets).toHaveBeenCalledWith([assetId1], { + includeTokenSecurityData: true, + }); + + rerender({ assetId: assetId2 }); + + await waitFor(() => { + expect(mockFetchTokenAssets).toHaveBeenCalledTimes(2); + }); + + expect(mockFetchTokenAssets).toHaveBeenCalledWith([assetId2], { + includeTokenSecurityData: true, + }); + }); + + it('cleans up on unmount', async () => { + const assetId = 'eip155:1/erc20:0x1234' as CaipAssetType; + mockFetchTokenAssets.mockImplementation( + () => + new Promise((resolve) => { + setTimeout( + () => + resolve([ + { + assetId, + name: 'Test Token', + symbol: 'TEST', + decimals: 18, + securityData: mockSecurityData, + }, + ]), + 100, + ); + }), + ); + + const { unmount } = renderHook(() => useTokenSecurityData({ assetId })); + + unmount(); + + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + it('stops loading when assetId changes to null', async () => { + const testAssetId = 'eip155:1/erc20:0x1234' as CaipAssetType; + mockFetchTokenAssets.mockResolvedValue([ + { + assetId: testAssetId, + name: 'Test Token', + symbol: 'TEST', + decimals: 18, + securityData: mockSecurityData, + }, + ]); + + const { result, rerender } = renderHook( + ({ assetId }: { assetId: CaipAssetType | null }) => + useTokenSecurityData({ assetId }), + { initialProps: { assetId: testAssetId as CaipAssetType | null } }, + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.securityData).toBe(mockSecurityData); + + rerender({ assetId: null as CaipAssetType | null }); + + expect(result.current.isLoading).toBe(false); + expect(mockFetchTokenAssets).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/components/UI/TokenDetails/hooks/useTokenSecurityData.ts b/app/components/UI/TokenDetails/hooks/useTokenSecurityData.ts new file mode 100644 index 00000000000..177878838f6 --- /dev/null +++ b/app/components/UI/TokenDetails/hooks/useTokenSecurityData.ts @@ -0,0 +1,75 @@ +import { useEffect, useState, useRef, useCallback } from 'react'; +import type { CaipAssetType } from '@metamask/utils'; +import { + fetchTokenAssets, + TokenSecurityData, +} from '@metamask/assets-controllers'; + +interface UseTokenSecurityDataOpts { + /** CAIP-19 asset ID. When null, no fetch is attempted. */ + assetId: CaipAssetType | null; + /** Pre-fetched security data from trending/search — returned immediately if provided. */ + prefetchedData?: TokenSecurityData; +} + +interface UseTokenSecurityDataResult { + securityData: TokenSecurityData | null; + isLoading: boolean; + error: Error | null; +} + +export const useTokenSecurityData = ({ + assetId, + prefetchedData, +}: UseTokenSecurityDataOpts): UseTokenSecurityDataResult => { + const [securityData, setSecurityData] = useState( + prefetchedData ?? null, + ); + const [isLoading, setIsLoading] = useState(!prefetchedData && !!assetId); + const [error, setError] = useState(null); + const isMountedRef = useRef(true); + + const fetchData = useCallback(async () => { + if (!assetId) return; + try { + const assets = await fetchTokenAssets([assetId], { + includeTokenSecurityData: true, + }); + if (!isMountedRef.current) return; + const asset = assets?.[0]; + setSecurityData(asset?.securityData ?? null); + setError(null); + } catch (err) { + if (!isMountedRef.current) return; + setError(err as Error); + } finally { + if (isMountedRef.current) { + setIsLoading(false); + } + } + }, [assetId]); + + useEffect(() => { + isMountedRef.current = true; + + if (prefetchedData) { + setSecurityData(prefetchedData); + setIsLoading(false); + return; + } + + if (!assetId) { + setIsLoading(false); + return; + } + + setIsLoading(true); + fetchData(); + + return () => { + isMountedRef.current = false; + }; + }, [assetId, prefetchedData, fetchData]); + + return { securityData, isLoading, error }; +}; diff --git a/app/components/UI/Transactions/index.js b/app/components/UI/Transactions/index.js index 787324f3331..68535026c27 100644 --- a/app/components/UI/Transactions/index.js +++ b/app/components/UI/Transactions/index.js @@ -168,6 +168,10 @@ class Transactions extends PureComponent { * Optional header component */ header: PropTypes.object, + /** + * When true, suppresses the empty state footer when there are no transactions + */ + hideEmptyState: PropTypes.bool, /** * Optional header height */ @@ -359,6 +363,10 @@ class Transactions extends PureComponent { }; renderEmpty = () => { + if (this.props.hideEmptyState) { + return null; + } + const { colors } = this.context || mockTheme; const styles = createStyles(colors); diff --git a/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx index 8a6c17a3d7c..8dd4341253e 100644 --- a/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx +++ b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx @@ -163,6 +163,7 @@ const getAssetNavigationParams = (token: TrendingAsset) => { isFromTrending: true, source: TokenDetailsSource.Trending, rwaData: token.rwaData, + securityData: token.securityData, }; }; diff --git a/app/components/UI/Trending/hooks/useRwaTokens/useRwaTokens.ts b/app/components/UI/Trending/hooks/useRwaTokens/useRwaTokens.ts index baaeb61ad69..022bb99d0c7 100644 --- a/app/components/UI/Trending/hooks/useRwaTokens/useRwaTokens.ts +++ b/app/components/UI/Trending/hooks/useRwaTokens/useRwaTokens.ts @@ -130,6 +130,7 @@ export const useRwaTokens = (opts?: { rwaData: asset.rwaData as unknown as | TrendingAsset['rwaData'] | undefined, + securityData: asset.securityData, })); if (searchQuery?.trim()) { diff --git a/app/components/UI/Trending/hooks/useSearchRequest/useSearchRequest.ts b/app/components/UI/Trending/hooks/useSearchRequest/useSearchRequest.ts index 385c7b6578d..8e0d63772ea 100644 --- a/app/components/UI/Trending/hooks/useSearchRequest/useSearchRequest.ts +++ b/app/components/UI/Trending/hooks/useSearchRequest/useSearchRequest.ts @@ -1,6 +1,10 @@ import { useCallback, useEffect, useState, useRef, useMemo } from 'react'; import { CaipChainId } from '@metamask/utils'; -import { searchTokens, TrendingAsset } from '@metamask/assets-controllers'; +import { + searchTokens, + TrendingAsset, + TokenSecurityData, +} from '@metamask/assets-controllers'; import { useStableArray } from '../../../Perps/hooks/useStableArray'; import { TRENDING_NETWORKS_LIST } from '../../utils/trendingNetworksList'; @@ -14,6 +18,7 @@ interface SearchResult { price: string; pricePercentChange1d: string; rwaData?: TrendingAsset['rwaData']; + securityData?: TokenSecurityData; } const DEBOUNCE_MS = 300; @@ -85,6 +90,7 @@ export const useSearchRequest = (options: { const searchResults = await searchTokens(stableChainIds, debouncedQuery, { limit, includeMarketData, + includeTokenSecurityData: true, }); // Only update state if this is still the current request if (currentRequestId === requestIdRef.current) { diff --git a/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.ts b/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.ts index 5c4b6822a3e..e20179843c7 100644 --- a/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.ts +++ b/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.ts @@ -222,6 +222,7 @@ export const useTrendingRequest = (options: { minMarketCap, maxMarketCap, excludeLabels: ['stable_coin', 'blue_chip'], + includeTokenSecurityData: true, }); // Only update state if this is still the current request if (currentRequestId === requestIdRef.current) { diff --git a/app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.ts b/app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.ts index b8baa6eb536..9e9cec68616 100644 --- a/app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.ts +++ b/app/components/UI/Trending/hooks/useTrendingSearch/useTrendingSearch.ts @@ -122,6 +122,7 @@ export const useTrendingSearch = (opts?: { rwaData: asset.rwaData as unknown as | TrendingAsset['rwaData'] | undefined, + securityData: asset.securityData, }); } }); diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index aca275b8012..59ff53524b5 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -123,6 +123,7 @@ const Routes = { TRADE_WALLET_ACTIONS: 'TradeWalletActions', FUND_ACTION_MENU: 'FundActionMenu', MORE_TOKEN_ACTIONS_MENU: 'MoreTokenActionsMenu', + SECURITY_BADGE_BOTTOM_SHEET: 'SecurityBadgeBottomSheet', NFT_AUTO_DETECTION_MODAL: 'NFTAutoDetectionModal', MULTI_RPC_MIGRATION_MODAL: 'MultiRPcMigrationModal', MAX_BROWSER_TABS_MODAL: 'MaxBrowserTabsModal', @@ -470,6 +471,7 @@ const Routes = { RETURN_TO_DAPP_NOTIFICATION: 'ReturnToDappToast', }, FEATURE_FLAG_OVERRIDE: 'FeatureFlagOverride', + SECURITY_TRUST: 'SecurityTrust', }; export default Routes; diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index c76a106bea7..8235844ef12 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -77,6 +77,8 @@ enum EVENT_NAME { NFT_DETAILS_OPENED = 'NFT Details Opened', TOKEN_LIST_ITEM_CLICKED = 'Token List Item Clicked', TOKEN_DETAILS_OPENED = 'Token Details Opened', + SECURITY_TRUST_BOTTOM_SHEET_OPENED = 'Security Trust BottomSheet Opened', + SECURITY_TRUST_BOTTOM_SHEET_ACTION_TAKEN = 'Security Trust BottomSheet Action Taken', DEFI_TAB_SELECTED = 'DeFi Tab Selected', DEFI_PROTOCOL_DETAILS_OPENED = 'DeFi Protocol Details Opened', VIEW_ALL_ASSETS_CLICKED = 'View All Assets Clicked', @@ -1470,6 +1472,12 @@ const events = { EVENT_NAME.EARN_TOKEN_LIST_ITEM_CLICKED, ), TOKEN_DETAILS_OPENED: generateOpt(EVENT_NAME.TOKEN_DETAILS_OPENED), + SECURITY_TRUST_BOTTOM_SHEET_OPENED: generateOpt( + EVENT_NAME.SECURITY_TRUST_BOTTOM_SHEET_OPENED, + ), + SECURITY_TRUST_BOTTOM_SHEET_ACTION_TAKEN: generateOpt( + EVENT_NAME.SECURITY_TRUST_BOTTOM_SHEET_ACTION_TAKEN, + ), // Bridge SWAP_PAGE_VIEWED: generateOpt(EVENT_NAME.SWAP_PAGE_VIEWED), // Temporary event until unified swap/bridge is done diff --git a/locales/languages/en.json b/locales/languages/en.json index 138d91ba1d1..69b4b987a0e 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -3569,6 +3569,53 @@ "activity": "{{symbol}} activity", "disclaimer": "Market data is provided by third-party sources like CoinGecko. Data is for informational purposes only. MetaMask is not responsible for its accuracy." }, + "security_trust": { + "title": "Security and trust", + "malicious": "Malicious", + "risky": "Risky", + "malicious_token_title": "Malicious token", + "malicious_token_description": "{{symbol}} is a malicious token. Avoid interacting with it or trading it.", + "verified_token_title": "Verified token", + "verified_token_description": "{{symbol}} is actively traded and is widely recognized. Verification is not an endorsement by MetaMask.", + "risky_token_title": "Risky token", + "risky_token_description": "Cautionary signals detected on {{symbol}}. Research carefully before trading this token.", + "malicious_token_sheet_description": "Serious risk signals detected on {{symbol}}. We recommend not trading this token.", + "got_it": "Got it", + "proceed": "Proceed", + "cancel": "Cancel", + "data_unavailable": "Security data unavailable", + "subtitle_known": "No risk signals detected. Always research any asset before trading.", + "subtitle_no_issues": "No risk signals detected. Always research any asset before trading.", + "subtitle_suspicious": "Cautionary signals detected. Review the flagged issues carefully before trading this asset.", + "subtitle_malicious": "Serious risk signals detected. We recommend avoiding this asset.", + "subtitle_unavailable": "Security analysis could not be loaded for this token.", + "token_distribution": "Token distribution", + "total_supply": "Total supply", + "top_10_holders": "Top 10 holders", + "other": "Other", + "no_hidden_fees_detected": "No hidden fees detected", + "buy_sell_tax": "Buy/Sell Tax", + "buy_tax": "Buy tax", + "sell_tax": "Sell tax", + "transfer": "Transfer", + "token_info": "Token Info", + "created": "Created", + "token_age": "Token age", + "network": "Network", + "type": "Type", + "official_links": "Official Links", + "website": "Website", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "N/A", + "verified": "Verified", + "no_issues": "No issues", + "suspicious": "Suspicious", + "malicious_label": "Malicious", + "more": "more", + "evaluation_disclaimer": "This security review is for evaluation only and does not constitute an endorsement or recommendation to trade." + }, "account_details": { "title": "Account details", "share_account": "Share", diff --git a/tests/api-mocking/mock-responses/defaults/token-apis.ts b/tests/api-mocking/mock-responses/defaults/token-apis.ts index 725a12cee28..d7988066183 100644 --- a/tests/api-mocking/mock-responses/defaults/token-apis.ts +++ b/tests/api-mocking/mock-responses/defaults/token-apis.ts @@ -9,6 +9,9 @@ import { TOKEN_API_TOKENS_RESPONSE } from '../token-api-responses.ts'; const tokenListRegex = /^https:\/\/token\.api\.cx\.metamask\.io\/tokens\/\d+\?.*$/; +const tokenAssetsRegex = + /^https:\/\/token\.api\.cx\.metamask\.io\/assets\?assetIds=.*&includeTokenSecurityData=true$/; + export const TOKEN_API_MOCKS: MockEventsObject = { GET: [ { @@ -16,5 +19,10 @@ export const TOKEN_API_MOCKS: MockEventsObject = { responseCode: 200, response: TOKEN_API_TOKENS_RESPONSE, }, + { + urlEndpoint: tokenAssetsRegex, + responseCode: 200, + response: [], + }, ], }; From 6f6b6192e4119d9132cd9d94c6f74334d2dd47a8 Mon Sep 17 00:00:00 2001 From: Kirill Zyusko Date: Tue, 17 Mar 2026 14:02:00 +0100 Subject: [PATCH 044/206] feat: migrate Label (core scope) (#27263) ## **Description** Migrated `Label` component (core scope). ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/DSYS-276 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** image ### **After** image ## **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. ## **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** > Mostly a UI-only component swap affecting label rendering; main risk is minor visual/regression differences in Snap UI screens and snapshot brittleness. > > **Overview** > Migrates Snap UI form components (`SnapUIInput`, `SnapUIAddressInput`, `SnapUICheckbox`, `SnapUIRadioGroup`, `SnapUIDateTimePicker`, `SnapUISelector`) to use the design-system `Label` (`@metamask/design-system-react-native`) instead of the component-library label. > > Updates label usage from `variant={TextVariant.BodyMDMedium}` to `fontWeight={FontWeight.Medium}`, and refreshes Jest snapshots to reflect the new rendered text styles/props (e.g., style arrays and `fontWeight` output). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 9415f789065ff31a278053c28f11b8b79b50b1a7. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../SnapUIAddressInput/SnapUIAddressInput.tsx | 6 +- .../Snaps/SnapUICheckbox/SnapUICheckbox.tsx | 7 +- .../SnapUIDateTimePicker.tsx | 6 +- .../Snaps/SnapUIInput/SnapUIInput.tsx | 5 +- .../SnapUIRadioGroup/SnapUIRadioGroup.tsx | 5 +- .../__snapshots__/SnapUIRenderer.test.ts.snap | 19 ++-- .../account-selector.test.ts.snap | 19 ++-- .../date-time-picker.test.tsx.snap | 38 ++++---- .../__snapshots__/form.test.ts.snap | 95 +++++++++++-------- .../Snaps/SnapUISelector/SnapUISelector.tsx | 5 +- 10 files changed, 112 insertions(+), 93 deletions(-) diff --git a/app/components/Snaps/SnapUIAddressInput/SnapUIAddressInput.tsx b/app/components/Snaps/SnapUIAddressInput/SnapUIAddressInput.tsx index e2b63bae6e6..94d559db42b 100644 --- a/app/components/Snaps/SnapUIAddressInput/SnapUIAddressInput.tsx +++ b/app/components/Snaps/SnapUIAddressInput/SnapUIAddressInput.tsx @@ -12,7 +12,7 @@ import { Box } from '../../UI/Box/Box'; import Text, { TextVariant, } from '../../../component-library/components/Texts/Text'; -import Label from '../../../component-library/components/Form/Label'; +import { Label, FontWeight } from '@metamask/design-system-react-native'; import TextField from '../../../component-library/components/Form/TextField'; import HelpText, { HelpTextSeverity, @@ -82,7 +82,7 @@ const MatchedAccountInfo = ({ }); return ( - {label && } + {label && } - {label && } + {label && } = ({ return ( - {fieldLabel && ( - - )} + {fieldLabel && } - {label && } + {label && } - {label && } + {label && } = ({ return ( - {label && } + {label && } {options.map((option) => ( My Input diff --git a/app/components/Snaps/SnapUIRenderer/components/__snapshots__/account-selector.test.ts.snap b/app/components/Snaps/SnapUIRenderer/components/__snapshots__/account-selector.test.ts.snap index 6cb5e675da5..7960fcca259 100644 --- a/app/components/Snaps/SnapUIRenderer/components/__snapshots__/account-selector.test.ts.snap +++ b/app/components/Snaps/SnapUIRenderer/components/__snapshots__/account-selector.test.ts.snap @@ -189,15 +189,18 @@ exports[`SnapUIAccountSelector renders inside a field 1`] = ` Account Selector diff --git a/app/components/Snaps/SnapUIRenderer/components/__snapshots__/date-time-picker.test.tsx.snap b/app/components/Snaps/SnapUIRenderer/components/__snapshots__/date-time-picker.test.tsx.snap index 26e10e36b3c..30cd1f5627d 100644 --- a/app/components/Snaps/SnapUIRenderer/components/__snapshots__/date-time-picker.test.tsx.snap +++ b/app/components/Snaps/SnapUIRenderer/components/__snapshots__/date-time-picker.test.tsx.snap @@ -67,15 +67,18 @@ exports[`SnapUIDateTimePicker can show an error 1`] = ` Select date and time @@ -749,15 +752,18 @@ exports[`SnapUIDateTimePicker renders inside a field 1`] = ` Select date and time diff --git a/app/components/Snaps/SnapUIRenderer/components/__snapshots__/form.test.ts.snap b/app/components/Snaps/SnapUIRenderer/components/__snapshots__/form.test.ts.snap index 37f69a42a68..128b313bdce 100644 --- a/app/components/Snaps/SnapUIRenderer/components/__snapshots__/form.test.ts.snap +++ b/app/components/Snaps/SnapUIRenderer/components/__snapshots__/form.test.ts.snap @@ -282,15 +282,18 @@ exports[`SnapUIForm will render with fields 1`] = ` My Input @@ -398,15 +401,18 @@ exports[`SnapUIForm will render with fields 1`] = ` My Checkbox @@ -490,15 +496,18 @@ exports[`SnapUIForm will render with fields 1`] = ` My Radio Group @@ -631,15 +640,18 @@ exports[`SnapUIForm will render with fields 1`] = ` My Dropdown @@ -1331,15 +1343,18 @@ exports[`SnapUIForm will render with fields 1`] = ` My Selector diff --git a/app/components/Snaps/SnapUISelector/SnapUISelector.tsx b/app/components/Snaps/SnapUISelector/SnapUISelector.tsx index 06e28b85732..7d000a7ec9d 100644 --- a/app/components/Snaps/SnapUISelector/SnapUISelector.tsx +++ b/app/components/Snaps/SnapUISelector/SnapUISelector.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react'; import { useSnapInterfaceContext } from '../SnapInterfaceContext'; -import Label from '../../../component-library/components/Form/Label'; +import { Label, FontWeight } from '@metamask/design-system-react-native'; import HelpText, { HelpTextSeverity, } from '../../../component-library/components/Form/HelpText'; @@ -14,7 +14,6 @@ import stylesheet from './SnapUISelector.styles'; import { View, ScrollView, ViewStyle } from 'react-native'; import BottomSheetHeader from '../../../component-library/components/BottomSheets/BottomSheetHeader'; import ApprovalModal from '../../Approvals/ApprovalModal'; -import { TextVariant } from '../../../component-library/components/Texts/Text'; import { State } from '@metamask/snaps-sdk'; import { isObject } from '@metamask/utils'; @@ -139,7 +138,7 @@ export const SnapUISelector: React.FunctionComponent = ({ return ( <> - {label && } + {label && } Date: Tue, 17 Mar 2026 13:33:32 +0000 Subject: [PATCH 045/206] test: disables swap test due to flakiness (#27531) ## **Description** Disables the swap test due to flakiness. Will work on a follow up fix for this. ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Low risk: only disables a smoke test by marking the suite as skipped, with no production code changes. > > **Overview** > **Disables** the `Swap from Actions` smoke test by switching the suite to `describe.skip` and adding a comment/link to the failing CI run, to avoid flaky failures while the test is reworked. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit d0f73073ca4e4041a4ffc195c49c5a1088c69808. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- tests/smoke/swap/swap-action-smoke.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/smoke/swap/swap-action-smoke.spec.ts b/tests/smoke/swap/swap-action-smoke.spec.ts index 8329f005bbc..073b7149fdd 100644 --- a/tests/smoke/swap/swap-action-smoke.spec.ts +++ b/tests/smoke/swap/swap-action-smoke.spec.ts @@ -39,7 +39,9 @@ const expectedEventNames = [ expectedEvents.UnifiedSwapBridgeCompleted, ]; -describe(SmokeTrade('Swap from Actions'), (): void => { +// Disabling as this test is flaky and needs to be reworked +// https://github.com/MetaMask/metamask-mobile/actions/runs/23176367570/job/67340273878 +describe.skip(SmokeTrade('Swap from Actions'), (): void => { beforeEach(async (): Promise => { jest.setTimeout(180000); }); From 7430a847ddba1b292b9832bb5bdcef41f12fdefe Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Tue, 17 Mar 2026 19:04:39 +0530 Subject: [PATCH 046/206] fix: amount formatting in simulation section for approve confirmations (#27523) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Fix amount formatting in simulation section for approve confirmations. ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/CONF-732 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** Screenshot 2026-03-17 at 5 06 45 PM ## **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. ## **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** > Low risk UI-only change that adjusts display formatting for approval spending caps and adds coverage for large numbers and non-numeric values like "Unlimited". > > **Overview** > Fixes spending-cap amount rendering in `ApproveAndPermit2` by formatting numeric ERC20 amounts using locale-aware `formatAmountMaxPrecision` (preserving full decimal precision and adding thousands separators) while leaving non-numeric strings (e.g., `Unlimited`) unchanged. > > Adds tests to verify large-number full-precision formatting and that `Unlimited` is displayed as-is. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 91e0095d7bef5f01db2dd41d9da2b767c69c7cad. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../approve-and-permit2.test.tsx | 46 +++++++++++++++++++ .../approve-and-permit2.tsx | 14 +++++- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/app/components/Views/confirmations/components/approve-static-simulations/approve-and-permit2/approve-and-permit2.test.tsx b/app/components/Views/confirmations/components/approve-static-simulations/approve-and-permit2/approve-and-permit2.test.tsx index 9482b484bad..3d8e93b967e 100644 --- a/app/components/Views/confirmations/components/approve-static-simulations/approve-and-permit2/approve-and-permit2.test.tsx +++ b/app/components/Views/confirmations/components/approve-static-simulations/approve-and-permit2/approve-and-permit2.test.tsx @@ -115,6 +115,52 @@ describe('ApproveAndPermit2', () => { expect(getByText('0x12345...56789')).toBeTruthy(); }); + it('formats large amounts with full precision', () => { + const mockUseApproveTransactionData = jest.spyOn( + useApproveTransactionDataModule, + 'useApproveTransactionData', + ); + mockUseApproveTransactionData.mockReturnValue({ + approveMethod: ApproveMethod.APPROVE, + amount: '1000000.123456', + decimals: 18, + tokenBalance: '0', + tokenStandard: TokenStandard.ERC20, + rawAmount: '1000000.123456', + spender: '0x123456789', + isLoading: false, + } as ReturnType< + typeof useApproveTransactionDataModule.useApproveTransactionData + >); + const { getByText } = renderWithProvider(, { + state: approveERC20TransactionStateMock, + }); + expect(getByText('1,000,000.123456')).toBeTruthy(); + }); + + it('displays Unlimited as-is without formatting', () => { + const mockUseApproveTransactionData = jest.spyOn( + useApproveTransactionDataModule, + 'useApproveTransactionData', + ); + mockUseApproveTransactionData.mockReturnValue({ + approveMethod: ApproveMethod.APPROVE, + amount: 'Unlimited', + decimals: 18, + tokenBalance: '0', + tokenStandard: TokenStandard.ERC20, + rawAmount: '0', + spender: '0x123456789', + isLoading: false, + } as ReturnType< + typeof useApproveTransactionDataModule.useApproveTransactionData + >); + const { getByText } = renderWithProvider(, { + state: approveERC20TransactionStateMock, + }); + expect(getByText('Unlimited')).toBeTruthy(); + }); + describe('revoke', () => { it('renders spending cap and spender for ERC20', () => { const { getByText } = renderWithProvider(, { diff --git a/app/components/Views/confirmations/components/approve-static-simulations/approve-and-permit2/approve-and-permit2.tsx b/app/components/Views/confirmations/components/approve-static-simulations/approve-and-permit2/approve-and-permit2.tsx index 9cfb7e195c8..8f45ddcddb1 100644 --- a/app/components/Views/confirmations/components/approve-static-simulations/approve-and-permit2/approve-and-permit2.tsx +++ b/app/components/Views/confirmations/components/approve-static-simulations/approve-and-permit2/approve-and-permit2.tsx @@ -1,8 +1,10 @@ import React from 'react'; import { View } from 'react-native'; +import { BigNumber } from 'bignumber.js'; import { TransactionMeta } from '@metamask/transaction-controller'; -import { strings } from '../../../../../../../locales/i18n'; +import I18n, { strings } from '../../../../../../../locales/i18n'; +import { formatAmountMaxPrecision } from '../../../../../UI/SimulationDetails/formatAmount'; import { useStyles } from '../../../../../../component-library/hooks'; import { ApproveComponentIDs } from '../../../ConfirmationView.testIds'; import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest'; @@ -129,6 +131,14 @@ interface PillAndAddressProps { transactionMetadata: TransactionMeta; } +function formatDisplayAmount(amount: string | undefined): string { + if (!amount) { + return ''; + } + const value = new BigNumber(amount); + return value.isNaN() ? amount : formatAmountMaxPrecision(I18n.locale, value); +} + function PillAndAddress({ amount, isERC20, @@ -139,7 +149,7 @@ function PillAndAddress({ <>
Date: Tue, 17 Mar 2026 14:45:16 +0100 Subject: [PATCH 047/206] test: fix flaky (#27521) ## **Description** - **Problem:** E2E test `predict-existing-polymarket-balance` was flaky because after scrolling to the Predictions section the tap sometimes hit the main tab bar "+" button. - **Approach:** Keep the existing scroll behaviour and add an optional **overshoot swipe** after scrolling so the section sits higher on screen and away from the tab bar. - **Changes:** - `WalletView`: added `walletScrollView` getter (ScrollView as element for gestures). - `scrollAndTapSection`: new optional `overshootSwipe?: { direction; percentage? }`; when set, a small swipe is performed on the wallet scroll view after `scrollToElement`, then the tap. - `scrollAndTapPredictionsSection`: uses `overshootSwipe: { direction: 'up', percentage: 0.15 }` so one extra small scroll-down moves the Predictions section up and avoids tapping the "+". ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Low risk: changes are confined to Detox page-object gesture helpers and a smoke test, with no production code impact. Main risk is altering scroll/tap behavior that could affect other wallet section interactions if reused broadly. > > **Overview** > Fixes flakiness in the Predictions E2E flow where tapping the Predictions section could hit the tab bar "+" button after scrolling. > > `WalletView.scrollAndTapSection` now supports optional tuning (`scrollAmount`) and an optional post-scroll **overshoot swipe** on the wallet ScrollView before tapping; `scrollAndTapPredictionsSection` defaults to using this overshoot behavior and the smoke test overrides the swipe direction/options when returning to Wallet. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 80b5f4598aa058165a23b8e783f9d76b259a43b2. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- tests/page-objects/wallet/WalletView.ts | 35 ++++++++++++++++++- .../predict/predict-claim-positions.spec.ts | 8 +++-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/tests/page-objects/wallet/WalletView.ts b/tests/page-objects/wallet/WalletView.ts index 19ab9a8383d..88f96ab29a5 100644 --- a/tests/page-objects/wallet/WalletView.ts +++ b/tests/page-objects/wallet/WalletView.ts @@ -38,20 +38,40 @@ class WalletView { return Matchers.getIdentifier(WalletViewSelectorsIDs.WALLET_SCROLL_VIEW); } + /** Wallet ScrollView as element (for gestures like swipe). */ + get walletScrollView(): DetoxElement { + return Matchers.getElementByID(WalletViewSelectorsIDs.WALLET_SCROLL_VIEW); + } + /** * Progressive scroll for homepage sections: * try tap -> small scroll down -> retry, until the section is tappable. + * @param options.scrollAmount - Pixels to scroll per step. + * @param options.overshootSwipe - After scroll, perform a small swipe to move the section away + * from the tab bar (e.g. direction 'up' = one more scroll down = section moves higher on screen). */ private async scrollAndTapSection( target: DetoxElement, description: string, direction: 'up' | 'down' = 'down', + options: { + scrollAmount?: number; + overshootSwipe?: { direction: 'up' | 'down'; percentage?: number }; + } = {}, ): Promise { + const { scrollAmount = 200, overshootSwipe } = options; await Gestures.scrollToElement(target, this.walletScrollViewIdentifier, { direction, - scrollAmount: 200, + scrollAmount, elemDescription: `Scroll to ${description}`, }); + if (overshootSwipe) { + await Gestures.swipe(this.walletScrollView, overshootSwipe.direction, { + percentage: overshootSwipe.percentage ?? 0.15, + speed: 'slow', + elemDescription: `Overshoot swipe for ${description}`, + }); + } await Gestures.waitAndTap(target, { elemDescription: description, }); @@ -685,13 +705,26 @@ class WalletView { ); } + /** + * Scrolls to the Predictions section and taps it. After scroll, does a small overshoot swipe + * so the section sits higher on screen and the tap does not hit the main menu "+" button. + */ async scrollAndTapPredictionsSection( direction: 'up' | 'down' = 'down', + options: { + overshootSwipe?: { direction: 'up' | 'down'; percentage?: number }; + } = {}, ): Promise { await this.scrollAndTapSection( this.predictionsSectionHeader, 'Predictions section', direction, + { + overshootSwipe: options.overshootSwipe ?? { + direction: 'up', + percentage: 0.15, + }, + }, ); } diff --git a/tests/smoke/predict/predict-claim-positions.spec.ts b/tests/smoke/predict/predict-claim-positions.spec.ts index b29db748d8b..f6dc3c8c5af 100644 --- a/tests/smoke/predict/predict-claim-positions.spec.ts +++ b/tests/smoke/predict/predict-claim-positions.spec.ts @@ -124,7 +124,6 @@ describe(SmokePredictions('Claim winnings:'), () => { // Claim button is animated - disabling sync on iOS to prevent test hang await device.disableSynchronization(); - //await WalletView.scrollAndTapPredictionsSection(); await WalletView.tapClaimButton(); await postClaimMocks(mockServer); @@ -160,7 +159,12 @@ describe(SmokePredictions('Claim winnings:'), () => { description: 'Wallet screen should be visible after returning from activity', }); - await WalletView.scrollAndTapPredictionsSection('up'); + await WalletView.scrollAndTapPredictionsSection('down', { + overshootSwipe: { + direction: 'down', + percentage: 0.15, + }, + }); await Assertions.expectTextDisplayed('$48.16'); // Verify analytics events From 7a4ae4e384198de309f6b3caea7af633e3f028f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20=C5=81ucka?= <5708018+PatrykLucka@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:42:16 +0100 Subject: [PATCH 048/206] refactor: update NetworkDetailsView to use KeyboardProvider and improve keyboard handling (#27479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** On the Add/Edit Network screen (Network Details), when the keyboard was already open and the user tapped a second input (e.g. Symbol then Block explorer URL), the scroll view would first scroll correctly to show the focused field, then a second scroll event would move the content back up so the input was partly hidden behind the keyboard. **Reason for the change:** The previous implementation used `react-native-keyboard-aware-scroll-view`, which on focus switch (e.g. `keyboardWillHide` then `keyboardWillShow`) was updating `contentInset` and triggering a native scroll adjustment that caused the jump. **Solution:** Use `react-native-keyboard-controller` for this screen instead: wrap the view in `KeyboardProvider` and use its `KeyboardAwareScrollView` with `bottomOffset` and `disableScrollOnKeyboardHide`. This matches the approach used on other long multi-field forms (Import New Secret Recovery Phrase, Import From Secret Recovery Phrase) and avoids the contentInset-based scroll reset. ## **Changelog** CHANGELOG entry: Fixed scroll position jumping when switching between form fields with the keyboard open on the Add/Edit Network screen. ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TMCU-532 ## **Manual testing steps** ```gherkin Feature: Add/Edit Network form keyboard and scroll behaviour Scenario: user switches focus between bottom fields with keyboard open Given the user is on the Add Network or Edit Network screen And the keyboard is open (e.g. after focusing Network Name or RPC URL) When the user taps the Symbol (or Chain ID) field Then the scroll view scrolls so the Symbol field is visible above the keyboard When the user taps the Block explorer URL field Then the scroll view scrolls so the Block explorer field is visible above the keyboard And the view does not jump back up so that the field is hidden behind the keyboard ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/5868bc0d-d4a4-424d-b619-33d512e65c63 ### **After** https://github.com/user-attachments/assets/7e340157-ec60-403f-8dfe-611d80ac27ff ## **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. ## **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** > Low risk UI-only change that swaps the keyboard-aware scroll implementation to prevent scroll jumps when switching focused inputs; main risk is platform-specific keyboard/scroll behavior regressions on the Add/Edit Network form. > > **Overview** > Fixes keyboard-driven scroll jumping on the Add/Edit Network form by replacing `react-native-keyboard-aware-scroll-view` with `react-native-keyboard-controller`. > > `NetworkDetailsView` is now wrapped in `KeyboardProvider`, and its `KeyboardAwareScrollView` configuration is updated to use `bottomOffset` and `disableScrollOnKeyboardHide` (removing the prior Android auto-scroll/inset settings) to keep focused fields visible without a second “bounce” scroll. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit dfad0b80df661d041caaaa206406d27267b5c50b. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../NetworkDetailsView/NetworkDetailsView.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.tsx b/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.tsx index c0df596a4c7..e9849a75d45 100644 --- a/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.tsx +++ b/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.tsx @@ -7,7 +7,10 @@ import React, { } from 'react'; import { ImageSourcePropType, Platform, Pressable } from 'react-native'; import { useRoute, RouteProp, useNavigation } from '@react-navigation/native'; -import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'; +import { + KeyboardAwareScrollView, + KeyboardProvider, +} from 'react-native-keyboard-controller'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useSelector } from 'react-redux'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; @@ -181,7 +184,7 @@ const NetworkDetailsView = () => { const placeholderTextColor = colors.text.muted; - return ( + const content = ( { {/* Network Name */} @@ -356,6 +358,8 @@ const NetworkDetailsView = () => { )} ); + + return {content}; }; export default NetworkDetailsView; From d9347f400e7a8e0c55157e96f55a95063b7d80aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20=C5=81ucka?= <5708018+PatrykLucka@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:44:21 +0100 Subject: [PATCH 049/206] fix(network-details): correct RPC URL focus and border styling (#27482) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The RPC URL input in the Add Network form had several visual inconsistencies: 1. **Focus state was never cleared** — `isRpcUrlFieldFocused` was set on focus but never reset on blur, leaving the input styled as focused permanently after the first tap. 2. **Border color mismatch** — the base (unfocused) input used `border.default`, the same token `TextField` uses for its *focused* state, so the RPC field always looked focused. The focused override used `primary.default` (blue) instead of `border.default`. 3. **Font size jump on focus** — the base style used `fontStyles.normal` (no `fontSize`), while the focused/error overrides used `typography.sBodyMD` (includes `fontSize`), causing the placeholder text to resize when the user tapped in. **Changes:** - Added `onRpcUrlBlur` handler in `useFormFocus` to reset `isRpcUrlFieldFocused` to `false` - Wired `onBlur={onRpcUrlBlur}` on both `RpcUrlInput` instances (inline form + modal form) - Fixed style precedence: focus style now takes priority over error style (focus → error → default) - Extracted a shared `baseInput` object using `typography.sBodyMD` so all three states (default, focused, error) use identical typography - Changed default border to `border.muted` and focused border to `border.default`, matching `TextField` conventions - Changed focused border from `primary.default` to `border.default` to align with `TextField` - Added `underlineColorAndroid="transparent"` on the raw `TextInput` in `RpcUrlInput` - Extracted `RpcFormFields` component to deduplicate the identical RPC URL + name form rendered in both `RpcEndpointSection` (addMode) and `RpcEndpointModals` (modal form), resolving ~80% code duplication flagged by SonarCloud - Added dedicated unit tests for `RpcFormFields` covering all three style states (default, focused, error), focus precedence over error, focus/blur callbacks, and pre-filled values ## **Changelog** CHANGELOG entry: Fixed a bug where the RPC URL field in network details could appear focused after blur and had inconsistent typography between states. ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TMCU-536 ## **Manual testing steps** ```gherkin Feature: Network details RPC URL focus visuals Scenario: RPC URL field is not visually focused on form open Given I open Settings > Networks > Add network When the form is first displayed Then the RPC URL field shows a neutral border (not focused blue) Scenario: RPC URL field focus style clears after changing field Given I am on Settings > Networks > Add network And I tap RPC URL and type an invalid URL to show validation messaging When I tap Symbol input Then the Symbol input is focused And RPC URL no longer shows focused blue styling Scenario: Android input does not show native blue underline artifact Given I am on Android and open Add network form When I focus and blur the RPC URL input Then no persistent native blue underline remains after blur ``` ## **Screenshots/Recordings** ### **Before** Screenshot 2026-03-16 at 14 27 07 ### **After** Screenshot 2026-03-16 at 14 37 03 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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.
Open in Web Open in Cursor 
--- > [!NOTE] > **Low Risk** > Low risk UI/UX fix and small refactor in the network details RPC form; main risk is limited to regressions in input focus/validation wiring in add/edit RPC flows. > > **Overview** > Fixes RPC URL input focus/blur behavior and visual styling in Network Details so the field no longer appears permanently focused and uses consistent typography and border tokens across default/focus/error states. > > Refactors the duplicated RPC URL + RPC name form into a reusable `RpcFormFields` component used in both add-mode and the RPC bottom-sheet modal, adds an Android `TextInput` underline suppression, and expands unit coverage to assert blur cleanup and style precedence. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit b8dfb8ed2d13bb32d8c4ad4aba2e68e6f7e3adb7. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Cursor Agent Co-authored-by: Patryk Łucka Co-authored-by: Pedro Pablo Aste Kompen --- .../NetworkDetailsView.styles.ts | 45 +++--- .../NetworkDetailsView.test.tsx | 14 ++ .../components/RpcEndpointSection.tsx | 136 ++++++----------- .../components/RpcFormFields.test.tsx | 140 ++++++++++++++++++ .../components/RpcFormFields.tsx | 101 +++++++++++++ .../components/RpcUrlInput.tsx | 7 +- .../hooks/useFormFocus.test.ts | 5 +- .../NetworkDetailsView/hooks/useFormFocus.ts | 6 + 8 files changed, 331 insertions(+), 123 deletions(-) create mode 100644 app/components/Views/NetworksManagement/NetworkDetailsView/components/RpcFormFields.test.tsx create mode 100644 app/components/Views/NetworksManagement/NetworkDetailsView/components/RpcFormFields.tsx diff --git a/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.styles.ts b/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.styles.ts index bdd4d7b6751..939bae49686 100644 --- a/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.styles.ts +++ b/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.styles.ts @@ -15,6 +15,18 @@ import type { Theme } from '../../../../util/theme/models'; const createStyles = (params: { theme: Theme }) => { const { colors } = params.theme; + const baseInput = { + ...typography.sBodyMD, + fontWeight: typography.sBodyMD.fontWeight as '400', + fontFamily: getFontFamily(TextVariant.BodyMD), + borderRadius: 12, + borderWidth: 1, + padding: 10, + height: 48, + color: colors.text.default, + backgroundColor: colors.background.muted, + }; + return StyleSheet.create({ // ---- Modal content layout ------------------------------------------------ rpcTitleWrapper: { @@ -29,39 +41,16 @@ const createStyles = (params: { theme: Theme }) => { // ---- RpcUrlInput still uses these for the modal form --------------------- input: { - ...fontStyles.normal, - fontWeight: fontStyles.normal.fontWeight as '400', - borderColor: colors.border.default, - borderRadius: 12, - borderWidth: 1, - padding: 10, - height: 48, - color: colors.text.default, - backgroundColor: colors.background.muted, + ...baseInput, + borderColor: colors.border.muted, }, inputWithError: { - ...typography.sBodyMD, - fontWeight: typography.sBodyMD.fontWeight as '400', - fontFamily: getFontFamily(TextVariant.BodyMD), + ...baseInput, borderColor: colors.error.default, - borderRadius: 12, - borderWidth: 1, - padding: 10, - height: 48, - color: colors.text.default, - backgroundColor: colors.background.muted, }, inputWithFocus: { - ...typography.sBodyMD, - fontWeight: typography.sBodyMD.fontWeight as '400', - fontFamily: getFontFamily(TextVariant.BodyMD), - borderColor: colors.primary.default, - borderRadius: 12, - borderWidth: 1, - padding: 10, - height: 48, - color: colors.text.default, - backgroundColor: colors.background.muted, + ...baseInput, + borderColor: colors.border.default, }, warningText: { ...fontStyles.normal, diff --git a/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.test.tsx b/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.test.tsx index 29a111f4655..a6bc031900f 100644 --- a/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.test.tsx +++ b/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.test.tsx @@ -145,6 +145,7 @@ const createMockFormHook = (overrides: Record = {}) => ({ onSymbolFocused: jest.fn(), onSymbolBlur: jest.fn(), onRpcUrlFocused: jest.fn(), + onRpcUrlBlur: jest.fn(), onChainIdFocused: jest.fn(), onChainIdBlur: jest.fn(), jumpToRpcURL: jest.fn(), @@ -691,6 +692,19 @@ describe('NetworkDetailsView', () => { expect(val.validateName).toHaveBeenCalled(); }); + it('triggers RPC focus cleanup on RPC URL blur', () => { + const formReturn = createMockFormHook(); + mockFormHook.mockReturnValue(formReturn); + + const { getByTestId } = render(); + + fireEvent( + getByTestId(NetworkDetailsViewSelectorsIDs.RPC_URL_INPUT), + 'blur', + ); + expect(formReturn.onRpcUrlBlur).toHaveBeenCalled(); + }); + it('triggers handleValidateSymbol on symbol field blur', () => { const val = createMockValidation(); mockValidation.mockReturnValue(val); diff --git a/app/components/Views/NetworksManagement/NetworkDetailsView/components/RpcEndpointSection.tsx b/app/components/Views/NetworksManagement/NetworkDetailsView/components/RpcEndpointSection.tsx index 724f82ee84c..60cd2778455 100644 --- a/app/components/Views/NetworksManagement/NetworkDetailsView/components/RpcEndpointSection.tsx +++ b/app/components/Views/NetworksManagement/NetworkDetailsView/components/RpcEndpointSection.tsx @@ -28,7 +28,7 @@ import Tag from '../../../../../component-library/components/Tags/Tag/Tag'; import { CellComponentSelectorsIDs } from '../../../../../component-library/components/Cells/Cell/CellComponent.testIds'; import { RpcEndpointType } from '@metamask/network-controller'; import { IconName } from '../../../../../component-library/components/Icons/Icon'; -import RpcUrlInput from './RpcUrlInput'; +import RpcFormFields from './RpcFormFields'; import SelectField from './SelectField'; import { NetworkDetailsViewSelectorsIDs } from '../NetworkDetailsView.testIds'; import { formatNetworkRpcUrl } from '../NetworkDetailsView.utils'; @@ -73,6 +73,7 @@ const RpcEndpointSection: React.FC = ({ onRpcUrlAdd, onRpcNameAdd, onRpcUrlFocused, + onRpcUrlBlur, jumpToChainId, focus: { isRpcUrlFieldFocused }, } = formHook; @@ -86,53 +87,26 @@ const RpcEndpointSection: React.FC = ({ if (addMode) { return ( - <> - - - - - - - - - + ); } @@ -290,6 +264,7 @@ const RpcEndpointModals: React.FC = ({ onRpcUrlChangeWithName, onRpcUrlDelete, onRpcUrlFocused, + onRpcUrlBlur, jumpToChainId, modals: { showMultiRpcAddModal, rpcModalShowForm: showForm }, setRpcModalShowForm: setShowForm, @@ -384,51 +359,26 @@ const RpcEndpointModals: React.FC = ({ pointerEvents={showForm ? 'auto' : 'none'} > - - - - - - - - + { + beforeEach(() => jest.clearAllMocks()); + + it('renders both RPC URL and RPC name inputs', () => { + const { getByTestId } = render(); + + expect( + getByTestId(NetworkDetailsViewSelectorsIDs.RPC_URL_INPUT), + ).toBeOnTheScreen(); + expect( + getByTestId(NetworkDetailsViewSelectorsIDs.RPC_NAME_INPUT), + ).toBeOnTheScreen(); + }); + + it('applies base input style when not focused and no warning', () => { + const { getByTestId } = render(); + + const input = getByTestId(NetworkDetailsViewSelectorsIDs.RPC_URL_INPUT); + const flatStyle = Array.isArray(input.props.style) + ? Object.assign({}, ...input.props.style.filter(Boolean)) + : input.props.style; + + expect(flatStyle.borderColor).toBe(mockTheme.colors.border.muted); + }); + + it('applies focus style when isRpcUrlFieldFocused is true', () => { + const { getByTestId } = render( + , + ); + + const input = getByTestId(NetworkDetailsViewSelectorsIDs.RPC_URL_INPUT); + const flatStyle = Array.isArray(input.props.style) + ? Object.assign({}, ...input.props.style.filter(Boolean)) + : input.props.style; + + expect(flatStyle.borderColor).toBe(mockTheme.colors.border.default); + }); + + it('applies error style when not focused and warningRpcUrl is set', () => { + const { getByTestId } = render( + , + ); + + const input = getByTestId(NetworkDetailsViewSelectorsIDs.RPC_URL_INPUT); + const flatStyle = Array.isArray(input.props.style) + ? Object.assign({}, ...input.props.style.filter(Boolean)) + : input.props.style; + + expect(flatStyle.borderColor).toBe(mockTheme.colors.error.default); + }); + + it('focus style takes precedence over error style', () => { + const { getByTestId } = render( + , + ); + + const input = getByTestId(NetworkDetailsViewSelectorsIDs.RPC_URL_INPUT); + const flatStyle = Array.isArray(input.props.style) + ? Object.assign({}, ...input.props.style.filter(Boolean)) + : input.props.style; + + expect(flatStyle.borderColor).toBe(mockTheme.colors.border.default); + }); + + it('calls onRpcUrlFocused on focus and onRpcUrlBlur on blur', () => { + const { getByTestId } = render(); + + const input = getByTestId(NetworkDetailsViewSelectorsIDs.RPC_URL_INPUT); + fireEvent(input, 'focus'); + expect(defaultProps.onRpcUrlFocused).toHaveBeenCalled(); + + fireEvent(input, 'blur'); + expect(defaultProps.onRpcUrlBlur).toHaveBeenCalled(); + }); + + it('calls onRpcNameAdd when RPC name text changes', () => { + const { getByTestId } = render(); + + fireEvent.changeText( + getByTestId(NetworkDetailsViewSelectorsIDs.RPC_NAME_INPUT), + 'My RPC', + ); + expect(defaultProps.onRpcNameAdd).toHaveBeenCalledWith('My RPC'); + }); + + it('displays pre-filled values', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(NetworkDetailsViewSelectorsIDs.RPC_URL_INPUT).props.value, + ).toBe('https://rpc.example.com'); + expect( + getByTestId(NetworkDetailsViewSelectorsIDs.RPC_NAME_INPUT).props.value, + ).toBe('Example RPC'); + }); +}); diff --git a/app/components/Views/NetworksManagement/NetworkDetailsView/components/RpcFormFields.tsx b/app/components/Views/NetworksManagement/NetworkDetailsView/components/RpcFormFields.tsx new file mode 100644 index 00000000000..2220f18f89c --- /dev/null +++ b/app/components/Views/NetworksManagement/NetworkDetailsView/components/RpcFormFields.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { TextInput } from 'react-native'; +import { Box, Label } from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../locales/i18n'; +import TextField from '../../../../../component-library/components/Form/TextField'; +import RpcUrlInput from './RpcUrlInput'; +import { NetworkDetailsViewSelectorsIDs } from '../NetworkDetailsView.testIds'; +import type { NetworkDetailsStyles } from '../NetworkDetailsView.styles'; + +interface RpcFormFieldsProps { + inputRpcURL: React.RefObject; + inputNameRpcURL: React.RefObject; + rpcUrlForm: string; + rpcNameForm: string; + isRpcUrlFieldFocused: boolean; + warningRpcUrl: string | undefined; + onRpcUrlAdd: (url: string) => void; + onRpcNameAdd: (name: string) => void; + onRpcUrlFocused: () => void; + onRpcUrlBlur: () => void; + jumpToChainId: () => void; + checkIfNetworkExists: (rpcUrl: string) => Promise<{ chainId: string }[]>; + checkIfRpcUrlExists: (rpcUrl: string) => Promise<{ chainId: string }[]>; + onValidationSuccess: () => void; + onRpcUrlValidationChange: (isValid: boolean) => void; + styles: NetworkDetailsStyles; + themeAppearance: 'light' | 'dark' | 'default'; + placeholderTextColor: string; +} + +const RpcFormFields: React.FC = ({ + inputRpcURL, + inputNameRpcURL, + rpcUrlForm, + rpcNameForm, + isRpcUrlFieldFocused, + warningRpcUrl, + onRpcUrlAdd, + onRpcNameAdd, + onRpcUrlFocused, + onRpcUrlBlur, + jumpToChainId, + checkIfNetworkExists, + checkIfRpcUrlExists, + onValidationSuccess, + onRpcUrlValidationChange, + styles, + themeAppearance, + placeholderTextColor, +}) => ( + <> + + + + + + + + + +); + +export default RpcFormFields; diff --git a/app/components/Views/NetworksManagement/NetworkDetailsView/components/RpcUrlInput.tsx b/app/components/Views/NetworksManagement/NetworkDetailsView/components/RpcUrlInput.tsx index 0563048837b..7dada7e2dd5 100644 --- a/app/components/Views/NetworksManagement/NetworkDetailsView/components/RpcUrlInput.tsx +++ b/app/components/Views/NetworksManagement/NetworkDetailsView/components/RpcUrlInput.tsx @@ -91,7 +91,12 @@ const RpcUrlInput = forwardRef((props, ref) => { return ( <> - + {warningRpcUrl && ( {warningRpcUrl} diff --git a/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useFormFocus.test.ts b/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useFormFocus.test.ts index 9a27aff5d83..65a3e8a4f49 100644 --- a/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useFormFocus.test.ts +++ b/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useFormFocus.test.ts @@ -30,11 +30,14 @@ describe('useFormFocus', () => { }, ); - it('sets rpc url focus on onRpcUrlFocused', () => { + it('toggles rpc url focus on focus/blur', () => { const { result } = renderHook(() => useFormFocus()); act(() => result.current.onRpcUrlFocused()); expect(result.current.focus.isRpcUrlFieldFocused).toBe(true); + + act(() => result.current.onRpcUrlBlur()); + expect(result.current.focus.isRpcUrlFieldFocused).toBe(false); }); it('creates refs for all input fields', () => { diff --git a/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useFormFocus.ts b/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useFormFocus.ts index ff7b76ae1e1..47acec85693 100644 --- a/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useFormFocus.ts +++ b/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useFormFocus.ts @@ -16,6 +16,7 @@ export interface UseFormFocusReturn { onSymbolFocused: () => void; onSymbolBlur: () => void; onRpcUrlFocused: () => void; + onRpcUrlBlur: () => void; onChainIdFocused: () => void; onChainIdBlur: () => void; @@ -59,6 +60,10 @@ export const useFormFocus = (): UseFormFocusReturn => { () => setFocus((prev) => ({ ...prev, isRpcUrlFieldFocused: true })), [], ); + const onRpcUrlBlur = useCallback( + () => setFocus((prev) => ({ ...prev, isRpcUrlFieldFocused: false })), + [], + ); const onChainIdFocused = useCallback( () => setFocus((prev) => ({ ...prev, isChainIdFieldFocused: true })), [], @@ -88,6 +93,7 @@ export const useFormFocus = (): UseFormFocusReturn => { onSymbolFocused, onSymbolBlur, onRpcUrlFocused, + onRpcUrlBlur, onChainIdFocused, onChainIdBlur, jumpToRpcURL, From 204e2223b404016b38cd2dc995a4542eec0004d6 Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:57:56 +0800 Subject: [PATCH 050/206] =?UTF-8?q?feat(perps):=20CDP=20improvements=20?= =?UTF-8?q?=E2=80=94=20recipe=20actions,=20agentic=20commands,=20fast-laun?= =?UTF-8?q?ch=20docs=20(#27510)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Enhances the CDP agentic tooling with new recipe actions (press, scroll, set_input), additional agentic commands, and documents a tiered command reference including fast-launch mode that skips wallet import. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: N/A — developer tooling improvement ## **Manual testing steps** ```gherkin Feature: Fast relaunch via preflight.sh Scenario: Developer relaunches app without wallet import Given app is already installed on the simulator When developer runs `scripts/perps/agentic/preflight.sh --platform ios` Then simulator boots, Metro starts, app launches, and CDP connects And wallet import is skipped (~10-15s faster) ``` ## **Screenshots/Recordings** ### **Before** N/A — docs and tooling only ### **After** N/A — docs and tooling only ## **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. ## **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** > Changes are limited to __DEV__ agentic tooling, shell/node scripts, and documentation; main risk is minor breakage in local developer workflows (navigation/recipe execution) rather than production behavior. > > **Overview** > Improves the Perps CDP “agentic” developer toolkit by adding a new text-entry action (`set-input`) and wiring it end-to-end through `cdp-bridge.js` and `app-state.sh`, including a new `__AGENTIC__.setInput()` implementation that finds a component by `testID` and triggers `onChangeText` via fiber/parent traversal. > > Extends `__AGENTIC__.setupWallet()` to optionally set auto-lock to *Never* (`setLockTime(-1)`) and enable OS/device auth (`setOsAuthEnabled(true)`), with accompanying unit tests. > > Enhances navigation ergonomics and recipe validation: adds a `PerpsHomeView` route alias, updates nested route mapping, allows recipe `navigate` steps to pass params, and adds new recipe step actions (`press`, `scroll`, `set_input`). Documentation is updated to reflect the new commands and a faster relaunch workflow, and `.tsbuildinfo` is added to `.gitignore`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 59e7cfe895e7e57a7b82ac393c975b92d9960114. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .gitignore | 3 + .../AgenticService/AgenticService.test.ts | 54 +++++++++++++++ app/core/AgenticService/AgenticService.ts | 69 +++++++++++++++++-- docs/perps/perps-agentic-feedback-loop.md | 62 +++++++++++++---- scripts/perps/agentic/app-navigate.sh | 2 + scripts/perps/agentic/app-state.sh | 5 ++ scripts/perps/agentic/cdp-bridge.js | 56 ++++++++++++++- scripts/perps/agentic/validate-recipe.sh | 46 ++++++++++++- 8 files changed, 276 insertions(+), 21 deletions(-) diff --git a/.gitignore b/.gitignore index 1c5d326b37f..c43caf58174 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# typescript incremental build cache +.tsbuildinfo + # osx .DS_Store # don't save asdf tools-version config as nvm is prioritized. diff --git a/app/core/AgenticService/AgenticService.test.ts b/app/core/AgenticService/AgenticService.test.ts index e7429058e00..2fc46f7ad68 100644 --- a/app/core/AgenticService/AgenticService.test.ts +++ b/app/core/AgenticService/AgenticService.test.ts @@ -69,6 +69,13 @@ jest.mock('../../actions/security', () => ({ setDataCollectionForMarketing: () => ({ type: 'SET_DATA_COLLECTION_FOR_MARKETING', }), + setOsAuthEnabled: (enabled: boolean) => ({ + type: 'SET_OS_AUTH_ENABLED', + enabled, + }), +})); +jest.mock('../../actions/settings', () => ({ + setLockTime: (lockTime: number) => ({ type: 'SET_LOCK_TIME', lockTime }), })); jest.mock('@metamask/key-tree', () => ({ mnemonicPhraseToBytes: jest.fn((s: string) => new Uint8Array(s.length)), @@ -111,6 +118,7 @@ function makeFiber( return { child: null, sibling: null, + return: null, memoizedProps: testID || onPress ? { testID, onPress } : null, stateNode: null, ...rest, @@ -705,5 +713,51 @@ describe('AgenticService.install', () => { }); expect(mockMarkTutorial).not.toHaveBeenCalled(); }); + + it('dispatches setLockTime(-1) when autoLockNever is true', async () => { + mockDispatch.mockClear(); + await bridge().setupWallet({ + password: 'test123', + accounts: [], + settings: { autoLockNever: true }, + }); + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: 'SET_LOCK_TIME', lockTime: -1 }), + ); + }); + + it('does not dispatch setLockTime when autoLockNever is not set', async () => { + mockDispatch.mockClear(); + await bridge().setupWallet({ + password: 'test123', + accounts: [], + }); + expect(mockDispatch).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'SET_LOCK_TIME' }), + ); + }); + + it('dispatches setOsAuthEnabled(true) when deviceAuthEnabled is true', async () => { + mockDispatch.mockClear(); + await bridge().setupWallet({ + password: 'test123', + accounts: [], + settings: { deviceAuthEnabled: true }, + }); + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: 'SET_OS_AUTH_ENABLED', enabled: true }), + ); + }); + + it('does not dispatch setOsAuthEnabled when deviceAuthEnabled is not set', async () => { + mockDispatch.mockClear(); + await bridge().setupWallet({ + password: 'test123', + accounts: [], + }); + expect(mockDispatch).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'SET_OS_AUTH_ENABLED' }), + ); + }); }); }); diff --git a/app/core/AgenticService/AgenticService.ts b/app/core/AgenticService/AgenticService.ts index 014815c58c6..b8f1fb81435 100644 --- a/app/core/AgenticService/AgenticService.ts +++ b/app/core/AgenticService/AgenticService.ts @@ -29,7 +29,11 @@ import { REWARDS_GTM_MODAL_SHOWN, } from '../../constants/storage'; import { analytics } from '../../util/analytics/analytics'; -import { setDataCollectionForMarketing } from '../../actions/security'; +import { + setDataCollectionForMarketing, + setOsAuthEnabled, +} from '../../actions/security'; +import { setLockTime } from '../../actions/settings'; import AccountTreeInitService from '../../multichain-accounts/AccountTreeInitService'; import NavigationService from '../NavigationService'; import Routes from '../../constants/navigation/Routes'; @@ -43,6 +47,7 @@ import Routes from '../../constants/navigation/Routes'; interface FiberNode { child: FiberNode | null; sibling: FiberNode | null; + return: FiberNode | null; memoizedProps: { testID?: string; onPress?: (...args: unknown[]) => unknown; @@ -91,6 +96,15 @@ interface AgenticBridge { offset?: number; animated?: boolean; }; + setInput: ( + testId: string, + value: string, + ) => { + ok: boolean; + testId?: string; + value?: string; + error?: string; + }; switchAccount: (address: string) => { switched: boolean; id: string; @@ -108,6 +122,8 @@ interface AgenticBridge { metametrics?: boolean; skipGtmModals?: boolean; skipPerpsTutorial?: boolean; + autoLockNever?: boolean; + deviceAuthEnabled?: boolean; }; }) => Promise<{ ok: boolean; @@ -317,6 +333,41 @@ const AgenticService = { return { ok: false, error: String(e) }; } }, + setInput: (testId: string, value: string) => { + try { + const result: { + ok: boolean; + testId?: string; + value?: string; + error?: string; + } = { + ok: false, + error: `No component with testID="${testId}" found`, + }; + walkFiberRoots((rootFiber) => { + const target = findFiberByTestId(rootFiber, testId); + if (!target) return false; + // Walk the found fiber and its parents looking for onChangeText + let current: FiberNode | null = target; + while (current) { + if (typeof current.memoizedProps?.onChangeText === 'function') { + current.memoizedProps.onChangeText(value); + result.ok = true; + result.testId = testId; + result.value = value; + result.error = undefined; + return true; + } + current = current.return; + } + result.error = `Component with testID="${testId}" has no onChangeText prop`; + return true; + }); + return result; + } catch (e) { + return { ok: false, error: String(e) }; + } + }, switchAccount: (address: string) => { const accounts = Engine.context.AccountsController.listAccounts(); const target = accounts.find( @@ -399,19 +450,29 @@ const AgenticService = { Engine.context.PerpsController?.markTutorialCompleted(); } - // 7. Configure MetaMetrics if specified + // 7. Set auto-lock to "Never" (-1) for agentic workflows + if (settings.autoLockNever === true) { + ReduxService.store.dispatch(setLockTime(-1)); + } + + // 8. Enable device authentication (biometrics/passcode bypass) + if (settings.deviceAuthEnabled === true) { + ReduxService.store.dispatch(setOsAuthEnabled(true)); + } + + // 9. Configure MetaMetrics if specified if (settings.metametrics === false) { await analytics.optOut(); } else if (settings.metametrics === true) { await analytics.optIn(); } - // 8. Navigate to wallet (same as Authentication.unlockWallet) + // 10. Navigate to wallet (same as Authentication.unlockWallet) NavigationService.navigation?.reset({ routes: [{ name: Routes.ONBOARDING.HOME_NAV }], }); - // 9. Collect all ETH accounts for the summary + // 11. Collect all ETH accounts for the summary const ethAccs = ( Object.values( AccountsController.state.internalAccounts.accounts, diff --git a/docs/perps/perps-agentic-feedback-loop.md b/docs/perps/perps-agentic-feedback-loop.md index e8240e2a65f..98b25994901 100644 --- a/docs/perps/perps-agentic-feedback-loop.md +++ b/docs/perps/perps-agentic-feedback-loop.md @@ -103,6 +103,7 @@ All tools work on **both iOS and Android**. Platform is auto-detected (see Secti ``` scripts/perps/agentic/app-navigate.sh [params-json] # navigate + auto-screenshot +scripts/perps/agentic/app-state.sh status # route + selected account snapshot scripts/perps/agentic/app-state.sh route # current route + params scripts/perps/agentic/app-state.sh state # Redux state at path scripts/perps/agentic/app-state.sh eval "" # run JS in app context (sync) @@ -113,6 +114,10 @@ scripts/perps/agentic/app-state.sh go-back # navigate back scripts/perps/agentic/app-state.sh accounts # list all accounts scripts/perps/agentic/app-state.sh account # get selected account scripts/perps/agentic/app-state.sh switch-account # switch to account by address +scripts/perps/agentic/app-state.sh press # press component by testID +scripts/perps/agentic/app-state.sh scroll [--test-id ] [--offset ] # scroll a view +scripts/perps/agentic/app-state.sh sentry-debug [enable|disable] # patch Sentry to log to console +scripts/perps/agentic/app-state.sh unlock # unlock wallet via fiber tree scripts/perps/agentic/app-state.sh recipe # run a recipe (e.g. perps/positions) scripts/perps/agentic/app-state.sh recipe --list # list all available recipes scripts/perps/agentic/screenshot.sh [label] # take screenshot, returns path @@ -124,13 +129,26 @@ scripts/perps/agentic/reload-metro.sh # trigger hot-re **yarn shortcuts** (human-friendly aliases): ```bash -yarn a:start # start/attach Metro -yarn a:stop # stop Metro -yarn a:status # current route -yarn a:reload # hot-reload all connected apps -yarn a:navigate # navigate to a screen (pass route + optional params) +yarn a:start # start/attach Metro (no app launch) +yarn a:stop # stop Metro +yarn a:status # current route + account snapshot +yarn a:reload # hot-reload all connected apps +yarn a:navigate # navigate to a screen +yarn a:ios # boot sim → Metro → launch app → wallet setup → CDP ready +yarn a:android # boot device → Metro → launch app → wallet setup → CDP ready +yarn a:setup:ios # clean build: yarn setup → build → install → Metro → wallet +yarn a:setup:android # clean build: same for Android ``` +**Fast relaunch** (skip wallet import, ~10-15s faster — `yarn` aliases coming soon): + +```bash +scripts/perps/agentic/preflight.sh --platform ios # boot sim → Metro → launch → CDP ready +scripts/perps/agentic/preflight.sh --platform android # boot device → Metro → launch → CDP ready +``` + +> **Which command?** First time → `yarn a:setup:ios`. Daily restart → `preflight.sh --platform ios` (no wallet). Wallet corrupted → `yarn a:ios`. + **Metro log**: `.agent/metro.log` — grep for errors after changes. **Architecture**: @@ -144,6 +162,12 @@ scripts/perps/agentic/ ├── start-metro.sh # Start Metro (or attach to existing) ├── stop-metro.sh # Stop Metro background process ├── reload-metro.sh # Trigger hot-reload on all connected apps +├── preflight.sh # Full env setup: build → Metro → CDP → wallet seed +├── setup-wallet.sh # Seed wallet from .agent/wallet-fixture.json via CDP +├── unlock-wallet.sh # Unlock wallet on lock screen +├── interactive-start.sh # Interactive guided setup +├── validate-recipe.sh # Run a recipe folder against the live app +├── validate-myx.sh # MYX-specific validation └── recipes/ # Per-team recipe files (see recipes/README.md) ├── perps.json # Perps team recipes (positions, auth, balances, markets, trade-flow, etc.) └── README.md # How to add recipes for your team @@ -372,14 +396,26 @@ All routes are in `app/constants/navigation/Routes.ts`. Nested routes are handle > **Note**: Route strings don't always match component names. `PerpsMarketListView` is the **home** screen route (renders PerpsHomeView). The actual market list component is at route `PerpsTrendingView`. -| Route | Description | Params | -| --------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -| `PerpsMarketListView` | Perps home (positions, orders, watchlist) | | -| `PerpsTrendingView` | Market list (all markets, full view) | | -| `PerpsMarketDetails` | Market detail view | `{"market":{"symbol":"BTC","name":"BTC","price":"0","change24h":"0","change24hPercent":"0","volume":"0","maxLeverage":"100"}}` | -| `PerpsTradingView` | Redirect: navigates to wallet home and selects perps tab | | -| `PerpsPositions` | Open positions | | -| `PerpsActivity` | Activity history | | +| Route | Description | Params | +| ------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `PerpsMarketListView` | Perps home (positions, orders, watchlist) | | +| `PerpsTrendingView` | Market list (all markets, full view) | | +| `PerpsMarketDetails` | Market detail view | `{"market":{"symbol":"BTC","name":"BTC","price":"0","change24h":"0","change24hPercent":"0","volume":"0","maxLeverage":"100"}}` | +| `PerpsTradingView` | Redirect: navigates to wallet home and selects perps tab | | +| `PerpsPositions` | Open positions | | +| `PerpsActivity` | Activity history | | +| `PerpsWithdraw` | Withdraw funds | | +| `PerpsTutorial` | Onboarding tutorial | | +| `PerpsClosePosition` | Close a position | | +| `PerpsTPSL` | Take-profit / stop-loss | | +| `PerpsAdjustMargin` | Adjust position margin | | +| `PerpsSelectModifyAction` | Select modify action sheet | | +| `PerpsSelectAdjustMarginAction` | Select adjust margin action | | +| `PerpsSelectOrderType` | Select order type | | +| `PerpsOrderDetailsView` | Order detail view | | +| `PerpsOrderBook` | Full order book depth view | | +| `PerpsPnlHeroCard` | PnL hero card | | +| `PerpsHIP3Debug` | HIP3 debug view | | All perps route constants are in `app/constants/navigation/Routes.ts` under `Routes.PERPS`. For the full list of navigable routes, check `NESTED_ROUTE_PARENTS` in `cdp-bridge.js`. diff --git a/scripts/perps/agentic/app-navigate.sh b/scripts/perps/agentic/app-navigate.sh index 6c86f3a960a..87fdb17cdcb 100755 --- a/scripts/perps/agentic/app-navigate.sh +++ b/scripts/perps/agentic/app-navigate.sh @@ -5,6 +5,7 @@ # Usage: # scripts/perps/agentic/app-navigate.sh [params-json] # scripts/perps/agentic/app-navigate.sh --no-screenshot +# scripts/perps/agentic/app-navigate.sh PerpsHomeView # perps home (alias for PerpsMarketListView) # scripts/perps/agentic/app-navigate.sh PerpsMarketListView # perps home # scripts/perps/agentic/app-navigate.sh PerpsTrendingView # market list (all markets) # scripts/perps/agentic/app-navigate.sh PerpsMarketDetails '{"market":{"symbol":"BTC","name":"BTC","price":"0","change24h":"0","change24hPercent":"0","volume":"0","maxLeverage":"100"}}' @@ -107,6 +108,7 @@ if [ -z "$ROUTE" ]; then echo "" echo "Common routes:" echo " WalletTabHome Wallet home screen" + echo " PerpsHomeView Perps home (alias for PerpsMarketListView)" echo " PerpsMarketListView Perps home (positions, orders, watchlist)" echo " PerpsTrendingView Market list (all markets, full view)" echo " PerpsMarketDetails Perps market details" diff --git a/scripts/perps/agentic/app-state.sh b/scripts/perps/agentic/app-state.sh index f066fb02905..92d81e2f727 100755 --- a/scripts/perps/agentic/app-state.sh +++ b/scripts/perps/agentic/app-state.sh @@ -16,6 +16,7 @@ # scripts/perps/agentic/app-state.sh switch-account # Switch account # scripts/perps/agentic/app-state.sh press # Press component by testID # scripts/perps/agentic/app-state.sh scroll [--test-id ] [--offset ] # Scroll +# scripts/perps/agentic/app-state.sh set-input # Set text input value # scripts/perps/agentic/app-state.sh recipe perps/positions # Run a recipe # scripts/perps/agentic/app-state.sh recipe --list # List recipes @@ -66,6 +67,9 @@ case "$COMMAND" in scroll) node scripts/perps/agentic/cdp-bridge.js scroll-view "$@" ;; + set-input) + node scripts/perps/agentic/cdp-bridge.js set-input "$@" + ;; sentry-debug) node scripts/perps/agentic/cdp-bridge.js sentry-debug "$@" ;; @@ -93,6 +97,7 @@ case "$COMMAND" in echo " press Press a component by its testID prop" echo " scroll [opts] Scroll a ScrollView/FlatList" echo " --test-id --offset --animated" + echo " set-input Set text input value by testID" echo " sentry-debug [enable|disable] Patch Sentry to log errors to console" echo " unlock Unlock wallet via fiber tree" echo " recipe Run a recipe (e.g. perps/positions)" diff --git a/scripts/perps/agentic/cdp-bridge.js b/scripts/perps/agentic/cdp-bridge.js index 2ded848fa9e..d1716733aed 100644 --- a/scripts/perps/agentic/cdp-bridge.js +++ b/scripts/perps/agentic/cdp-bridge.js @@ -392,12 +392,17 @@ async function cdpEvalAsync(client, expression, timeoutMs = 30000) { // --------------------------------------------------------------------------- // Map of nested route names to their parent navigator. +// Friendly aliases → actual route names (for developer convenience) +const ROUTE_ALIASES = { + PerpsHomeView: 'PerpsMarketListView', +}; + // When navigating to a nested route, we need: navigate('Parent', { screen: 'Child', params }) // Routes not in this map are assumed to be root-level and navigated to directly. const NESTED_ROUTE_PARENTS = { // Perps PerpsMarketListView: 'Perps', PerpsMarketDetails: 'Perps', PerpsPositions: 'Perps', - PerpsTrendingView: 'Perps', PerpsWithdraw: 'Perps', PerpsTutorial: 'Perps', + PerpsTrendingView: 'Perps', PerpsWithdraw: 'Perps', PerpsTutorial: 'Perps', PerpsOrderRedirect: 'Perps', PerpsClosePosition: 'Perps', PerpsTPSL: 'Perps', PerpsAdjustMargin: 'Perps', PerpsOrderDetailsView: 'Perps', PerpsActivity: 'Perps', PerpsOrderBook: 'Perps', PerpsPnlHeroCard: 'Perps', PerpsHIP3Debug: 'Perps', @@ -425,7 +430,7 @@ const NESTED_ROUTE_PARENTS = { const COMMANDS = { async navigate(client, args, { deviceName, platform } = {}) { - const routeName = args[0]; + const routeName = ROUTE_ALIASES[args[0]] || args[0]; if (!routeName) { throw new Error('Usage: navigate [params-json]'); } @@ -656,6 +661,52 @@ const COMMANDS = { return { ...result, deviceName }; }, + async 'set-input'(client, args, { deviceName } = {}) { + const testId = args[0]; + const value = args.slice(1).join(' '); + if (!testId) { + throw new Error('Usage: set-input '); + } + // Try __AGENTIC__ bridge first, fall back to inline fiber walking + const expr = `(function() { + if (globalThis.__AGENTIC__?.setInput) return globalThis.__AGENTIC__.setInput(${JSON.stringify(testId)}, ${JSON.stringify(value)}); + var hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__; + if (!hook) return { ok: false, error: 'No React DevTools hook' }; + var renderers = hook.renderers; + if (!renderers) return { ok: false, error: 'No renderers' }; + var getFiberRoots = hook.getFiberRoots; + function findByTestId(fiber) { + if (!fiber) return null; + var props = fiber.memoizedProps; + if (props && props.testID === ${JSON.stringify(testId)}) return fiber; + return findByTestId(fiber.child) || findByTestId(fiber.sibling); + } + for (var [id] of renderers) { + var roots = getFiberRoots ? getFiberRoots(id) : undefined; + if (!roots) continue; + var result = null; + roots.forEach(function(r) { + if (result) return; + var fiber = findByTestId(r.current); + if (!fiber) return; + var cur = fiber; + while (cur) { + if (cur.memoizedProps && typeof cur.memoizedProps.onChangeText === 'function') { + cur.memoizedProps.onChangeText(${JSON.stringify(value)}); + result = { ok: true, testId: ${JSON.stringify(testId)}, value: ${JSON.stringify(value)} }; + return; + } + cur = cur.return || null; + } + }); + if (result) return result; + } + return { ok: false, error: 'No component with testID=' + ${JSON.stringify(testId)} + ' found or no onChangeText' }; + })()`; + const result = await cdpEval(client, expr); + return { ...result, testId, value, deviceName }; + }, + async 'sentry-debug'(client, args, { deviceName } = {}) { const action = args[0] || 'enable'; if (action === 'enable') { @@ -893,6 +944,7 @@ Commands: press-test-id Press a component by its testID prop scroll-view [--test-id ] [--offset ] [--animated] Scroll a ScrollView/FlatList + set-input Set text input value by testID (calls onChangeText) sentry-debug [enable|disable] Patch Sentry to log errors to console with [SENTRY-DEBUG] prefix unlock Unlock wallet (inject password + press login button via fiber tree) recipe Run a recipe (e.g. perps/positions) diff --git a/scripts/perps/agentic/validate-recipe.sh b/scripts/perps/agentic/validate-recipe.sh index 32f4244c6ce..550f8d03886 100755 --- a/scripts/perps/agentic/validate-recipe.sh +++ b/scripts/perps/agentic/validate-recipe.sh @@ -161,8 +161,14 @@ while IFS= read -r sj; do case "$ACT" in navigate) TARGET=$(node -p "JSON.parse(process.argv[1]).target||''" "$sj") - echo " -> app-navigate.sh --no-screenshot $TARGET" - [ "$DRY" = false ] && bash "$SD/app-navigate.sh" --no-screenshot "$TARGET" >/dev/null 2>&1 + PARAMS=$(node -p "const p=JSON.parse(process.argv[1]).params;p?JSON.stringify(p):''" "$sj") + if [ -n "$PARAMS" ]; then + echo " -> app-navigate.sh --no-screenshot $TARGET ''" + [ "$DRY" = false ] && bash "$SD/app-navigate.sh" --no-screenshot "$TARGET" "$PARAMS" >/dev/null 2>&1 + else + echo " -> app-navigate.sh --no-screenshot $TARGET" + [ "$DRY" = false ] && bash "$SD/app-navigate.sh" --no-screenshot "$TARGET" >/dev/null 2>&1 + fi ;; eval_sync) EXPR=$(node -p "JSON.parse(process.argv[1]).expression||''" "$sj") @@ -232,6 +238,42 @@ while IFS= read -r sj; do fi PASSED=$((PASSED + 1)); echo " ✅ PASS"; echo ""; continue ;; + press) + TEST_ID=$(node -p "JSON.parse(process.argv[1]).test_id||''" "$sj") + if [[ -z "$TEST_ID" ]]; then + RESULT='{"ok":false,"error":"press requires test_id"}' + else + echo " -> press-test-id $TEST_ID" + if [ "$DRY" = false ]; then + RESULT=$(node "$SD/cdp-bridge.js" press-test-id "$TEST_ID" 2>/dev/null) || RESULT='{"ok":false,"error":"press-test-id failed"}' + fi + fi + ;; + scroll) + SCROLL_ARGS=() + S_TID=$(node -p "JSON.parse(process.argv[1]).test_id||''" "$sj") + S_OFF=$(node -p "JSON.parse(process.argv[1]).offset??300" "$sj") + S_ANIM=$(node -p "JSON.parse(process.argv[1]).animated===true?'true':'false'" "$sj") + [[ -n "$S_TID" ]] && SCROLL_ARGS+=(--test-id "$S_TID") + SCROLL_ARGS+=(--offset "$S_OFF") + [[ "$S_ANIM" == "true" ]] && SCROLL_ARGS+=(--animated) || SCROLL_ARGS+=(--no-animated) + echo " -> scroll-view ${SCROLL_ARGS[*]}" + if [ "$DRY" = false ]; then + RESULT=$(node "$SD/cdp-bridge.js" scroll-view "${SCROLL_ARGS[@]}" 2>/dev/null) || RESULT='{"ok":false,"error":"scroll-view failed"}' + fi + ;; + set_input) + SI_TID=$(node -p "JSON.parse(process.argv[1]).test_id||''" "$sj") + SI_VAL=$(node -p "JSON.parse(process.argv[1]).value??''" "$sj") + if [[ -z "$SI_TID" ]]; then + RESULT='{"ok":false,"error":"set_input requires test_id"}' + else + echo " -> set-input $SI_TID \"${SI_VAL:0:40}\"" + if [ "$DRY" = false ]; then + RESULT=$(node "$SD/cdp-bridge.js" set-input "$SI_TID" "$SI_VAL" 2>/dev/null) || RESULT='{"ok":false,"error":"set-input failed"}' + fi + fi + ;; *) echo " ❌ FAIL: unknown action '$ACT'" FAILED=$((FAILED + 1)); echo ""; continue From 9104ee1f586ed25c2a5c8f7732d9fa2dbb3ea6fd Mon Sep 17 00:00:00 2001 From: Christopher Ferreira <104831203+christopherferreira9@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:08:01 +0000 Subject: [PATCH 051/206] test: refactor hardcoded testIds into dedicated files (#27380) ## **Description** This PR moves hardcoded testIds related to Ramps into dedicated .testIds.ts files. ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [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. ## **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** > Pure refactor of `testID` props into exported constants across Ramp/Deposit/Aggregator/native-flow UI; minimal runtime risk but can break E2E/unit tests if any selectors relied on removed inline strings or snapshots weren't updated. > > **Overview** > Refactors Ramp UI components to stop hardcoding `testID` strings inline and instead import them from new co-located `*.testIds.ts` files. > > Touches key Ramp surfaces (Aggregator checkout, quotes/custom actions/animations, Deposit + native-flow KYC/OTP/basic info/address/bank/order processing, and several modals/pills/list items) but keeps the underlying IDs the same; changes are primarily test-maintenance/consistency focused. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit d1256c10e927b65bf672d5bf9b2940bc2d167113. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../Views/Checkout/Checkout.testIds.ts | 4 ++++ .../Ramp/Aggregator/Views/Checkout/Checkout.tsx | 11 ++++++----- .../CustomAction/CustomAction.testIds.ts | 4 ++++ .../components/CustomAction/CustomAction.tsx | 8 ++++++-- .../components/Quote/Quote.testIds.ts | 4 ++++ .../Ramp/Aggregator/components/Quote/Quote.tsx | 8 ++++++-- .../ShapesBackgroundAnimation.testIds.ts | 3 +++ .../ShapesBackgroundAnimation/index.tsx | 3 ++- .../Views/BankDetails/BankDetails.testIds.ts | 4 ++++ .../Deposit/Views/BankDetails/BankDetails.tsx | 5 +++-- .../Views/BasicInfo/BasicInfo.testIds.ts | 9 +++++++++ .../Ramp/Deposit/Views/BasicInfo/BasicInfo.tsx | 15 ++++++++------- .../Views/EnterAddress/EnterAddress.testIds.ts | 9 +++++++++ .../Deposit/Views/EnterAddress/EnterAddress.tsx | 17 +++++++++-------- .../KycProcessing/KycProcessing.testIds.ts | 3 +++ .../Views/KycProcessing/KycProcessing.tsx | 3 ++- .../OrderProcessing/OrderProcessing.testIds.ts | 3 +++ .../Views/OrderProcessing/OrderProcessing.tsx | 3 ++- .../Deposit/Views/OtpCode/OtpCode.testIds.ts | 5 +++++ .../UI/Ramp/Deposit/Views/OtpCode/OtpCode.tsx | 7 ++++--- .../VerifyIdentity/VerifyIdentity.testIds.ts | 4 ++++ .../Views/VerifyIdentity/VerifyIdentity.tsx | 5 +++-- .../BankDetailRow/BankDetailRow.testIds.ts | 3 +++ .../components/BankDetailRow/BankDetailRow.tsx | 3 ++- .../DepositPhoneField.testIds.ts | 3 +++ .../DepositPhoneField/DepositPhoneField.tsx | 3 ++- .../DepositProgressBar.testIds.ts | 4 ++++ .../DepositProgressBar/DepositProgressBar.tsx | 8 ++++++-- .../Ramp/Views/BuildQuote/BuildQuote.testIds.ts | 4 ++++ .../UI/Ramp/Views/BuildQuote/BuildQuote.tsx | 5 +++-- .../UI/Ramp/Views/Checkout/Checkout.testIds.ts | 4 ++++ .../UI/Ramp/Views/Checkout/Checkout.tsx | 5 +++-- .../ProcessingInfoModal.testIds.ts | 4 ++++ .../ProcessingInfoModal/ProcessingInfoModal.tsx | 7 +++++-- .../TokenNotAvailableModal.testIds.ts | 6 ++++++ .../TokenNotAvailableModal.tsx | 11 +++++++---- .../Views/NativeFlow/BankDetails.testIds.ts | 4 ++++ .../UI/Ramp/Views/NativeFlow/BankDetails.tsx | 5 +++-- .../Ramp/Views/NativeFlow/BasicInfo.testIds.ts | 9 +++++++++ .../UI/Ramp/Views/NativeFlow/BasicInfo.tsx | 15 ++++++++------- .../Views/NativeFlow/EnterAddress.testIds.ts | 9 +++++++++ .../UI/Ramp/Views/NativeFlow/EnterAddress.tsx | 15 ++++++++------- .../Views/NativeFlow/KycProcessing.testIds.ts | 3 +++ .../UI/Ramp/Views/NativeFlow/KycProcessing.tsx | 3 ++- .../Views/NativeFlow/OrderProcessing.testIds.ts | 3 +++ .../Ramp/Views/NativeFlow/OrderProcessing.tsx | 3 ++- .../RegionSelector/RegionSelector.testIds.ts | 4 ++++ .../Settings/RegionSelector/RegionSelector.tsx | 8 ++++++-- .../EligibilityFailedModal.testIds.ts | 4 ++++ .../EligibilityFailedModal.tsx | 7 +++++-- .../PaymentMethodPill.testIds.ts | 3 +++ .../PaymentMethodPill/PaymentMethodPill.tsx | 3 ++- .../QuickAmounts/QuickAmounts.testIds.ts | 4 ++++ .../components/QuickAmounts/QuickAmounts.tsx | 3 ++- .../RampUnsupportedModal.testIds.ts | 4 ++++ .../RampUnsupportedModal.tsx | 7 +++++-- .../TokenListItem/TokenListItem.testIds.ts | 4 ++++ .../components/TokenListItem/TokenListItem.tsx | 5 +++-- 58 files changed, 256 insertions(+), 76 deletions(-) create mode 100644 app/components/UI/Ramp/Aggregator/Views/Checkout/Checkout.testIds.ts create mode 100644 app/components/UI/Ramp/Aggregator/components/CustomAction/CustomAction.testIds.ts create mode 100644 app/components/UI/Ramp/Aggregator/components/Quote/Quote.testIds.ts create mode 100644 app/components/UI/Ramp/Aggregator/components/ShapesBackgroundAnimation/ShapesBackgroundAnimation.testIds.ts create mode 100644 app/components/UI/Ramp/Deposit/Views/BankDetails/BankDetails.testIds.ts create mode 100644 app/components/UI/Ramp/Deposit/Views/BasicInfo/BasicInfo.testIds.ts create mode 100644 app/components/UI/Ramp/Deposit/Views/EnterAddress/EnterAddress.testIds.ts create mode 100644 app/components/UI/Ramp/Deposit/Views/KycProcessing/KycProcessing.testIds.ts create mode 100644 app/components/UI/Ramp/Deposit/Views/OrderProcessing/OrderProcessing.testIds.ts create mode 100644 app/components/UI/Ramp/Deposit/Views/OtpCode/OtpCode.testIds.ts create mode 100644 app/components/UI/Ramp/Deposit/Views/VerifyIdentity/VerifyIdentity.testIds.ts create mode 100644 app/components/UI/Ramp/Deposit/components/BankDetailRow/BankDetailRow.testIds.ts create mode 100644 app/components/UI/Ramp/Deposit/components/DepositPhoneField/DepositPhoneField.testIds.ts create mode 100644 app/components/UI/Ramp/Deposit/components/DepositProgressBar/DepositProgressBar.testIds.ts create mode 100644 app/components/UI/Ramp/Views/BuildQuote/BuildQuote.testIds.ts create mode 100644 app/components/UI/Ramp/Views/Checkout/Checkout.testIds.ts create mode 100644 app/components/UI/Ramp/Views/Modals/ProcessingInfoModal/ProcessingInfoModal.testIds.ts create mode 100644 app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.testIds.ts create mode 100644 app/components/UI/Ramp/Views/NativeFlow/BankDetails.testIds.ts create mode 100644 app/components/UI/Ramp/Views/NativeFlow/BasicInfo.testIds.ts create mode 100644 app/components/UI/Ramp/Views/NativeFlow/EnterAddress.testIds.ts create mode 100644 app/components/UI/Ramp/Views/NativeFlow/KycProcessing.testIds.ts create mode 100644 app/components/UI/Ramp/Views/NativeFlow/OrderProcessing.testIds.ts create mode 100644 app/components/UI/Ramp/Views/Settings/RegionSelector/RegionSelector.testIds.ts create mode 100644 app/components/UI/Ramp/components/EligibilityFailedModal/EligibilityFailedModal.testIds.ts create mode 100644 app/components/UI/Ramp/components/PaymentMethodPill/PaymentMethodPill.testIds.ts create mode 100644 app/components/UI/Ramp/components/QuickAmounts/QuickAmounts.testIds.ts create mode 100644 app/components/UI/Ramp/components/RampUnsupportedModal/RampUnsupportedModal.testIds.ts create mode 100644 app/components/UI/Ramp/components/TokenListItem/TokenListItem.testIds.ts diff --git a/app/components/UI/Ramp/Aggregator/Views/Checkout/Checkout.testIds.ts b/app/components/UI/Ramp/Aggregator/Views/Checkout/Checkout.testIds.ts new file mode 100644 index 00000000000..ec7a04b5882 --- /dev/null +++ b/app/components/UI/Ramp/Aggregator/Views/Checkout/Checkout.testIds.ts @@ -0,0 +1,4 @@ +export const CHECKOUT_TEST_IDS = { + CLOSE_BUTTON: 'checkout-close-button', + WEBVIEW: 'checkout-webview', +} as const; diff --git a/app/components/UI/Ramp/Aggregator/Views/Checkout/Checkout.tsx b/app/components/UI/Ramp/Aggregator/Views/Checkout/Checkout.tsx index 64c54946b01..d2bf3b17d08 100644 --- a/app/components/UI/Ramp/Aggregator/Views/Checkout/Checkout.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/Checkout/Checkout.tsx @@ -42,6 +42,7 @@ import { useStyles } from '../../../../../../component-library/hooks'; import styleSheet from './Checkout.styles'; import Device from '../../../../../../util/device'; import { shouldStartLoadWithRequest } from '../../../../../../util/browser'; +import { CHECKOUT_TEST_IDS } from './Checkout.testIds'; interface CheckoutParams { url: string; @@ -201,7 +202,7 @@ const CheckoutWebView = () => { iconName={IconName.Close} size={ButtonIconSizes.Lg} iconColor={IconColor.Default} - testID="checkout-close-button" + testID={CHECKOUT_TEST_IDS.CLOSE_BUTTON} onPress={handleClosePress} /> } @@ -233,7 +234,7 @@ const CheckoutWebView = () => { iconName={IconName.Close} size={ButtonIconSizes.Lg} iconColor={IconColor.Default} - testID="checkout-close-button" + testID={CHECKOUT_TEST_IDS.CLOSE_BUTTON} onPress={handleClosePress} /> } @@ -272,7 +273,7 @@ const CheckoutWebView = () => { iconName={IconName.Close} size={ButtonIconSizes.Lg} iconColor={IconColor.Default} - testID="checkout-close-button" + testID={CHECKOUT_TEST_IDS.CLOSE_BUTTON} onPress={handleClosePress} /> } @@ -302,7 +303,7 @@ const CheckoutWebView = () => { onNavigationStateChange={handleNavigationStateChange} onShouldStartLoadWithRequest={handleShouldStartLoadWithRequest} userAgent={provider?.features?.buy?.userAgent ?? undefined} - testID="checkout-webview" + testID={CHECKOUT_TEST_IDS.WEBVIEW} /> ); @@ -321,7 +322,7 @@ const CheckoutWebView = () => { iconName={IconName.Close} size={ButtonIconSizes.Lg} iconColor={IconColor.Default} - testID="checkout-close-button" + testID={CHECKOUT_TEST_IDS.CLOSE_BUTTON} onPress={handleClosePress} /> } diff --git a/app/components/UI/Ramp/Aggregator/components/CustomAction/CustomAction.testIds.ts b/app/components/UI/Ramp/Aggregator/components/CustomAction/CustomAction.testIds.ts new file mode 100644 index 00000000000..b5c0e1d783d --- /dev/null +++ b/app/components/UI/Ramp/Aggregator/components/CustomAction/CustomAction.testIds.ts @@ -0,0 +1,4 @@ +export const CUSTOM_ACTION_TEST_IDS = { + ANIMATED_VIEW_OPACITY: 'animated-view-opacity', + ANIMATED_VIEW_HEIGHT: 'animated-view-height', +} as const; diff --git a/app/components/UI/Ramp/Aggregator/components/CustomAction/CustomAction.tsx b/app/components/UI/Ramp/Aggregator/components/CustomAction/CustomAction.tsx index e7ca9436a5d..64e94435c5a 100644 --- a/app/components/UI/Ramp/Aggregator/components/CustomAction/CustomAction.tsx +++ b/app/components/UI/Ramp/Aggregator/components/CustomAction/CustomAction.tsx @@ -23,6 +23,7 @@ import TagColored from '../../../../../../component-library/components-temp/TagC import styleSheet from './CustomAction.styles'; import { useStyles } from '../../../../../../component-library/hooks'; import ListItem from '../../../../../../component-library/components/List/ListItem'; +import { CUSTOM_ACTION_TEST_IDS } from './CustomAction.testIds'; interface Props { customAction: PaymentCustomAction; @@ -82,7 +83,10 @@ const CustomAction: React.FC = ({ })); return ( - + = ({ - - - + ) : ( + + + + + + + + + )} {strings('market_insights.footer_disclaimer')} diff --git a/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.tsx b/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.tsx index d2d33dee398..62fae8b6699 100644 --- a/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.tsx +++ b/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.tsx @@ -37,10 +37,13 @@ const MarketInsightsEntryCard: React.FC = ({ useEffect(() => { // End the trace started by the parent (AssetOverviewContent) to measure // how long it takes for the entry card to mount after navigation. - endTrace({ - name: TraceName.MarketInsightsEntryCardLoad, - id: caip19Id, - }); + // caip19Id is only provided when the parent started a matching trace. + if (caip19Id) { + endTrace({ + name: TraceName.MarketInsightsEntryCardLoad, + id: caip19Id, + }); + } }, [caip19Id]); return ( diff --git a/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.types.ts b/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.types.ts index ec95f84db6b..67b88ed2da3 100644 --- a/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.types.ts +++ b/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.types.ts @@ -8,8 +8,11 @@ export interface MarketInsightsEntryCardProps { timeAgo: string; /** Callback when the card is pressed to open the full view */ onPress: () => void; - /** The CAIP-19 asset ID, used to match the trace started by the parent */ - caip19Id: CaipAssetType; + /** The CAIP-19 asset ID, used to match the trace started by the parent. + * Optional, only provide this when a corresponding startTrace was initiated + * by the parent component (AssetOverviewContent in the token details flow). + */ + caip19Id?: CaipAssetType; /** Optional test ID */ testID?: string; } diff --git a/app/components/UI/MarketInsights/hooks/useMarketInsights.test.ts b/app/components/UI/MarketInsights/hooks/useMarketInsights.test.ts index f515dacb821..50feee32ebc 100644 --- a/app/components/UI/MarketInsights/hooks/useMarketInsights.test.ts +++ b/app/components/UI/MarketInsights/hooks/useMarketInsights.test.ts @@ -25,7 +25,7 @@ describe('useMarketInsights', () => { jest.useRealTimers(); }); - it('does not fetch when caip19Id is missing', () => { + it('does not fetch when assetIdentifier is missing', () => { const { result } = renderHook(() => useMarketInsights(undefined)); expect(mockFetchMarketInsights).not.toHaveBeenCalled(); @@ -99,4 +99,26 @@ describe('useMarketInsights', () => { expect(result.current.error).toBe('fetch failed'); expect(result.current.timeAgo).toBe(''); }); + + it('fetches using a perps market symbol as assetIdentifier', async () => { + const report = { + version: '1.0', + asset: 'eth', + generatedAt: '2026-02-17T11:55:00.000Z', + headline: 'ETH perpetuals update', + summary: 'Perps funding rates normalizing.', + trends: [], + sources: [], + }; + + mockFetchMarketInsights.mockResolvedValue(report); + + const { result } = renderHook(() => useMarketInsights('ETH', true)); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(mockFetchMarketInsights).toHaveBeenCalledWith('ETH'); + expect(result.current.report).toEqual(report); + expect(result.current.error).toBeNull(); + }); }); diff --git a/app/components/UI/MarketInsights/hooks/useMarketInsights.ts b/app/components/UI/MarketInsights/hooks/useMarketInsights.ts index b49f228397d..a60acfececf 100644 --- a/app/components/UI/MarketInsights/hooks/useMarketInsights.ts +++ b/app/components/UI/MarketInsights/hooks/useMarketInsights.ts @@ -21,22 +21,25 @@ export interface UseMarketInsightsResult { * Hook to fetch market insights for a given asset. * * This hook reads market insights through AiDigestController, which caches - * insights per CAIP-19 ID and fetches them from the digest service as needed. + * insights per asset identifier and fetches them from the digest service as needed. * - * @param caip19Id - The CAIP-19 asset identifier. + * @param assetIdentifier - The asset identifier: either a CAIP-19 ID (e.g. "eip155:1/slip44:60") + * or a perps market symbol (e.g. "ETH"). * @param isEnabled - Whether market insights requests are enabled. * @returns Market insights report data with loading/error states */ export const useMarketInsights = ( - caip19Id: string | undefined | null, + assetIdentifier: string | undefined | null, isEnabled = false, ): UseMarketInsightsResult => { const [report, setReport] = useState(null); - const [isLoading, setIsLoading] = useState(Boolean(isEnabled && caip19Id)); + const [isLoading, setIsLoading] = useState( + Boolean(isEnabled && assetIdentifier), + ); const [error, setError] = useState(null); const fetchInsights = useCallback(async () => { - if (!isEnabled || !caip19Id) { + if (!isEnabled || !assetIdentifier) { setReport(null); setError(null); setIsLoading(false); @@ -48,7 +51,9 @@ export const useMarketInsights = ( try { const data = - await Engine.context.AiDigestController.fetchMarketInsights(caip19Id); + await Engine.context.AiDigestController.fetchMarketInsights( + assetIdentifier, + ); setReport(data as MarketInsightsReport | null); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to fetch insights'); @@ -56,7 +61,7 @@ export const useMarketInsights = ( } finally { setIsLoading(false); } - }, [caip19Id, isEnabled]); + }, [assetIdentifier, isEnabled]); useEffect(() => { fetchInsights(); diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx index d94f56ac5ca..8af28c33329 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx @@ -50,7 +50,7 @@ import Routes from '../../../../../constants/navigation/Routes'; import Engine from '../../../../../core/Engine'; import Logger from '../../../../../util/Logger'; import { isNotificationsFeatureEnabled } from '../../../../../util/notifications'; -import { TraceName } from '../../../../../util/trace'; +import { trace, TraceName, TraceOperation } from '../../../../../util/trace'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; import ComponentErrorBoundary from '../../../ComponentErrorBoundary'; import PerpsBottomSheetTooltip from '../../components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip'; @@ -122,6 +122,11 @@ import { selectPerpsButtonColorTestVariant, selectPerpsOrderBookEnabledFlag, } from '../../selectors/featureFlags'; +import { + MarketInsightsEntryCard, + useMarketInsights, +} from '../../../MarketInsights'; +import { selectMarketInsightsPerpsEnabled } from '../../../../../selectors/featureFlagController/marketInsights'; import { createSelectIsWatchlistMarket, selectPerpsEligibility, @@ -222,6 +227,11 @@ const PerpsMarketDetailsView: React.FC = () => { // Feature flag for Order Book visibility const isOrderBookEnabled = useSelector(selectPerpsOrderBookEnabledFlag); + // Feature flag for Market Insights in Perps + const isPerpsInsightsEnabled = useSelector(selectMarketInsightsPerpsEnabled); + const { report: perpsInsightsReport, timeAgo: perpsInsightsTimeAgo } = + useMarketInsights(market?.symbol, isPerpsInsightsEnabled); + // Check if current market is in watchlist const selectIsWatchlist = useMemo( () => createSelectIsWatchlistMarket(market?.symbol || ''), @@ -1005,6 +1015,20 @@ const PerpsMarketDetailsView: React.FC = () => { handleBannerDismissComplete(); }, [handleBannerDismissComplete]); + // Handler for market insights card tap - navigates to full market insights view + const handleMarketInsightsPress = useCallback(() => { + if (!market?.symbol) return; + trace({ + name: TraceName.MarketInsightsViewLoad, + op: TraceOperation.MarketInsightsLoad, + }); + navigation.navigate(Routes.MARKET_INSIGHTS.VIEW, { + assetSymbol: market.symbol, + assetIdentifier: market.symbol, + isPerps: true, + }); + }, [market?.symbol, navigation]); + // Handler for order selection - navigates to order details const handleOrderSelect = useCallback( (order: (typeof displayOrders)[number]) => { @@ -1299,6 +1323,15 @@ const PerpsMarketDetailsView: React.FC = () => { )} + {/* Market Insights Section - shown when perps insights flag is enabled and a report is available */} + {isPerpsInsightsEnabled && perpsInsightsReport && market?.symbol ? ( + + ) : null} + {/* Statistics Section - Always shown */} = ({ navigation.navigate(Routes.MARKET_INSIGHTS.VIEW, { assetSymbol: token.symbol, - caip19Id: marketInsightsCaip19Id, + assetIdentifier: marketInsightsCaip19Id, tokenImageUrl: token.image || token.logo, pricePercentChange: percentChange, // Pass token data needed for swap navigation diff --git a/app/selectors/featureFlagController/marketInsights/index.ts b/app/selectors/featureFlagController/marketInsights/index.ts index bbcf9afbcc0..0a7cecdf761 100644 --- a/app/selectors/featureFlagController/marketInsights/index.ts +++ b/app/selectors/featureFlagController/marketInsights/index.ts @@ -14,3 +14,5 @@ export const selectMarketInsightsEnabled = createSelector( return validatedVersionGatedFeatureFlag(remoteFlag) ?? false; }, ); + +export { selectMarketInsightsPerpsEnabled } from './perps'; diff --git a/app/selectors/featureFlagController/marketInsights/perps.test.ts b/app/selectors/featureFlagController/marketInsights/perps.test.ts new file mode 100644 index 00000000000..e05c4162ac5 --- /dev/null +++ b/app/selectors/featureFlagController/marketInsights/perps.test.ts @@ -0,0 +1,70 @@ +import { selectMarketInsightsPerpsEnabled } from './perps'; +// eslint-disable-next-line import/no-namespace +import * as remoteFeatureFlagModule from '../../../util/remoteFeatureFlag'; + +jest.mock('react-native-device-info', () => ({ + getVersion: jest.fn().mockReturnValue('1.0.0'), +})); + +describe('selectMarketInsightsPerpsEnabled', () => { + let mockHasMinimumRequiredVersion: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + mockHasMinimumRequiredVersion = jest.spyOn( + remoteFeatureFlagModule, + 'hasMinimumRequiredVersion', + ); + mockHasMinimumRequiredVersion.mockReturnValue(true); + }); + + afterEach(() => { + mockHasMinimumRequiredVersion?.mockRestore(); + }); + + it('returns true when remote flag is enabled and version requirement is met', () => { + const result = selectMarketInsightsPerpsEnabled.resultFunc({ + aiSocialMarketInsightsPerpsEnabled: { + enabled: true, + minimumVersion: '1.0.0', + }, + }); + expect(result).toBe(true); + }); + + it('returns false when remote flag is disabled', () => { + const result = selectMarketInsightsPerpsEnabled.resultFunc({ + aiSocialMarketInsightsPerpsEnabled: { + enabled: false, + minimumVersion: '1.0.0', + }, + }); + expect(result).toBe(false); + }); + + it('returns false when version requirement is not met', () => { + mockHasMinimumRequiredVersion.mockReturnValue(false); + const result = selectMarketInsightsPerpsEnabled.resultFunc({ + aiSocialMarketInsightsPerpsEnabled: { + enabled: true, + minimumVersion: '99.0.0', + }, + }); + expect(result).toBe(false); + }); + + it('returns false when remote flag is invalid', () => { + const result = selectMarketInsightsPerpsEnabled.resultFunc({ + aiSocialMarketInsightsPerpsEnabled: { + enabled: 'invalid', + minimumVersion: 123, + }, + }); + expect(result).toBe(false); + }); + + it('returns false when remote feature flags are empty', () => { + const result = selectMarketInsightsPerpsEnabled.resultFunc({}); + expect(result).toBe(false); + }); +}); diff --git a/app/selectors/featureFlagController/marketInsights/perps.ts b/app/selectors/featureFlagController/marketInsights/perps.ts new file mode 100644 index 00000000000..1749de20af2 --- /dev/null +++ b/app/selectors/featureFlagController/marketInsights/perps.ts @@ -0,0 +1,16 @@ +import { createSelector } from 'reselect'; +import { selectRemoteFeatureFlags } from '..'; +import { + VersionGatedFeatureFlag, + validatedVersionGatedFeatureFlag, +} from '../../../util/remoteFeatureFlag'; + +export const selectMarketInsightsPerpsEnabled = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => { + const remoteFlag = + remoteFeatureFlags?.aiSocialMarketInsightsPerpsEnabled as unknown as VersionGatedFeatureFlag; + + return validatedVersionGatedFeatureFlag(remoteFlag) ?? false; + }, +); diff --git a/package.json b/package.json index 0b7398dba73..0237533d8ac 100644 --- a/package.json +++ b/package.json @@ -204,7 +204,7 @@ "@metamask/account-tree-controller": "^5.0.0", "@metamask/accounts-controller": "^37.0.0", "@metamask/address-book-controller": "^7.0.1", - "@metamask/ai-controllers": "^0.2.0", + "@metamask/ai-controllers": "^0.3.0", "@metamask/analytics-controller": "^1.0.0", "@metamask/app-metadata-controller": "^2.0.0", "@metamask/approval-controller": "^8.0.0", diff --git a/tests/feature-flags/feature-flag-registry.ts b/tests/feature-flags/feature-flag-registry.ts index b3856d3ae16..4ec5c6f3312 100644 --- a/tests/feature-flags/feature-flag-registry.ts +++ b/tests/feature-flags/feature-flag-registry.ts @@ -93,6 +93,17 @@ export const FEATURE_FLAG_REGISTRY: Record = { status: FeatureFlagStatus.Active, }, + aiSocialMarketInsightsPerpsEnabled: { + name: 'aiSocialMarketInsightsPerpsEnabled', + type: FeatureFlagType.Remote, + inProd: false, + productionDefault: { + enabled: false, + minimumVersion: '7.70.0', + }, + status: FeatureFlagStatus.Active, + }, + aiSocialWhatsHappeningEnabled: { name: 'aiSocialWhatsHappeningEnabled', type: FeatureFlagType.Remote, diff --git a/yarn.lock b/yarn.lock index 43c9ba6f879..408124f237f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7622,15 +7622,15 @@ __metadata: languageName: node linkType: hard -"@metamask/ai-controllers@npm:^0.2.0": - version: 0.2.0 - resolution: "@metamask/ai-controllers@npm:0.2.0" +"@metamask/ai-controllers@npm:^0.3.0": + version: 0.3.0 + resolution: "@metamask/ai-controllers@npm:0.3.0" dependencies: "@metamask/base-controller": "npm:^9.0.0" "@metamask/messenger": "npm:^0.3.0" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.9.0" - checksum: 10/02a8088036e2e7e70ac94a6a7617df28738500696e37a67f4f71adaa277969afb91a6fd7d6b001f5d2c015d6ea3b97d67e300eb5ed8bf1e739103b7f85c81688 + checksum: 10/a1a4655243a5de34378aad291a34a1b5bf441b186b9e541083bc870ee65bb3244c31e45d84ec3a24bcf8f36452408f3518445939bd450304feaacfcadffb51f5 languageName: node linkType: hard @@ -35353,7 +35353,7 @@ __metadata: "@metamask/account-tree-controller": "npm:^5.0.0" "@metamask/accounts-controller": "npm:^37.0.0" "@metamask/address-book-controller": "npm:^7.0.1" - "@metamask/ai-controllers": "npm:^0.2.0" + "@metamask/ai-controllers": "npm:^0.3.0" "@metamask/analytics-controller": "npm:^1.0.0" "@metamask/app-metadata-controller": "npm:^2.0.0" "@metamask/approval-controller": "npm:^8.0.0" From 8482f6c88ac92f9acad99e27b6f316751d92cfab Mon Sep 17 00:00:00 2001 From: Nick Gambino Date: Tue, 17 Mar 2026 11:51:55 -0700 Subject: [PATCH 063/206] fix: fix stop loss banner debounce (#27458) ## **Description** Simplifying debounce logic to allow the stop loss banner to be shown predictably. ## **Changelog** CHANGELOG entry: fix stop loss banner rendering issue ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TAT-2323 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** https://github.com/user-attachments/assets/2c52c315-1ac7-498d-9ec7-888b99afc78a ## **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. ## **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 the stop-loss prompt banner trigger logic, which can alter when risk-management guidance appears for active positions; incorrect thresholds/timing could cause missing or premature prompts. > > **Overview** > Fixes stop-loss prompt banner timing by **removing the ROE debounce requirement** and showing the `stop_loss` variant immediately once ROE is below the configured threshold (still gated by the 2-minute minimum position age). > > Simplifies the age gating by using `positionOpenedTimestamp` only to **bypass the client-side age wait** for already-old positions, and removes the now-unused `RoeDebounceMs` config and associated tests/logic updates. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 77a60a9222d1622c7f9c7902139587e0758f6c41. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../PerpsMarketDetailsView.tsx | 1 - .../UI/Perps/constants/perpsConfig.ts | 5 - .../UI/Perps/hooks/useStopLossPrompt.test.ts | 183 ++++++------------ .../UI/Perps/hooks/useStopLossPrompt.ts | 117 +++-------- 4 files changed, 83 insertions(+), 223 deletions(-) diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx index 8af28c33329..efbb8cc5b70 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx @@ -406,7 +406,6 @@ const PerpsMarketDetailsView: React.FC = () => { }, [candleData, selectedCandlePeriod, visibleCandleCount]); // Check if user has an existing position for this market - // Also provides positionOpenedTimestamp for stop loss prompt timing const { isLoading: isLoadingPosition, existingPosition, diff --git a/app/components/UI/Perps/constants/perpsConfig.ts b/app/components/UI/Perps/constants/perpsConfig.ts index 142186148b0..d023148f711 100644 --- a/app/components/UI/Perps/constants/perpsConfig.ts +++ b/app/components/UI/Perps/constants/perpsConfig.ts @@ -243,11 +243,6 @@ export const STOP_LOSS_PROMPT_CONFIG = { // No banner shown until ROE drops below this value MinLossThreshold: -10, - // Debounce duration for ROE threshold (milliseconds) - // User must have ROE below threshold for this duration before showing banner - // Prevents banner from appearing during temporary price fluctuations - RoeDebounceMs: 60_000, // 60 seconds - // Minimum position age before showing any banner (milliseconds) // Prevents banner from appearing immediately after opening a position PositionMinAgeMs: 120_000, // 2 minutes diff --git a/app/components/UI/Perps/hooks/useStopLossPrompt.test.ts b/app/components/UI/Perps/hooks/useStopLossPrompt.test.ts index fcafa5854c7..7921f54d136 100644 --- a/app/components/UI/Perps/hooks/useStopLossPrompt.test.ts +++ b/app/components/UI/Perps/hooks/useStopLossPrompt.test.ts @@ -3,7 +3,7 @@ import { useStopLossPrompt } from './useStopLossPrompt'; import { type Position } from '@metamask/perps-controller'; import { STOP_LOSS_PROMPT_CONFIG } from '../constants/perpsConfig'; -// Mock timers for debounce testing +// Mock timers for position age testing jest.useFakeTimers(); describe('useStopLossPrompt', () => { @@ -191,7 +191,7 @@ describe('useStopLossPrompt', () => { }); describe('stop_loss variant', () => { - it('shows stop_loss variant after both position age and ROE debounce requirements are met', () => { + it('shows stop_loss variant after position age requirement is met', () => { const position = createMockPosition({ returnOnEquity: '-0.15', // -15% ROE (below -10% threshold) liquidationPrice: '40000', // Far from liquidation @@ -204,26 +204,21 @@ describe('useStopLossPrompt', () => { }), ); - // Initially should not show (debounce not complete) + // Initially should not show (position age not met) expect(result.current.shouldShowBanner).toBe(false); - // Explicitly advance past BOTH position age AND ROE debounce requirements - // Both timers must complete for the banner to show - const requiredTime = - Math.max( - STOP_LOSS_PROMPT_CONFIG.RoeDebounceMs, - STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs, - ) + 100; - + // Advance past position age requirement act(() => { - jest.advanceTimersByTime(requiredTime); + jest.advanceTimersByTime( + STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs + 100, + ); }); expect(result.current.shouldShowBanner).toBe(true); expect(result.current.variant).toBe('stop_loss'); }); - it('does not show stop_loss variant if ROE recovers before debounce', () => { + it('hides stop_loss variant when ROE recovers above threshold', () => { const position = createMockPosition({ returnOnEquity: '-0.15', // -15% ROE (below -10% threshold) liquidationPrice: '40000', @@ -238,11 +233,15 @@ describe('useStopLossPrompt', () => { { initialProps: { pos: position } }, ); - // Fast-forward halfway through debounce + // Fast-forward past position age requirement act(() => { - jest.advanceTimersByTime(STOP_LOSS_PROMPT_CONFIG.RoeDebounceMs / 2); + jest.advanceTimersByTime( + STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs + 100, + ); }); + expect(result.current.shouldShowBanner).toBe(true); + // ROE recovers const recoveredPosition = createMockPosition({ returnOnEquity: '-0.05', // -5% ROE (above threshold) @@ -251,11 +250,6 @@ describe('useStopLossPrompt', () => { rerender({ pos: recoveredPosition }); - // Fast-forward past original debounce time - act(() => { - jest.advanceTimersByTime(STOP_LOSS_PROMPT_CONFIG.RoeDebounceMs); - }); - expect(result.current.shouldShowBanner).toBe(false); }); }); @@ -267,12 +261,12 @@ describe('useStopLossPrompt', () => { jest.setSystemTime(new Date('2024-01-01T12:00:00.000Z')); }); - it('bypasses debounce immediately when position is older than 2 minutes and ROE is below threshold', async () => { + it('bypasses position age wait when position is older than 2 minutes', async () => { const now = Date.now(); - const positionOpenedTimestamp = now - POSITION_AGE_THRESHOLD_MS - 1000; // 2 minutes 1 second ago + const positionOpenedTimestamp = now - POSITION_AGE_THRESHOLD_MS - 1000; const position = createMockPosition({ - returnOnEquity: '-0.15', // -15% ROE (below -10% threshold) - liquidationPrice: '40000', // Far from liquidation + returnOnEquity: '-0.15', + liquidationPrice: '40000', }); const { result } = renderHook(() => @@ -283,21 +277,19 @@ describe('useStopLossPrompt', () => { }), ); - // Flush effects to allow timestamp bypass to run await act(async () => { jest.runAllTimers(); }); - // Shows after effects run (server timestamp bypasses debounce) expect(result.current.shouldShowBanner).toBe(true); expect(result.current.variant).toBe('stop_loss'); }); - it('does not bypass debounce when position is less than 2 minutes old', () => { + it('does not bypass when position is less than 2 minutes old', () => { const now = Date.now(); - const positionOpenedTimestamp = now - POSITION_AGE_THRESHOLD_MS + 1000; // 1 minute 59 seconds ago + const positionOpenedTimestamp = now - POSITION_AGE_THRESHOLD_MS + 1000; const position = createMockPosition({ - returnOnEquity: '-0.15', // -15% ROE (below -10% threshold) + returnOnEquity: '-0.15', liquidationPrice: '40000', }); @@ -309,37 +301,24 @@ describe('useStopLossPrompt', () => { }), ); - // Should NOT show immediately (position too new) - expect(result.current.shouldShowBanner).toBe(false); - - // Should still require full debounce period AND position age - // Need to wait for max of both: RoeDebounceMs (60s) and PositionMinAgeMs (120s) - const requiredTime = - Math.max( - STOP_LOSS_PROMPT_CONFIG.RoeDebounceMs, - STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs, - ) + 100; - - act(() => { - jest.advanceTimersByTime(requiredTime - 200); - }); - expect(result.current.shouldShowBanner).toBe(false); - // After full time passes, should show + // Still requires client-side position age timer act(() => { - jest.advanceTimersByTime(200); + jest.advanceTimersByTime( + STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs + 100, + ); }); expect(result.current.shouldShowBanner).toBe(true); expect(result.current.variant).toBe('stop_loss'); }); - it('does not bypass debounce when ROE is above threshold even if position is old', () => { + it('does not show banner when ROE is above threshold even if position is old', () => { const now = Date.now(); - const positionOpenedTimestamp = now - POSITION_AGE_THRESHOLD_MS - 1000; // 2 minutes 1 second ago + const positionOpenedTimestamp = now - POSITION_AGE_THRESHOLD_MS - 1000; const position = createMockPosition({ - returnOnEquity: '-0.05', // -5% ROE (above -10% threshold) + returnOnEquity: '-0.05', liquidationPrice: '40000', }); @@ -351,10 +330,8 @@ describe('useStopLossPrompt', () => { }), ); - // Should NOT show (ROE above threshold) expect(result.current.shouldShowBanner).toBe(false); - // Even after time passes, should not show act(() => { jest.advanceTimersByTime(10000); }); @@ -362,11 +339,11 @@ describe('useStopLossPrompt', () => { expect(result.current.shouldShowBanner).toBe(false); }); - it('bypasses debounce when position is exactly 2 minutes old', async () => { + it('bypasses when position is exactly 2 minutes old', async () => { const now = Date.now(); - const positionOpenedTimestamp = now - POSITION_AGE_THRESHOLD_MS; // Exactly 2 minutes ago + const positionOpenedTimestamp = now - POSITION_AGE_THRESHOLD_MS; const position = createMockPosition({ - returnOnEquity: '-0.15', // -15% ROE (below -10% threshold) + returnOnEquity: '-0.15', liquidationPrice: '40000', }); @@ -378,21 +355,19 @@ describe('useStopLossPrompt', () => { }), ); - // Flush effects to allow timestamp bypass to run await act(async () => { jest.runAllTimers(); }); - // Shows after effects run (exactly at threshold) expect(result.current.shouldShowBanner).toBe(true); expect(result.current.variant).toBe('stop_loss'); }); - it('bypasses debounce only once per position lifecycle', async () => { + it('keeps showing banner when position updates but still below threshold', async () => { const now = Date.now(); const positionOpenedTimestamp = now - POSITION_AGE_THRESHOLD_MS - 1000; const position = createMockPosition({ - returnOnEquity: '-0.15', // -15% ROE (below -10% threshold) + returnOnEquity: '-0.15', liquidationPrice: '40000', }); @@ -411,31 +386,27 @@ describe('useStopLossPrompt', () => { }, ); - // Flush effects to allow timestamp bypass to run await act(async () => { jest.runAllTimers(); }); - // Shows after effects run expect(result.current.shouldShowBanner).toBe(true); expect(result.current.variant).toBe('stop_loss'); - // Simulate position update (ROE changes but still below threshold) const updatedPosition = createMockPosition({ - returnOnEquity: '-0.12', // Still below threshold + returnOnEquity: '-0.12', liquidationPrice: '40000', }); rerender({ pos: updatedPosition, timestamp: positionOpenedTimestamp }); - // Still shows (bypass already happened) expect(result.current.shouldShowBanner).toBe(true); expect(result.current.variant).toBe('stop_loss'); }); - it('does not bypass when positionOpenedTimestamp is undefined', () => { + it('falls back to client-side timer when positionOpenedTimestamp is undefined', () => { const position = createMockPosition({ - returnOnEquity: '-0.15', // -15% ROE (below -10% threshold) + returnOnEquity: '-0.15', liquidationPrice: '40000', }); @@ -447,26 +418,19 @@ describe('useStopLossPrompt', () => { }), ); - // Should NOT show immediately (no timestamp provided) expect(result.current.shouldShowBanner).toBe(false); - // Should require full debounce period AND position age - // Need to wait for max of both: RoeDebounceMs (60s) and PositionMinAgeMs (120s) - const requiredTime = - Math.max( - STOP_LOSS_PROMPT_CONFIG.RoeDebounceMs, - STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs, - ) + 100; - act(() => { - jest.advanceTimersByTime(requiredTime); + jest.advanceTimersByTime( + STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs + 100, + ); }); expect(result.current.shouldShowBanner).toBe(true); expect(result.current.variant).toBe('stop_loss'); }); - it('resets bypass state when position is closed', async () => { + it('resets when position is closed and reopened', async () => { const now = Date.now(); const positionOpenedTimestamp = now - POSITION_AGE_THRESHOLD_MS - 1000; const position = createMockPosition({ @@ -489,28 +453,22 @@ describe('useStopLossPrompt', () => { }, ); - // Flush effects to allow timestamp bypass to run await act(async () => { jest.runAllTimers(); }); - // Shows after effects run expect(result.current.shouldShowBanner).toBe(true); - // Close position rerender({ pos: null, timestamp: undefined }); expect(result.current.shouldShowBanner).toBe(false); - // Reopen position with same timestamp rerender({ pos: position, timestamp: positionOpenedTimestamp }); - // Flush effects again for the reopened position await act(async () => { jest.runAllTimers(); }); - // Shows again (state was reset) expect(result.current.shouldShowBanner).toBe(true); expect(result.current.variant).toBe('stop_loss'); }); @@ -532,7 +490,6 @@ describe('useStopLossPrompt', () => { }), ); - // Should NOT show (hook disabled) expect(result.current.shouldShowBanner).toBe(false); act(() => { @@ -929,15 +886,11 @@ describe('useStopLossPrompt', () => { { initialProps: { pos: position } }, ); - // Fast-forward past both age and debounce requirements - const requiredTime = - Math.max( - STOP_LOSS_PROMPT_CONFIG.RoeDebounceMs, - STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs, - ) + 100; - + // Fast-forward past position age requirement act(() => { - jest.advanceTimersByTime(requiredTime); + jest.advanceTimersByTime( + STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs + 100, + ); }); expect(result.current.shouldShowBanner).toBe(true); @@ -1103,15 +1056,11 @@ describe('useStopLossPrompt', () => { }), ); - // Fast-forward past both position age and debounce requirements - const requiredTime = - Math.max( - STOP_LOSS_PROMPT_CONFIG.RoeDebounceMs, - STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs, - ) + 100; - + // Fast-forward past position age requirement act(() => { - jest.advanceTimersByTime(requiredTime); + jest.advanceTimersByTime( + STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs + 100, + ); }); // Midpoint = (50000 + 47500) / 2 = 48750 @@ -1139,15 +1088,11 @@ describe('useStopLossPrompt', () => { }), ); - // Fast-forward past both position age and debounce requirements - const requiredTime = - Math.max( - STOP_LOSS_PROMPT_CONFIG.RoeDebounceMs, - STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs, - ) + 100; - + // Fast-forward past position age requirement act(() => { - jest.advanceTimersByTime(requiredTime); + jest.advanceTimersByTime( + STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs + 100, + ); }); // Midpoint = (50000 + 40000) / 2 = 45000 @@ -1176,15 +1121,11 @@ describe('useStopLossPrompt', () => { }), ); - // Fast-forward past both position age and debounce requirements - const requiredTime = - Math.max( - STOP_LOSS_PROMPT_CONFIG.RoeDebounceMs, - STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs, - ) + 100; - + // Fast-forward past position age requirement act(() => { - jest.advanceTimersByTime(requiredTime); + jest.advanceTimersByTime( + STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs + 100, + ); }); // Midpoint = (50000 + 47000) / 2 = 48500 @@ -1212,15 +1153,11 @@ describe('useStopLossPrompt', () => { }), ); - // Fast-forward past both position age and debounce requirements - const requiredTime = - Math.max( - STOP_LOSS_PROMPT_CONFIG.RoeDebounceMs, - STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs, - ) + 100; - + // Fast-forward past position age requirement act(() => { - jest.advanceTimersByTime(requiredTime); + jest.advanceTimersByTime( + STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs + 100, + ); }); // Without a valid liquidation price, we can't calculate a stop loss price diff --git a/app/components/UI/Perps/hooks/useStopLossPrompt.ts b/app/components/UI/Perps/hooks/useStopLossPrompt.ts index 0ff5e0f45bc..879ff9c3692 100644 --- a/app/components/UI/Perps/hooks/useStopLossPrompt.ts +++ b/app/components/UI/Perps/hooks/useStopLossPrompt.ts @@ -24,7 +24,7 @@ export interface UseStopLossPromptParams { currentPrice: number; /** Enable/disable the hook (default: true) */ enabled?: boolean; - /** Timestamp when position was opened (from order fills) - bypasses debounce if position is >2min old */ + /** Timestamp when position was opened (from order fills) - bypasses client-side age wait for old positions */ positionOpenedTimestamp?: number; } @@ -52,8 +52,9 @@ export interface UseStopLossPromptResult { * * Implements the logic from TASK_AUTOSET.md: * - Shows "add_margin" variant when within 3% of liquidation - * - Shows "stop_loss" variant when ROE <= -10% for 60s (debounced) + * - Shows "stop_loss" variant when ROE <= -10% * - Suppresses when position has cross margin or existing stop loss + * - Suppresses for the first 2 minutes after a position is detected * * @example * ```tsx @@ -65,7 +66,6 @@ export interface UseStopLossPromptResult { * } = useStopLossPrompt({ * position: existingPosition, * currentPrice: 50000, - * positionOpenedTimestamp: 1234567890000, // Optional: from order fills * }); * ``` */ @@ -75,11 +75,6 @@ export const useStopLossPrompt = ({ enabled = true, positionOpenedTimestamp, }: UseStopLossPromptParams): UseStopLossPromptResult => { - // Track when ROE first dropped below threshold for debouncing - const roeBelowThresholdSinceRef = useRef(null); - const hasBeenShownRef = useRef(false); - const [roeDebounceComplete, setRoeDebounceComplete] = useState(false); - // Track when the current position was first detected (client-side) // This is used to enforce the minimum position age requirement const positionFirstSeenRef = useRef<{ @@ -124,51 +119,27 @@ export const useStopLossPrompt = ({ return roeValue * 100; }, [position?.returnOnEquity]); - // Callback to finish debounce (from main - for server timestamp bypass) - const finishDebounce = useCallback(() => { - setRoeDebounceComplete(true); - hasBeenShownRef.current = true; - }, []); - - // Reset hasBeenShownRef when position changes (from main) - useEffect(() => { - hasBeenShownRef.current = false; - }, [position?.symbol, position?.liquidationPrice, position?.entryPrice]); - - // Server timestamp bypass effect (from main) - // If positionOpenedTimestamp shows position is >2 minutes old, bypass debounce AND position age check - useEffect(() => { - if (!enabled || roePercent === null || hasBeenShownRef.current) { - return; - } - - // Check if position was opened more than 2 minutes ago (from order fills timestamp) - const POSITION_AGE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes - const positionAge = positionOpenedTimestamp - ? Date.now() - positionOpenedTimestamp - : 0; - - const isBelowThreshold = roePercent <= STOP_LOSS_PROMPT_CONFIG.RoeThreshold; - - // If position is old enough (from actual order fill data), bypass both debounce and position age check - // Server timestamp is authoritative - no need to wait for client-side age tracking - if (positionAge >= POSITION_AGE_THRESHOLD_MS && isBelowThreshold) { - setPositionAgeCheckPassed(true); // Also bypass client-side age check - finishDebounce(); - } - }, [positionOpenedTimestamp, enabled, roePercent, finishDebounce]); - - // Handle client-side position age tracking (from HEAD) - // Track when a position is first detected and enforce minimum age before showing banners + // Handle position age tracking + // Positions must be open for PositionMinAgeMs before showing the banner. + // If positionOpenedTimestamp (from order fills) proves the position is already old enough, + // the check passes immediately — no client-side timer needed. useEffect(() => { if (!enabled || !position?.symbol) { - // Reset when disabled or no position positionFirstSeenRef.current = null; setPositionAgeCheckPassed(false); return; } - // Check if this is a new position (different symbol or first time seeing it) + // Server timestamp bypass: if order fills confirm the position is old enough, skip the wait + if (positionOpenedTimestamp) { + const positionAge = Date.now() - positionOpenedTimestamp; + if (positionAge >= STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs) { + setPositionAgeCheckPassed(true); + return; + } + } + + // Client-side fallback: track when this position was first seen on this screen if ( !positionFirstSeenRef.current || positionFirstSeenRef.current.symbol !== position.symbol @@ -180,12 +151,10 @@ export const useStopLossPrompt = ({ setPositionAgeCheckPassed(false); } - // Check if minimum age has passed const elapsed = Date.now() - positionFirstSeenRef.current.timestamp; if (elapsed >= STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs) { setPositionAgeCheckPassed(true); } else { - // Set up timer to check again when age threshold is reached const remainingTime = STOP_LOSS_PROMPT_CONFIG.PositionMinAgeMs - elapsed; const timer = setTimeout(() => { setPositionAgeCheckPassed(true); @@ -195,49 +164,7 @@ export const useStopLossPrompt = ({ } return undefined; - }, [enabled, position?.symbol]); - - // Handle ROE debounce logic - useEffect(() => { - if (!enabled || roePercent === null) { - roeBelowThresholdSinceRef.current = null; - setRoeDebounceComplete(false); - hasBeenShownRef.current = false; // Reset when position is closed - return; - } - - const isBelowThreshold = roePercent <= STOP_LOSS_PROMPT_CONFIG.RoeThreshold; - - if (isBelowThreshold) { - // Start tracking if not already - if (roeBelowThresholdSinceRef.current === null) { - roeBelowThresholdSinceRef.current = Date.now(); - } - - // Check if debounce period has passed - const elapsed = Date.now() - roeBelowThresholdSinceRef.current; - if (elapsed >= STOP_LOSS_PROMPT_CONFIG.RoeDebounceMs) { - finishDebounce(); - } else { - // Set up timer to check again - const remainingTime = STOP_LOSS_PROMPT_CONFIG.RoeDebounceMs - elapsed; - const timer = setTimeout(() => { - // Re-check if still below threshold - if (roeBelowThresholdSinceRef.current !== null) { - finishDebounce(); - } - }, remainingTime); - - return () => clearTimeout(timer); - } - } else { - // Reset tracking when ROE goes above threshold - roeBelowThresholdSinceRef.current = null; - setRoeDebounceComplete(false); - } - - return undefined; - }, [enabled, roePercent, position, positionOpenedTimestamp, finishDebounce]); + }, [enabled, position?.symbol, positionOpenedTimestamp]); // Calculate suggested stop loss price as midpoint between current price and liquidation price // This provides a balanced protection point that limits losses while avoiding premature triggers @@ -380,9 +307,12 @@ export const useStopLossPrompt = ({ return { shouldShowBanner: true, variant: 'add_margin' }; } - // Priority 2: ROE below threshold with debounce → Stop loss variant + // Priority 2: ROE below threshold → Stop loss variant // But if suggested SL is too close to current price (within 3%), show add_margin instead - if (roeDebounceComplete) { + if ( + roePercent !== null && + roePercent <= STOP_LOSS_PROMPT_CONFIG.RoeThreshold + ) { // Guard: Don't show stop_loss variant if we can't calculate a valid suggested price // This prevents displaying garbled banner text like "Set a stop loss at ( ROE)" if (!suggestedStopLossPrice) { @@ -400,7 +330,6 @@ export const useStopLossPrompt = ({ enabled, position, liquidationDistance, - roeDebounceComplete, isSuggestedSlTooClose, suggestedStopLossPrice, positionAgeCheckPassed, From f93a7a7aaeb9c26817ac7239deec36f88fd52343 Mon Sep 17 00:00:00 2001 From: Pedro Pablo Aste Kompen Date: Tue, 17 Mar 2026 17:28:02 -0300 Subject: [PATCH 064/206] refactor(ramp): align TokenSelection rows with app token list typography and cadence (#27545) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Token rows and spacing in the Ramp TokenSelection screen did not match the rest of the app (homepage token section, token full view, Cash section). This change aligns typography and cadence so the experience is consistent. 1. **Reason for the change:** Inconsistent token row styling in Ramp TokenSelection compared to homepage tokens, token full view, and Cash section. 2. **Improvement:** Ramp `TokenListItem` now uses the same typography (BodyMDMedium for name, BodySMMedium for symbol), avatar size (Lg), row height (64px), and avatar-to-text gap (20px) as the app token list components. Snapshots updated accordingly. ## **Changelog** CHANGELOG entry: Update Token Selection list for the Buy flow. ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Ramp token selection Scenario: user opens token selection in Ramp flow Given the user is in the Ramp (buy) flow and has reached the token selection screen When the token list is displayed Then token rows use the same typography and spacing as the homepage token section (name/symbol size, avatar size, row height, spacing between avatar and text) ``` ## **Screenshots/Recordings** ### **Before** tl_b ### **After** tl_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. ## **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 adjustments to `TokenListItem` styling (spacing, typography, avatar sizing) with snapshot updates; minimal behavioral risk beyond potential visual/layout regressions. > > **Overview** > Aligns Ramp token selection rows with the app’s standard token list styling by adjusting `TokenListItem` layout and typography: increases avatar size, widens avatar-to-text gap, tweaks row padding/height, and updates name/symbol text variants. > > Updates associated Jest snapshots for `TokenSelectorModal`, `TokenSelection`, and `TokenListItem` to match the new rendered styles. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 28b4a07f500efd3fa4391123552c5f6f1febd4e4. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../TokenSelectorModal.test.tsx.snap | 90 ++++---- .../TokenSelection.test.tsx.snap | 200 ++++++++++-------- .../TokenListItem/TokenListItem.tsx | 10 +- .../__snapshots__/TokenListItem.test.tsx.snap | 38 ++-- 4 files changed, 188 insertions(+), 150 deletions(-) diff --git a/app/components/UI/Ramp/Deposit/Views/Modals/TokenSelectorModal/__snapshots__/TokenSelectorModal.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/Modals/TokenSelectorModal/__snapshots__/TokenSelectorModal.test.tsx.snap index f973748df94..08d15c5992c 100644 --- a/app/components/UI/Ramp/Deposit/Views/Modals/TokenSelectorModal/__snapshots__/TokenSelectorModal.test.tsx.snap +++ b/app/components/UI/Ramp/Deposit/Views/Modals/TokenSelectorModal/__snapshots__/TokenSelectorModal.test.tsx.snap @@ -3185,6 +3185,8 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] style={ { "padding": 16, + "paddingHorizontal": 16, + "paddingVertical": 8, } } > @@ -3219,10 +3221,10 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] style={ { "backgroundColor": "#ffffff", - "borderRadius": 16, - "height": 32, + "borderRadius": 20, + "height": 40, "overflow": "hidden", - "width": 32, + "width": 40, } } > @@ -3329,7 +3331,7 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -3348,7 +3350,7 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, + "fontSize": 16, "letterSpacing": 0, "lineHeight": 24, } @@ -3361,10 +3363,10 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] style={ { "color": "#66676a", - "fontFamily": "Geist-Regular", - "fontSize": 16, + "fontFamily": "Geist-Medium", + "fontSize": 14, "letterSpacing": 0, - "lineHeight": 24, + "lineHeight": 22, } } > @@ -3414,6 +3416,8 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] style={ { "padding": 16, + "paddingHorizontal": 16, + "paddingVertical": 8, } } > @@ -3448,10 +3452,10 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] style={ { "backgroundColor": "#ffffff", - "borderRadius": 16, - "height": 32, + "borderRadius": 20, + "height": 40, "overflow": "hidden", - "width": 32, + "width": 40, } } > @@ -3558,7 +3562,7 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -3577,7 +3581,7 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, + "fontSize": 16, "letterSpacing": 0, "lineHeight": 24, } @@ -3590,10 +3594,10 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] style={ { "color": "#66676a", - "fontFamily": "Geist-Regular", - "fontSize": 16, + "fontFamily": "Geist-Medium", + "fontSize": 14, "letterSpacing": 0, - "lineHeight": 24, + "lineHeight": 22, } } > @@ -3628,6 +3632,8 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] style={ { "padding": 16, + "paddingHorizontal": 16, + "paddingVertical": 8, } } > @@ -3662,10 +3668,10 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] style={ { "backgroundColor": "#ffffff", - "borderRadius": 16, - "height": 32, + "borderRadius": 20, + "height": 40, "overflow": "hidden", - "width": 32, + "width": 40, } } > @@ -3772,7 +3778,7 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -3791,7 +3797,7 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, + "fontSize": 16, "letterSpacing": 0, "lineHeight": 24, } @@ -3804,10 +3810,10 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] style={ { "color": "#66676a", - "fontFamily": "Geist-Regular", - "fontSize": 16, + "fontFamily": "Geist-Medium", + "fontSize": 14, "letterSpacing": 0, - "lineHeight": 24, + "lineHeight": 22, } } > @@ -3842,6 +3848,8 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] style={ { "padding": 16, + "paddingHorizontal": 16, + "paddingVertical": 8, } } > @@ -3876,10 +3884,10 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] style={ { "backgroundColor": "#ffffff", - "borderRadius": 16, - "height": 32, + "borderRadius": 20, + "height": 40, "overflow": "hidden", - "width": 32, + "width": 40, } } > @@ -3986,7 +3994,7 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -4005,7 +4013,7 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, + "fontSize": 16, "letterSpacing": 0, "lineHeight": 24, } @@ -4018,10 +4026,10 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] style={ { "color": "#66676a", - "fontFamily": "Geist-Regular", - "fontSize": 16, + "fontFamily": "Geist-Medium", + "fontSize": 14, "letterSpacing": 0, - "lineHeight": 24, + "lineHeight": 22, } } > @@ -4056,6 +4064,8 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] style={ { "padding": 16, + "paddingHorizontal": 16, + "paddingVertical": 8, } } > @@ -4090,10 +4100,10 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] style={ { "backgroundColor": "#ffffff", - "borderRadius": 16, - "height": 32, + "borderRadius": 20, + "height": 40, "overflow": "hidden", - "width": 32, + "width": 40, } } > @@ -4200,7 +4210,7 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -4219,7 +4229,7 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, + "fontSize": 16, "letterSpacing": 0, "lineHeight": 24, } @@ -4232,10 +4242,10 @@ exports[`TokenSelectorModal Component renders correctly and matches snapshot 1`] style={ { "color": "#66676a", - "fontFamily": "Geist-Regular", - "fontSize": 16, + "fontFamily": "Geist-Medium", + "fontSize": 14, "letterSpacing": 0, - "lineHeight": 24, + "lineHeight": 22, } } > diff --git a/app/components/UI/Ramp/Views/TokenSelection/__snapshots__/TokenSelection.test.tsx.snap b/app/components/UI/Ramp/Views/TokenSelection/__snapshots__/TokenSelection.test.tsx.snap index 1b1313e4bed..682a8a90658 100644 --- a/app/components/UI/Ramp/Views/TokenSelection/__snapshots__/TokenSelection.test.tsx.snap +++ b/app/components/UI/Ramp/Views/TokenSelection/__snapshots__/TokenSelection.test.tsx.snap @@ -1687,6 +1687,8 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena style={ { "padding": 16, + "paddingHorizontal": 16, + "paddingVertical": 8, } } > @@ -1721,10 +1723,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena style={ { "backgroundColor": "#ffffff", - "borderRadius": 16, - "height": 32, + "borderRadius": 20, + "height": 40, "overflow": "hidden", - "width": 32, + "width": 40, } } > @@ -1831,7 +1833,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -1850,7 +1852,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, + "fontSize": 16, "letterSpacing": 0, "lineHeight": 24, } @@ -1863,10 +1865,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena style={ { "color": "#66676a", - "fontFamily": "Geist-Regular", - "fontSize": 16, + "fontFamily": "Geist-Medium", + "fontSize": 14, "letterSpacing": 0, - "lineHeight": 24, + "lineHeight": 22, } } > @@ -1877,7 +1879,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -1987,6 +1989,8 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena style={ { "padding": 16, + "paddingHorizontal": 16, + "paddingVertical": 8, } } > @@ -2021,10 +2025,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena style={ { "backgroundColor": "#ffffff", - "borderRadius": 16, - "height": 32, + "borderRadius": 20, + "height": 40, "overflow": "hidden", - "width": 32, + "width": 40, } } > @@ -2131,7 +2135,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -2150,7 +2154,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, + "fontSize": 16, "letterSpacing": 0, "lineHeight": 24, } @@ -2163,10 +2167,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena style={ { "color": "#66676a", - "fontFamily": "Geist-Regular", - "fontSize": 16, + "fontFamily": "Geist-Medium", + "fontSize": 14, "letterSpacing": 0, - "lineHeight": 24, + "lineHeight": 22, } } > @@ -2177,7 +2181,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -2287,6 +2291,8 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena style={ { "padding": 16, + "paddingHorizontal": 16, + "paddingVertical": 8, } } > @@ -2321,10 +2327,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena style={ { "backgroundColor": "#ffffff", - "borderRadius": 16, - "height": 32, + "borderRadius": 20, + "height": 40, "overflow": "hidden", - "width": 32, + "width": 40, } } > @@ -2431,7 +2437,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -2450,7 +2456,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, + "fontSize": 16, "letterSpacing": 0, "lineHeight": 24, } @@ -2463,10 +2469,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena style={ { "color": "#66676a", - "fontFamily": "Geist-Regular", - "fontSize": 16, + "fontFamily": "Geist-Medium", + "fontSize": 14, "letterSpacing": 0, - "lineHeight": 24, + "lineHeight": 22, } } > @@ -2477,7 +2483,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -2587,6 +2593,8 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena style={ { "padding": 16, + "paddingHorizontal": 16, + "paddingVertical": 8, } } > @@ -2621,10 +2629,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena style={ { "backgroundColor": "#ffffff", - "borderRadius": 16, - "height": 32, + "borderRadius": 20, + "height": 40, "overflow": "hidden", - "width": 32, + "width": 40, } } > @@ -2731,7 +2739,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -2750,7 +2758,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, + "fontSize": 16, "letterSpacing": 0, "lineHeight": 24, } @@ -2763,10 +2771,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena style={ { "color": "#66676a", - "fontFamily": "Geist-Regular", - "fontSize": 16, + "fontFamily": "Geist-Medium", + "fontSize": 14, "letterSpacing": 0, - "lineHeight": 24, + "lineHeight": 22, } } > @@ -2777,7 +2785,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -2887,6 +2895,8 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena style={ { "padding": 16, + "paddingHorizontal": 16, + "paddingVertical": 8, } } > @@ -2921,10 +2931,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena style={ { "backgroundColor": "#ffffff", - "borderRadius": 16, - "height": 32, + "borderRadius": 20, + "height": 40, "overflow": "hidden", - "width": 32, + "width": 40, } } > @@ -3031,7 +3041,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -3050,7 +3060,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, + "fontSize": 16, "letterSpacing": 0, "lineHeight": 24, } @@ -3063,10 +3073,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena style={ { "color": "#66676a", - "fontFamily": "Geist-Regular", - "fontSize": 16, + "fontFamily": "Geist-Medium", + "fontSize": 14, "letterSpacing": 0, - "lineHeight": 24, + "lineHeight": 22, } } > @@ -3077,7 +3087,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (V2 ena accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -4007,6 +4017,8 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy style={ { "padding": 16, + "paddingHorizontal": 16, + "paddingVertical": 8, } } > @@ -4041,10 +4053,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy style={ { "backgroundColor": "#ffffff", - "borderRadius": 16, - "height": 32, + "borderRadius": 20, + "height": 40, "overflow": "hidden", - "width": 32, + "width": 40, } } > @@ -4151,7 +4163,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -4170,7 +4182,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, + "fontSize": 16, "letterSpacing": 0, "lineHeight": 24, } @@ -4183,10 +4195,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy style={ { "color": "#66676a", - "fontFamily": "Geist-Regular", - "fontSize": 16, + "fontFamily": "Geist-Medium", + "fontSize": 14, "letterSpacing": 0, - "lineHeight": 24, + "lineHeight": 22, } } > @@ -4197,7 +4209,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -4307,6 +4319,8 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy style={ { "padding": 16, + "paddingHorizontal": 16, + "paddingVertical": 8, } } > @@ -4341,10 +4355,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy style={ { "backgroundColor": "#ffffff", - "borderRadius": 16, - "height": 32, + "borderRadius": 20, + "height": 40, "overflow": "hidden", - "width": 32, + "width": 40, } } > @@ -4451,7 +4465,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -4470,7 +4484,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, + "fontSize": 16, "letterSpacing": 0, "lineHeight": 24, } @@ -4483,10 +4497,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy style={ { "color": "#66676a", - "fontFamily": "Geist-Regular", - "fontSize": 16, + "fontFamily": "Geist-Medium", + "fontSize": 14, "letterSpacing": 0, - "lineHeight": 24, + "lineHeight": 22, } } > @@ -4497,7 +4511,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -4607,6 +4621,8 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy style={ { "padding": 16, + "paddingHorizontal": 16, + "paddingVertical": 8, } } > @@ -4641,10 +4657,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy style={ { "backgroundColor": "#ffffff", - "borderRadius": 16, - "height": 32, + "borderRadius": 20, + "height": 40, "overflow": "hidden", - "width": 32, + "width": 40, } } > @@ -4751,7 +4767,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -4770,7 +4786,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, + "fontSize": 16, "letterSpacing": 0, "lineHeight": 24, } @@ -4783,10 +4799,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy style={ { "color": "#66676a", - "fontFamily": "Geist-Regular", - "fontSize": 16, + "fontFamily": "Geist-Medium", + "fontSize": 14, "letterSpacing": 0, - "lineHeight": 24, + "lineHeight": 22, } } > @@ -4797,7 +4813,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -4907,6 +4923,8 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy style={ { "padding": 16, + "paddingHorizontal": 16, + "paddingVertical": 8, } } > @@ -4941,10 +4959,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy style={ { "backgroundColor": "#ffffff", - "borderRadius": 16, - "height": 32, + "borderRadius": 20, + "height": 40, "overflow": "hidden", - "width": 32, + "width": 40, } } > @@ -5051,7 +5069,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -5070,7 +5088,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, + "fontSize": 16, "letterSpacing": 0, "lineHeight": 24, } @@ -5083,10 +5101,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy style={ { "color": "#66676a", - "fontFamily": "Geist-Regular", - "fontSize": 16, + "fontFamily": "Geist-Medium", + "fontSize": 14, "letterSpacing": 0, - "lineHeight": 24, + "lineHeight": 22, } } > @@ -5097,7 +5115,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -5207,6 +5225,8 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy style={ { "padding": 16, + "paddingHorizontal": 16, + "paddingVertical": 8, } } > @@ -5241,10 +5261,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy style={ { "backgroundColor": "#ffffff", - "borderRadius": 16, - "height": 32, + "borderRadius": 20, + "height": 40, "overflow": "hidden", - "width": 32, + "width": 40, } } > @@ -5351,7 +5371,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -5370,7 +5390,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, + "fontSize": 16, "letterSpacing": 0, "lineHeight": 24, } @@ -5383,10 +5403,10 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy style={ { "color": "#66676a", - "fontFamily": "Geist-Regular", - "fontSize": 16, + "fontFamily": "Geist-Medium", + "fontSize": 14, "letterSpacing": 0, - "lineHeight": 24, + "lineHeight": 22, } } > @@ -5397,7 +5417,7 @@ exports[`TokenSelection Component renders correctly and matches snapshot (legacy accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" diff --git a/app/components/UI/Ramp/components/TokenListItem/TokenListItem.tsx b/app/components/UI/Ramp/components/TokenListItem/TokenListItem.tsx index e56c00c7a0f..248cb3485a2 100644 --- a/app/components/UI/Ramp/components/TokenListItem/TokenListItem.tsx +++ b/app/components/UI/Ramp/components/TokenListItem/TokenListItem.tsx @@ -54,6 +54,10 @@ function TokenListItem({ isSelected={isSelected} onPress={onPress} isDisabled={isDisabled} + gap={20} + listItemProps={{ + style: { paddingVertical: 8, paddingHorizontal: 16 }, + }} testID={`${TOKEN_LIST_ITEM_TEST_IDS.ITEM_PREFIX}${token.assetId}`} > @@ -69,13 +73,13 @@ function TokenListItem({ - {token.name} - + {token.name} + {token.symbol} diff --git a/app/components/UI/Ramp/components/TokenListItem/__snapshots__/TokenListItem.test.tsx.snap b/app/components/UI/Ramp/components/TokenListItem/__snapshots__/TokenListItem.test.tsx.snap index f36a5439299..af0ec048537 100644 --- a/app/components/UI/Ramp/components/TokenListItem/__snapshots__/TokenListItem.test.tsx.snap +++ b/app/components/UI/Ramp/components/TokenListItem/__snapshots__/TokenListItem.test.tsx.snap @@ -20,6 +20,8 @@ exports[`TokenListItem basic rendering renders correctly and matches snapshot 1` style={ { "padding": 16, + "paddingHorizontal": 16, + "paddingVertical": 8, } } > @@ -54,10 +56,10 @@ exports[`TokenListItem basic rendering renders correctly and matches snapshot 1` style={ { "backgroundColor": "#ffffff", - "borderRadius": 16, - "height": 32, + "borderRadius": 20, + "height": 40, "overflow": "hidden", - "width": 32, + "width": 40, } } > @@ -168,7 +170,7 @@ exports[`TokenListItem basic rendering renders correctly and matches snapshot 1` accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -187,7 +189,7 @@ exports[`TokenListItem basic rendering renders correctly and matches snapshot 1` { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, + "fontSize": 16, "letterSpacing": 0, "lineHeight": 24, } @@ -200,10 +202,10 @@ exports[`TokenListItem basic rendering renders correctly and matches snapshot 1` style={ { "color": "#66676a", - "fontFamily": "Geist-Regular", - "fontSize": 16, + "fontFamily": "Geist-Medium", + "fontSize": 14, "letterSpacing": 0, - "lineHeight": 24, + "lineHeight": 22, } } > @@ -235,6 +237,8 @@ exports[`TokenListItem basic rendering renders disabled token with info button a style={ { "padding": 16, + "paddingHorizontal": 16, + "paddingVertical": 8, } } > @@ -269,10 +273,10 @@ exports[`TokenListItem basic rendering renders disabled token with info button a style={ { "backgroundColor": "#ffffff", - "borderRadius": 16, - "height": 32, + "borderRadius": 20, + "height": 40, "overflow": "hidden", - "width": 32, + "width": 40, } } > @@ -383,7 +387,7 @@ exports[`TokenListItem basic rendering renders disabled token with info button a accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" @@ -402,7 +406,7 @@ exports[`TokenListItem basic rendering renders disabled token with info button a { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, + "fontSize": 16, "letterSpacing": 0, "lineHeight": 24, } @@ -415,10 +419,10 @@ exports[`TokenListItem basic rendering renders disabled token with info button a style={ { "color": "#66676a", - "fontFamily": "Geist-Regular", - "fontSize": 16, + "fontFamily": "Geist-Medium", + "fontSize": 14, "letterSpacing": 0, - "lineHeight": 24, + "lineHeight": 22, } } > @@ -429,7 +433,7 @@ exports[`TokenListItem basic rendering renders disabled token with info button a accessible={false} style={ { - "width": 16, + "width": 20, } } testID="listitem-gap" From ed16e5c3da776941178b5d02f89bf6f2319d0ff3 Mon Sep 17 00:00:00 2001 From: Bruno Nascimento Date: Tue, 17 Mar 2026 18:10:10 -0300 Subject: [PATCH 065/206] feat(card): add attention badge on Card button (#27425) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** add attention badge on Card button ## **Changelog** CHANGELOG entry: add attention badge on Card button ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** Simulator Screenshot - iPhone 17 -
2026-03-12 at 09 46 35 ## **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. ## **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 the `CardButton` UI and press behavior by integrating remote feature-flag/A-B-test logic, Redux state updates, and analytics properties; regressions could affect badge visibility or event tracking but scope is localized. > > **Overview** > Adds an **A/B-tested attention badge** to the `CardButton`, shown only when the user hasn’t previously interacted with it and the `cardCARD338AbtestAttentionBadge` variant enables `showBadge`. > > Updates `CardButton` to persist “viewed” state via `setHasViewedCardButton(true)` on first press, and enhances the `MetaMetricsEvents.CARD_BUTTON_VIEWED` event to optionally include `active_ab_tests` (only after remote flags are resolved). > > Expands tests/snapshots to cover badge rendering, one-time “viewed” dispatch behavior, flag-resolution gating, and analytics property inclusion; adds `CARD_BUTTON_BADGE` test id and new `abTestConfig.ts` for variants/key. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit a03d116fa387ed6356ae0c510491fd5b4afc3a30. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../components/CardButton/CardButton.test.tsx | 225 ++++++- .../Card/components/CardButton/CardButton.tsx | 86 ++- .../__snapshots__/CardButton.test.tsx.snap | 604 ++++++++++++++++-- .../components/CardButton/abTestConfig.ts | 14 + .../Views/Wallet/WalletView.testIds.ts | 1 + 5 files changed, 851 insertions(+), 79 deletions(-) create mode 100644 app/components/UI/Card/components/CardButton/abTestConfig.ts diff --git a/app/components/UI/Card/components/CardButton/CardButton.test.tsx b/app/components/UI/Card/components/CardButton/CardButton.test.tsx index 33f475a010a..8beb89a86ca 100644 --- a/app/components/UI/Card/components/CardButton/CardButton.test.tsx +++ b/app/components/UI/Card/components/CardButton/CardButton.test.tsx @@ -4,28 +4,57 @@ import CardButton from './CardButton'; import { renderScreen } from '../../../../../util/test/renderWithProvider'; import { backgroundState } from '../../../../../util/test/initial-root-state'; import { WalletViewSelectorsIDs } from '../../../../Views/Wallet/WalletView.testIds'; +import { useABTest } from '../../../../../hooks/useABTest'; +import { CARD_BUTTON_BADGE_AB_KEY } from './abTestConfig'; + +const mockTrackEvent = jest.fn(); +const mockBuild = jest.fn().mockReturnValue({}); +const mockAddProperties = jest.fn().mockReturnValue({ build: mockBuild }); +const mockCreateEventBuilder = jest.fn().mockReturnValue({ + addProperties: mockAddProperties, +}); jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({ useAnalytics: () => ({ - trackEvent: jest.fn(), - createEventBuilder: jest.fn(() => ({ - addProperties: jest.fn(() => ({ build: jest.fn() })), - build: jest.fn(), - })), + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, }), })); -function renderWithProvider(component: React.ComponentType) { +jest.mock('../../../../../hooks/useABTest'); +jest.mock('../../../../../util/Logger', () => ({ log: jest.fn() })); + +const mockUseABTest = useABTest as jest.MockedFunction; + +interface RenderOptions { + cardState?: { hasViewedCardButton?: boolean }; + /** Set to 0 to simulate flags not yet loaded. Defaults to 1 (resolved). */ + cacheTimestamp?: number; +} + +function renderWithProvider( + component: React.ComponentType, + { cardState = {}, cacheTimestamp = 1 }: RenderOptions = {}, +) { return renderScreen( component, { name: 'CardButton' }, { state: { - engine: { backgroundState }, + engine: { + backgroundState: { + ...backgroundState, + RemoteFeatureFlagController: { + ...backgroundState.RemoteFeatureFlagController, + cacheTimestamp, + }, + }, + }, card: { cardholderAccounts: [], hasViewedCardButton: false, isLoaded: false, + ...cardState, }, }, }, @@ -37,9 +66,19 @@ describe('CardButton Component', () => { beforeEach(() => { jest.clearAllMocks(); + mockBuild.mockReturnValue({}); + mockAddProperties.mockReturnValue({ build: mockBuild }); + mockCreateEventBuilder.mockReturnValue({ + addProperties: mockAddProperties, + }); + mockUseABTest.mockReturnValue({ + variant: { showBadge: true }, + variantName: 'withBadge', + isActive: true, + }); }); - it('renders and matches snapshot', () => { + it('renders with badge (not yet viewed) and matches snapshot', () => { const { toJSON, getByTestId } = renderWithProvider(() => ( { /> )); - expect(getByTestId(WalletViewSelectorsIDs.CARD_BUTTON)).toBeTruthy(); + expect( + getByTestId(WalletViewSelectorsIDs.CARD_BUTTON_BADGE), + ).toBeOnTheScreen(); expect(toJSON()).toMatchSnapshot(); }); - it('calls onPress when button is pressed', () => { - const { getByTestId } = renderWithProvider(() => ( + it('dispatches setHasViewedCardButton(true) and hides badge on first press', () => { + const { getByTestId, store, queryByTestId } = renderWithProvider(() => ( { fireEvent.press(button); expect(mockOnPress).toHaveBeenCalledTimes(1); + expect(store.getState().card.hasViewedCardButton).toBe(true); + expect(queryByTestId(WalletViewSelectorsIDs.CARD_BUTTON_BADGE)).toBeNull(); + }); + + it('does not dispatch setHasViewedCardButton again if already viewed', () => { + const { getByTestId, store } = renderWithProvider( + () => ( + + ), + { cardState: { hasViewedCardButton: true } }, + ); + + const button = getByTestId(WalletViewSelectorsIDs.CARD_BUTTON); + fireEvent.press(button); + + expect(mockOnPress).toHaveBeenCalledTimes(1); + expect(store.getState().card.hasViewedCardButton).toBe(true); + }); + + it('renders without badge when already viewed', () => { + const { toJSON, queryByTestId } = renderWithProvider( + () => ( + + ), + { cardState: { hasViewedCardButton: true } }, + ); + + expect(queryByTestId(WalletViewSelectorsIDs.CARD_BUTTON_BADGE)).toBeNull(); + expect(toJSON()).toMatchSnapshot(); + }); + + describe('A/B test: cardCARD338AbtestAttentionBadge', () => { + it('control variant: does not show badge even when button has not been viewed', () => { + mockUseABTest.mockReturnValue({ + variant: { showBadge: false }, + variantName: 'control', + isActive: false, + }); + + const { queryByTestId } = renderWithProvider(() => ( + + )); + + expect( + queryByTestId(WalletViewSelectorsIDs.CARD_BUTTON_BADGE), + ).toBeNull(); + }); + + it('withBadge variant: shows badge when button has not been viewed', () => { + mockUseABTest.mockReturnValue({ + variant: { showBadge: true }, + variantName: 'withBadge', + isActive: true, + }); + + const { getByTestId } = renderWithProvider(() => ( + + )); + + expect( + getByTestId(WalletViewSelectorsIDs.CARD_BUTTON_BADGE), + ).toBeOnTheScreen(); + }); + + it('withBadge variant: hides badge after button has been viewed', () => { + mockUseABTest.mockReturnValue({ + variant: { showBadge: true }, + variantName: 'withBadge', + isActive: true, + }); + + const { queryByTestId } = renderWithProvider( + () => ( + + ), + { cardState: { hasViewedCardButton: true } }, + ); + + expect( + queryByTestId(WalletViewSelectorsIDs.CARD_BUTTON_BADGE), + ).toBeNull(); + }); + + describe('analytics: CARD_BUTTON_VIEWED event', () => { + it('fires exactly once on mount', () => { + renderWithProvider(() => ( + + )); + + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + }); + + it('does not fire event when flags are not yet resolved (cacheTimestamp = 0)', () => { + renderWithProvider( + () => ( + + ), + { cacheTimestamp: 0 }, + ); + + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + + it('includes active_ab_tests when withBadge variant is active', () => { + mockUseABTest.mockReturnValue({ + variant: { showBadge: true }, + variantName: 'withBadge', + isActive: true, + }); + + renderWithProvider(() => ( + + )); + + expect(mockAddProperties).toHaveBeenCalledWith({ + active_ab_tests: [ + { key: CARD_BUTTON_BADGE_AB_KEY, value: 'withBadge' }, + ], + }); + }); + + it('omits active_ab_tests when control variant is inactive', () => { + mockUseABTest.mockReturnValue({ + variant: { showBadge: false }, + variantName: 'control', + isActive: false, + }); + + renderWithProvider(() => ( + + )); + + expect(mockAddProperties).toHaveBeenCalledWith({}); + }); + }); }); }); diff --git a/app/components/UI/Card/components/CardButton/CardButton.tsx b/app/components/UI/Card/components/CardButton/CardButton.tsx index fea41471a4b..218cd0065d8 100644 --- a/app/components/UI/Card/components/CardButton/CardButton.tsx +++ b/app/components/UI/Card/components/CardButton/CardButton.tsx @@ -3,12 +3,29 @@ import { ButtonIconSize, IconName, IconColor as MMDSIconColor, + BadgeStatus, + BadgeStatusStatus, + BadgeWrapper, + BadgeWrapperPosition, + BadgeWrapperPositionAnchorShape, } from '@metamask/design-system-react-native'; -import React, { useEffect } from 'react'; +import React, { useEffect, useRef } from 'react'; import { WalletViewSelectorsIDs } from '../../../../Views/Wallet/WalletView.testIds'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import { + selectHasViewedCardButton, + setHasViewedCardButton, +} from '../../../../../core/redux/slices/card'; +import { useDispatch, useSelector } from 'react-redux'; +import { RootState } from '../../../../../reducers'; +import { useABTest } from '../../../../../hooks/useABTest'; +import { + CARD_BUTTON_BADGE_AB_KEY, + CARD_BUTTON_BADGE_VARIANTS, +} from './abTestConfig'; +import Logger from '../../../../../util/Logger'; interface CardButtonProps { onPress: () => void; @@ -22,22 +39,69 @@ interface CardButtonProps { const CardButton: React.FC = ({ onPress, touchAreaSlop }) => { const { trackEvent, createEventBuilder } = useAnalytics(); + const dispatch = useDispatch(); + const hasViewedCardButton = useSelector(selectHasViewedCardButton); + const flagsResolved = useSelector( + (state: RootState) => + (state.engine.backgroundState.RemoteFeatureFlagController + ?.cacheTimestamp ?? 0) > 0, + ); + const { variant, variantName, isActive } = useABTest( + CARD_BUTTON_BADGE_AB_KEY, + CARD_BUTTON_BADGE_VARIANTS, + ); + + const hasTrackedViewedEvent = useRef(false); useEffect(() => { + if (hasTrackedViewedEvent.current || !flagsResolved) return; + hasTrackedViewedEvent.current = true; + Logger.log({ + active_ab_tests: [{ key: CARD_BUTTON_BADGE_AB_KEY, value: variantName }], + }); + trackEvent( - createEventBuilder(MetaMetricsEvents.CARD_BUTTON_VIEWED).build(), + createEventBuilder(MetaMetricsEvents.CARD_BUTTON_VIEWED) + .addProperties({ + ...(isActive && { + active_ab_tests: [ + { key: CARD_BUTTON_BADGE_AB_KEY, value: variantName }, + ], + }), + }) + .build(), ); - }, [trackEvent, createEventBuilder]); + }, [trackEvent, createEventBuilder, isActive, variantName, flagsResolved]); + + const onPressHandler = () => { + if (!hasViewedCardButton) { + dispatch(setHasViewedCardButton(true)); + } + onPress(); + }; return ( - + + ) : null + } + > + + ); }; diff --git a/app/components/UI/Card/components/CardButton/__snapshots__/CardButton.test.tsx.snap b/app/components/UI/Card/components/CardButton/__snapshots__/CardButton.test.tsx.snap index 12a13066a96..c1f18ca9a80 100644 --- a/app/components/UI/Card/components/CardButton/__snapshots__/CardButton.test.tsx.snap +++ b/app/components/UI/Card/components/CardButton/__snapshots__/CardButton.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`CardButton Component renders and matches snapshot 1`] = ` +exports[`CardButton Component renders with badge (not yet viewed) and matches snapshot 1`] = ` + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`CardButton Component renders without badge when already viewed 1`] = ` + + + + + + + + + + + + + CardButton + + + + + + + + + + + + + + + + + - + + + + + diff --git a/app/components/UI/Card/components/CardButton/abTestConfig.ts b/app/components/UI/Card/components/CardButton/abTestConfig.ts new file mode 100644 index 00000000000..9ec20a426e9 --- /dev/null +++ b/app/components/UI/Card/components/CardButton/abTestConfig.ts @@ -0,0 +1,14 @@ +export const CARD_BUTTON_BADGE_AB_KEY = 'cardCARD338AbtestAttentionBadge'; + +export enum CardButtonBadgeVariant { + Control = 'control', + WithBadge = 'withBadge', +} + +export const CARD_BUTTON_BADGE_VARIANTS: Record< + CardButtonBadgeVariant, + { showBadge: boolean } +> = { + [CardButtonBadgeVariant.Control]: { showBadge: false }, + [CardButtonBadgeVariant.WithBadge]: { showBadge: true }, +}; diff --git a/app/components/Views/Wallet/WalletView.testIds.ts b/app/components/Views/Wallet/WalletView.testIds.ts index 0aba48ca7cd..278e01c5b8e 100644 --- a/app/components/Views/Wallet/WalletView.testIds.ts +++ b/app/components/Views/Wallet/WalletView.testIds.ts @@ -11,6 +11,7 @@ export const WalletViewSelectorsIDs = { TOTAL_BALANCE_TEXT: 'total-balance-text', CARD_BUTTON: 'card-button', STAKE_BUTTON: 'stake-button', + CARD_BUTTON_BADGE: 'card-button-badge', EARN_EARNINGS_HISTORY_BUTTON: 'earn-earnings-history-button', UNSTAKE_BUTTON: 'unstake-button', STAKE_MORE_BUTTON: 'stake-more-button', From 31f5c1db86effa7ac3298e60fa73991d7ff77e21 Mon Sep 17 00:00:00 2001 From: Bruno Nascimento Date: Tue, 17 Mar 2026 18:12:38 -0300 Subject: [PATCH 066/206] fix(card): Refactors Card onboarding to use the useRegions hook instead of Redux selectedCountry for region/country data (#27539) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Refactors Card onboarding to use the `useRegions` hook instead of Redux `selectedCountry` for region/country data. This centralizes region logic (from registration settings + user data) in one hook and removes redundant state from the Card slice. **Why**: Redux `selectedCountry` duplicated data already derivable from `useRegistrationSettings` and `useCardSDK` user data. The `useRegions` hook provides a single source of truth with `allRegions`, `userCountry`, `getRegionByCode`, and `signUpRegions`, reducing coupling to the Card slice. **What changed**: - **`useRegions` hook**: Centralizes region data from `useRegistrationSettings` and `useCardSDK`, exposing `allRegions`, `signUpRegions`, `userCountry`, `userNationality`, `getRegionByCode`, and `isLoading`. - **Card Redux slice**: Removed `selectedCountry` from `OnboardingState`, and removed `selectSelectedCountry` and `setSelectedCountry` actions/selectors. - **Onboarding components**: - **ConfirmEmail**: Uses `useRegions().userCountry` instead of `selectSelectedCountry`. - **PersonalDetails**: Uses `useRegions` for `allRegions`, `userCountry`, `getRegionByCode`; replaced `useRegistrationSettings` for countries; passes `selectedRegionKey` to `RegionSelectorModal`. - **PhysicalAddress**: Uses `useRegions().userCountry`; passes `selectedCountry` as prop to `AddressFields`; moved CRB/Coinme legal URLs to `constants.ts`. - **RegionSelectorModal**: Accepts `selectedRegionKey` param instead of reading from Redux; `Region` type moved to shared Card types. - **SetPhoneNumber**: Uses `useRegions().signUpRegions` and `userCountry` instead of `useRegistrationSettings`. - **SignUp**, **VerifyIdentity**: Use `useRegions` for region data. - **Constants**: Added `COINME_TERMS_URL`, `CRB_TERMS_URL`, `CRB_ACCOUNT_OPENING_URL`, `CRB_PRIVACY_NOTICE_URL`, `CRB_PRIVACY_POLICY_URL` to `constants.ts`. - **Tests**: Updated unit tests to mock `useRegions` instead of Redux `selectedCountry` or `useRegistrationSettings` where applicable. ## **Changelog** CHANGELOG entry: Refactors Card onboarding to use the `useRegions` hook instead of Redux `selectedCountry` for region/country data. ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Card onboarding - region/country selection Scenario: User completes Card onboarding with US country Given I am on the Card sign-up flow And my device/user is detected as US (or I select US) When I complete email verification And I fill personal details (including SSN for US) And I fill physical address with state field Then onboarding should complete successfully Scenario: User completes Card onboarding with non-US country Given I am on the Card sign-up flow And my device/user is detected as non-US (e.g. Canada) When I complete email verification And I fill personal details (no SSN field shown) And I fill physical address (no state field) Then onboarding should complete successfully Scenario: Region selector shows correct selection Given I am on Personal Details or Set Phone Number screen When I tap the country/nationality selector Then the region modal should open And the currently selected region should be highlighted ``` ## **Screenshots/Recordings** No visual design changes — region selection and conditional fields (SSN, state) behave the same; only the data source (useRegions vs Redux) changed. ## **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. ## **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** > Touches multiple onboarding steps and navigation params while removing `selectedCountry` from Redux, so regressions could affect country-dependent flows (SSN/state fields, phone/email verification, consent policy). Changes are mostly refactors with added guards/tests, but the breadth of UI/state updates increases risk. > > **Overview** > Refactors Card onboarding to **stop storing `selectedCountry` in Redux** and instead derive region/country data via a new `useRegions` hook (built from registration settings + SDK user data), with `Region` moved to shared `card/types`. > > Updates onboarding screens (`SignUp`, `ConfirmEmail`, `SetPhoneNumber`, `PersonalDetails`, `PhysicalAddress`, `VerifyIdentity`) and `RegionSelectorModal` to use `useRegions`, pass `selectedRegionKey` for correct highlighting, and thread `countryKey` through navigation (e.g., from `SignUp` -> `ConfirmEmail` -> `SetPhoneNumber`). Adds a one-time auto-selection guard in `SignUp`/`SetPhoneNumber` to avoid resetting user choice on settings refetch. > > Fixes a navigator loading race by tracking `isFetchingUserData` separately from SDK `isLoading`, adjusts “keep going” modal trigger to include `cardUserPhase`, switches consent policy selection to `selectUserCardLocation`, and moves legal URLs into `card/constants`. Tests are updated/added accordingly (including new `useRegions` unit tests and expanded onboarding navigator coverage). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit a855c5737d190095d0811457bab273048136b8dc. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../Onboarding/ConfirmEmail.test.tsx | 23 ++ .../components/Onboarding/ConfirmEmail.tsx | 31 +- .../Onboarding/PersonalDetails.test.tsx | 260 ++++++-------- .../components/Onboarding/PersonalDetails.tsx | 52 +-- .../Onboarding/PhysicalAddress.test.tsx | 113 +++--- .../components/Onboarding/PhysicalAddress.tsx | 94 ++--- .../Onboarding/RegionSelectorModal.tsx | 21 +- .../Onboarding/SetPhoneNumber.test.tsx | 333 +++++++++++++++--- .../components/Onboarding/SetPhoneNumber.tsx | 107 +++--- .../components/Onboarding/SignUp.test.tsx | 185 ++++++---- .../UI/Card/components/Onboarding/SignUp.tsx | 81 +++-- .../Onboarding/VerifyIdentity.test.tsx | 10 + .../components/Onboarding/VerifyIdentity.tsx | 5 +- app/components/UI/Card/constants.ts | 9 + .../UI/Card/hooks/useRegions.test.ts | 216 ++++++++++++ app/components/UI/Card/hooks/useRegions.ts | 68 ++++ .../Card/hooks/useRegisterUserConsent.test.ts | 193 +--------- .../UI/Card/hooks/useRegisterUserConsent.ts | 8 +- .../Card/routes/OnboardingNavigator.test.tsx | 154 +++++++- .../UI/Card/routes/OnboardingNavigator.tsx | 15 +- app/components/UI/Card/types.ts | 12 + .../UI/Card/util/mapCountryToLocation.test.ts | 16 - .../UI/Card/util/mapCountryToLocation.ts | 2 +- .../handleCardKycNotification.test.ts | 42 +-- .../legacy/handleCardKycNotification.ts | 13 +- app/core/redux/slices/card/index.test.ts | 114 ------ app/core/redux/slices/card/index.ts | 13 - 27 files changed, 1216 insertions(+), 974 deletions(-) create mode 100644 app/components/UI/Card/hooks/useRegions.test.ts create mode 100644 app/components/UI/Card/hooks/useRegions.ts diff --git a/app/components/UI/Card/components/Onboarding/ConfirmEmail.test.tsx b/app/components/UI/Card/components/Onboarding/ConfirmEmail.test.tsx index 46defc15e67..993cfae872d 100644 --- a/app/components/UI/Card/components/Onboarding/ConfirmEmail.test.tsx +++ b/app/components/UI/Card/components/Onboarding/ConfirmEmail.test.tsx @@ -6,6 +6,7 @@ import { useNavigation } from '@react-navigation/native'; import ConfirmEmail from './ConfirmEmail'; import Routes from '../../../../../constants/navigation/Routes'; import { useParams } from '../../../../../util/navigation/navUtils'; +import useRegions from '../../hooks/useRegions'; // Mock dependencies jest.mock('@react-navigation/native', () => ({ @@ -342,6 +343,11 @@ jest.mock('../../hooks/useEmailVerificationSend', () => ({ default: () => mockUseEmailVerificationSend(), })); +jest.mock('../../hooks/useRegions', () => ({ + __esModule: true, + default: jest.fn(), +})); + // Mock SDK jest.mock('../../sdk', () => ({ useCardSDK: jest.fn(() => ({ @@ -410,6 +416,14 @@ describe('ConfirmEmail Component', () => { mockUseParams.mockReturnValue({ email: 'test@example.com', password: 'testPassword123', + countryKey: 'US', + }); + + (useRegions as jest.Mock).mockReturnValue({ + getRegionByCode: (code: string) => + code === 'US' + ? { key: 'US', name: 'United States', emoji: '🇺🇸' } + : null, }); // Set up useAnalytics mock @@ -623,6 +637,7 @@ describe('ConfirmEmail Component', () => { await waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith( Routes.CARD.ONBOARDING.SET_PHONE_NUMBER, + { countryKey: 'US' }, ); }); }); @@ -669,6 +684,7 @@ describe('ConfirmEmail Component', () => { // Navigation should be called after verification expect(mockNavigate).toHaveBeenCalledWith( Routes.CARD.ONBOARDING.SET_PHONE_NUMBER, + { countryKey: 'US' }, ); }, 10000); @@ -735,6 +751,10 @@ describe('ConfirmEmail Component', () => { // Navigation should be called again on duplicate input expect(mockNavigate).toHaveBeenCalledTimes(2); + expect(mockNavigate).toHaveBeenCalledWith( + Routes.CARD.ONBOARDING.SET_PHONE_NUMBER, + { countryKey: 'US' }, + ); }, 20000); }); @@ -742,6 +762,8 @@ describe('ConfirmEmail Component', () => { it('should display email from params in description', () => { mockUseParams.mockReturnValue({ email: 'user@test.com', + password: 'testPassword123', + countryKey: 'US', }); const store = createTestStore(); @@ -1203,6 +1225,7 @@ describe('ConfirmEmail Component', () => { ); expect(mockNavigate).toHaveBeenCalledWith( Routes.CARD.ONBOARDING.SET_PHONE_NUMBER, + { countryKey: 'US' }, ); }); }); diff --git a/app/components/UI/Card/components/Onboarding/ConfirmEmail.tsx b/app/components/UI/Card/components/Onboarding/ConfirmEmail.tsx index 7b5f57d6c3c..8d58d1356d9 100644 --- a/app/components/UI/Card/components/Onboarding/ConfirmEmail.tsx +++ b/app/components/UI/Card/components/Onboarding/ConfirmEmail.tsx @@ -16,7 +16,6 @@ import { CardError } from '../../types'; import { resetOnboardingState, selectContactVerificationId, - selectSelectedCountry, setContactVerificationId, setOnboardingId, } from '../../../../../core/redux/slices/card'; @@ -26,6 +25,7 @@ import { CardActions, CardScreens } from '../../util/metrics'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { IconName } from '../../../../../component-library/components/Icons/Icon'; +import useRegions from '../../hooks/useRegions'; const CODE_LENGTH = 6; @@ -34,18 +34,21 @@ const ConfirmEmail = () => { const dispatch = useDispatch(); const [confirmCode, setConfirmCode] = useState(''); const [resendCooldown, setResendCooldown] = useState(60); - const selectedCountry = useSelector(selectSelectedCountry); + const { getRegionByCode } = useRegions(); const contactVerificationId = useSelector(selectContactVerificationId); const { trackEvent, createEventBuilder } = useAnalytics(); const [latestValueSubmitted, setLatestValueSubmitted] = useState< string | null >(null); - const { email, password } = useParams<{ + const { email, password, countryKey } = useParams<{ email: string; password: string; + countryKey: string; }>(); + const selectedCountry = getRegionByCode(countryKey); + const { sendEmailVerification, isLoading: emailVerificationIsLoading, @@ -139,7 +142,9 @@ const ConfirmEmail = () => { if (onboardingId) { dispatch(setOnboardingId(onboardingId)); - navigation.navigate(Routes.CARD.ONBOARDING.SET_PHONE_NUMBER); + navigation.navigate(Routes.CARD.ONBOARDING.SET_PHONE_NUMBER, { + countryKey, + }); } else if (hasAccount) { const navigateToAuthentication = () => { navigation.reset({ @@ -183,6 +188,7 @@ const ConfirmEmail = () => { }, [ confirmCode, contactVerificationId, + countryKey, dispatch, email, navigation, @@ -204,16 +210,27 @@ const ConfirmEmail = () => { } }, [resendCooldown]); - // Auto-submit when all digits are entered useEffect(() => { if ( confirmCode.length === CODE_LENGTH && - latestValueSubmitted !== confirmCode + latestValueSubmitted !== confirmCode && + selectedCountry && + email && + password && + contactVerificationId ) { setLatestValueSubmitted(confirmCode); handleContinue(); } - }, [confirmCode, handleContinue, latestValueSubmitted]); + }, [ + confirmCode, + contactVerificationId, + email, + handleContinue, + latestValueSubmitted, + password, + selectedCountry, + ]); const isDisabled = verifyLoading || diff --git a/app/components/UI/Card/components/Onboarding/PersonalDetails.test.tsx b/app/components/UI/Card/components/Onboarding/PersonalDetails.test.tsx index bc76d729bbe..92b827ced6d 100644 --- a/app/components/UI/Card/components/Onboarding/PersonalDetails.test.tsx +++ b/app/components/UI/Card/components/Onboarding/PersonalDetails.test.tsx @@ -229,7 +229,7 @@ jest.mock('../../hooks/useRegisterPersonalDetails', () => ({ default: jest.fn(), })); -jest.mock('../../hooks/useRegistrationSettings', () => ({ +jest.mock('../../hooks/useRegions', () => ({ __esModule: true, default: jest.fn(), })); @@ -297,7 +297,7 @@ import { useNavigation } from '@react-navigation/native'; import { useDispatch, useSelector } from 'react-redux'; import PersonalDetails from './PersonalDetails'; import useRegisterPersonalDetails from '../../hooks/useRegisterPersonalDetails'; -import useRegistrationSettings from '../../hooks/useRegistrationSettings'; +import useRegions from '../../hooks/useRegions'; import { useCardSDK } from '../../sdk'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import { CardError, CardErrorType } from '../../types'; @@ -323,22 +323,25 @@ const mockCreateEventBuilder = jest.fn(() => ({ (useDispatch as jest.Mock).mockReturnValue(mockDispatch); -(useSelector as jest.Mock).mockImplementation((selector) => { - const mockState = { - card: { - onboarding: { - onboardingId: 'test-onboarding-id', - selectedCountry: { - key: 'US', - name: 'United States', - emoji: '🇺🇸', - areaCode: '1', - }, - }, +const mockAllRegions = [ + { key: 'US', name: 'United States', emoji: '🇺🇸', areaCode: '1' }, + { key: 'CA', name: 'Canada', emoji: '🇨🇦', areaCode: '1' }, +]; +const mockUserCountryUS = mockAllRegions[0]; +const mockGetRegionByCode = (code: string) => + mockAllRegions.find((r) => r.key === code) ?? null; + +const defaultCardState = { + card: { + onboarding: { + onboardingId: 'test-onboarding-id', }, - }; - return selector(mockState); -}); + }, +}; + +(useSelector as jest.Mock).mockImplementation((selector) => + selector(defaultCardState), +); (useRegisterPersonalDetails as jest.Mock).mockReturnValue({ registerPersonalDetails: mockRegisterPersonalDetails, @@ -348,13 +351,10 @@ const mockCreateEventBuilder = jest.fn(() => ({ reset: jest.fn(), }); -(useRegistrationSettings as jest.Mock).mockReturnValue({ - data: { - countries: [ - { iso3166alpha2: 'US', name: 'United States', callingCode: '1' }, - { iso3166alpha2: 'CA', name: 'Canada', callingCode: '1' }, - ], - }, +(useRegions as jest.Mock).mockReturnValue({ + allRegions: mockAllRegions, + userCountry: mockUserCountryUS, + getRegionByCode: mockGetRegionByCode, }); (useCardSDK as jest.Mock).mockReturnValue({ @@ -374,6 +374,9 @@ const mockCreateEventBuilder = jest.fn(() => ({ describe('PersonalDetails Component', () => { beforeEach(() => { jest.clearAllMocks(); + (useSelector as jest.Mock).mockImplementation( + (selector: (state: unknown) => unknown) => selector(defaultCardState), + ); }); describe('Initial Render', () => { @@ -409,21 +412,10 @@ describe('PersonalDetails Component', () => { describe('Conditional SSN Field Rendering', () => { it('shows SSN field when selected country is US', () => { - (useSelector as jest.Mock).mockImplementation((selector) => { - const mockState = { - card: { - onboarding: { - onboardingId: 'test-onboarding-id', - selectedCountry: { - key: 'US', - name: 'United States', - emoji: '🇺🇸', - areaCode: '1', - }, - }, - }, - }; - return selector(mockState); + (useRegions as jest.Mock).mockReturnValue({ + allRegions: mockAllRegions, + userCountry: mockUserCountryUS, + getRegionByCode: mockGetRegionByCode, }); const { getByTestId } = render(); @@ -432,21 +424,10 @@ describe('PersonalDetails Component', () => { }); it('does not show SSN field when selected country is not US', () => { - (useSelector as jest.Mock).mockImplementation((selector) => { - const mockState = { - card: { - onboarding: { - onboardingId: 'test-onboarding-id', - selectedCountry: { - key: 'CA', - name: 'Canada', - emoji: '🇨🇦', - areaCode: '1', - }, - }, - }, - }; - return selector(mockState); + (useRegions as jest.Mock).mockReturnValue({ + allRegions: mockAllRegions, + userCountry: mockAllRegions[1], + getRegionByCode: mockGetRegionByCode, }); const { queryByTestId } = render(); @@ -491,21 +472,10 @@ describe('PersonalDetails Component', () => { describe('SSN Validation', () => { beforeEach(() => { - (useSelector as jest.Mock).mockImplementation((selector) => { - const mockState = { - card: { - onboarding: { - onboardingId: 'test-onboarding-id', - selectedCountry: { - key: 'US', - name: 'United States', - emoji: '🇺🇸', - areaCode: '1', - }, - }, - }, - }; - return selector(mockState); + (useRegions as jest.Mock).mockReturnValue({ + allRegions: mockAllRegions, + userCountry: mockUserCountryUS, + getRegionByCode: mockGetRegionByCode, }); }); @@ -772,21 +742,10 @@ describe('PersonalDetails Component', () => { countryOfNationality: 'US', ssn: '123456789', }; - (useSelector as jest.Mock).mockImplementation((selector) => { - const mockState = { - card: { - onboarding: { - onboardingId: 'test-onboarding-id', - selectedCountry: { - key: 'US', - name: 'United States', - emoji: '🇺🇸', - areaCode: '1', - }, - }, - }, - }; - return selector(mockState); + (useRegions as jest.Mock).mockReturnValue({ + allRegions: mockAllRegions, + userCountry: mockUserCountryUS, + getRegionByCode: mockGetRegionByCode, }); (useCardSDK as jest.Mock).mockReturnValue({ user: mockUserData, @@ -804,21 +763,10 @@ describe('PersonalDetails Component', () => { describe('Nationality Population from userData', () => { beforeEach(() => { - (useSelector as jest.Mock).mockImplementation((selector) => { - const mockState = { - card: { - onboarding: { - onboardingId: 'test-onboarding-id', - selectedCountry: { - key: 'US', - name: 'United States', - emoji: '🇺🇸', - areaCode: '1', - }, - }, - }, - }; - return selector(mockState); + (useRegions as jest.Mock).mockReturnValue({ + allRegions: mockAllRegions, + userCountry: mockUserCountryUS, + getRegionByCode: mockGetRegionByCode, }); }); @@ -848,21 +796,10 @@ describe('PersonalDetails Component', () => { describe('registerPersonalDetails Function Call', () => { beforeEach(() => { - (useSelector as jest.Mock).mockImplementation((selector) => { - const mockState = { - card: { - onboarding: { - onboardingId: 'test-onboarding-id', - selectedCountry: { - key: 'US', - name: 'United States', - emoji: '🇺🇸', - areaCode: '1', - }, - }, - }, - }; - return selector(mockState); + (useRegions as jest.Mock).mockReturnValue({ + allRegions: mockAllRegions, + userCountry: mockUserCountryUS, + getRegionByCode: mockGetRegionByCode, }); }); @@ -962,17 +899,16 @@ describe('PersonalDetails Component', () => { card: { onboarding: { onboardingId: null, - selectedCountry: { - key: 'US', - name: 'United States', - emoji: '🇺🇸', - areaCode: '1', - }, }, }, }; return selector(mockState); }); + (useRegions as jest.Mock).mockReturnValue({ + allRegions: mockAllRegions, + userCountry: mockUserCountryUS, + getRegionByCode: mockGetRegionByCode, + }); const { getByTestId } = render(); @@ -1009,19 +945,10 @@ describe('PersonalDetails Component', () => { }); it('handles onboarding ID not found error by resetting state', async () => { - // Setup: Pre-fill all required fields via userData - const mockUserData = { - firstName: 'John', - lastName: 'Doe', - dateOfBirth: '1990-01-01T00:00:00.000Z', - countryOfNationality: 'US', - ssn: '123456789', - }; - (useCardSDK as jest.Mock).mockReturnValue({ - user: mockUserData, - setUser: mockSetUser, - fetchUserData: mockFetchUserData, - logoutFromProvider: jest.fn(), + (useRegions as jest.Mock).mockReturnValue({ + allRegions: mockAllRegions, + userCountry: mockUserCountryUS, + getRegionByCode: mockGetRegionByCode, }); mockRegisterPersonalDetails.mockRejectedValue( @@ -1030,16 +957,36 @@ describe('PersonalDetails Component', () => { const { getByTestId } = render(); - const continueButton = getByTestId('personal-details-continue-button'); + const firstNameInput = getByTestId('personal-details-first-name-input'); + const lastNameInput = getByTestId('personal-details-last-name-input'); + const dateOfBirthInput = getByTestId( + 'personal-details-date-of-birth-input', + ); + const nationalitySelect = getByTestId( + 'personal-details-nationality-select', + ); + const ssnInput = getByTestId('personal-details-ssn-input'); await act(async () => { - fireEvent.press(continueButton); + fireEvent.changeText(firstNameInput, 'John'); + fireEvent.changeText(lastNameInput, 'Doe'); + fireEvent.changeText(dateOfBirthInput, '631152000000'); + fireEvent.press(nationalitySelect); + fireEvent.changeText(ssnInput, '123456789'); }); - await waitFor(() => { - expect(mockDispatch).toHaveBeenCalled(); - expect(mockNavigate).toHaveBeenCalled(); + const continueButton = getByTestId('personal-details-continue-button'); + await act(async () => { + fireEvent.press(continueButton); }); + + await waitFor( + () => { + expect(mockDispatch).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalled(); + }, + { timeout: 3000 }, + ); }); it('disables continue button when SSN is invalid', () => { @@ -1058,6 +1005,11 @@ describe('PersonalDetails Component', () => { }); it('includes dateOfBirth in registration payload when provided', async () => { + (useRegions as jest.Mock).mockReturnValue({ + allRegions: mockAllRegions, + userCountry: mockUserCountryUS, + getRegionByCode: mockGetRegionByCode, + }); mockRegisterPersonalDetails.mockResolvedValue({ user: { id: 'user-123' }, }); @@ -1074,14 +1026,15 @@ describe('PersonalDetails Component', () => { ); const ssnInput = getByTestId('personal-details-ssn-input'); - fireEvent.changeText(firstNameInput, 'John'); - fireEvent.changeText(lastNameInput, 'Doe'); - fireEvent.changeText(dateOfBirthInput, '631152000000'); // Valid timestamp for 1990-01-01 - fireEvent.press(nationalitySelect); // Triggers setOnValueChange which sets nationalityKey - fireEvent.changeText(ssnInput, '123456789'); + await act(async () => { + fireEvent.changeText(firstNameInput, 'John'); + fireEvent.changeText(lastNameInput, 'Doe'); + fireEvent.changeText(dateOfBirthInput, '631152000000'); // 1990-01-01 + fireEvent.press(nationalitySelect); + fireEvent.changeText(ssnInput, '123456789'); + }); const continueButton = getByTestId('personal-details-continue-button'); - await act(async () => { fireEvent.press(continueButton); }); @@ -1097,21 +1050,10 @@ describe('PersonalDetails Component', () => { }); it('does not require SSN when country is not US', () => { - (useSelector as jest.Mock).mockImplementation((selector) => { - const mockState = { - card: { - onboarding: { - onboardingId: 'test-onboarding-id', - selectedCountry: { - key: 'CA', - name: 'Canada', - emoji: '🇨🇦', - areaCode: '1', - }, - }, - }, - }; - return selector(mockState); + (useRegions as jest.Mock).mockReturnValue({ + allRegions: mockAllRegions, + userCountry: mockAllRegions[1], + getRegionByCode: mockGetRegionByCode, }); const { getByTestId, queryByTestId } = render(); diff --git a/app/components/UI/Card/components/Onboarding/PersonalDetails.tsx b/app/components/UI/Card/components/Onboarding/PersonalDetails.tsx index 55992d878cc..fe37e20b4bd 100644 --- a/app/components/UI/Card/components/Onboarding/PersonalDetails.tsx +++ b/app/components/UI/Card/components/Onboarding/PersonalDetails.tsx @@ -20,12 +20,10 @@ import DepositDateField from '../../../Ramp/Deposit/components/DepositDateField' import { resetOnboardingState, selectOnboardingId, - selectSelectedCountry, - setSelectedCountry, } from '../../../../../core/redux/slices/card'; import { useDispatch, useSelector } from 'react-redux'; import useRegisterPersonalDetails from '../../hooks/useRegisterPersonalDetails'; -import useRegistrationSettings from '../../hooks/useRegistrationSettings'; +import useRegions from '../../hooks/useRegions'; import { formatDateOfBirth, validateDateOfBirth, @@ -35,11 +33,9 @@ import { useCardSDK } from '../../sdk'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { CardActions, CardScreens } from '../../util/metrics'; -import { countryCodeToFlag } from '../../util/countryCodeToFlag'; import { clearOnValueChange, createRegionSelectorModalNavigationDetails, - Region, setOnValueChange, } from './RegionSelectorModal'; @@ -48,7 +44,11 @@ const PersonalDetails = () => { const dispatch = useDispatch(); const { setUser, fetchUserData, user: userData } = useCardSDK(); const onboardingId = useSelector(selectOnboardingId); - const initialSelectedCountry = useSelector(selectSelectedCountry); + const { + allRegions, + userCountry: selectedCountry, + getRegionByCode, + } = useRegions(); const { trackEvent, createEventBuilder } = useAnalytics(); const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); @@ -60,9 +60,6 @@ const PersonalDetails = () => { const [isSSNError, setIsSSNError] = useState(false); const [isSSNTouched, setIsSSNTouched] = useState(false); - // Get registration settings data - const { data: registrationSettings } = useRegistrationSettings(); - useEffect(() => { fetchUserData(); }, [fetchUserData]); @@ -106,37 +103,7 @@ const PersonalDetails = () => { } }, [userData]); - const regions: Region[] = useMemo(() => { - if (!registrationSettings?.countries) { - return []; - } - return [...registrationSettings.countries] - .sort((a, b) => a.name.localeCompare(b.name)) - .map((country) => ({ - key: country.iso3166alpha2, - name: country.name, - emoji: countryCodeToFlag(country.iso3166alpha2), - areaCode: country.callingCode, - })); - }, [registrationSettings]); - - const nationalityName = useMemo( - () => regions.find((region) => region.key === nationalityKey)?.name, - [regions, nationalityKey], - ); - - const selectedCountry = useMemo( - () => - initialSelectedCountry || - regions.find((region) => region.key === userData?.countryOfResidence), - [initialSelectedCountry, regions, userData?.countryOfResidence], - ); - - useEffect(() => { - if (!initialSelectedCountry && selectedCountry) { - dispatch(setSelectedCountry(selectedCountry)); - } - }, [selectedCountry, dispatch, initialSelectedCountry]); + const nationalityName = getRegionByCode(nationalityKey)?.name; const { registerPersonalDetails, @@ -169,10 +136,11 @@ const PersonalDetails = () => { }); navigation.navigate( ...createRegionSelectorModalNavigationDetails({ - regions, + regions: allRegions, + selectedRegionKey: nationalityKey || null, }), ); - }, [navigation, regions, resetRegisterPersonalDetails]); + }, [navigation, allRegions, nationalityKey, resetRegisterPersonalDetails]); const handleDateOfBirthChange = useCallback( (timestamp: string) => { diff --git a/app/components/UI/Card/components/Onboarding/PhysicalAddress.test.tsx b/app/components/UI/Card/components/Onboarding/PhysicalAddress.test.tsx index 3952cd7f2ed..4994ea3cd87 100644 --- a/app/components/UI/Card/components/Onboarding/PhysicalAddress.test.tsx +++ b/app/components/UI/Card/components/Onboarding/PhysicalAddress.test.tsx @@ -10,6 +10,7 @@ import Routes from '../../../../../constants/navigation/Routes'; import useRegisterPhysicalAddress from '../../hooks/useRegisterPhysicalAddress'; import useRegisterUserConsent from '../../hooks/useRegisterUserConsent'; import useRegistrationSettings from '../../hooks/useRegistrationSettings'; +import useRegions from '../../hooks/useRegions'; import { useCardSDK } from '../../sdk'; // Mock navigation @@ -21,6 +22,7 @@ jest.mock('@react-navigation/native', () => ({ jest.mock('../../hooks/useRegisterPhysicalAddress'); jest.mock('../../hooks/useRegisterUserConsent'); jest.mock('../../hooks/useRegistrationSettings'); +jest.mock('../../hooks/useRegions'); // Mock SDK jest.mock('../../sdk', () => ({ @@ -351,18 +353,8 @@ const createTestStore = (initialState = {}) => card: ( state = { onboarding: { - selectedCountry: { - key: 'US', - name: 'United States', - emoji: '🇺🇸', - areaCode: '1', - }, onboardingId: 'test-id', contactVerificationId: 'contact-id', - user: { - id: 'user-id', - email: 'test@example.com', - }, }, userCardLocation: 'us', ...initialState, @@ -444,7 +436,17 @@ describe('PhysicalAddress Component', () => { reset: jest.fn(), }); - // Mock useRegistrationSettings + const mockUserCountryUS = { + key: 'US', + name: 'United States', + emoji: '🇺🇸', + areaCode: '1', + }; + (useRegions as jest.Mock).mockReturnValue({ + userCountry: mockUserCountryUS, + }); + + // Mock useRegistrationSettings (for AddressFields usStates and links) mockUseRegistrationSettings.mockReturnValue({ data: { countries: [ @@ -524,18 +526,9 @@ describe('PhysicalAddress Component', () => { selector({ card: { onboarding: { - selectedCountry: { - key: 'US', - name: 'United States', - emoji: '🇺🇸', - areaCode: '1', - }, onboardingId: 'test-id', - user: { - id: 'user-id', - email: 'test@example.com', - }, }, + consentSetId: null, }, }), ); @@ -1399,22 +1392,14 @@ describe('PhysicalAddress Component', () => { describe('Conditional Rendering', () => { it('shows state field for US users', () => { - const { useSelector } = jest.requireMock('react-redux'); - useSelector.mockImplementation((selector: any) => - selector({ - card: { - onboarding: { - selectedCountry: { - key: 'US', - name: 'United States', - emoji: '🇺🇸', - areaCode: '1', - }, - onboardingId: 'test-id', - }, - }, - }), - ); + (useRegions as jest.Mock).mockReturnValue({ + userCountry: { + key: 'US', + name: 'United States', + emoji: '🇺🇸', + areaCode: '1', + }, + }); const { getByTestId } = render( @@ -1426,22 +1411,14 @@ describe('PhysicalAddress Component', () => { }); it('hides state field for non-US users', () => { - const { useSelector } = jest.requireMock('react-redux'); - useSelector.mockImplementation((selector: any) => - selector({ - card: { - onboarding: { - selectedCountry: { - key: 'CA', - name: 'Canada', - emoji: '🇨🇦', - areaCode: '1', - }, - onboardingId: 'test-id', - }, - }, - }), - ); + (useRegions as jest.Mock).mockReturnValue({ + userCountry: { + key: 'CA', + name: 'Canada', + emoji: '🇨🇦', + areaCode: '1', + }, + }); const { queryByTestId } = render( @@ -1460,13 +1437,15 @@ describe('PhysicalAddress Component', () => { selector({ card: { onboarding: { - selectedCountry: null, onboardingId: null, - user: null, }, + consentSetId: null, }, }), ); + (useRegions as jest.Mock).mockReturnValue({ + userCountry: null, + }); const { getByTestId } = render( @@ -1761,22 +1740,14 @@ describe('PhysicalAddress Component', () => { }); it('does not render legal links for non-US users', () => { - const { useSelector } = jest.requireMock('react-redux'); - useSelector.mockImplementation((selector: any) => - selector({ - card: { - onboarding: { - selectedCountry: { - key: 'CA', - name: 'Canada', - emoji: '🇨🇦', - areaCode: '1', - }, - onboardingId: 'test-id', - }, - }, - }), - ); + (useRegions as jest.Mock).mockReturnValue({ + userCountry: { + key: 'CA', + name: 'Canada', + emoji: '🇨🇦', + areaCode: '1', + }, + }); const { queryByText } = render( diff --git a/app/components/UI/Card/components/Onboarding/PhysicalAddress.tsx b/app/components/UI/Card/components/Onboarding/PhysicalAddress.tsx index c0ad10e966c..1376f9dd570 100644 --- a/app/components/UI/Card/components/Onboarding/PhysicalAddress.tsx +++ b/app/components/UI/Card/components/Onboarding/PhysicalAddress.tsx @@ -29,16 +29,15 @@ import { resetOnboardingState, selectConsentSetId, selectOnboardingId, - selectSelectedCountry, setConsentSetId, setIsAuthenticatedCard, - setSelectedCountry, setUserCardLocation, } from '../../../../../core/redux/slices/card'; import { selectMetalCardCheckoutFeatureFlag } from '../../../../../selectors/featureFlagController/card'; import useRegisterUserConsent from '../../hooks/useRegisterUserConsent'; -import { CardError } from '../../types'; +import { CardError, type Region } from '../../types'; import useRegistrationSettings from '../../hooks/useRegistrationSettings'; +import useRegions from '../../hooks/useRegions'; import { storeCardBaanxToken } from '../../util/cardTokenVault'; import { mapCountryToLocation } from '../../util/mapCountryToLocation'; import { extractTokenExpiration } from '../../util/extractTokenExpiration'; @@ -53,10 +52,16 @@ import Checkbox from '../../../../../component-library/components/Checkbox'; import { clearOnValueChange, createRegionSelectorModalNavigationDetails, - Region, setOnValueChange, } from './RegionSelectorModal'; import { countryCodeToFlag } from '../../util/countryCodeToFlag'; +import { + COINME_TERMS_URL, + CRB_ACCOUNT_OPENING_URL, + CRB_PRIVACY_NOTICE_URL, + CRB_PRIVACY_POLICY_URL, + CRB_TERMS_URL, +} from '../../constants'; const VERIFICATION_POLLING_INTERVAL_MS = 3000; @@ -71,6 +76,7 @@ export const AddressFields = ({ handleStateChange, zipCode, handleZipCodeChange, + selectedCountry, }: { addressLine1: string; handleAddressLine1Change: (text: string) => void; @@ -82,10 +88,10 @@ export const AddressFields = ({ handleStateChange: (text: string) => void; zipCode: string; handleZipCodeChange: (text: string) => void; + selectedCountry: Region | null; }) => { const navigation = useNavigation(); const { data: registrationSettings } = useRegistrationSettings(); - const selectedCountry = useSelector(selectSelectedCountry); const regions: Region[] = useMemo(() => { if (!registrationSettings?.usStates) { @@ -225,11 +231,11 @@ const PhysicalAddress = () => { const dispatch = useDispatch(); const { user, setUser, sdk } = useCardSDK(); const onboardingId = useSelector(selectOnboardingId); - const initialSelectedCountry = useSelector(selectSelectedCountry); const existingConsentSetId = useSelector(selectConsentSetId); const isMetalCardCheckoutEnabled = useSelector( selectMetalCardCheckoutFeatureFlag, ); + const { userCountry: selectedCountry } = useRegions(); const { trackEvent, createEventBuilder } = useAnalytics(); const [addressLine1, setAddressLine1] = useState(''); const [addressLine2, setAddressLine2] = useState(''); @@ -256,20 +262,6 @@ const PhysicalAddress = () => { [], ); - const regions: Region[] = useMemo(() => { - if (!registrationSettings?.countries) { - return []; - } - return [...registrationSettings.countries] - .sort((a, b) => a.name.localeCompare(b.name)) - .map((country) => ({ - key: country.iso3166alpha2, - name: country.name, - emoji: countryCodeToFlag(country.iso3166alpha2), - areaCode: country.callingCode, - })); - }, [registrationSettings]); - // If user data is available, set the state values useEffect(() => { if (user) { @@ -278,43 +270,14 @@ const PhysicalAddress = () => { setCity(user.city || ''); setState(user.usState || ''); setZipCode(user.zip || ''); - const country = regions.find( - (region) => region.key === user.countryOfResidence, - ); - if (country) { - dispatch(setSelectedCountry(country)); - } } - }, [dispatch, regions, user]); - - const selectedCountry = useMemo( - () => - initialSelectedCountry || - regions.find((region) => region.key === user?.countryOfResidence), - [initialSelectedCountry, regions, user?.countryOfResidence], - ); - - useEffect(() => { - if (!initialSelectedCountry && selectedCountry) { - dispatch(setSelectedCountry(selectedCountry)); - } - }, [selectedCountry, dispatch, initialSelectedCountry]); + }, [user]); const eSignConsentDisclosureUSUrl = useMemo( () => registrationSettings?.links?.us?.eSignConsentDisclosure || '', [registrationSettings?.links?.us?.eSignConsentDisclosure], ); - const coinmeTermsUrl = 'https://coinme.com/legal/'; - - const crbTermsUrl = - 'https://baanx-public.s3-eu-west-1.amazonaws.com/Ledger/public-files/BaanxUS_CLCard_TOS.undefined-fddb292f91ce3.pdf'; - const crbAccountOpeningUrl = - 'https://secure.baanx.co.uk/BAANX_US_ACCOUNT_OPENING_AGREEMENTS_AND_DISCLOSURES_08152025.pdf'; - const crbPrivacyNoticeUrl = - 'https://secure.baanx.co.uk/Baanx_(CL)_U.S._Privacy_Notice_06.2025.pdf'; - const crbPrivacyPolicyUrl = 'https://www.crossriver.com/legal/privacy-notice'; - const { registerAddress, isLoading: registerLoading, @@ -340,34 +303,34 @@ const PhysicalAddress = () => { }, [eSignConsentDisclosureUSUrl]); const openCoinmeTerms = useCallback(() => { - if (coinmeTermsUrl) { - Linking.openURL(coinmeTermsUrl); + if (COINME_TERMS_URL) { + Linking.openURL(COINME_TERMS_URL); } - }, [coinmeTermsUrl]); + }, []); const openCrbTerms = useCallback(() => { - if (crbTermsUrl) { - Linking.openURL(crbTermsUrl); + if (CRB_TERMS_URL) { + Linking.openURL(CRB_TERMS_URL); } - }, [crbTermsUrl]); + }, []); const openCrbAccountOpening = useCallback(() => { - if (crbAccountOpeningUrl) { - Linking.openURL(crbAccountOpeningUrl); + if (CRB_ACCOUNT_OPENING_URL) { + Linking.openURL(CRB_ACCOUNT_OPENING_URL); } - }, [crbAccountOpeningUrl]); + }, []); const openCrbPrivacyNotice = useCallback(() => { - if (crbPrivacyNoticeUrl) { - Linking.openURL(crbPrivacyNoticeUrl); + if (CRB_PRIVACY_NOTICE_URL) { + Linking.openURL(CRB_PRIVACY_NOTICE_URL); } - }, [crbPrivacyNoticeUrl]); + }, []); const openCrbPrivacyPolicy = useCallback(() => { - if (crbPrivacyPolicyUrl) { - Linking.openURL(crbPrivacyPolicyUrl); + if (CRB_PRIVACY_POLICY_URL) { + Linking.openURL(CRB_PRIVACY_POLICY_URL); } - }, [crbPrivacyPolicyUrl]); + }, []); const handleAddressLine1Change = useCallback( (text: string) => { @@ -691,6 +654,7 @@ const PhysicalAddress = () => { handleStateChange={handleStateChange} zipCode={zipCode} handleZipCodeChange={handleZipCodeChange} + selectedCountry={selectedCountry} /> {/* Electronic Consent (US only) */} {selectedCountry?.key === 'US' && ( diff --git a/app/components/UI/Card/components/Onboarding/RegionSelectorModal.tsx b/app/components/UI/Card/components/Onboarding/RegionSelectorModal.tsx index a9bec105790..91662247223 100644 --- a/app/components/UI/Card/components/Onboarding/RegionSelectorModal.tsx +++ b/app/components/UI/Card/components/Onboarding/RegionSelectorModal.tsx @@ -23,8 +23,6 @@ import { } from '../../../../../util/navigation/navUtils'; import Routes from '../../../../../constants/navigation/Routes'; import { strings } from '../../../../../../locales/i18n'; -import { useSelector } from 'react-redux'; -import { selectSelectedCountry } from '../../../../../core/redux/slices/card'; import { Box, BoxAlignItems, @@ -32,15 +30,11 @@ import { Text, TextVariant, } from '@metamask/design-system-react-native'; +import type { Region } from '../../types'; const MAX_REGION_RESULTS = 20; -export interface Region { - key: string; // country code - name: string; - emoji?: string; - areaCode?: string; -} +export type { Region }; // Simple callback registry for onValueChange let onValueChangeCallback: ((region: Region) => void) | null = null; @@ -56,6 +50,7 @@ export const clearOnValueChange = () => { interface RegionSelectorModalParams { regions: Region[]; renderAreaCode?: boolean; + selectedRegionKey?: string | null; } export const createRegionSelectorModalNavigationDetails = @@ -67,9 +62,9 @@ export const createRegionSelectorModalNavigationDetails = function RegionSelectorModal() { const sheetRef = useRef(null); const listRef = useRef>(null); - const { regions, renderAreaCode } = useParams(); + const { regions, renderAreaCode, selectedRegionKey } = + useParams(); const [searchString, setSearchString] = useState(''); - const selectedCountry = useSelector(selectSelectedCountry); const [currentData, setCurrentData] = useState(regions || []); const { height: screenHeight } = useWindowDimensions(); @@ -140,7 +135,7 @@ function RegionSelectorModal() { return ( handleOnRegionPressCallback(region)} accessibilityRole="button" accessible @@ -168,7 +163,7 @@ function RegionSelectorModal() { ); }, - [selectedCountry, renderAreaCode, handleOnRegionPressCallback], + [selectedRegionKey, renderAreaCode, handleOnRegionPressCallback], ); const renderEmptyList = useCallback( @@ -234,7 +229,7 @@ function RegionSelectorModal() { style={listStyle} data={dataSearchResults} renderItem={renderRegionItem} - extraData={selectedCountry} + extraData={selectedRegionKey} keyExtractor={(item) => `${item?.key}-${item?.areaCode}`} ListEmptyComponent={renderEmptyList} keyboardDismissMode="none" diff --git a/app/components/UI/Card/components/Onboarding/SetPhoneNumber.test.tsx b/app/components/UI/Card/components/Onboarding/SetPhoneNumber.test.tsx index 26db65da436..83a012d51ec 100644 --- a/app/components/UI/Card/components/Onboarding/SetPhoneNumber.test.tsx +++ b/app/components/UI/Card/components/Onboarding/SetPhoneNumber.test.tsx @@ -5,7 +5,7 @@ import { configureStore } from '@reduxjs/toolkit'; import usePhoneVerificationSend from '../../hooks/usePhoneVerificationSend'; import { useDebouncedValue } from '../../../../hooks/useDebouncedValue'; import { CardError, CardErrorType } from '../../types'; -import useRegistrationSettings from '../../hooks/useRegistrationSettings'; +import useRegions from '../../hooks/useRegions'; import SetPhoneNumber from './SetPhoneNumber'; // Mock whenEngineReady to prevent async polling after test teardown @@ -21,6 +21,10 @@ jest.mock('@react-navigation/native', () => ({ })), })); +jest.mock('../../../../../util/navigation/navUtils', () => ({ + useParams: jest.fn(), +})); + // Mock i18n jest.mock('../../../../../../locales/i18n', () => ({ strings: (key: string) => key, @@ -28,12 +32,27 @@ jest.mock('../../../../../../locales/i18n', () => ({ // Mock hooks jest.mock('../../hooks/usePhoneVerificationSend'); -jest.mock('../../hooks/useRegistrationSettings'); +jest.mock('../../hooks/useRegions'); jest.mock('../../../../hooks/useDebouncedValue'); jest.mock('../../sdk', () => ({ useCardSDK: jest.fn(), })); +// Capture setOnValueChange callbacks and navigation args so tests can simulate +// a user picking a country from the region selector modal. +let capturedOnValueChange: ((region: unknown) => void) | null = null; +const mockCreateRegionSelectorModalNavigationDetails = jest.fn( + (params: unknown) => ['RegionSelectorModal', { params }] as const, +); +jest.mock('./RegionSelectorModal', () => ({ + setOnValueChange: jest.fn((cb) => { + capturedOnValueChange = cb; + }), + clearOnValueChange: jest.fn(), + createRegionSelectorModalNavigationDetails: (params: unknown) => + mockCreateRegionSelectorModalNavigationDetails(params), +})); + import { useCardSDK } from '../../sdk'; // Mock OnboardingStep @@ -70,15 +89,9 @@ jest.mock('./OnboardingStep', () => { ); }); -// Default card state +// Default card state (no selectedCountry - useRegions provides userCountry) const defaultCardState = { onboarding: { - selectedCountry: { - key: 'US', - name: 'United States', - emoji: '🇺🇸', - areaCode: '1', - }, contactVerificationId: 'test-verification-id', }, userCardLocation: null, @@ -119,12 +132,6 @@ const createInternationalUserStore = (overrides = {}) => createTestStore({ userCardLocation: 'international', onboarding: { - selectedCountry: { - key: 'GB', - name: 'United Kingdom', - emoji: '🇬🇧', - areaCode: '44', - }, contactVerificationId: 'test-verification-id', }, ...overrides, @@ -153,31 +160,45 @@ describe('SetPhoneNumber Component', () => { reset: jest.fn(), }); - (useRegistrationSettings as jest.Mock).mockReturnValue({ - data: { - countries: [ - { - iso3166alpha2: 'US', - name: 'United States', - callingCode: '1', - canSignUp: true, - }, - { - iso3166alpha2: 'CA', - name: 'Canada', - callingCode: '1', - canSignUp: true, - }, - { - iso3166alpha2: 'GB', - name: 'United Kingdom', - callingCode: '44', - canSignUp: true, - }, - ], + const defaultSignUpRegions = [ + { + key: 'US', + name: 'United States', + emoji: '🇺🇸', + areaCode: '1', + canSignUp: true, + }, + { + key: 'CA', + name: 'Canada', + emoji: '🇨🇦', + areaCode: '1', + canSignUp: true, + }, + { + key: 'GB', + name: 'United Kingdom', + emoji: '🇬🇧', + areaCode: '44', + canSignUp: true, }, + ]; + const defaultUserCountry = defaultSignUpRegions[0]; + + (useRegions as jest.Mock).mockReturnValue({ + signUpRegions: defaultSignUpRegions, + userCountry: defaultUserCountry, + getRegionByCode: (code: string) => + defaultSignUpRegions.find((r) => r.key === code) ?? null, + isLoading: false, }); + jest + .requireMock('../../../../../util/navigation/navUtils') + .useParams.mockReturnValue({ + countryKey: undefined, + }); + (useDebouncedValue as jest.Mock).mockImplementation((value) => value); (useCardSDK as jest.Mock).mockReturnValue({ @@ -351,6 +372,206 @@ describe('SetPhoneNumber Component', () => { expect(mockNavigate).toHaveBeenCalled(); }); + + it('highlights the most recently selected country when reopening the modal, not the original profile country', () => { + // userCountry from the profile is US (+1) + // The user opens the modal and picks GB (+44) instead + // Reopening the modal must highlight GB, not US + const { getByTestId } = render( + + + , + ); + + const countrySelect = getByTestId( + 'set-phone-number-country-area-code-select', + ); + + // First open: selectedRegionKey should be userCountry.key ('US') + fireEvent.press(countrySelect); + const firstCallParams = + mockCreateRegionSelectorModalNavigationDetails.mock.calls[0][0]; + expect(firstCallParams).toMatchObject({ selectedRegionKey: 'US' }); + + // Simulate user picking GB from the modal + act(() => { + capturedOnValueChange?.({ + key: 'GB', + name: 'United Kingdom', + emoji: '🇬🇧', + areaCode: '44', + canSignUp: true, + }); + }); + + // Second open: selectedRegionKey must now be 'GB', not 'US' + fireEvent.press(countrySelect); + const secondCallParams = + mockCreateRegionSelectorModalNavigationDetails.mock.calls[1][0]; + expect(secondCallParams).toMatchObject({ selectedRegionKey: 'GB' }); + }); + }); + + describe('Country Pre-selection', () => { + it('pre-selects country from countryKey nav param, ignoring userCountry', () => { + // countryKey = 'GB' even though userCountry defaults to 'US' + jest + .requireMock('../../../../../util/navigation/navUtils') + .useParams.mockReturnValue({ countryKey: 'GB' }); + + const { getByTestId } = render( + + + , + ); + + // SelectField renders the value as text content inside a TouchableOpacity. + // Area code for GB is +44. + const countrySelect = getByTestId( + 'set-phone-number-country-area-code-select', + ); + expect(countrySelect).toHaveTextContent(/\+44/); + }); + + it('falls back to userCountry when countryKey nav param is absent', () => { + // Default beforeEach: countryKey = undefined, userCountry = US (+1) + const { getByTestId } = render( + + + , + ); + + const countrySelect = getByTestId( + 'set-phone-number-country-area-code-select', + ); + expect(countrySelect).toHaveTextContent(/\+1/); + }); + + it('does not overwrite pre-selected country when userCountry reference changes', () => { + // countryKey resolves to GB; a subsequent re-fetch that creates a new userCountry + // object reference must not reset the selection back to US. + jest + .requireMock('../../../../../util/navigation/navUtils') + .useParams.mockReturnValue({ countryKey: 'GB' }); + + const { getByTestId, rerender } = render( + + + , + ); + + // Simulate registrationSettings refetch: new userCountry object reference, same data + const regions = [ + { + key: 'US', + name: 'United States', + emoji: '🇺🇸', + areaCode: '1', + canSignUp: true, + }, + { + key: 'CA', + name: 'Canada', + emoji: '🇨🇦', + areaCode: '1', + canSignUp: true, + }, + { + key: 'GB', + name: 'United Kingdom', + emoji: '🇬🇧', + areaCode: '44', + canSignUp: true, + }, + ]; + (useRegions as jest.Mock).mockReturnValue({ + signUpRegions: regions, + userCountry: { ...regions[0] }, // new US object reference + getRegionByCode: (code: string) => + regions.find((r) => r.key === code) ?? null, + isLoading: false, + }); + + rerender( + + + , + ); + + // GB must still be selected — the sync effect must not have fired + const countrySelect = getByTestId( + 'set-phone-number-country-area-code-select', + ); + expect(countrySelect).toHaveTextContent(/\+44/); + }); + + it('does not overwrite manual selection when userCountry reference changes', () => { + // Start with no countryKey; userCountry = US. User manually picks GB via the + // region selector. A subsequent re-fetch produces a new userCountry reference + // (US) — it must not reset the selection back to US. + const { getByTestId, rerender } = render( + + + , + ); + + const countrySelect = getByTestId( + 'set-phone-number-country-area-code-select', + ); + + // Open the modal to register the onValueChange callback, then pick GB + fireEvent.press(countrySelect); + act(() => { + capturedOnValueChange?.({ + key: 'GB', + name: 'United Kingdom', + emoji: '🇬🇧', + areaCode: '44', + canSignUp: true, + }); + }); + + // Simulate re-fetch: new userCountry reference (US), same data + const regions = [ + { + key: 'US', + name: 'United States', + emoji: '🇺🇸', + areaCode: '1', + canSignUp: true, + }, + { + key: 'CA', + name: 'Canada', + emoji: '🇨🇦', + areaCode: '1', + canSignUp: true, + }, + { + key: 'GB', + name: 'United Kingdom', + emoji: '🇬🇧', + areaCode: '44', + canSignUp: true, + }, + ]; + (useRegions as jest.Mock).mockReturnValue({ + signUpRegions: regions, + userCountry: { ...regions[0] }, + getRegionByCode: (code: string) => + regions.find((r) => r.key === code) ?? null, + isLoading: false, + }); + + rerender( + + + , + ); + + // GB must still be selected + expect(countrySelect).toHaveTextContent(/\+44/); + }); }); describe('Continue Button State Management', () => { @@ -607,12 +828,6 @@ describe('SetPhoneNumber Component', () => { it('handles missing contact verification ID', () => { const storeWithoutVerificationId = createTestStore({ onboarding: { - selectedCountry: { - key: 'US', - name: 'United States', - emoji: '🇺🇸', - areaCode: '1', - }, contactVerificationId: null, }, }); @@ -628,8 +843,11 @@ describe('SetPhoneNumber Component', () => { }); it('handles missing registration settings', () => { - (useRegistrationSettings as jest.Mock).mockReturnValue({ - data: null, + (useRegions as jest.Mock).mockReturnValue({ + signUpRegions: [], + userCountry: null, + getRegionByCode: () => null, + isLoading: false, }); const { getByTestId } = render( @@ -646,20 +864,23 @@ describe('SetPhoneNumber Component', () => { }); it('handles missing selected country area code', () => { - const storeWithNoAreaCode = createTestStore({ - onboarding: { - selectedCountry: { - key: 'XX', - name: 'Unknown Country', - emoji: '🏳️', - // areaCode is undefined - }, - contactVerificationId: 'test-verification-id', - }, + const unknownCountry = { + key: 'XX', + name: 'Unknown Country', + emoji: '🏳️', + areaCode: undefined, + canSignUp: true, + }; + (useRegions as jest.Mock).mockReturnValue({ + signUpRegions: [unknownCountry], + userCountry: unknownCountry, + getRegionByCode: (code: string) => + code === 'XX' ? unknownCountry : null, + isLoading: false, }); const { getByTestId } = render( - + , ); diff --git a/app/components/UI/Card/components/Onboarding/SetPhoneNumber.tsx b/app/components/UI/Card/components/Onboarding/SetPhoneNumber.tsx index fe069f47d2c..68bd8541732 100644 --- a/app/components/UI/Card/components/Onboarding/SetPhoneNumber.tsx +++ b/app/components/UI/Card/components/Onboarding/SetPhoneNumber.tsx @@ -1,4 +1,10 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { useNavigation } from '@react-navigation/native'; import { Box, @@ -17,27 +23,23 @@ import { strings } from '../../../../../../locales/i18n'; import OnboardingStep from './OnboardingStep'; import { useDebouncedValue } from '../../../../hooks/useDebouncedValue'; import usePhoneVerificationSend from '../../hooks/usePhoneVerificationSend'; -import useRegistrationSettings from '../../hooks/useRegistrationSettings'; +import useRegions from '../../hooks/useRegions'; +import { useParams } from '../../../../../util/navigation/navUtils'; import { resetOnboardingState, selectContactVerificationId, - selectSelectedCountry, selectUserCardLocation, - setSelectedCountry, } from '../../../../../core/redux/slices/card'; import { useDispatch, useSelector } from 'react-redux'; -import { CardError } from '../../types'; +import { CardError, Region } from '../../types'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { CardActions, CardScreens } from '../../util/metrics'; -import { countryCodeToFlag } from '../../util/countryCodeToFlag'; import { clearOnValueChange, createRegionSelectorModalNavigationDetails, - Region, setOnValueChange, } from './RegionSelectorModal'; -import { useCardSDK } from '../../sdk'; import SelectField from './SelectField'; const US_PHONE_REGEX = /^[2-9]\d{2}[2-9]\d{6}$/; @@ -46,66 +48,38 @@ const SetPhoneNumber = () => { const navigation = useNavigation(); const dispatch = useDispatch(); const contactVerificationId = useSelector(selectContactVerificationId); - const initialSelectedCountry = useSelector(selectSelectedCountry); const { trackEvent, createEventBuilder } = useAnalytics(); - const { data: registrationSettings } = useRegistrationSettings(); - const { user } = useCardSDK(); + const { signUpRegions, userCountry, getRegionByCode } = useRegions(); const userCardLocation = useSelector(selectUserCardLocation); - - const regions: Region[] = useMemo(() => { - if (!registrationSettings?.countries) { - return []; - } - return [...registrationSettings.countries] - .sort((a, b) => a.name.localeCompare(b.name)) - .filter((country) => country.canSignUp) - .map((country) => ({ - key: country.iso3166alpha2, - name: country.name, - emoji: countryCodeToFlag(country.iso3166alpha2), - areaCode: country.callingCode, - })); - }, [registrationSettings]); - - const selectedCountry = useMemo( - () => - initialSelectedCountry || - regions.find((region) => region.key === user?.countryOfResidence), - [initialSelectedCountry, regions, user?.countryOfResidence], - ); - - useEffect(() => { - if (!initialSelectedCountry && selectedCountry) { - dispatch(setSelectedCountry(selectedCountry)); - } - }, [selectedCountry, dispatch, initialSelectedCountry]); - + const { countryKey } = useParams<{ countryKey?: string }>(); const [phoneNumber, setPhoneNumber] = useState(''); const [isPhoneNumberError, setIsPhoneNumberError] = useState(false); const [isUsPhoneNumberError, setIsUsPhoneNumberError] = useState(false); - const [selectedCountryAreaCode, setSelectedCountryAreaCode] = - useState(selectedCountry?.areaCode || ''); - const [selectedCountryEmoji, setSelectedCountryEmoji] = useState( - selectedCountry?.emoji || '', + const [selectedCountry, setSelectedCountry] = useState( + () => getRegionByCode(countryKey) ?? userCountry ?? null, ); - + const hasAutoSelected = useRef(selectedCountry !== null); const isUsUser = userCardLocation === 'us'; // For US users, only show US in the region selector const availableRegions = useMemo(() => { if (isUsUser) { - return regions.filter((region) => region.key === 'US'); + return signUpRegions.filter((region) => region.key === 'US'); } - return regions; - }, [regions, isUsUser]); + return signUpRegions; + }, [signUpRegions, isUsUser]); - // Sync local state when selectedCountry changes (e.g., after regions load) + // Sync local state once when registration settings first become available + // (cache miss on first render). Preserves countryKey nav param priority over + // userCountry, mirroring the lazy initializer's resolution order. useEffect(() => { - if (selectedCountry) { - setSelectedCountryAreaCode(selectedCountry.areaCode || ''); - setSelectedCountryEmoji(selectedCountry.emoji || ''); + if (hasAutoSelected.current) return; + const country = getRegionByCode(countryKey) ?? userCountry ?? null; + if (country) { + hasAutoSelected.current = true; + setSelectedCountry(country); } - }, [selectedCountry]); + }, [userCountry, getRegionByCode, countryKey]); const debouncedPhoneNumber = useDebouncedValue(phoneNumber, 1000); const { @@ -127,7 +101,8 @@ const SetPhoneNumber = () => { }, [trackEvent, createEventBuilder]); const handleContinue = async () => { - if (!phoneNumber || !selectedCountryAreaCode || !contactVerificationId) { + const areaCode = selectedCountry?.areaCode; + if (!phoneNumber || !areaCode || !contactVerificationId) { return; } @@ -148,19 +123,19 @@ const SetPhoneNumber = () => { createEventBuilder(MetaMetricsEvents.CARD_BUTTON_CLICKED) .addProperties({ action: CardActions.SET_PHONE_NUMBER_BUTTON, - phone_number_country_code: selectedCountryAreaCode, + phone_number_country_code: areaCode, }) .build(), ); const { success } = await sendPhoneVerification({ - phoneCountryCode: selectedCountryAreaCode, + phoneCountryCode: areaCode, phoneNumber, contactVerificationId, }); if (success) { navigation.navigate(Routes.CARD.ONBOARDING.CONFIRM_PHONE_NUMBER, { - phoneCountryCode: selectedCountryAreaCode, + phoneCountryCode: areaCode, phoneNumber, }); } @@ -181,17 +156,23 @@ const SetPhoneNumber = () => { setIsUsPhoneNumberError(false); setOnValueChange((region) => { - setSelectedCountryAreaCode(region.areaCode || ''); - setSelectedCountryEmoji(region.emoji || ''); + hasAutoSelected.current = true; + setSelectedCountry(region); }); navigation.navigate( ...createRegionSelectorModalNavigationDetails({ regions: availableRegions, renderAreaCode: true, + selectedRegionKey: selectedCountry?.key ?? null, }), ); - }, [navigation, availableRegions, resetPhoneVerificationSend]); + }, [ + navigation, + availableRegions, + selectedCountry?.key, + resetPhoneVerificationSend, + ]); const handlePhoneNumberChange = (text: string) => { resetPhoneVerificationSend(); @@ -228,7 +209,7 @@ const SetPhoneNumber = () => { return ( !phoneNumber || - !selectedCountryAreaCode || + !selectedCountry?.areaCode || !contactVerificationId || !isCurrentPhoneNumberValid || !isUsPhoneValid || @@ -237,7 +218,7 @@ const SetPhoneNumber = () => { ); }, [ phoneNumber, - selectedCountryAreaCode, + selectedCountry?.areaCode, contactVerificationId, phoneVerificationIsLoading, phoneVerificationIsError, @@ -255,7 +236,7 @@ const SetPhoneNumber = () => { ({ // Mock hooks jest.mock('../../hooks/useEmailVerificationSend'); -jest.mock('../../hooks/useRegistrationSettings', () => ({ +const mockSignUpRegions = [ + { key: 'US', name: 'United States', emoji: '🇺🇸', canSignUp: true }, + { key: 'CA', name: 'Canada', emoji: '🇨🇦', canSignUp: true }, + { key: 'GB', name: 'United Kingdom', emoji: '🇬🇧', canSignUp: false }, + { key: 'DE', name: 'Germany', emoji: '🇩🇪', canSignUp: true }, +]; +const mockGetRegionByCode = (code: string) => + mockSignUpRegions.find((r) => r.key === code) ?? null; +jest.mock('../../hooks/useRegions', () => ({ __esModule: true, default: jest.fn(() => ({ - data: { - countries: [ - { iso3166alpha2: 'US', name: 'United States', canSignUp: true }, - { iso3166alpha2: 'CA', name: 'Canada', canSignUp: true }, - { iso3166alpha2: 'GB', name: 'United Kingdom', canSignUp: false }, - { iso3166alpha2: 'DE', name: 'Germany', canSignUp: true }, - ], - }, + signUpRegions: mockSignUpRegions, + getRegionByCode: mockGetRegionByCode, + isLoading: false, })), })); jest.mock('../../../../hooks/useDebouncedValue'); @@ -100,14 +103,6 @@ const createTestStore = (initialState = {}) => action = { type: '', payload: null }, ) => { switch (action.type) { - case 'card/setSelectedCountry': - return { - ...state, - onboarding: { - ...state.onboarding, - selectedCountry: action.payload, - }, - }; case 'card/setUserCardLocation': return { ...state, @@ -406,13 +401,10 @@ describe('SignUp Component', () => { , ); - expect(storeWithGeo.getState().card.onboarding.selectedCountry).toEqual( - expect.objectContaining({ key: 'US', name: 'United States' }), - ); expect(storeWithGeo.getState().card.userCardLocation).toBe('us'); }); - it('does not prefill country when geoLocation is UNKNOWN', () => { + it('does not set userCardLocation when geoLocation is UNKNOWN', () => { const storeWithUnknown = createTestStore({ geoLocation: 'UNKNOWN' }); render( @@ -421,12 +413,12 @@ describe('SignUp Component', () => { , ); - expect( - storeWithUnknown.getState().card.onboarding.selectedCountry, - ).toBeNull(); + expect(storeWithUnknown.getState().card.userCardLocation).toBe( + 'international', + ); }); - it('does not prefill country when geoLocation does not match any available region', () => { + it('does not set userCardLocation when geoLocation does not match any available region', () => { const storeWithUnsupported = createTestStore({ geoLocation: 'JP' }); render( @@ -435,48 +427,79 @@ describe('SignUp Component', () => { , ); - expect( - storeWithUnsupported.getState().card.onboarding.selectedCountry, - ).toBeNull(); + expect(storeWithUnsupported.getState().card.userCardLocation).toBe( + 'international', + ); }); - it('does not override an already selected country with geoLocation', () => { - const storeWithExisting = createTestStore({ - geoLocation: 'US', - onboarding: { - selectedCountry: { key: 'DE', name: 'Germany' }, - onboardingId: null, - contactVerificationId: null, - user: null, - }, + it('does not pre-select country when geoLocation matches a canSignUp: false country', () => { + // GB exists in allRegions but has canSignUp: false — must not be pre-selected + const storeWithGB = createTestStore({ geoLocation: 'GB' }); + + const { queryByText, getByTestId } = render( + + + , + ); + + expect(queryByText('United Kingdom')).toBeNull(); + // Continue button must remain disabled — no eligible country was selected + expect(getByTestId('signup-continue-button').props.disabled).toBe(true); + expect(storeWithGB.getState().card.userCardLocation).toBe( + 'international', + ); + }); + + it('does not re-run auto-selection when getRegionByCode reference changes after initial selection', () => { + // Simulates a background re-fetch of registrationSettings that produces a + // new getRegionByCode reference without changing the actual data. + const storeWithGeo = createTestStore({ geoLocation: 'US' }); + const mockUseRegions = jest.requireMock('../../hooks/useRegions').default; + + const firstGetRegionByCode = jest.fn(mockGetRegionByCode); + mockUseRegions.mockReturnValue({ + signUpRegions: mockSignUpRegions, + getRegionByCode: firstGetRegionByCode, + isLoading: false, }); - render( - + const { getByText, rerender } = render( + , ); - expect( - storeWithExisting.getState().card.onboarding.selectedCountry, - ).toEqual(expect.objectContaining({ key: 'DE', name: 'Germany' })); + // US was auto-selected on first render + expect(getByText('United States')).toBeOnTheScreen(); + expect(firstGetRegionByCode).toHaveBeenCalledTimes(1); + + // Simulate background refetch: new function identity, same data + const secondGetRegionByCode = jest.fn(mockGetRegionByCode); + mockUseRegions.mockReturnValue({ + signUpRegions: mockSignUpRegions, + getRegionByCode: secondGetRegionByCode, + isLoading: false, + }); + + rerender( + + + , + ); + + // hasAutoSelectedCountry ref must have blocked the second run + expect(secondGetRegionByCode).not.toHaveBeenCalled(); + expect(getByText('United States')).toBeOnTheScreen(); }); }); describe('Form Validation', () => { it('enables continue button when all fields are valid', async () => { - // Create store with pre-selected country - const storeWithCountry = createTestStore({ - onboarding: { - selectedCountry: { key: 'US', name: 'United States' }, - onboardingId: null, - contactVerificationId: null, - user: null, - }, - }); + // useRegions is mocked to return signUpRegions; geoLocation US prefills country via effect + const storeWithGeo = createTestStore({ geoLocation: 'US' }); const { getByTestId } = render( - + , ); @@ -502,17 +525,10 @@ describe('SignUp Component', () => { it('keeps continue button disabled when email is invalid', async () => { (validateEmail as jest.Mock).mockReturnValue(false); - const storeWithCountry = createTestStore({ - onboarding: { - selectedCountry: { key: 'US', name: 'United States' }, - onboardingId: null, - contactVerificationId: null, - user: null, - }, - }); + const storeWithGeo = createTestStore({ geoLocation: 'US' }); const { getByTestId } = render( - + , ); @@ -587,17 +603,10 @@ describe('SignUp Component', () => { describe('Form Submission', () => { it('calls sendEmailVerification when continue button is pressed', async () => { - const storeWithCountry = createTestStore({ - onboarding: { - selectedCountry: { key: 'US', name: 'United States' }, - onboardingId: null, - contactVerificationId: null, - user: null, - }, - }); + const storeWithGeo = createTestStore({ geoLocation: 'US' }); const { getByTestId } = render( - + , ); @@ -622,6 +631,42 @@ describe('SignUp Component', () => { expect(mockSendEmailVerification).toHaveBeenCalled(); }); + it('passes countryKey to ConfirmEmail navigation params', async () => { + const storeWithGeo = createTestStore({ geoLocation: 'US' }); + + const { getByTestId } = render( + + + , + ); + + const emailInput = getByTestId('signup-email-input'); + const passwordInput = getByTestId('signup-password-input'); + const continueButton = getByTestId('signup-continue-button'); + + await act(async () => { + fireEvent.changeText(emailInput, 'test@example.com'); + fireEvent.changeText(passwordInput, 'Password123!'); + }); + + await waitFor(() => { + expect(continueButton.props.disabled).toBe(false); + }); + + await act(async () => { + fireEvent.press(continueButton); + }); + + expect(mockNavigate).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + email: 'test@example.com', + password: 'Password123!', + countryKey: 'US', + }), + ); + }); + it('does not call sendEmailVerification when continue button is disabled', async () => { const { getByTestId } = render( diff --git a/app/components/UI/Card/components/Onboarding/SignUp.tsx b/app/components/UI/Card/components/Onboarding/SignUp.tsx index 7942b7994b7..4d3f672a15f 100644 --- a/app/components/UI/Card/components/Onboarding/SignUp.tsx +++ b/app/components/UI/Card/components/Onboarding/SignUp.tsx @@ -1,4 +1,10 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { useNavigation } from '@react-navigation/native'; import { Box, @@ -22,12 +28,10 @@ import OnboardingStep from './OnboardingStep'; import { validateEmail } from '../../../Ramp/Deposit/utils'; import { useDebouncedValue } from '../../../../hooks/useDebouncedValue'; import useEmailVerificationSend from '../../hooks/useEmailVerificationSend'; -import useRegistrationSettings from '../../hooks/useRegistrationSettings'; +import useRegions from '../../hooks/useRegions'; import { selectCardGeoLocation, - selectSelectedCountry, setContactVerificationId, - setSelectedCountry, setUserCardLocation, } from '../../../../../core/redux/slices/card'; import { useDispatch, useSelector } from 'react-redux'; @@ -39,11 +43,11 @@ import { ActivityIndicator, TouchableOpacity } from 'react-native'; import { clearOnValueChange, createRegionSelectorModalNavigationDetails, - Region, setOnValueChange, } from './RegionSelectorModal'; -import { countryCodeToFlag } from '../../util/countryCodeToFlag'; import SelectField from './SelectField'; +import { mapCountryToLocation } from '../../util/mapCountryToLocation'; +import type { Region } from '../../types'; const SignUp = () => { const navigation = useNavigation(); @@ -55,12 +59,14 @@ const SignUp = () => { const [isPasswordError, setIsPasswordError] = useState(false); const [isPasswordValid, setIsPasswordValid] = useState(false); const [isPasswordVisible, setIsPasswordVisible] = useState(false); - const selectedCountry = useSelector(selectSelectedCountry); + const [selectedCountry, setSelectedCountry] = useState(null); + const hasAutoSelectedCountry = useRef(false); const geoLocation = useSelector(selectCardGeoLocation); const { - data: registrationSettings, + signUpRegions, + getRegionByCode, isLoading: isLoadingRegistrationSettings, - } = useRegistrationSettings(); + } = useRegions(); const { trackEvent, createEventBuilder } = useAnalytics(); useEffect(() => { @@ -84,36 +90,28 @@ const SignUp = () => { const debouncedEmail = useDebouncedValue(email, 1000); const debouncedPassword = useDebouncedValue(password, 1000); - const regions: Region[] = useMemo(() => { - if (!registrationSettings?.countries) { - return []; + useEffect(() => { + if (!signUpRegions.length || geoLocation === 'UNKNOWN') { + return; } - return [...registrationSettings.countries] - .sort((a, b) => a.name.localeCompare(b.name)) - .filter((country) => country.canSignUp) - .map((country) => ({ - key: country.iso3166alpha2, - name: country.name, - emoji: countryCodeToFlag(country.iso3166alpha2), - areaCode: country.callingCode, - })); - }, [registrationSettings]); - useEffect(() => { - if (selectedCountry || !regions.length || geoLocation === 'UNKNOWN') { + // Run at most once: prevents a background re-fetch of registrationSettings + // (which produces a new getRegionByCode reference) from overwriting the + // user's manual country selection. + if (hasAutoSelectedCountry.current) { return; } - const matchedRegion = regions.find((region) => region.key === geoLocation); - if (matchedRegion) { - dispatch(setSelectedCountry(matchedRegion)); - dispatch( - setUserCardLocation( - matchedRegion.key === 'US' ? 'us' : 'international', - ), - ); + const matchedRegion = getRegionByCode(geoLocation); + + // Only pre-select countries that are eligible for sign-up. + // getRegionByCode searches allRegions, which includes canSignUp: false entries. + if (matchedRegion?.canSignUp) { + hasAutoSelectedCountry.current = true; + setSelectedCountry(matchedRegion); + dispatch(setUserCardLocation(mapCountryToLocation(matchedRegion.key))); } - }, [regions, geoLocation, selectedCountry, dispatch]); + }, [signUpRegions.length, geoLocation, dispatch, getRegionByCode]); useEffect(() => { if (!debouncedEmail) { @@ -201,6 +199,7 @@ const SignUp = () => { navigation.navigate(Routes.CARD.ONBOARDING.CONFIRM_EMAIL, { email, password, + countryKey: selectedCountry.key, }); } else { // If no contactVerificationId, assume user is registered or email not valid @@ -212,35 +211,35 @@ const SignUp = () => { }, [ email, password, - selectedCountry, trackEvent, createEventBuilder, sendEmailVerification, dispatch, navigation, + selectedCountry, ]); const handleCountrySelect = useCallback(() => { if (isLoadingRegistrationSettings) return; resetEmailVerificationSend(); setOnValueChange((region) => { - dispatch(setSelectedCountry(region)); - dispatch( - setUserCardLocation(region.key === 'US' ? 'us' : 'international'), - ); + setSelectedCountry(region); + dispatch(setUserCardLocation(mapCountryToLocation(region.key))); }); navigation.navigate( ...createRegionSelectorModalNavigationDetails({ - regions, + regions: signUpRegions, + selectedRegionKey: selectedCountry?.key ?? null, }), ); }, [ - dispatch, navigation, - regions, + signUpRegions, + selectedCountry?.key, resetEmailVerificationSend, isLoadingRegistrationSettings, + dispatch, ]); useEffect(() => () => clearOnValueChange(), []); diff --git a/app/components/UI/Card/components/Onboarding/VerifyIdentity.test.tsx b/app/components/UI/Card/components/Onboarding/VerifyIdentity.test.tsx index 5337f2e321d..36ec6b1e498 100644 --- a/app/components/UI/Card/components/Onboarding/VerifyIdentity.test.tsx +++ b/app/components/UI/Card/components/Onboarding/VerifyIdentity.test.tsx @@ -9,6 +9,7 @@ import { mockTheme } from '../../../../../util/theme'; import VerifyIdentity from './VerifyIdentity'; import Routes from '../../../../../constants/navigation/Routes'; import useStartVerification from '../../hooks/useStartVerification'; +import useRegions from '../../hooks/useRegions'; // Mock dependencies jest.mock('@react-navigation/native', () => ({ @@ -44,6 +45,8 @@ jest.mock('../../../../../util/Logger', () => ({ // Mock useStartVerification hook jest.mock('../../hooks/useStartVerification'); +jest.mock('../../hooks/useRegions'); + // Mock useAnalytics hook const mockTrackEvent = jest.fn(); const mockCreateEventBuilder = jest.fn(() => ({ @@ -273,6 +276,10 @@ describe('VerifyIdentity Component', () => { error: null, }); + (useRegions as jest.Mock).mockReturnValue({ + userCountry: { key: 'US', name: 'United States', emoji: '🇺🇸' }, + }); + (VeriffSdk.launchVeriff as jest.Mock).mockResolvedValue({ status: VeriffSdk.statusDone, }); @@ -686,6 +693,9 @@ describe('VerifyIdentity Component', () => { it('uses correct i18n keys for terms text', () => { const { strings } = jest.requireMock('../../../../../../locales/i18n'); + (useRegions as jest.Mock).mockReturnValue({ + userCountry: { key: 'CA', name: 'Canada', emoji: '🇨🇦' }, + }); render( diff --git a/app/components/UI/Card/components/Onboarding/VerifyIdentity.tsx b/app/components/UI/Card/components/Onboarding/VerifyIdentity.tsx index 03022f30856..63314b3ce43 100644 --- a/app/components/UI/Card/components/Onboarding/VerifyIdentity.tsx +++ b/app/components/UI/Card/components/Onboarding/VerifyIdentity.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Image } from 'react-native'; import { useNavigation, StackActions } from '@react-navigation/native'; -import { useSelector } from 'react-redux'; import VeriffSdk, { type VeriffBranding } from '@veriff/react-native-sdk'; import OnboardingStep from './OnboardingStep'; import { strings } from '../../../../../../locales/i18n'; @@ -26,17 +25,17 @@ import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { CardActions, CardScreens } from '../../util/metrics'; import MM_CARD_VERIFY_IDENTITY from '../../../../../images/card-fingerprint-kyc-image.png'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { selectSelectedCountry } from '../../../../../core/redux/slices/card'; import Logger from '../../../../../util/Logger'; import { useTheme } from '../../../../../util/theme'; import { brandColor } from '@metamask/design-tokens'; +import useRegions from '../../hooks/useRegions'; const VerifyIdentity = () => { const navigation = useNavigation(); const tw = useTailwind(); const { colors } = useTheme(); const { trackEvent, createEventBuilder } = useAnalytics(); - const selectedCountry = useSelector(selectSelectedCountry); + const { userCountry: selectedCountry } = useRegions(); const [isLaunchingVeriff, setIsLaunchingVeriff] = useState(false); const veriffBranding: VeriffBranding = useMemo( diff --git a/app/components/UI/Card/constants.ts b/app/components/UI/Card/constants.ts index 10d885bb881..1191249de21 100644 --- a/app/components/UI/Card/constants.ts +++ b/app/components/UI/Card/constants.ts @@ -9,6 +9,15 @@ const infuraProjectId = InfuraKey === 'null' ? '' : InfuraKey; export const LINEA_MAINNET_RPC_URL = `https://linea-mainnet.infura.io/v3/${infuraProjectId}`; export const BASE_MAINNET_RPC_URL = `https://base-mainnet.infura.io/v3/${infuraProjectId}`; export const MONAD_MAINNET_RPC_URL = `https://monad-mainnet.infura.io/v3/${infuraProjectId}`; +export const COINME_TERMS_URL = 'https://coinme.com/legal/'; +export const CRB_TERMS_URL = + 'https://baanx-public.s3-eu-west-1.amazonaws.com/Ledger/public-files/BaanxUS_CLCard_TOS.undefined-fddb292f91ce3.pdf'; +export const CRB_ACCOUNT_OPENING_URL = + 'https://secure.baanx.co.uk/BAANX_US_ACCOUNT_OPENING_AGREEMENTS_AND_DISCLOSURES_08152025.pdf'; +export const CRB_PRIVACY_NOTICE_URL = + 'https://secure.baanx.co.uk/Baanx_(CL)_U.S._Privacy_Notice_06.2025.pdf'; +export const CRB_PRIVACY_POLICY_URL = + 'https://www.crossriver.com/legal/privacy-notice'; export const BALANCE_SCANNER_ABI = balanceScannerAbi as ethers.ContractInterface; export const ARBITRARY_ALLOWANCE = 100000000000; diff --git a/app/components/UI/Card/hooks/useRegions.test.ts b/app/components/UI/Card/hooks/useRegions.test.ts new file mode 100644 index 00000000000..69a4a3c3387 --- /dev/null +++ b/app/components/UI/Card/hooks/useRegions.test.ts @@ -0,0 +1,216 @@ +import { renderHook } from '@testing-library/react-hooks'; +import useRegions from './useRegions'; +import { useCardSDK } from '../sdk'; +import useRegistrationSettings from './useRegistrationSettings'; + +jest.mock('../sdk', () => ({ + useCardSDK: jest.fn(), +})); + +jest.mock('./useRegistrationSettings', () => ({ + __esModule: true, + default: jest.fn(), +})); + +const mockUseCardSDK = useCardSDK as jest.MockedFunction; +const mockUseRegistrationSettings = + useRegistrationSettings as jest.MockedFunction< + typeof useRegistrationSettings + >; + +const MOCK_COUNTRIES = [ + { + id: '1', + name: 'Germany', + iso3166alpha2: 'DE', + callingCode: '49', + canSignUp: true, + }, + { + id: '2', + name: 'United States', + iso3166alpha2: 'US', + callingCode: '1', + canSignUp: true, + }, + { + id: '3', + name: 'Canada', + iso3166alpha2: 'CA', + callingCode: '1', + canSignUp: false, + }, + { + id: '4', + name: 'Albania', + iso3166alpha2: 'AL', + callingCode: '355', + canSignUp: true, + }, +]; + +describe('useRegions', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseCardSDK.mockReturnValue({ + user: { + countryOfResidence: 'US', + countryOfNationality: 'DE', + }, + } as ReturnType); + mockUseRegistrationSettings.mockReturnValue({ + data: { countries: MOCK_COUNTRIES }, + isLoading: false, + } as ReturnType); + }); + + describe('allRegions', () => { + it('returns all countries sorted by name', () => { + const { result } = renderHook(() => useRegions()); + + expect(result.current.allRegions).toHaveLength(4); + expect(result.current.allRegions.map((r) => r.name)).toEqual([ + 'Albania', + 'Canada', + 'Germany', + 'United States', + ]); + }); + + it('maps country fields to Region with key, name, emoji, areaCode, canSignUp', () => { + const { result } = renderHook(() => useRegions()); + + const us = result.current.allRegions.find((r) => r.key === 'US'); + expect(us).toBeDefined(); + expect(us?.name).toBe('United States'); + expect(us?.areaCode).toBe('1'); + expect(us?.canSignUp).toBe(true); + expect(us?.emoji).toBeDefined(); + }); + + it('returns empty array when registration settings data is null', () => { + mockUseRegistrationSettings.mockReturnValue({ + data: null, + isLoading: false, + } as ReturnType); + + const { result } = renderHook(() => useRegions()); + + expect(result.current.allRegions).toEqual([]); + }); + }); + + describe('signUpRegions', () => { + it('filters to regions with canSignUp === true', () => { + const { result } = renderHook(() => useRegions()); + + expect(result.current.signUpRegions).toHaveLength(3); + expect(result.current.signUpRegions.map((r) => r.key)).toEqual( + expect.arrayContaining(['AL', 'DE', 'US']), + ); + expect( + result.current.signUpRegions.find((r) => r.key === 'CA'), + ).toBeUndefined(); + }); + }); + + describe('getRegionByCode', () => { + it('returns region for valid code', () => { + const { result } = renderHook(() => useRegions()); + + expect(result.current.getRegionByCode('US')).toEqual( + expect.objectContaining({ key: 'US', name: 'United States' }), + ); + expect(result.current.getRegionByCode('DE')).toEqual( + expect.objectContaining({ key: 'DE', name: 'Germany' }), + ); + }); + + it('returns null for unknown code', () => { + const { result } = renderHook(() => useRegions()); + + expect(result.current.getRegionByCode('XX')).toBeNull(); + }); + + it('returns null for null or undefined code', () => { + const { result } = renderHook(() => useRegions()); + + expect(result.current.getRegionByCode(null)).toBeNull(); + expect(result.current.getRegionByCode(undefined)).toBeNull(); + }); + }); + + describe('userCountry', () => { + it('resolves from user.countryOfResidence', () => { + const { result } = renderHook(() => useRegions()); + + expect(result.current.userCountry).toEqual( + expect.objectContaining({ key: 'US', name: 'United States' }), + ); + }); + + it('returns null when user has no countryOfResidence', () => { + mockUseCardSDK.mockReturnValue({ + user: { countryOfNationality: 'DE' }, + } as ReturnType); + + const { result } = renderHook(() => useRegions()); + + expect(result.current.userCountry).toBeNull(); + }); + + it('returns null when user is null', () => { + mockUseCardSDK.mockReturnValue({ user: null } as ReturnType< + typeof useCardSDK + >); + + const { result } = renderHook(() => useRegions()); + + expect(result.current.userCountry).toBeNull(); + }); + }); + + describe('userNationality', () => { + it('resolves from user.countryOfNationality', () => { + const { result } = renderHook(() => useRegions()); + + expect(result.current.userNationality).toEqual( + expect.objectContaining({ key: 'DE', name: 'Germany' }), + ); + }); + + it('returns null when user has no countryOfNationality', () => { + mockUseCardSDK.mockReturnValue({ + user: { countryOfResidence: 'US' }, + } as ReturnType); + + const { result } = renderHook(() => useRegions()); + + expect(result.current.userNationality).toBeNull(); + }); + }); + + describe('isLoading', () => { + it('returns isLoading from useRegistrationSettings', () => { + mockUseRegistrationSettings.mockReturnValue({ + data: { countries: MOCK_COUNTRIES }, + isLoading: true, + } as ReturnType); + + const { result } = renderHook(() => useRegions()); + + expect(result.current.isLoading).toBe(true); + }); + }); + + describe('regionsByCode', () => { + it('provides Map for O(1) lookup', () => { + const { result } = renderHook(() => useRegions()); + + expect(result.current.regionsByCode.get('US')).toEqual( + expect.objectContaining({ key: 'US' }), + ); + expect(result.current.regionsByCode.size).toBe(4); + }); + }); +}); diff --git a/app/components/UI/Card/hooks/useRegions.ts b/app/components/UI/Card/hooks/useRegions.ts new file mode 100644 index 00000000000..be8b6671af9 --- /dev/null +++ b/app/components/UI/Card/hooks/useRegions.ts @@ -0,0 +1,68 @@ +import { useCallback, useMemo } from 'react'; +import { useCardSDK } from '../sdk'; +import useRegistrationSettings from './useRegistrationSettings'; +import { countryCodeToFlag } from '../util/countryCodeToFlag'; +import type { Region } from '../types'; + +/** + * Centralized hook for Card onboarding regions: composes useCardSDK (user) and + * useRegistrationSettings (countries), and exposes sorted regions, O(1) lookup, + * and pre-resolved userCountry / userNationality. + */ +const useRegions = () => { + const { user } = useCardSDK(); + const { data: registrationSettings, isLoading } = useRegistrationSettings(); + + const allRegions: Region[] = useMemo(() => { + if (!registrationSettings?.countries) { + return []; + } + return [...registrationSettings.countries] + .sort((a, b) => a.name.localeCompare(b.name)) + .map((country) => ({ + key: country.iso3166alpha2, + name: country.name, + emoji: countryCodeToFlag(country.iso3166alpha2), + areaCode: country.callingCode, + canSignUp: country.canSignUp, + })); + }, [registrationSettings]); + + const signUpRegions: Region[] = useMemo( + () => allRegions.filter((r) => r.canSignUp === true), + [allRegions], + ); + + const regionsByCode: Map = useMemo( + () => new Map(allRegions.map((r) => [r.key, r])), + [allRegions], + ); + + const getRegionByCode = useCallback( + (code: string | null | undefined): Region | null => + code ? (regionsByCode.get(code) ?? null) : null, + [regionsByCode], + ); + + const userCountry = useMemo( + () => getRegionByCode(user?.countryOfResidence), + [getRegionByCode, user?.countryOfResidence], + ); + + const userNationality = useMemo( + () => getRegionByCode(user?.countryOfNationality), + [getRegionByCode, user?.countryOfNationality], + ); + + return { + allRegions, + signUpRegions, + regionsByCode, + getRegionByCode, + userCountry, + userNationality, + isLoading, + }; +}; + +export default useRegions; diff --git a/app/components/UI/Card/hooks/useRegisterUserConsent.test.ts b/app/components/UI/Card/hooks/useRegisterUserConsent.test.ts index a5618cdd8aa..539305a319c 100644 --- a/app/components/UI/Card/hooks/useRegisterUserConsent.test.ts +++ b/app/components/UI/Card/hooks/useRegisterUserConsent.test.ts @@ -6,7 +6,6 @@ import { CardError, CardErrorType } from '../types'; import { getErrorMessage } from '../util/getErrorMessage'; import AppConstants from '../../../../core/AppConstants'; import { CardSDK } from '../sdk/CardSDK'; -import { Region } from '../components/Onboarding/RegionSelectorModal'; const mockFetchQuery = jest.fn(); @@ -45,20 +44,6 @@ const mockGetErrorMessage = getErrorMessage as jest.MockedFunction< typeof getErrorMessage >; -// Mock Region objects for testing -const MOCK_REGION_US: Region = { - key: 'US', - name: 'United States', - emoji: '🇺🇸', -}; -const MOCK_REGION_CA: Region = { key: 'CA', name: 'Canada', emoji: '🇨🇦' }; -const MOCK_REGION_GB: Region = { - key: 'GB', - name: 'United Kingdom', - emoji: '🇬🇧', -}; -const MOCK_REGION_DE: Region = { key: 'DE', name: 'Germany', emoji: '🇩🇪' }; - describe('useRegisterUserConsent', () => { const mockCreateOnboardingConsent = jest.fn(); const mockLinkUserToConsent = jest.fn(); @@ -84,8 +69,7 @@ describe('useRegisterUserConsent', () => { sdk: mockSDK, }); - mockUseSelector.mockReturnValue(MOCK_REGION_US); - + mockUseSelector.mockReturnValue('us'); mockGetErrorMessage.mockReturnValue('Mocked error message'); mockCreateOnboardingConsent.mockResolvedValue(mockConsentResponse); mockLinkUserToConsent.mockResolvedValue(undefined); @@ -195,7 +179,6 @@ describe('useRegisterUserConsent', () => { describe('createOnboardingConsent function', () => { describe('successful consent creation', () => { it('creates consent record for US users with eSignAct consent', async () => { - mockUseSelector.mockReturnValue(MOCK_REGION_US); const { result } = renderHook(() => useRegisterUserConsent()); let returnedConsentSetId = ''; @@ -258,7 +241,7 @@ describe('useRegisterUserConsent', () => { }); it('creates consent record for international users without eSignAct', async () => { - mockUseSelector.mockReturnValue(MOCK_REGION_CA); + mockUseSelector.mockReturnValue('international'); const { result } = renderHook(() => useRegisterUserConsent()); await act(async () => { @@ -738,33 +721,13 @@ describe('useRegisterUserConsent', () => { }); describe('country-specific behavior', () => { - const countryTestCases = [ - { - country: MOCK_REGION_US, - expectedPolicy: 'us', - description: 'US users', - }, - { - country: MOCK_REGION_CA, - expectedPolicy: 'global', - description: 'Canadian users', - }, - { - country: MOCK_REGION_GB, - expectedPolicy: 'global', - description: 'UK users', - }, - { - country: MOCK_REGION_DE, - expectedPolicy: 'global', - description: 'German users', - }, - ]; - - it.each(countryTestCases)( - 'uses correct policy for $description', - async ({ country, expectedPolicy }) => { - mockUseSelector.mockReturnValue(country); + it.each([ + { location: 'us', expectedPolicy: 'us' }, + { location: 'international', expectedPolicy: 'global' }, + ])( + 'uses "$expectedPolicy" policy for location "$location"', + async ({ location, expectedPolicy }) => { + mockUseSelector.mockReturnValue(location); const { result } = renderHook(() => useRegisterUserConsent()); await act(async () => { @@ -780,144 +743,8 @@ describe('useRegisterUserConsent', () => { ); }); - describe('SDK integration', () => { - it('uses SDK from useCardSDK hook for consent creation', async () => { - const customSDK = { - createOnboardingConsent: jest - .fn() - .mockResolvedValue(mockConsentResponse), - linkUserToConsent: jest.fn().mockResolvedValue(undefined), - getConsentSetByOnboardingId: jest.fn().mockResolvedValue(null), - } as unknown as CardSDK; - - mockUseCardSDK.mockReturnValue({ - ...jest.requireMock('../sdk'), - sdk: customSDK, - }); - - const { result } = renderHook(() => useRegisterUserConsent()); - - await act(async () => { - await result.current.createOnboardingConsent(testOnboardingId); - }); - - expect(customSDK.createOnboardingConsent).toHaveBeenCalled(); - }); - - it('uses SDK from useCardSDK hook for consent linking', async () => { - const customSDK = { - createOnboardingConsent: jest - .fn() - .mockResolvedValue(mockConsentResponse), - linkUserToConsent: jest.fn().mockResolvedValue(undefined), - getConsentSetByOnboardingId: jest.fn().mockResolvedValue(null), - } as unknown as CardSDK; - - mockUseCardSDK.mockReturnValue({ - ...jest.requireMock('../sdk'), - sdk: customSDK, - }); - - const { result } = renderHook(() => useRegisterUserConsent()); - - await act(async () => { - await result.current.linkUserToConsent('consent-123', testUserId); - }); - - expect(customSDK.linkUserToConsent).toHaveBeenCalled(); - }); - - it('uses queryClient.fetchQuery for getting consent set', async () => { - const mockConsentSet = { - consentSetId: 'test-consent', - userId: 'test-user', - completedAt: '2024-01-01T00:00:00.000Z', - }; - mockFetchQuery.mockResolvedValue(mockConsentSet); - - const { result } = renderHook(() => useRegisterUserConsent()); - - await act(async () => { - await result.current.getOnboardingConsentSetByOnboardingId( - testOnboardingId, - ); - }); - - expect(mockFetchQuery).toHaveBeenCalledWith( - expect.objectContaining({ - queryKey: ['card', 'dashboard', 'consentSet', testOnboardingId], - }), - ); - }); - - it('handles SDK loading state', () => { - mockUseCardSDK.mockReturnValue({ - ...jest.requireMock('../sdk'), - sdk: mockSDK, - }); - - const { result } = renderHook(() => useRegisterUserConsent()); - - // Hook initializes properly even when SDK is loading - expect(result.current.isLoading).toBe(false); - expect(typeof result.current.createOnboardingConsent).toBe('function'); - expect(typeof result.current.linkUserToConsent).toBe('function'); - expect(typeof result.current.getOnboardingConsentSetByOnboardingId).toBe( - 'function', - ); - }); - }); - describe('edge cases', () => { - it('handles undefined SDK gracefully for createOnboardingConsent', async () => { - mockUseCardSDK.mockReturnValue({ - ...jest.requireMock('../sdk'), - sdk: null, - }); - - const { result } = renderHook(() => useRegisterUserConsent()); - - await expect( - act(async () => { - await result.current.createOnboardingConsent(testOnboardingId); - }), - ).rejects.toThrow('Card SDK not initialized'); - }); - - it('handles undefined SDK gracefully for linkUserToConsent', async () => { - mockUseCardSDK.mockReturnValue({ - ...jest.requireMock('../sdk'), - sdk: null, - }); - - const { result } = renderHook(() => useRegisterUserConsent()); - - await expect( - act(async () => { - await result.current.linkUserToConsent('consent-123', testUserId); - }), - ).rejects.toThrow('Card SDK not initialized'); - }); - - it('handles empty consentSetId response', async () => { - mockCreateOnboardingConsent.mockResolvedValue({ consentSetId: '' }); - mockGetErrorMessage.mockReturnValue( - 'Failed to create onboarding consent', - ); - - const { result } = renderHook(() => useRegisterUserConsent()); - - await expect( - act(async () => { - await result.current.createOnboardingConsent(testOnboardingId); - }), - ).rejects.toThrow('Failed to create onboarding consent'); - - expect(result.current.isError).toBe(true); - expect(result.current.error).toBe('Failed to create onboarding consent'); - }); - - it('handles undefined consentSetId response', async () => { + it('handles undefined consentSetId in createOnboardingConsent response', async () => { mockCreateOnboardingConsent.mockResolvedValue({ consentSetId: undefined, }); diff --git a/app/components/UI/Card/hooks/useRegisterUserConsent.ts b/app/components/UI/Card/hooks/useRegisterUserConsent.ts index 1811097eaeb..724699c766a 100644 --- a/app/components/UI/Card/hooks/useRegisterUserConsent.ts +++ b/app/components/UI/Card/hooks/useRegisterUserConsent.ts @@ -1,12 +1,12 @@ import { useCallback, useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { useCardSDK } from '../sdk'; -import { selectSelectedCountry } from '../../../../core/redux/slices/card'; import { useSelector } from 'react-redux'; import AppConstants from '../../../../core/AppConstants'; import { getErrorMessage } from '../util/getErrorMessage'; import { Consent, ConsentSet } from '../types'; import { cardQueries } from '../queries'; +import { selectUserCardLocation } from '../../../../core/redux/slices/card'; interface UseRegisterUserConsentState { isLoading: boolean; @@ -43,7 +43,7 @@ interface UseRegisterUserConsentReturn extends UseRegisterUserConsentState { export const useRegisterUserConsent = (): UseRegisterUserConsentReturn => { const { sdk } = useCardSDK(); const queryClient = useQueryClient(); - const selectedCountry = useSelector(selectSelectedCountry); + const location = useSelector(selectUserCardLocation); const [state, setState] = useState({ isLoading: false, isSuccess: false, @@ -104,7 +104,7 @@ export const useRegisterUserConsent = (): UseRegisterUserConsentReturn => { throw new Error('Card SDK not initialized'); } - const policy = selectedCountry?.key === 'US' ? 'us' : 'global'; + const policy = location === 'us' ? 'us' : 'global'; try { // Reset state and start loading @@ -199,7 +199,7 @@ export const useRegisterUserConsent = (): UseRegisterUserConsentReturn => { throw err; } }, - [sdk, selectedCountry], + [sdk, location], ); /** diff --git a/app/components/UI/Card/routes/OnboardingNavigator.test.tsx b/app/components/UI/Card/routes/OnboardingNavigator.test.tsx index da89064d4fb..cdc46c9c65b 100644 --- a/app/components/UI/Card/routes/OnboardingNavigator.test.tsx +++ b/app/components/UI/Card/routes/OnboardingNavigator.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, fireEvent } from '@testing-library/react-native'; +import { render, fireEvent, act } from '@testing-library/react-native'; import { useSelector } from 'react-redux'; import { NavigationContainer, @@ -229,7 +229,7 @@ describe('OnboardingNavigator', () => { user: null, setUser: jest.fn(), logoutFromProvider: jest.fn(), - fetchUserData: jest.fn(), + fetchUserData: jest.fn().mockResolvedValue(undefined), isReturningSession: false, }); @@ -1078,7 +1078,7 @@ describe('OnboardingNavigator', () => { describe('fetchUserData on mount', () => { it('calls fetchUserData when onboardingId exists and user is null', () => { - const mockFetchUserData = jest.fn(); + const mockFetchUserData = jest.fn().mockResolvedValue(undefined); mockUseSelector.mockReturnValue('onboarding-123'); mockUseCardSDK.mockReturnValue({ user: null, @@ -1094,6 +1094,154 @@ describe('OnboardingNavigator', () => { expect(mockFetchUserData).toHaveBeenCalledTimes(1); }); + + it('does not call fetchUserData when onboardingId is null', () => { + const mockFetchUserData = jest.fn().mockResolvedValue(undefined); + mockUseSelector.mockReturnValue(null); + mockUseCardSDK.mockReturnValue({ + user: null, + isLoading: false, + sdk: null, + setUser: jest.fn(), + logoutFromProvider: jest.fn(), + fetchUserData: mockFetchUserData, + isReturningSession: false, + }); + + renderWithNavigation(); + + expect(mockFetchUserData).not.toHaveBeenCalled(); + }); + + it('does not call fetchUserData when user is already loaded', () => { + const mockFetchUserData = jest.fn().mockResolvedValue(undefined); + mockUseSelector.mockReturnValue('onboarding-123'); + mockUseCardSDK.mockReturnValue({ + user: { id: 'user-123', verificationState: 'VERIFIED' }, + isLoading: false, + sdk: {} as CardSDK, + setUser: jest.fn(), + logoutFromProvider: jest.fn(), + fetchUserData: mockFetchUserData, + isReturningSession: false, + }); + + renderWithNavigation(); + + expect(mockFetchUserData).not.toHaveBeenCalled(); + }); + }); + + describe('isFetchingUserData guard (race condition fix)', () => { + it('shows loading indicator when onboardingId exists, user is null, and isLoading is false', async () => { + // Simulates the race condition: SDK re-init resets isLoading to false + // mid-fetch, but isFetchingUserData keeps the loading guard active. + const mockFetchUserData = jest.fn( + () => + new Promise(() => { + // never resolves — fetch still in progress + }), + ); + mockUseSelector.mockReturnValue('onboarding-123'); + mockUseCardSDK.mockReturnValue({ + user: null, + isLoading: false, // SDK incorrectly reset this during re-init + sdk: {} as CardSDK, + setUser: jest.fn(), + logoutFromProvider: jest.fn(), + fetchUserData: mockFetchUserData, + isReturningSession: false, + }); + + const { getByTestId, queryByTestId } = renderWithNavigation( + , + ); + + expect(getByTestId('activity-indicator')).toBeTruthy(); + expect(queryByTestId('stack-navigator')).toBeNull(); + }); + + it('does not show loading indicator when onboardingId is null (new user flow)', () => { + mockUseSelector.mockReturnValue(null); + mockUseCardSDK.mockReturnValue({ + user: null, + isLoading: false, + sdk: null, + setUser: jest.fn(), + logoutFromProvider: jest.fn(), + fetchUserData: jest.fn().mockResolvedValue(undefined), + isReturningSession: false, + }); + + const { queryByTestId } = renderWithNavigation(); + + expect(queryByTestId('activity-indicator')).toBeNull(); + const stackNavigator = queryByTestId('stack-navigator'); + expect(stackNavigator).not.toBeNull(); + expect(stackNavigator?.props.initialRouteName).toBe( + Routes.CARD.ONBOARDING.SIGN_UP, + ); + }); + + it('shows correct route once fetchUserData resolves and user data is available', async () => { + let resolveFetch!: () => void; + const fetchPromise = new Promise((resolve) => { + resolveFetch = resolve; + }); + const mockFetchUserData = jest.fn().mockReturnValue(fetchPromise); + + mockUseSelector.mockReturnValue('onboarding-123'); + mockUseCardSDK.mockReturnValue({ + user: null, + isLoading: false, + sdk: {} as CardSDK, + setUser: jest.fn(), + logoutFromProvider: jest.fn(), + fetchUserData: mockFetchUserData, + isReturningSession: false, + }); + + const { getByTestId, queryByTestId, rerender } = renderWithNavigation( + , + ); + + // Loading indicator is shown while fetch is pending + expect(getByTestId('activity-indicator')).toBeTruthy(); + + // Simulate the SDK completing the fetch and setting user data + mockUseCardSDK.mockReturnValue({ + user: { + id: 'user-123', + verificationState: 'PENDING', + firstName: 'John', + contactVerificationId: 'contact-123', + }, + isLoading: false, + sdk: {} as CardSDK, + setUser: jest.fn(), + logoutFromProvider: jest.fn(), + fetchUserData: mockFetchUserData, + isReturningSession: false, + }); + + await act(async () => { + resolveFetch(); + await fetchPromise; + }); + + rerender( + + + , + ); + + expect(queryByTestId('activity-indicator')).toBeNull(); + const stackNavigator = queryByTestId('stack-navigator'); + expect(stackNavigator).not.toBeNull(); + expect(stackNavigator?.props.initialRouteName).toBe( + Routes.CARD.ONBOARDING.VERIFYING_VERIFF_KYC, + ); + }); }); describe('Auto-lock Management', () => { diff --git a/app/components/UI/Card/routes/OnboardingNavigator.tsx b/app/components/UI/Card/routes/OnboardingNavigator.tsx index 50484a356b8..be1f16487d8 100644 --- a/app/components/UI/Card/routes/OnboardingNavigator.tsx +++ b/app/components/UI/Card/routes/OnboardingNavigator.tsx @@ -113,6 +113,12 @@ const OnboardingNavigator: React.FC = () => { const onboardingId = useSelector(selectOnboardingId); const { user, isLoading, fetchUserData, isReturningSession } = useCardSDK(); const [isMounted, setIsMounted] = useState(false); + // Track user data fetch separately from SDK's isLoading to guard against + // the SDK init effect resetting isLoading mid-fetch (e.g. when fetchUserData + // dispatches setUserCardLocation and triggers SDK re-initialization). + const [isFetchingUserData, setIsFetchingUserData] = useState( + () => !!onboardingId && !user, + ); const navigation = useNavigation(); const route = useRoute< @@ -132,7 +138,9 @@ const OnboardingNavigator: React.FC = () => { // when the navigator is accessed useEffect(() => { if (!isMounted && onboardingId && !user) { - fetchUserData(); + fetchUserData().finally(() => setIsFetchingUserData(false)); + } else { + setIsFetchingUserData(false); } setIsMounted(true); // eslint-disable-next-line react-compiler/react-compiler @@ -228,7 +236,7 @@ const OnboardingNavigator: React.FC = () => { // Skip when deeplink navigates directly to Complete screen (e.g., KYC notification) useEffect(() => { if ( - isReturningSession && + (isReturningSession || cardUserPhase) && initialRouteName !== Routes.CARD.ONBOARDING.SIGN_UP && initialRouteName !== Routes.CARD.ONBOARDING.COMPLETE && !hasShownKeepGoingModal.current && @@ -254,12 +262,13 @@ const OnboardingNavigator: React.FC = () => { }, [ isReturningSession, initialRouteName, + cardUserPhase, navigation, user?.verificationState, isDeeplinkToComplete, ]); - if (isLoading && !user) { + if ((isLoading || isFetchingUserData) && !user) { return ( diff --git a/app/components/UI/Card/types.ts b/app/components/UI/Card/types.ts index 052cf2322d7..d93a0a8141f 100644 --- a/app/components/UI/Card/types.ts +++ b/app/components/UI/Card/types.ts @@ -94,6 +94,18 @@ export interface CardLoginInitiateResponse { export type CardLocation = 'us' | 'international'; +/** + * Region representation for country/region selectors (e.g. sign-up, phone, address). + * Used by useRegions and RegionSelectorModal. + */ +export interface Region { + key: string; + name: string; + emoji?: string; + areaCode?: string; + canSignUp?: boolean; +} + export type CardNetwork = 'linea' | 'solana' | 'base' | 'monad'; export interface CardNetworkInfo { diff --git a/app/components/UI/Card/util/mapCountryToLocation.test.ts b/app/components/UI/Card/util/mapCountryToLocation.test.ts index 86af5612039..8c7ed951148 100644 --- a/app/components/UI/Card/util/mapCountryToLocation.test.ts +++ b/app/components/UI/Card/util/mapCountryToLocation.test.ts @@ -71,22 +71,6 @@ describe('mapCountryToLocation', () => { expect(result).toBe('international' as CardLocation); }); - it('returns international location for lowercase us country code', () => { - const countryCode = 'us'; - - const result = mapCountryToLocation(countryCode); - - expect(result).toBe('international' as CardLocation); - }); - - it('returns international location for mixed case Us country code', () => { - const countryCode = 'Us'; - - const result = mapCountryToLocation(countryCode); - - expect(result).toBe('international' as CardLocation); - }); - it('returns international location for invalid country code', () => { const countryCode = 'INVALID'; diff --git a/app/components/UI/Card/util/mapCountryToLocation.ts b/app/components/UI/Card/util/mapCountryToLocation.ts index 45077487103..2df2978ed6e 100644 --- a/app/components/UI/Card/util/mapCountryToLocation.ts +++ b/app/components/UI/Card/util/mapCountryToLocation.ts @@ -8,7 +8,7 @@ import { CardLocation } from '../types'; export const mapCountryToLocation = ( countryCode: string | null, ): CardLocation => { - if (countryCode === 'US') { + if (countryCode?.toUpperCase() === 'US') { return 'us'; } return 'international'; diff --git a/app/core/DeeplinkManager/handlers/legacy/__tests__/handleCardKycNotification.test.ts b/app/core/DeeplinkManager/handlers/legacy/__tests__/handleCardKycNotification.test.ts index 070ec5cf1ff..bf0b64bbca2 100644 --- a/app/core/DeeplinkManager/handlers/legacy/__tests__/handleCardKycNotification.test.ts +++ b/app/core/DeeplinkManager/handlers/legacy/__tests__/handleCardKycNotification.test.ts @@ -6,7 +6,6 @@ import Logger from '../../../../../util/Logger'; import { selectIsAuthenticatedCard, selectOnboardingId, - selectSelectedCountry, selectUserCardLocation, selectCardGeoLocation, selectAlwaysShowCardButton, @@ -17,7 +16,6 @@ import { selectCardFeatureFlag, } from '../../../../../selectors/featureFlagController/card'; import { CardSDK } from '../../../../../components/UI/Card/sdk/CardSDK'; -import { mapCountryToLocation } from '../../../../../components/UI/Card/util/mapCountryToLocation'; jest.mock('../../../../redux', () => ({ __esModule: true, @@ -39,7 +37,6 @@ describe('handleCardKycNotification', () => { const mockNavigate = jest.fn(); const mockLoggerError = Logger.error as jest.Mock; const mockLoggerLog = Logger.log as jest.Mock; - const mockMapCountryToLocation = mapCountryToLocation as jest.Mock; const mockCardFeatureFlag = { chains: { @@ -71,7 +68,6 @@ describe('handleCardKycNotification', () => { // Default mocks - feature disabled (selectOnboardingId as unknown as jest.Mock).mockReturnValue(null); (selectIsAuthenticatedCard as unknown as jest.Mock).mockReturnValue(false); - (selectSelectedCountry as unknown as jest.Mock).mockReturnValue(null); (selectUserCardLocation as unknown as jest.Mock).mockReturnValue( 'international', ); @@ -90,11 +86,6 @@ describe('handleCardKycNotification', () => { getRegistrationStatus: mockGetRegistrationStatus, getUserDetails: mockGetUserDetails, })); - - // Mock mapCountryToLocation - mockMapCountryToLocation.mockImplementation((countryCode: string | null) => - countryCode === 'US' ? 'us' : 'international', - ); }); afterEach(() => { @@ -279,51 +270,30 @@ describe('handleCardKycNotification', () => { }); describe('location handling', () => { - it('uses US location when selectedCountry is US', async () => { - (selectSelectedCountry as unknown as jest.Mock).mockReturnValue({ - key: 'US', - name: 'United States', - }); + it('uses US location when userCardLocation is us', async () => { + (selectUserCardLocation as unknown as jest.Mock).mockReturnValue('us'); mockGetRegistrationStatus.mockResolvedValue({ verificationState: 'VERIFIED', }); await handleCardKycNotification(); - expect(mockMapCountryToLocation).toHaveBeenCalledWith('US'); expect(CardSDK).toHaveBeenCalledWith({ cardFeatureFlag: mockCardFeatureFlag, userCardLocation: 'us', }); }); - it('uses international location when selectedCountry is not US', async () => { - (selectSelectedCountry as unknown as jest.Mock).mockReturnValue({ - key: 'GB', - name: 'United Kingdom', - }); - mockGetRegistrationStatus.mockResolvedValue({ - verificationState: 'VERIFIED', - }); - - await handleCardKycNotification(); - - expect(mockMapCountryToLocation).toHaveBeenCalledWith('GB'); - expect(CardSDK).toHaveBeenCalledWith({ - cardFeatureFlag: mockCardFeatureFlag, - userCardLocation: 'international', - }); - }); - - it('uses international location when selectedCountry is null', async () => { - (selectSelectedCountry as unknown as jest.Mock).mockReturnValue(null); + it('uses international location when userCardLocation is international', async () => { + (selectUserCardLocation as unknown as jest.Mock).mockReturnValue( + 'international', + ); mockGetRegistrationStatus.mockResolvedValue({ verificationState: 'VERIFIED', }); await handleCardKycNotification(); - expect(mockMapCountryToLocation).toHaveBeenCalledWith(null); expect(CardSDK).toHaveBeenCalledWith({ cardFeatureFlag: mockCardFeatureFlag, userCardLocation: 'international', diff --git a/app/core/DeeplinkManager/handlers/legacy/handleCardKycNotification.ts b/app/core/DeeplinkManager/handlers/legacy/handleCardKycNotification.ts index d4c04a42f87..9badee2aa64 100644 --- a/app/core/DeeplinkManager/handlers/legacy/handleCardKycNotification.ts +++ b/app/core/DeeplinkManager/handlers/legacy/handleCardKycNotification.ts @@ -5,7 +5,6 @@ import Routes from '../../../../constants/navigation/Routes'; import { selectIsAuthenticatedCard, selectOnboardingId, - selectSelectedCountry, selectUserCardLocation, selectCardGeoLocation, selectAlwaysShowCardButton, @@ -18,11 +17,7 @@ import { } from '../../../../selectors/featureFlagController/card'; import { isBaanxLoginEnabled } from '../../../../components/UI/Card/hooks/isBaanxLoginEnabled'; import { CardSDK } from '../../../../components/UI/Card/sdk/CardSDK'; -import { mapCountryToLocation } from '../../../../components/UI/Card/util/mapCountryToLocation'; -import { - CardLocation, - CardVerificationState, -} from '../../../../components/UI/Card/types'; +import { CardVerificationState } from '../../../../components/UI/Card/types'; /** * Card KYC notification deeplink handler @@ -149,13 +144,9 @@ async function handleOnboardingFlow( ); // Get location from selectedCountry - const selectedCountry = selectSelectedCountry(state); - const location: CardLocation = mapCountryToLocation( - selectedCountry?.key ?? null, - ); + const location = selectUserCardLocation(state); Logger.log('[handleCardKycNotification] Determined location:', { - selectedCountryKey: selectedCountry?.key, location, }); diff --git a/app/core/redux/slices/card/index.test.ts b/app/core/redux/slices/card/index.test.ts index 440df785c71..4255c829d99 100644 --- a/app/core/redux/slices/card/index.test.ts +++ b/app/core/redux/slices/card/index.test.ts @@ -18,17 +18,14 @@ import cardReducer, { setUserCardLocation, verifyCardAuthentication, setOnboardingId, - setSelectedCountry, setContactVerificationId, setConsentSetId, resetOnboardingState, selectOnboardingId, - selectSelectedCountry, selectContactVerificationId, selectConsentSetId, resetAuthenticatedData, } from '.'; -import { Region } from '../../../../components/UI/Card/components/Onboarding/RegionSelectorModal'; // Mock the multichain selectors jest.mock('../../../../selectors/multichainAccounts/accounts', () => ({ @@ -92,22 +89,6 @@ const CARDHOLDER_ACCOUNTS_MOCK: string[] = [ '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', ]; -// Mock Region objects for testing -const MOCK_REGION_US: Region = { - key: 'US', - name: 'United States', - emoji: '🇺🇸', -}; -const MOCK_REGION_GB: Region = { - key: 'GB', - name: 'United Kingdom', - emoji: '🇬🇧', -}; -const MOCK_REGION_CA: Region = { key: 'CA', name: 'Canada', emoji: '🇨🇦' }; -const MOCK_REGION_DE: Region = { key: 'DE', name: 'Germany', emoji: '🇩🇪' }; -const MOCK_REGION_FR: Region = { key: 'FR', name: 'France', emoji: '🇫🇷' }; -const MOCK_REGION_JP: Region = { key: 'JP', name: 'Japan', emoji: '🇯🇵' }; - const CARD_STATE_MOCK: CardSliceState = { cardholderAccounts: CARDHOLDER_ACCOUNTS_MOCK, isDaimoDemo: false, @@ -119,7 +100,6 @@ const CARD_STATE_MOCK: CardSliceState = { userCardLocation: 'international', onboarding: { onboardingId: null, - selectedCountry: null, contactVerificationId: null, consentSetId: null, }, @@ -136,7 +116,6 @@ const EMPTY_CARD_STATE_MOCK: CardSliceState = { userCardLocation: 'international', onboarding: { onboardingId: null, - selectedCountry: null, contactVerificationId: null, consentSetId: null, }, @@ -330,53 +309,6 @@ describe('Card Selectors', () => { }); }); - describe('selectSelectedCountry', () => { - it('should return null by default from initial state', () => { - const mockRootState = { card: initialState } as unknown as RootState; - expect(selectSelectedCountry(mockRootState)).toBe(null); - }); - - it('should return the selected country when set', () => { - const selectedCountry = MOCK_REGION_US; - const stateWithCountry: CardSliceState = { - ...initialState, - onboarding: { - ...initialState.onboarding, - selectedCountry, - }, - }; - const mockRootState = { - card: stateWithCountry, - } as unknown as RootState; - expect(selectSelectedCountry(mockRootState)).toBe(selectedCountry); - }); - - it('should handle different country codes', () => { - const regions: Region[] = [ - MOCK_REGION_US, - MOCK_REGION_GB, - MOCK_REGION_CA, - MOCK_REGION_DE, - MOCK_REGION_FR, - MOCK_REGION_JP, - ]; - - regions.forEach((region) => { - const stateWithCountry: CardSliceState = { - ...initialState, - onboarding: { - ...initialState.onboarding, - selectedCountry: region, - }, - }; - const mockRootState = { - card: stateWithCountry, - } as unknown as RootState; - expect(selectSelectedCountry(mockRootState)).toBe(region); - }); - }); - }); - describe('selectContactVerificationId', () => { it('should return null by default from initial state', () => { const mockRootState = { card: initialState } as unknown as RootState; @@ -524,7 +456,6 @@ describe('Card Reducer', () => { userCardLocation: 'us', onboarding: { onboardingId: null, - selectedCountry: null, contactVerificationId: null, consentSetId: null, }, @@ -567,7 +498,6 @@ describe('Card Reducer', () => { ); expect(state.onboarding.onboardingId).toBe(onboardingId); // ensure other parts of state untouched - expect(state.onboarding.selectedCountry).toBe(null); expect(state.onboarding.contactVerificationId).toBe(null); expect(state.onboarding.consentSetId).toBe(null); }); @@ -598,43 +528,6 @@ describe('Card Reducer', () => { }); }); - describe('setSelectedCountry', () => { - it('should set selectedCountry', () => { - const country = MOCK_REGION_US; - const state = cardReducer(initialState, setSelectedCountry(country)); - expect(state.onboarding.selectedCountry).toBe(country); - // ensure other parts of state untouched - expect(state.onboarding.onboardingId).toBe(null); - expect(state.onboarding.contactVerificationId).toBe(null); - expect(state.onboarding.consentSetId).toBe(null); - }); - - it('should update selectedCountry when previously set', () => { - const current: CardSliceState = { - ...initialState, - onboarding: { - ...initialState.onboarding, - selectedCountry: MOCK_REGION_GB, - }, - }; - const newCountry = MOCK_REGION_CA; - const state = cardReducer(current, setSelectedCountry(newCountry)); - expect(state.onboarding.selectedCountry).toBe(newCountry); - }); - - it('should set selectedCountry to null', () => { - const current: CardSliceState = { - ...initialState, - onboarding: { - ...initialState.onboarding, - selectedCountry: MOCK_REGION_US, - }, - }; - const state = cardReducer(current, setSelectedCountry(null)); - expect(state.onboarding.selectedCountry).toBe(null); - }); - }); - describe('setContactVerificationId', () => { it('should set contactVerificationId', () => { const verificationId = 'verification-123'; @@ -645,7 +538,6 @@ describe('Card Reducer', () => { expect(state.onboarding.contactVerificationId).toBe(verificationId); // ensure other parts of state untouched expect(state.onboarding.onboardingId).toBe(null); - expect(state.onboarding.selectedCountry).toBe(null); expect(state.onboarding.consentSetId).toBe(null); }); @@ -690,7 +582,6 @@ describe('Card Reducer', () => { expect(state.onboarding.consentSetId).toBe(consentSetId); // ensure other parts of state untouched expect(state.onboarding.onboardingId).toBe(null); - expect(state.onboarding.selectedCountry).toBe(null); expect(state.onboarding.contactVerificationId).toBe(null); }); @@ -726,7 +617,6 @@ describe('Card Reducer', () => { ...initialState, onboarding: { onboardingId: 'test-id', - selectedCountry: MOCK_REGION_US, contactVerificationId: 'verification-123', consentSetId: 'consent-456', }, @@ -734,7 +624,6 @@ describe('Card Reducer', () => { const state = cardReducer(current, resetOnboardingState()); expect(state.onboarding).toEqual({ onboardingId: null, - selectedCountry: null, contactVerificationId: null, consentSetId: null, }); @@ -747,7 +636,6 @@ describe('Card Reducer', () => { const state = cardReducer(initialState, resetOnboardingState()); expect(state.onboarding).toEqual({ onboardingId: null, - selectedCountry: null, contactVerificationId: null, consentSetId: null, }); @@ -792,7 +680,6 @@ describe('Card Reducer', () => { isAuthenticated: true, onboarding: { onboardingId: 'test-id', - selectedCountry: MOCK_REGION_US, contactVerificationId: 'verification-123', consentSetId: 'consent-456', }, @@ -813,7 +700,6 @@ describe('Card Reducer', () => { expect(state.alwaysShowCardButton).toBe(true); expect(state.onboarding).toEqual({ onboardingId: 'test-id', - selectedCountry: MOCK_REGION_US, contactVerificationId: 'verification-123', consentSetId: 'consent-456', }); diff --git a/app/core/redux/slices/card/index.ts b/app/core/redux/slices/card/index.ts index 151d9208425..d92135d6663 100644 --- a/app/core/redux/slices/card/index.ts +++ b/app/core/redux/slices/card/index.ts @@ -12,11 +12,9 @@ import { selectDisplayCardButtonFeatureFlag, } from '../../../../selectors/featureFlagController/card'; import { handleLocalAuthentication } from '../../../../components/UI/Card/util/handleLocalAuthentication'; -import { Region } from '../../../../components/UI/Card/components/Onboarding/RegionSelectorModal'; export interface OnboardingState { onboardingId: string | null; - selectedCountry: Region | null; contactVerificationId: string | null; consentSetId: string | null; } @@ -43,7 +41,6 @@ export const initialState: CardSliceState = { userCardLocation: 'international', onboarding: { onboardingId: null, - selectedCountry: null, contactVerificationId: null, consentSetId: null, }, @@ -90,9 +87,6 @@ const slice = createSlice({ setOnboardingId: (state, action: PayloadAction) => { state.onboarding.onboardingId = action.payload; }, - setSelectedCountry: (state, action: PayloadAction) => { - state.onboarding.selectedCountry = action.payload; - }, setContactVerificationId: (state, action: PayloadAction) => { state.onboarding.contactVerificationId = action.payload; }, @@ -102,7 +96,6 @@ const slice = createSlice({ resetOnboardingState: (state) => { state.onboarding = { onboardingId: null, - selectedCountry: null, contactVerificationId: null, consentSetId: null, }; @@ -252,11 +245,6 @@ export const selectOnboardingId = createSelector( (card) => card.onboarding.onboardingId, ); -export const selectSelectedCountry = createSelector( - selectCardState, - (card) => card.onboarding.selectedCountry, -); - export const selectContactVerificationId = createSelector( selectCardState, (card) => card.onboarding.contactVerificationId, @@ -275,7 +263,6 @@ export const { setIsAuthenticatedCard, setUserCardLocation, setOnboardingId, - setSelectedCountry, setContactVerificationId, setConsentSetId, resetOnboardingState, From 6aaaebc738f8ec95f94fbeb25738fc029a78f281 Mon Sep 17 00:00:00 2001 From: tommasini <46944231+tommasini@users.noreply.github.com> Date: Tue, 17 Mar 2026 21:14:25 +0000 Subject: [PATCH 067/206] chore(ci): migrate nightly build from Bitrise to GitHub Actions with TestFlight deployment (#27555) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The nightly build pipeline previously relied on Bitrise for version bumping and deploying iOS exp/RC builds to TestFlight (`exp_builds_to_testflight_pipeline` and `rc_builds_to_testflight_pipeline`). The GitHub Actions `nightly-build.yml` existed alongside Bitrise but passed `skip_version_bump: true` because Bitrise owned the version bump during the parallel transition period. This PR completes the migration by making GitHub Actions the sole owner of the nightly build pipeline: - **Enabled version bumping** -- removed `skip_version_bump: true` from both `build-exp` and `build-rc` jobs so `build.yml` → `update-latest-build-version.yml` handles the version bump (commits use `[skip ci]` to prevent re-triggering) - **Updated permissions** -- changed `contents: read` to `contents: write` (required for the version bump commit push) - **Added TestFlight upload jobs** -- two new jobs (`upload-exp-testflight` and `upload-rc-testflight`) that download the iOS build artifact from their respective build jobs and upload to TestFlight via the existing `scripts/upload-to-testflight.sh`, using the `apple` GitHub Environment for App Store Connect API secrets - **Cleaned up comments** -- removed outdated references to Bitrise owning the version bump The existing sync workflow (`nightly-temp-branch-sync.yml`) remains unchanged and continues to sync `chore/temp-nightly` with `main` at 4 AM UTC, triggering this nightly build via the push event. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Nightly build with TestFlight deployment Scenario: Nightly build triggers with version bump and TestFlight upload Given the nightly-temp-branch-sync workflow has synced chore/temp-nightly with main When the push to chore/temp-nightly triggers nightly-build.yml Then the build-exp job builds main-exp for both platforms with version bump enabled And the build-rc job builds main-rc for both platforms with version bump enabled And the upload-exp-testflight job uploads the iOS exp IPA to TestFlight And the upload-rc-testflight job uploads the iOS RC IPA to TestFlight Scenario: Manual trigger via workflow_dispatch Given a user navigates to Actions > Nightly Build When the user triggers the workflow manually Then both exp and rc builds run with version bumping And both iOS builds are uploaded to TestFlight ``` ## **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. ## **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** > Moderate risk because it changes CI orchestration, adds write permissions, and introduces automated TestFlight uploads using App Store Connect secrets; failures or misconfiguration could impact nightly release artifacts and deployments. > > **Overview** > Adds a new `ref` input to the reusable `build.yml` workflow so callers skipping the built-in version bump can explicitly checkout a specific commit/ref (with conditional checkout logic based on `skip_version_bump` + `ref`). > > Updates `nightly-build.yml` to run an explicit version-bump reusable workflow first, pass the resulting commit hash into the build jobs, grant `contents: write`, and add two new jobs to download the iOS artifacts and upload exp/RC IPAs to TestFlight via the existing upload scripts (including API key setup/cleanup). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit ed747972103db2a7be3bb0a2c98dfc2f5c423ee8. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .github/workflows/build.yml | 13 ++- .github/workflows/nightly-build.yml | 149 ++++++++++++++++++++++++++-- 2 files changed, 151 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 621764cf240..2afc290ec0d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,6 +13,11 @@ on: required: false type: boolean default: false + ref: + description: 'Git ref to checkout when skip_version_bump is true. Defaults to the triggering event ref.' + required: false + type: string + default: '' workflow_dispatch: inputs: build_name: @@ -141,7 +146,13 @@ jobs: submodules: recursive - uses: actions/checkout@v4 - if: ${{ inputs.skip_version_bump }} + if: ${{ inputs.skip_version_bump && inputs.ref != '' }} + with: + ref: ${{ inputs.ref }} + submodules: recursive + + - uses: actions/checkout@v4 + if: ${{ inputs.skip_version_bump && inputs.ref == '' }} with: submodules: recursive diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index 0f4c33157b2..ffa5d1e0ef3 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -2,41 +2,170 @@ name: Nightly Build # Triggered by every push to chore/temp-nightly (which nightly-temp-branch-sync.yml # force-pushes daily at 4 AM UTC to match main). +# Temporarily now this is pointing to test/temp-nightly instead of chore/temp-nightly. # -# [skip ci] commits (e.g. version bumps pushed by Bitrise's bump_version_code job via -# update-latest-build-version.yml) are automatically skipped by GitHub Actions, so -# this workflow will NOT double-trigger on those commits. -# -# skip_version_bump=true is passed to build.yml because Bitrise already owns the -# version bump for chore/temp-nightly during the parallel transition period. -# When Bitrise is deprecated, remove skip_version_bump: true and the version bump -# will be handled by build.yml as normal. +# [skip ci] commits (e.g. version bumps pushed via update-latest-build-version.yml) +# are automatically skipped by GitHub Actions, so this workflow will NOT +# double-trigger on those commits. on: push: branches: - - chore/temp-nightly + - test/temp-nightly workflow_dispatch: +# contents: write required by build.yml update-build-version job (version bump commit push) permissions: - contents: read + contents: write id-token: write jobs: + bump-version: + name: Bump build version + uses: ./.github/workflows/update-latest-build-version.yml + permissions: + contents: write + id-token: write + with: + base-branch: ${{ github.ref_name }} + secrets: inherit + build-exp: name: Nightly exp build (main-exp) + needs: [bump-version] uses: ./.github/workflows/build.yml with: build_name: main-exp platform: both skip_version_bump: true + ref: ${{ needs.bump-version.outputs.commit-hash }} secrets: inherit build-rc: name: Nightly RC build (main-rc) + needs: [bump-version] uses: ./.github/workflows/build.yml with: build_name: main-rc platform: both skip_version_bump: true + ref: ${{ needs.bump-version.outputs.commit-hash }} secrets: inherit + + upload-exp-testflight: + name: Upload exp to TestFlight + needs: [build-exp] + runs-on: ghcr.io/cirruslabs/macos-runner:sequoia-xl + environment: apple + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Ruby (iOS) + uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb #v1 + with: + ruby-version: '3.2.9' + working-directory: ios + bundler-cache: true + + - name: Download iOS build artifact + uses: actions/download-artifact@v4 + with: + name: ios-main-exp + + - name: Find IPA path + id: ipa + run: | + IPA=$(find . -name '*.ipa' -type f | head -1) + if [ -z "$IPA" ]; then + echo "::error::No .ipa file found in artifact" + exit 1 + fi + case "$IPA" in /*) ABS="$IPA" ;; *) ABS="$PWD/$IPA" ;; esac + echo "path=$ABS" >> "$GITHUB_OUTPUT" + + - name: Setup App Store Connect API Key + run: | + bash scripts/setup-app-store-connect-api-key.sh \ + "$APP_STORE_CONNECT_API_KEY_ISSUER_ID" \ + "$APP_STORE_CONNECT_API_KEY_KEY_ID" \ + "$APP_STORE_CONNECT_API_KEY_KEY_CONTENT" + env: + APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} + APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_ID }} + APP_STORE_CONNECT_API_KEY_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_CONTENT }} + + - name: Upload to TestFlight + run: | + bash scripts/upload-to-testflight.sh \ + "github_actions_main-exp" \ + "${{ github.ref_name }}" \ + "${{ steps.ipa.outputs.path }}" \ + "MetaMask BETA & Release Candidates" + + - name: Cleanup API Key + if: always() + run: | + rm -f ios/AuthKey.p8 + echo "🧹 Cleaned up API key file" + + upload-rc-testflight: + name: Upload RC to TestFlight + needs: [build-rc] + runs-on: ghcr.io/cirruslabs/macos-runner:sequoia-xl + environment: apple + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Ruby (iOS) + uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb #v1 + with: + ruby-version: '3.2.9' + working-directory: ios + bundler-cache: true + + - name: Download iOS build artifact + uses: actions/download-artifact@v4 + with: + name: ios-main-rc + + - name: Find IPA path + id: ipa + run: | + IPA=$(find . -name '*.ipa' -type f | head -1) + if [ -z "$IPA" ]; then + echo "::error::No .ipa file found in artifact" + exit 1 + fi + case "$IPA" in /*) ABS="$IPA" ;; *) ABS="$PWD/$IPA" ;; esac + echo "path=$ABS" >> "$GITHUB_OUTPUT" + + - name: Setup App Store Connect API Key + run: | + bash scripts/setup-app-store-connect-api-key.sh \ + "$APP_STORE_CONNECT_API_KEY_ISSUER_ID" \ + "$APP_STORE_CONNECT_API_KEY_KEY_ID" \ + "$APP_STORE_CONNECT_API_KEY_KEY_CONTENT" + env: + APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} + APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_ID }} + APP_STORE_CONNECT_API_KEY_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_CONTENT }} + + - name: Upload to TestFlight + run: | + bash scripts/upload-to-testflight.sh \ + "github_actions_main-rc" \ + "${{ github.ref_name }}" \ + "${{ steps.ipa.outputs.path }}" \ + "MetaMask BETA & Release Candidates" + + - name: Cleanup API Key + if: always() + run: | + rm -f ios/AuthKey.p8 + echo "🧹 Cleaned up API key file" From 2e4645ab0baf043450fc06f39c7345bfdf539720 Mon Sep 17 00:00:00 2001 From: Shane T Date: Tue, 17 Mar 2026 21:19:29 +0000 Subject: [PATCH 068/206] feat: MUSD-518: Add segment event that tracks when the claim CTA is shown cp-7.70.0 (#27353) ## **Description** Adds `mUSD Claim Bonus CTA Available` event that fires when the Merkl claim CTA is mounted. `mUSD Claim Bonus CTA Available` Event Properties | Property | Type | Description | |---|---|---| | `location` | `string` | Where the CTA is rendered (e.g. `token_list_item`, `home_cash_section`) | | `view_trigger` | `string` | E.g. `component_mounted` | | `button_text` | `string` | E.g. `Claim bonus` | | `network_chain_id` | `string` | Chain ID of the token's network | | `network_name` | `string` | Human-readable network name (e.g. `Ethereum Mainnet`) | | `asset_symbol` | `string` | Token symbol (e.g. `mUSD`) | | `bonus_amount_range` | `string` | Bucketed reward value: `< 0.01`, `0.01 - 0.99`, `1.00 - 9.99`, `10.00 - 99.99`, `100.00 - 999.99`, `1000.00+` | | `has_claimed_before` | `boolean` | Whether the user has previously claimed a Merkl reward for this token | ## **Changelog** CHANGELOG entry: added mUSD Claim Bonus CTA Available event that fires when the Merkl claim CTA is mounted. ## **Related issues** Fixes: [MUSD-518: Mobile - Add segment event which tracks when the claim CTA is shown and how much the user would have to claim](https://consensyssoftware.atlassian.net/browse/MUSD-518) ## **Manual testing steps** ```gherkin Feature: Merkl claim bonus CTA impression tracking Scenario: user sees claim bonus CTA in token list Given user has a claimable Merkl reward on an eligible token When the token list item scrolls into view Then a "mUSD Claim Bonus CTA Available" event fires once with reward range, location, and network metadata Scenario: user sees claim bonus CTA in home cash section Given user holds mUSD on Linea and has a claimable Merkl bonus When the mUSD aggregated row is visible on the home screen Then a "mUSD Claim Bonus CTA Available" event fires once with location "home_cash_section" ``` ## **Screenshots/Recordings** ### **Before** N/A -`mUSD Claim Bonus CTA Available` event doesn't exist ### **After** https://github.com/user-attachments/assets/bf04a706-7a64-42a3-b21a-e522cbc7b38b ## **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. ## **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** > Adds new analytics emission tied to token-list viewport visibility and extends Merkl reward hooks, which could impact event volume/accuracy and token list rendering performance if mis-triggered. > > **Overview** > Adds a new MetaMetrics event, `MUSD_CLAIM_BONUS_CTA_DISPLAYED`, fired from `useMerklBonusClaim` **once per mount** when a Merkl bonus is claimable, there is no pending claim, and the CTA is *visible*; the event includes location, network metadata, a bucketed `bonus_amount_range`, and a new `has_claimed_before` flag derived from on-chain claimed amount. > > Updates token list rows (`TokenListItem`/`TokenListItemV2`) and the home cash section to pass `location` and viewport visibility into `useMerklBonusClaim`, and teaches `TokenList` (FlashList mode) to track visible items via `onViewableItemsChanged` and propagate `isVisible` down. Tests are expanded/updated to cover the new hook signature, impression gating, and reward-range bucketing. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 784b6b707f56abcee19db747da9a96ecd27c9f73. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Matthew Grainger Co-authored-by: Matthew Grainger <46547583+Matt561@users.noreply.github.com> --- .../hooks/useMerklBonusClaim.test.ts | 270 ++++++++++++++++-- .../MerklRewards/hooks/useMerklBonusClaim.ts | 75 ++++- .../hooks/useMerklRewards.test.ts | 17 ++ .../MerklRewards/hooks/useMerklRewards.ts | 10 + .../UI/Tokens/TokenList/TokenList.tsx | 35 ++- .../TokenListItem/TokenListItem.test.tsx | 6 +- .../TokenList/TokenListItem/TokenListItem.tsx | 8 +- .../TokenListItemV2/TokenListItemV2.test.tsx | 68 ++++- .../TokenListItemV2/TokenListItemV2.tsx | 9 +- .../Sections/Cash/MusdAggregatedRow.tsx | 5 +- .../Sections/Tokens/TokensSection.tsx | 1 + app/core/Analytics/MetaMetrics.events.ts | 4 + 12 files changed, 469 insertions(+), 39 deletions(-) diff --git a/app/components/UI/Earn/components/MerklRewards/hooks/useMerklBonusClaim.test.ts b/app/components/UI/Earn/components/MerklRewards/hooks/useMerklBonusClaim.test.ts index d00ff9e9fc1..fe7b5de0933 100644 --- a/app/components/UI/Earn/components/MerklRewards/hooks/useMerklBonusClaim.test.ts +++ b/app/components/UI/Earn/components/MerklRewards/hooks/useMerklBonusClaim.test.ts @@ -8,7 +8,9 @@ const mockClaimRewards = jest.fn().mockResolvedValue(undefined); const mockUseMerklRewards = jest.fn((_opts?: unknown) => ({ claimableReward: null as string | null, + hasClaimedBefore: false, })); + jest.mock('./useMerklRewards', () => ({ useMerklRewards: (...args: [unknown]) => mockUseMerklRewards(...args), isTokenEligibleForMerklRewards: @@ -52,6 +54,51 @@ jest.mock('react-redux', () => ({ useSelector: (selector: (state: unknown) => unknown) => selector({}), })); +jest.mock('../../../../../hooks/useAnalytics/useAnalytics', () => { + const mockBuild = jest.fn().mockReturnValue('built-event'); + const mockAddProperties = jest.fn(); + const mockEventBuilder = { + addProperties: mockAddProperties, + build: mockBuild, + }; + mockAddProperties.mockReturnValue(mockEventBuilder); + const mockCreateEventBuilder = jest.fn().mockReturnValue(mockEventBuilder); + const mockTrackEvent = jest.fn(); + + return { + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), + _mocks: { + trackEvent: mockTrackEvent, + addProperties: mockAddProperties, + createEventBuilder: mockCreateEventBuilder, + }, + }; +}); + +jest.mock('../../../../../../selectors/networkController', () => ({ + selectNetworkConfigurationByChainId: jest.fn(() => ({ + name: 'Ethereum Mainnet', + })), +})); + +const getAnalyticsMocks = (): { + trackEvent: jest.Mock; + addProperties: jest.Mock; + createEventBuilder: jest.Mock; +} => + ( + jest.requireMock('../../../../../hooks/useAnalytics/useAnalytics') as { + _mocks: { + trackEvent: jest.Mock; + addProperties: jest.Mock; + createEventBuilder: jest.Mock; + }; + } + )._mocks; + const eligibleAsset: TokenI = { address: AGLAMERKL_ADDRESS_MAINNET, chainId: CHAIN_IDS.MAINNET, @@ -89,10 +136,31 @@ describe('useMerklBonusClaim', () => { jest.clearAllMocks(); mockIsMerklCampaignClaimingEnabled = true; mockIsGeoEligible = true; + + mockUseMerklRewards.mockReturnValue({ + claimableReward: null, + hasClaimedBefore: false, + }); + mockUsePendingMerklClaim.mockReturnValue({ hasPendingClaim: false }); + mockUseMerklClaimTransaction.mockReturnValue({ + claimRewards: mockClaimRewards, + isClaiming: false, + error: null, + }); + + const { addProperties, createEventBuilder } = getAnalyticsMocks(); + const eventBuilder = { + addProperties, + build: jest.fn().mockReturnValue('built-event'), + }; + addProperties.mockReturnValue(eventBuilder); + createEventBuilder.mockReturnValue(eventBuilder); }); it('returns default claim data when asset is undefined', () => { - const { result } = renderHook(() => useMerklBonusClaim(undefined)); + const { result } = renderHook(() => + useMerklBonusClaim(undefined, 'test_location'), + ); expect(result.current.claimableReward).toBeNull(); expect(result.current.hasPendingClaim).toBe(false); @@ -102,7 +170,9 @@ describe('useMerklBonusClaim', () => { it('returns default claim data when feature flag is disabled', () => { mockIsMerklCampaignClaimingEnabled = false; - const { result } = renderHook(() => useMerklBonusClaim(eligibleAsset)); + const { result } = renderHook(() => + useMerklBonusClaim(eligibleAsset, 'test_location'), + ); expect(result.current.claimableReward).toBeNull(); expect(result.current.hasPendingClaim).toBe(false); @@ -111,28 +181,25 @@ describe('useMerklBonusClaim', () => { it('returns default claim data when user is geo-blocked', () => { mockIsGeoEligible = false; - const { result } = renderHook(() => useMerklBonusClaim(eligibleAsset)); + const { result } = renderHook(() => + useMerklBonusClaim(eligibleAsset, 'test_location'), + ); expect(result.current.claimableReward).toBeNull(); expect(result.current.hasPendingClaim).toBe(false); }); it('returns default claim data for ineligible token', () => { - const { result } = renderHook(() => useMerklBonusClaim(ineligibleAsset)); + const { result } = renderHook(() => + useMerklBonusClaim(ineligibleAsset, 'test_location'), + ); expect(result.current.claimableReward).toBeNull(); expect(result.current.hasPendingClaim).toBe(false); }); - it('passes undefined to underlying hooks when asset is ineligible', () => { - renderHook(() => useMerklBonusClaim(ineligibleAsset)); - - expect(mockUseMerklRewards).toHaveBeenCalledWith({ asset: undefined }); - expect(mockUseMerklClaimTransaction).toHaveBeenCalledWith(undefined); - }); - it('passes eligible asset to underlying hooks', () => { - renderHook(() => useMerklBonusClaim(eligibleAsset)); + renderHook(() => useMerklBonusClaim(eligibleAsset, 'test_location')); expect(mockUseMerklRewards).toHaveBeenCalledWith({ asset: eligibleAsset, @@ -141,7 +208,10 @@ describe('useMerklBonusClaim', () => { }); it('returns composed data from underlying hooks for eligible asset', () => { - mockUseMerklRewards.mockReturnValue({ claimableReward: '1.50' }); + mockUseMerklRewards.mockReturnValue({ + claimableReward: '1.50', + hasClaimedBefore: false, + }); mockUsePendingMerklClaim.mockReturnValue({ hasPendingClaim: true }); mockUseMerklClaimTransaction.mockReturnValue({ claimRewards: mockClaimRewards, @@ -149,7 +219,9 @@ describe('useMerklBonusClaim', () => { error: null, }); - const { result } = renderHook(() => useMerklBonusClaim(eligibleAsset)); + const { result } = renderHook(() => + useMerklBonusClaim(eligibleAsset, 'test_location'), + ); expect(result.current.claimableReward).toBe('1.50'); expect(result.current.hasPendingClaim).toBe(true); @@ -158,18 +230,180 @@ describe('useMerklBonusClaim', () => { }); it('returns claimableReward null when raw value is "< 0.01" (below threshold)', () => { - mockUseMerklRewards.mockReturnValue({ claimableReward: '< 0.01' }); + mockUseMerklRewards.mockReturnValue({ + claimableReward: '< 0.01', + hasClaimedBefore: false, + }); - const { result } = renderHook(() => useMerklBonusClaim(eligibleAsset)); + const { result } = renderHook(() => + useMerklBonusClaim(eligibleAsset, 'test_location'), + ); expect(result.current.claimableReward).toBeNull(); }); it('returns claimableReward null when raw value is below 0.01', () => { - mockUseMerklRewards.mockReturnValue({ claimableReward: '0.005' }); + mockUseMerklRewards.mockReturnValue({ + claimableReward: '0.005', + hasClaimedBefore: false, + }); - const { result } = renderHook(() => useMerklBonusClaim(eligibleAsset)); + const { result } = renderHook(() => + useMerklBonusClaim(eligibleAsset, 'test_location'), + ); expect(result.current.claimableReward).toBeNull(); }); + + describe('CTA available analytics event', () => { + it('fires trackEvent once when claimable bonus is available and visible', () => { + mockUseMerklRewards.mockReturnValue({ + claimableReward: '5.00', + hasClaimedBefore: false, + }); + + renderHook(() => + useMerklBonusClaim(eligibleAsset, 'test_location', true), + ); + + expect(getAnalyticsMocks().trackEvent).toHaveBeenCalledTimes(1); + }); + + it('does not fire trackEvent when not visible', () => { + mockUseMerklRewards.mockReturnValue({ + claimableReward: '5.00', + hasClaimedBefore: false, + }); + + renderHook(() => + useMerklBonusClaim(eligibleAsset, 'test_location', false), + ); + + expect(getAnalyticsMocks().trackEvent).not.toHaveBeenCalled(); + }); + + it('does not fire trackEvent when there is a pending claim', () => { + mockUseMerklRewards.mockReturnValue({ + claimableReward: '5.00', + hasClaimedBefore: false, + }); + mockUsePendingMerklClaim.mockReturnValue({ hasPendingClaim: true }); + + renderHook(() => + useMerklBonusClaim(eligibleAsset, 'test_location', true), + ); + + expect(getAnalyticsMocks().trackEvent).not.toHaveBeenCalled(); + }); + + it('does not fire trackEvent when claimableReward is null', () => { + mockUseMerklRewards.mockReturnValue({ + claimableReward: null, + hasClaimedBefore: false, + }); + + renderHook(() => + useMerklBonusClaim(eligibleAsset, 'test_location', true), + ); + + expect(getAnalyticsMocks().trackEvent).not.toHaveBeenCalled(); + }); + + it('does not fire trackEvent when claimableReward is "< 0.01" (below threshold)', () => { + mockUseMerklRewards.mockReturnValue({ + claimableReward: '< 0.01', + hasClaimedBefore: false, + }); + + renderHook(() => + useMerklBonusClaim(eligibleAsset, 'test_location', true), + ); + + expect(getAnalyticsMocks().trackEvent).not.toHaveBeenCalled(); + }); + + it('does not fire trackEvent when claimableReward is below threshold (e.g. "0.005")', () => { + mockUseMerklRewards.mockReturnValue({ + claimableReward: '0.005', + hasClaimedBefore: false, + }); + + renderHook(() => + useMerklBonusClaim(eligibleAsset, 'test_location', true), + ); + + expect(getAnalyticsMocks().trackEvent).not.toHaveBeenCalled(); + }); + + it('fires trackEvent only once across multiple re-renders', () => { + mockUseMerklRewards.mockReturnValue({ + claimableReward: '5.00', + hasClaimedBefore: false, + }); + + const { rerender } = renderHook(() => + useMerklBonusClaim(eligibleAsset, 'test_location', true), + ); + rerender(); + rerender(); + + expect(getAnalyticsMocks().trackEvent).toHaveBeenCalledTimes(1); + }); + + it('includes correct analytics properties in the event', () => { + mockUseMerklRewards.mockReturnValue({ + claimableReward: '5.00', + hasClaimedBefore: true, + }); + + renderHook(() => + useMerklBonusClaim(eligibleAsset, 'test_location', true), + ); + + expect(getAnalyticsMocks().addProperties).toHaveBeenCalledWith( + expect.objectContaining({ + location: 'test_location', + view_trigger: 'component_mounted', + button_text: 'Claim bonus', + network_chain_id: eligibleAsset.chainId, + asset_symbol: eligibleAsset.symbol, + bonus_amount_range: '1.00 - 9.99', + has_claimed_before: true, + }), + ); + }); + }); + + describe('getBonusAmountRange', () => { + const bonusRangeCases: [string, string][] = [ + ['0.50', '0.01 - 0.99'], + ['0.99', '0.01 - 0.99'], + ['1.00', '1.00 - 9.99'], + ['9.99', '1.00 - 9.99'], + ['10.00', '10.00 - 99.99'], + ['99.99', '10.00 - 99.99'], + ['100.00', '100.00 - 999.99'], + ['999.99', '100.00 - 999.99'], + ['1000.00', '1000.00+'], + ['9999.00', '1000.00+'], + ]; + + it.each(bonusRangeCases)( + 'maps reward "%s" to range "%s" via the analytics event', + (bonusValue, expectedRange) => { + mockUseMerklRewards.mockReturnValue({ + claimableReward: bonusValue, + hasClaimedBefore: false, + }); + + renderHook(() => + useMerklBonusClaim(eligibleAsset, 'test_location', true), + ); + + expect(getAnalyticsMocks().addProperties).toHaveBeenCalledWith( + expect.objectContaining({ bonus_amount_range: expectedRange }), + ); + }, + ); + }); }); diff --git a/app/components/UI/Earn/components/MerklRewards/hooks/useMerklBonusClaim.ts b/app/components/UI/Earn/components/MerklRewards/hooks/useMerklBonusClaim.ts index 16aea0f0e77..bf997985ec9 100644 --- a/app/components/UI/Earn/components/MerklRewards/hooks/useMerklBonusClaim.ts +++ b/app/components/UI/Earn/components/MerklRewards/hooks/useMerklBonusClaim.ts @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useMemo, useRef, useEffect } from 'react'; import { useSelector } from 'react-redux'; import { Hex } from '@metamask/utils'; import { TokenI } from '../../../../Tokens/types'; @@ -11,6 +11,10 @@ import { usePendingMerklClaim } from './usePendingMerklClaim'; import { useMerklClaimTransaction } from './useMerklClaimTransaction'; import { selectMerklCampaignClaimingEnabledFlag } from '../../../selectors/featureFlags'; import { useMusdConversionEligibility } from '../../../hooks/useMusdConversionEligibility'; +import { selectNetworkConfigurationByChainId } from '../../../../../../selectors/networkController'; +import { RootState } from '../../../../../../reducers'; +import { MetaMetricsEvents } from '../../../../../../core/Analytics'; +import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics'; export interface MerklClaimData { /** Claimable reward string when amount >= MIN_CLAIMABLE_BONUS_USD; null otherwise (e.g. "< 0.01" or below threshold). */ @@ -33,6 +37,16 @@ const DEFAULT_MERKL_CLAIM_DATA: MerklClaimData = { claimRewards: async () => undefined, }; +const getBonusAmountRange = (bonusAmount: string): string => { + if (bonusAmount.startsWith('<')) return '< 0.01'; + const value = parseFloat(bonusAmount); + if (value < 1) return '0.01 - 0.99'; + if (value < 10) return '1.00 - 9.99'; + if (value < 100) return '10.00 - 99.99'; + if (value < 1000) return '100.00 - 999.99'; + return '1000.00+'; +}; + /** * Combines `useMerklRewards`, `usePendingMerklClaim`, and `useMerklClaimTransaction` * into a single hook that can be called unconditionally in token list items. @@ -40,16 +54,27 @@ const DEFAULT_MERKL_CLAIM_DATA: MerklClaimData = { * For ineligible or geo-blocked assets, `undefined` is passed to the underlying * hooks which causes them to no-op (no API calls, no side effects). * + * Fires `MUSD_CLAIM_BONUS_CTA_DISPLAYED` at most once per mount when the claim + * CTA is both eligible and physically visible in the viewport. + * * @param asset - The token to check for Merkl bonus claim eligibility + * @param location - Where the claim CTA is rendered; used for analytics + * @param isVisible - Whether the component is currently visible in the viewport. * @returns MerklClaimData with claim state and actions */ export const useMerklBonusClaim = ( asset: TokenI | undefined, + location: string, + isVisible = true, ): MerklClaimData => { const isMerklCampaignClaimingEnabled = useSelector( selectMerklCampaignClaimingEnabledFlag, ); const { isEligible: isGeoEligible } = useMusdConversionEligibility(); + const { trackEvent, createEventBuilder } = useAnalytics(); + const network = useSelector((state: RootState) => + selectNetworkConfigurationByChainId(state, asset?.chainId as Hex), + ); const isEligible = useMemo(() => { if (!isMerklCampaignClaimingEnabled || !isGeoEligible) { @@ -71,10 +96,56 @@ export const useMerklBonusClaim = ( const eligibleAsset = isEligible ? asset : undefined; - const { claimableReward } = useMerklRewards({ asset: eligibleAsset }); + const { claimableReward, hasClaimedBefore } = useMerklRewards({ + asset: eligibleAsset, + }); const { hasPendingClaim } = usePendingMerklClaim(); const { claimRewards, isClaiming } = useMerklClaimTransaction(eligibleAsset); + const hasClaimableBonus = + isEligible && + isClaimableBonusAboveThreshold(claimableReward) && + !hasPendingClaim; + + const hasFiredCtaAvailableEvent = useRef(false); + + useEffect(() => { + if ( + !hasClaimableBonus || + !isVisible || + !claimableReward || + hasFiredCtaAvailableEvent.current + ) { + return; + } + hasFiredCtaAvailableEvent.current = true; + trackEvent( + createEventBuilder(MetaMetricsEvents.MUSD_CLAIM_BONUS_CTA_DISPLAYED) + .addProperties({ + location, + view_trigger: 'component_mounted', + button_text: 'Claim bonus', + network_chain_id: asset?.chainId, + network_name: network?.name, + asset_symbol: asset?.symbol, + bonus_amount_range: getBonusAmountRange(claimableReward), + has_claimed_before: hasClaimedBefore, + }) + .build(), + ); + }, [ + hasClaimableBonus, + isVisible, + trackEvent, + createEventBuilder, + location, + asset?.chainId, + asset?.symbol, + network?.name, + claimableReward, + hasClaimedBefore, + ]); + return useMemo(() => { if (!isEligible) { return DEFAULT_MERKL_CLAIM_DATA; diff --git a/app/components/UI/Earn/components/MerklRewards/hooks/useMerklRewards.test.ts b/app/components/UI/Earn/components/MerklRewards/hooks/useMerklRewards.test.ts index 6c392d441e8..93450eca2cd 100644 --- a/app/components/UI/Earn/components/MerklRewards/hooks/useMerklRewards.test.ts +++ b/app/components/UI/Earn/components/MerklRewards/hooks/useMerklRewards.test.ts @@ -6,6 +6,7 @@ import { useMerklRewards, } from './useMerklRewards'; import { selectSelectedInternalAccountFormattedAddress } from '../../../../../../selectors/accountsController'; +import { selectNetworkConfigurationByChainId } from '../../../../../../selectors/networkController'; import { TokenI } from '../../../../Tokens/types'; import { CHAIN_IDS } from '@metamask/transaction-controller'; import { @@ -50,6 +51,16 @@ jest.mock('../../../../../../util/Logger', () => ({ error: jest.fn(), })); +jest.mock('../../../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: jest.fn(), + createEventBuilder: () => ({ + addProperties: jest.fn().mockReturnThis(), + build: jest.fn(), + }), + }), +})); + // Mock fetch globally global.fetch = jest.fn(); @@ -155,6 +166,9 @@ describe('useMerklRewards', () => { if (selector === selectSelectedInternalAccountFormattedAddress) { return mockSelectedAddress; } + if (selector === selectNetworkConfigurationByChainId) { + return { name: 'Ethereum Mainnet' }; + } return undefined; }); }); @@ -198,6 +212,9 @@ describe('useMerklRewards', () => { if (selector === selectSelectedInternalAccountFormattedAddress) { return null; } + if (selector === selectNetworkConfigurationByChainId) { + return { name: 'Ethereum Mainnet' }; + } return undefined; }); diff --git a/app/components/UI/Earn/components/MerklRewards/hooks/useMerklRewards.ts b/app/components/UI/Earn/components/MerklRewards/hooks/useMerklRewards.ts index 35e5aceb9f0..e1dec172f61 100644 --- a/app/components/UI/Earn/components/MerklRewards/hooks/useMerklRewards.ts +++ b/app/components/UI/Earn/components/MerklRewards/hooks/useMerklRewards.ts @@ -57,6 +57,7 @@ interface UseMerklRewardsOptions { interface UseMerklRewardsReturn { claimableReward: string | null; + hasClaimedBefore: boolean; refetch: () => void; } @@ -67,6 +68,7 @@ export const useMerklRewards = ({ asset, }: UseMerklRewardsOptions): UseMerklRewardsReturn => { const [claimableReward, setClaimableReward] = useState(null); + const [hasClaimedBefore, setHasClaimedBefore] = useState(false); const selectedAddress = useSelector( selectSelectedInternalAccountFormattedAddress, @@ -77,6 +79,7 @@ export const useMerklRewards = ({ // Guard against undefined asset (can happen when selector returns undefined) if (!asset) { setClaimableReward(null); + setHasClaimedBefore(false); return; } @@ -87,6 +90,7 @@ export const useMerklRewards = ({ if (!isEligible || !selectedAddress) { setClaimableReward(null); + setHasClaimedBefore(false); return; } @@ -107,6 +111,7 @@ export const useMerklRewards = ({ } if (!matchingReward) { + setHasClaimedBefore(false); return; } @@ -128,6 +133,10 @@ export const useMerklRewards = ({ ? claimedFromContract : matchingReward.claimed; + if (!controller.signal.aborted) { + setHasClaimedBefore(BigInt(claimedAmount) > 0n); + } + // Use unclaimed amount as it represents claimable rewards in the Merkle tree // Use token decimals from API response, fallback to asset decimals // Convert string amounts to BigInt for subtraction, then back to string @@ -184,6 +193,7 @@ export const useMerklRewards = ({ return { claimableReward, + hasClaimedBefore, refetch, }; }; diff --git a/app/components/UI/Tokens/TokenList/TokenList.tsx b/app/components/UI/Tokens/TokenList/TokenList.tsx index b177c7cb3e4..34ff7d885ae 100644 --- a/app/components/UI/Tokens/TokenList/TokenList.tsx +++ b/app/components/UI/Tokens/TokenList/TokenList.tsx @@ -4,9 +4,10 @@ import React, { useRef, useMemo, useEffect, + useState, } from 'react'; import { DeviceEventEmitter, RefreshControl } from 'react-native'; -import { FlashList, FlashListRef } from '@shopify/flash-list'; +import { FlashList, FlashListRef, ViewToken } from '@shopify/flash-list'; import { useSelector } from 'react-redux'; import { useTheme } from '../../../../util/theme'; import { @@ -150,6 +151,24 @@ const TokenListComponent = ({ navigation.navigate(Routes.WALLET.TOKENS_FULL_VIEW); }, [navigation, trackEvent, createEventBuilder]); + const getTokenKey = useCallback( + (item: FlashListAssetKey): string => + `${item.address}-${item.chainId}-${item.isStaked ? 'staked' : 'unstaked'}`, + [], + ); + + // Track which items are currently visible in the viewport. + const [visibleKeys, setVisibleKeys] = useState>(new Set()); + + const handleViewableItemsChanged = useCallback( + ({ viewableItems }: { viewableItems: ViewToken[] }) => { + setVisibleKeys( + new Set(viewableItems.map(({ item }) => getTokenKey(item))), + ); + }, + [getTokenKey], + ); + const renderTokenListItem = useCallback( ({ item }: { item: FlashListAssetKey }) => ( ), [ @@ -170,6 +190,8 @@ const TokenListComponent = ({ showPercentageChange, isFullView, shouldShowTokenListItemCta, + visibleKeys, + getTokenKey, ], ); @@ -181,7 +203,7 @@ const TokenListComponent = ({ > {displayTokenKeys.map((item, index) => ( ))} {shouldShowViewAllButton && ( @@ -214,11 +237,9 @@ const TokenListComponent = ({ itemVisiblePercentThreshold: 50, minimumViewTime: 1000, }} + onViewableItemsChanged={handleViewableItemsChanged} renderItem={renderTokenListItem} - keyExtractor={(item, idx) => { - const staked = item.isStaked ? 'staked' : 'unstaked'; - return `${item.address}-${item.chainId}-${staked}-${idx}`; - }} + keyExtractor={(item, idx) => `${getTokenKey(item)}-${idx}`} refreshControl={ } - extraData={{ isTokenNetworkFilterEqualCurrentNetwork }} + extraData={{ isTokenNetworkFilterEqualCurrentNetwork, visibleKeys }} contentContainerStyle={!isFullView ? undefined : tw`px-4`} /> diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx index 90d7df11f6b..96e9f30d322 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx @@ -1484,7 +1484,11 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { />, ); - expect(mockUseMerklBonusClaim).toHaveBeenCalledWith(claimableAsset); + expect(mockUseMerklBonusClaim).toHaveBeenCalledWith( + claimableAsset, + 'token_list_item', + true, + ); }); }); }); diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx index d248eb23c74..1f2a055b357 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx @@ -109,6 +109,7 @@ interface TokenListItemProps { showPercentageChange?: boolean; isFullView?: boolean; shouldShowTokenListItemCta: (asset?: TokenI) => boolean; + isVisible?: boolean; } export const TokenListItem = React.memo( @@ -120,6 +121,7 @@ export const TokenListItem = React.memo( showPercentageChange = true, isFullView = false, shouldShowTokenListItemCta, + isVisible = true, }: TokenListItemProps) => { const { trackEvent, createEventBuilder } = useAnalytics(); const navigation = useNavigation(); @@ -165,7 +167,11 @@ export const TokenListItem = React.memo( [asset, shouldShowTokenListItemCta], ); - const merklClaimData = useMerklBonusClaim(asset); + const merklClaimData = useMerklBonusClaim( + asset, + MUSD_EVENTS_CONSTANTS.EVENT_LOCATIONS.TOKEN_LIST_ITEM, + isVisible, + ); const { claimRewards, claimableReward, hasPendingClaim } = merklClaimData; const hasClaimableBonus = !!claimableReward && !hasPendingClaim; diff --git a/app/components/UI/Tokens/TokenList/TokenListItemV2/TokenListItemV2.test.tsx b/app/components/UI/Tokens/TokenList/TokenListItemV2/TokenListItemV2.test.tsx index 475e2654d56..cbc8c771bc9 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItemV2/TokenListItemV2.test.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItemV2/TokenListItemV2.test.tsx @@ -168,16 +168,19 @@ jest.mock('../../../Stake/hooks/useStakingChain', () => ({ })); const mockClaimRewards = jest.fn(); -const mockUseMerklBonusClaim = jest.fn((_asset?: unknown) => ({ - claimableReward: null as string | null, - hasPendingClaim: false, - isClaiming: false, - claimRewards: mockClaimRewards, -})); +const mockUseMerklBonusClaim = jest.fn( + (_asset?: unknown, _location?: unknown, _isVisible?: unknown) => ({ + claimableReward: null as string | null, + hasPendingClaim: false, + isClaiming: false, + claimRewards: mockClaimRewards, + }), +); jest.mock( '../../../Earn/components/MerklRewards/hooks/useMerklBonusClaim', () => ({ - useMerklBonusClaim: (...args: [unknown]) => mockUseMerklBonusClaim(...args), + useMerklBonusClaim: (...args: [unknown, unknown, unknown]) => + mockUseMerklBonusClaim(...args), }), ); @@ -332,6 +335,7 @@ describe('TokenListItemV2 - Component Rendering Tests for Coverage', () => { isStablecoinLendingEnabled?: boolean; earnToken?: Record | null; claimableReward?: string | null; + isClaiming?: boolean; tokenMarketData?: Record>; currencyRatesData?: Record; nativeCurrency?: string; @@ -351,6 +355,7 @@ describe('TokenListItemV2 - Component Rendering Tests for Coverage', () => { isStablecoinLendingEnabled = false, earnToken, claimableReward = null, + isClaiming = false, tokenMarketData, currencyRatesData, nativeCurrency, @@ -367,7 +372,7 @@ describe('TokenListItemV2 - Component Rendering Tests for Coverage', () => { mockUseMerklBonusClaim.mockReturnValue({ claimableReward, hasPendingClaim: false, - isClaiming: false, + isClaiming, claimRewards: mockClaimRewards, }); @@ -1431,6 +1436,53 @@ describe('TokenListItemV2 - Component Rendering Tests for Coverage', () => { ).toBeNull(); expect(getByText('+1.50%')).toBeOnTheScreen(); }); + + it('shows Spinner instead of text when isClaiming is true', () => { + prepareMocks({ + asset: claimableAsset, + claimableReward: '1000000000000000000', + isClaiming: true, + }); + + const { queryByText, UNSAFE_getByType } = renderWithProvider( + , + ); + + expect(queryByText(strings('earn.claim_bonus'))).toBeNull(); + + const { Spinner } = jest.requireActual( + '@metamask/design-system-react-native/dist/components/temp-components/Spinner/index.cjs', + ); + expect(UNSAFE_getByType(Spinner)).toBeTruthy(); + }); + + it('passes asset to useMerklBonusClaim hook', () => { + prepareMocks({ + asset: claimableAsset, + }); + + renderWithProvider( + , + ); + + expect(mockUseMerklBonusClaim).toHaveBeenCalledWith( + claimableAsset, + 'token_list_item', + true, + ); + }); }); describe('Token Price in Fiat', () => { diff --git a/app/components/UI/Tokens/TokenList/TokenListItemV2/TokenListItemV2.tsx b/app/components/UI/Tokens/TokenList/TokenListItemV2/TokenListItemV2.tsx index ec354395b0f..1d5858be36c 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItemV2/TokenListItemV2.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItemV2/TokenListItemV2.tsx @@ -149,6 +149,8 @@ interface TokenListItemV2Props { showPercentageChange?: boolean; isFullView?: boolean; shouldShowTokenListItemCta: (asset?: TokenI) => boolean; + // Whether this item is currently visible in the viewport. + isVisible?: boolean; } export const TokenListItemV2 = React.memo( @@ -160,6 +162,7 @@ export const TokenListItemV2 = React.memo( showPercentageChange = true, isFullView = false, shouldShowTokenListItemCta, + isVisible = true, }: TokenListItemV2Props) => { const { trackEvent, createEventBuilder } = useAnalytics(); const navigation = useNavigation(); @@ -230,7 +233,11 @@ export const TokenListItemV2 = React.memo( [asset, shouldShowTokenListItemCta], ); - const merklClaimData = useMerklBonusClaim(asset); + const merklClaimData = useMerklBonusClaim( + asset, + MUSD_EVENTS_CONSTANTS.EVENT_LOCATIONS.TOKEN_LIST_ITEM, + isVisible, + ); const { claimRewards, claimableReward, hasPendingClaim } = merklClaimData; const hasClaimableBonus = !!claimableReward && !hasPendingClaim; diff --git a/app/components/Views/Homepage/Sections/Cash/MusdAggregatedRow.tsx b/app/components/Views/Homepage/Sections/Cash/MusdAggregatedRow.tsx index 3453733837e..56a02f2dfbb 100644 --- a/app/components/Views/Homepage/Sections/Cash/MusdAggregatedRow.tsx +++ b/app/components/Views/Homepage/Sections/Cash/MusdAggregatedRow.tsx @@ -65,7 +65,10 @@ const MusdAggregatedRow = () => { const { tokenBalanceAggregated, fiatBalanceAggregatedFormatted } = useMusdBalance(); const { claimableReward, hasPendingClaim, claimRewards, isClaiming } = - useMerklBonusClaim(LINEA_MUSD_ASSET); + useMerklBonusClaim( + LINEA_MUSD_ASSET, + MUSD_EVENTS_CONSTANTS.EVENT_LOCATIONS.HOME_CASH_SECTION, + ); const { trackEvent, createEventBuilder } = useAnalytics(); const networkName = useNetworkName(LINEA_MUSD_ASSET.chainId as Hex); diff --git a/app/components/Views/Homepage/Sections/Tokens/TokensSection.tsx b/app/components/Views/Homepage/Sections/Tokens/TokensSection.tsx index 9ec1cd36e77..52569b49a68 100644 --- a/app/components/Views/Homepage/Sections/Tokens/TokensSection.tsx +++ b/app/components/Views/Homepage/Sections/Tokens/TokensSection.tsx @@ -231,6 +231,7 @@ const TokensSection = forwardRef( privacyMode={privacyMode} showPercentageChange shouldShowTokenListItemCta={shouldShowTokenListItemCta} + isVisible /> )) )} diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index 8235844ef12..063eb7bc621 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -643,6 +643,7 @@ enum EVENT_NAME { MUSD_FULLSCREEN_ANNOUNCEMENT_BUTTON_CLICKED = 'mUSD Fullscreen Announcement Button Clicked', MUSD_CONVERSION_STATUS_UPDATED = 'mUSD Conversion Status Updated', MUSD_CLAIM_BONUS_BUTTON_CLICKED = 'mUSD Claim Bonus Button Clicked', + MUSD_CLAIM_BONUS_CTA_DISPLAYED = 'mUSD Claim Bonus CTA Displayed', MUSD_CLAIM_BONUS_STATUS_UPDATED = 'mUSD Claim Bonus Status Updated', MUSD_QUICK_CONVERT_SCREEN_VIEWED = 'mUSD Quick Convert Screen Viewed', MUSD_BONUS_TERMS_OF_USE_PRESSED = 'mUSD Bonus Terms of Use Pressed', @@ -1678,6 +1679,9 @@ const events = { MUSD_CLAIM_BONUS_BUTTON_CLICKED: generateOpt( EVENT_NAME.MUSD_CLAIM_BONUS_BUTTON_CLICKED, ), + MUSD_CLAIM_BONUS_CTA_DISPLAYED: generateOpt( + EVENT_NAME.MUSD_CLAIM_BONUS_CTA_DISPLAYED, + ), MUSD_CLAIM_BONUS_STATUS_UPDATED: generateOpt( EVENT_NAME.MUSD_CLAIM_BONUS_STATUS_UPDATED, ), From 739788f95137090f279b5ea36f3c00417a995b10 Mon Sep 17 00:00:00 2001 From: Wei Sun Date: Tue, 17 Mar 2026 14:26:22 -0700 Subject: [PATCH 069/206] chore: add ramp env variables to builds.yml and ota envs (#27553) ## **Description** Adding the following ramp envs to builds.yml and push-eas-updates.yml: **RAMP_DEV_BUILD RAMP_INTERNAL_BUILD RAMPS_ENVIRONMENT** ## **Changelog** CHANGELOG entry: Added Ramp env variables to push-eas-update.yml and builds.yml ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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] > [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) is generating a summary for commit c703cdfcbd934aa8314d50b7c2deaf612a1c403c. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .github/workflows/push-eas-update.yml | 4 +++- builds.yml | 27 ++++++++++++++++++--------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/.github/workflows/push-eas-update.yml b/.github/workflows/push-eas-update.yml index 6a01cc245f8..426db1b1615 100644 --- a/.github/workflows/push-eas-update.yml +++ b/.github/workflows/push-eas-update.yml @@ -277,7 +277,9 @@ jobs: EXPO_PROJECT_ID: ${{ secrets.EXPO_PROJECT_ID }} EXPO_CHANNEL: ${{ vars.EXPO_CHANNEL }} GIT_BRANCH: ${{ github.ref_name }} - RAMP_INTERNAL_BUILD: 'false' + RAMP_DEV_BUILD: ${{ secrets.RAMP_DEV_BUILD || 'false' }} + RAMP_INTERNAL_BUILD: ${{ secrets.RAMP_INTERNAL_BUILD || 'false' }} + RAMPS_ENVIRONMENT: ${{ secrets.RAMPS_ENVIRONMENT || 'production' }} MM_MUSD_CONVERSION_FLOW_ENABLED: 'false' MM_NETWORK_UI_REDESIGN_ENABLED: 'false' MM_NOTIFICATIONS_UI_ENABLED: 'true' diff --git a/builds.yml b/builds.yml index afc7068debe..9dc6fc714aa 100644 --- a/builds.yml +++ b/builds.yml @@ -28,8 +28,9 @@ _public_envs: &public_envs # Servers (production) DIGEST_API_URL: 'https://digest.api.cx.metamask.io/api/v1' REWARDS_API_URL: 'https://rewards.api.cx.metamask.io' BAANX_API_URL: 'https://api.baanx.com' - RAMPS_ENVIRONMENT: 'production' # Build flags (production defaults) + RAMPS_ENVIRONMENT: 'production' + RAMP_DEV_BUILD: 'false' BRIDGE_USE_DEV_APIS: 'false' RAMP_INTERNAL_BUILD: 'false' IS_TEST: 'false' @@ -213,8 +214,9 @@ builds: REWARDS_API_URL: 'https://rewards.uat-api.cx.metamask.io' BAANX_API_URL: 'https://dev.api.baanx.com' DIGEST_API_URL: 'https://digest.dev-api.cx.metamask.io/api/v1' - RAMPS_ENVIRONMENT: 'staging' BRIDGE_USE_DEV_APIS: 'true' + RAMPS_ENVIRONMENT: 'staging' + RAMP_DEV_BUILD: 'true' RAMP_INTERNAL_BUILD: 'true' IS_TEST: 'true' IGNORE_BOXLOGS_DEVELOPMENT: 'true' @@ -234,9 +236,10 @@ builds: REWARDS_API_URL: 'https://rewards.uat-api.cx.metamask.io' BAANX_API_URL: 'https://dev.api.baanx.com' DIGEST_API_URL: 'https://digest.dev-api.cx.metamask.io/api/v1' - RAMPS_ENVIRONMENT: 'staging' BRIDGE_USE_DEV_APIS: 'true' + RAMPS_ENVIRONMENT: 'staging' RAMP_INTERNAL_BUILD: 'true' + RAMP_DEV_BUILD: 'true' IS_TEST: 'true' IGNORE_BOXLOGS_DEVELOPMENT: 'true' IS_SIM_BUILD: 'true' @@ -255,9 +258,10 @@ builds: REWARDS_API_URL: 'https://rewards.uat-api.cx.metamask.io' BAANX_API_URL: 'https://dev.api.baanx.com' DIGEST_API_URL: 'https://digest.dev-api.cx.metamask.io/api/v1' - RAMPS_ENVIRONMENT: 'staging' BRIDGE_USE_DEV_APIS: 'true' + RAMPS_ENVIRONMENT: 'staging' RAMP_INTERNAL_BUILD: 'true' + RAMP_DEV_BUILD: 'true' IS_TEST: 'false' MM_ENABLE_SETTINGS_PAGE_DEV_OPTIONS: 'true' secrets: *secrets @@ -274,9 +278,10 @@ builds: REWARDS_API_URL: 'https://rewards.uat-api.cx.metamask.io' BAANX_API_URL: 'https://dev.api.baanx.com' DIGEST_API_URL: 'https://digest.dev-api.cx.metamask.io/api/v1' - RAMPS_ENVIRONMENT: 'staging' BRIDGE_USE_DEV_APIS: 'true' + RAMPS_ENVIRONMENT: 'staging' RAMP_INTERNAL_BUILD: 'true' + RAMP_DEV_BUILD: 'true' IS_TEST: 'false' MM_ENABLE_SETTINGS_PAGE_DEV_OPTIONS: 'true' IS_SIM_BUILD: 'true' @@ -312,9 +317,10 @@ builds: REWARDS_API_URL: 'https://rewards.uat-api.cx.metamask.io' BAANX_API_URL: 'https://dev.api.baanx.com' DIGEST_API_URL: 'https://digest.dev-api.cx.metamask.io/api/v1' - RAMPS_ENVIRONMENT: 'staging' BRIDGE_USE_DEV_APIS: 'true' + RAMPS_ENVIRONMENT: 'staging' RAMP_INTERNAL_BUILD: 'true' + RAMP_DEV_BUILD: 'true' IS_TEST: 'true' IGNORE_BOXLOGS_DEVELOPMENT: 'true' IS_SIM_BUILD: 'true' @@ -333,9 +339,10 @@ builds: REWARDS_API_URL: 'https://rewards.uat-api.cx.metamask.io' BAANX_API_URL: 'https://dev.api.baanx.com' DIGEST_API_URL: 'https://digest.dev-api.cx.metamask.io/api/v1' - RAMPS_ENVIRONMENT: 'staging' BRIDGE_USE_DEV_APIS: 'true' + RAMPS_ENVIRONMENT: 'staging' RAMP_INTERNAL_BUILD: 'true' + RAMP_DEV_BUILD: 'true' IS_TEST: 'true' IGNORE_BOXLOGS_DEVELOPMENT: 'true' IS_SIM_BUILD: 'true' @@ -353,8 +360,9 @@ builds: REWARDS_API_URL: 'https://rewards.uat-api.cx.metamask.io' BAANX_API_URL: 'https://dev.api.baanx.com' DIGEST_API_URL: 'https://digest.dev-api.cx.metamask.io/api/v1' - RAMPS_ENVIRONMENT: 'staging' BRIDGE_USE_DEV_APIS: 'true' + RAMPS_ENVIRONMENT: 'staging' + RAMP_DEV_BUILD: 'true' RAMP_INTERNAL_BUILD: 'true' IS_TEST: 'false' MM_ENABLE_SETTINGS_PAGE_DEV_OPTIONS: 'true' @@ -403,9 +411,10 @@ builds: REWARDS_API_URL: 'https://rewards.uat-api.cx.metamask.io' BAANX_API_URL: 'https://dev.api.baanx.com' DIGEST_API_URL: 'https://digest.dev-api.cx.metamask.io/api/v1' - RAMPS_ENVIRONMENT: 'staging' BRIDGE_USE_DEV_APIS: 'true' + RAMPS_ENVIRONMENT: 'staging' RAMP_INTERNAL_BUILD: 'true' + RAMP_DEV_BUILD: 'true' IS_TEST: 'false' MM_ENABLE_SETTINGS_PAGE_DEV_OPTIONS: 'true' IS_SIM_BUILD: 'true' From ed27aa0450f2ba84e51884422f2403090f2fb274 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Tue, 17 Mar 2026 16:26:47 -0600 Subject: [PATCH 070/206] fix(ramps): check for providers before checking array length (#27566) ## **Description** Fixes a bootstrap bug when providers are not available. Simulator Screenshot - mm-green -
2026-03-17 at 14 35 05 ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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] > [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) is generating a summary for commit 32f2796de9da8b88527bfed1e25303918a8240c4. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../UI/Ramp/hooks/useRampsProviders.test.ts | 14 ++++++++++++++ app/components/UI/Ramp/hooks/useRampsProviders.ts | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/app/components/UI/Ramp/hooks/useRampsProviders.test.ts b/app/components/UI/Ramp/hooks/useRampsProviders.test.ts index 06ddbdf0826..4a7c3d7614b 100644 --- a/app/components/UI/Ramp/hooks/useRampsProviders.test.ts +++ b/app/components/UI/Ramp/hooks/useRampsProviders.test.ts @@ -270,5 +270,19 @@ describe('useRampsProviders', () => { expect(mockDeterminePreferredProvider).not.toHaveBeenCalled(); }); + + it('does not call determinePreferredProvider when providers is undefined', () => { + const store = createMockStore({ data: undefined }); + mockDeterminePreferredProvider.mockClear(); + + renderHook(() => useRampsProviders(), { + wrapper: wrapper(store), + }); + + expect(mockDeterminePreferredProvider).not.toHaveBeenCalled(); + expect( + Engine.context.RampsController.setSelectedProvider, + ).not.toHaveBeenCalled(); + }); }); }); diff --git a/app/components/UI/Ramp/hooks/useRampsProviders.ts b/app/components/UI/Ramp/hooks/useRampsProviders.ts index 66447e34fd2..255aeff8af3 100644 --- a/app/components/UI/Ramp/hooks/useRampsProviders.ts +++ b/app/components/UI/Ramp/hooks/useRampsProviders.ts @@ -72,7 +72,7 @@ export function useRampsProviders(): UseRampsProvidersResult { ); useEffect(() => { - if (providers.length > 0 && !selectedProvider) { + if (providers && providers.length > 0 && !selectedProvider) { setSelectedProvider( determinePreferredProvider(completedOrders, providers), ); From 7329b92f25bc4b101159dc94667959d6a2424774 Mon Sep 17 00:00:00 2001 From: TylerC Date: Wed, 18 Mar 2026 13:36:49 +0800 Subject: [PATCH 071/206] test: improve coverage for onboarding JS components (#27508) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Rewrites and improves unit tests for three legacy `.js` onboarding components to comply with [unit-testing-guidelines](/.cursor/rules/unit-testing-guidelines.mdc) and increase branch coverage ahead of design system migration. ### Components improved: | Component | Branch Before | Branch After | Lines | |---|---|---|---| | `ManualBackupStep2` | 83.7% | **83.67%** | **97.94%** | | `AccountBackupStep1B` | 62.5% | **75%** | **100%** | | `ProtectYourWalletModal` | 66.7% | **83.33%** | **100%** | ### Key changes: - Replaced brittle snapshot tests with specific behavioral assertions - Fixed test names: no "should", action-oriented, single behavior per test - Enforced AAA (Arrange-Act-Assert) pattern with blank line separation - Replaced `toBeTruthy()`/`toBeDefined()` with `toBeOnTheScreen()` and specific matchers - Wrapped async state updates in `act()` - Added tests for modal interactions, navigation paths, error/success sheets - Mocked `ActionModal` for `AccountBackupStep1B` modal rendering in test env - Removed obsolete `.snap` files (snapshot tests replaced with behavioral tests) ## **Related issues** Refs: Foundation test coverage for onboarding `.js` component migration to design system. ## **Manual testing steps** ```gherkin Given the developer has checked out this branch When they run `yarn jest app/components/Views/ManualBackupStep2/index.test.tsx --forceExit` Then all 14 tests pass When they run `yarn jest app/components/Views/AccountBackupStep1B/index.test.tsx --forceExit` Then all 11 tests pass When they run `yarn jest app/components/UI/ProtectYourWalletModal/index.test.tsx --forceExit` Then all 11 tests pass ``` ## **Screenshots/Recordings** N/A — test-only changes, no UI modifications. ## **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 (unit tests improved for 3 components) - [x] I've documented my code using JSDoc format where applicable - [x] I've applied the right labels on this PR - [x] I've manually run the test suite and confirmed all tests pass - [x] I've considered if a CHANGELOG update is required — no, these are test-only improvements ## **Changelog entry** CHANGELOG entry: null Made with [Cursor](https://cursor.com) --- > [!NOTE] > **Low Risk** > Test-only changes: snapshot files are removed and replaced with targeted assertions around navigation, modal visibility, and analytics dispatch/track calls. Low risk to production behavior, but moderate risk of test brittleness due to heavier mocking and reliance on specific navigation params/testIDs. > > **Overview** > Removes legacy Jest snapshot testing for onboarding-related UI (`ProtectYourWalletModal`, `AccountBackupStep1B`) and replaces it with behavior-focused assertions that verify rendering, button/link presence, and conditional hiding when seedless onboarding login flow is active. > > Expands interaction coverage: validates navigation targets (SRP modal, webview, backup flows), analytics tracking calls, and Redux dispatch on dismiss actions, with async flows wrapped in `act()`/`waitFor()` and additional test helpers/mocks (notably `ActionModal` and navigation props) to reduce flakiness. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit aeb628fe0b2cc95648d684e9900dbfd28b1ef90b. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../__snapshots__/index.test.tsx.snap | 474 ----------- .../UI/ProtectYourWalletModal/index.test.tsx | 293 ++++--- .../__snapshots__/index.test.tsx.snap | 788 ------------------ .../Views/AccountBackupStep1B/index.test.tsx | 283 ++++--- .../Views/ManualBackupStep2/index.test.tsx | 424 ++++++---- 5 files changed, 626 insertions(+), 1636 deletions(-) delete mode 100644 app/components/UI/ProtectYourWalletModal/__snapshots__/index.test.tsx.snap delete mode 100644 app/components/Views/AccountBackupStep1B/__snapshots__/index.test.tsx.snap diff --git a/app/components/UI/ProtectYourWalletModal/__snapshots__/index.test.tsx.snap b/app/components/UI/ProtectYourWalletModal/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 11bfb3da4e8..00000000000 --- a/app/components/UI/ProtectYourWalletModal/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,474 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`ProtectYourWalletModal render matches snapshot 1`] = ` - - - - - - - - - - - - - Protect your wallet - - - -  - - - - - - - - Don’t risk losing your funds. Protect your wallet by saving your Secret Recovery Phrase in a place you trust. - - It’s the only way to recover your wallet if you get locked out of the app or get a new device. - - - - - Learn more - - - - - - - - Protect wallet - - - - - Remind me later - - - - - - - - - -`; - -exports[`ProtectYourWalletModal render matches snapshot when isSeedlessOnboardingLoginFlow is true 1`] = `null`; diff --git a/app/components/UI/ProtectYourWalletModal/index.test.tsx b/app/components/UI/ProtectYourWalletModal/index.test.tsx index 4018cd76f1d..b3ea730ed75 100644 --- a/app/components/UI/ProtectYourWalletModal/index.test.tsx +++ b/app/components/UI/ProtectYourWalletModal/index.test.tsx @@ -69,7 +69,7 @@ jest.mock( const mockStore = configureMockStore(); -const initialState = { +const createInitialState = (overrides = {}) => ({ user: { protectWalletModalVisible: true, passwordSet: true, @@ -81,166 +81,203 @@ const initialState = { }, }, }, -}; - -const store = mockStore(initialState); - -interface ProtectYourWalletModalProps { - navigation?: { - navigate: jest.Mock; - }; -} + ...overrides, +}); const mockNavigation = { navigate: jest.fn(), }; -const defaultProps: ProtectYourWalletModalProps = { - navigation: mockNavigation, +const renderModal = (storeOverride?: ReturnType) => { + const store = storeOverride ?? mockStore(createInitialState()); + return render( + + + + + , + ); }; describe('ProtectYourWalletModal', () => { beforeEach(() => { jest.clearAllMocks(); }); - it('render matches snapshot', () => { - const { toJSON } = render( - - - - - , - ); - expect(toJSON()).toMatchSnapshot(); - }); - it('render title, top button and bottom button', () => { - const { getByText } = render( - - - - - , - ); - expect(getByText(strings('protect_wallet_modal.title'))).toBeOnTheScreen(); - expect( - getByText(strings('protect_wallet_modal.top_button')), - ).toBeOnTheScreen(); - expect( - getByText(strings('protect_wallet_modal.bottom_button')), - ).toBeOnTheScreen(); - }); + describe('rendering', () => { + it('renders modal title', () => { + const { getByText } = renderModal(); + + expect( + getByText(strings('protect_wallet_modal.title')), + ).toBeOnTheScreen(); + }); + + it('renders top (protect) button', () => { + const { getByText } = renderModal(); - it('render learn more button and open webview', async () => { - const { getByTestId } = render( - - - - - , - ); - - const learnMoreButton = getByTestId( - ProtectWalletModalSelectorsIDs.LEARN_MORE_BUTTON, - ); - expect(learnMoreButton).toBeOnTheScreen(); - - await act(async () => { - fireEvent.press(learnMoreButton); + expect( + getByText(strings('protect_wallet_modal.top_button')), + ).toBeOnTheScreen(); }); - await waitFor(() => { - expect(mockNavigation.navigate).toHaveBeenCalledWith('Webview', { - screen: 'SimpleWebview', - params: { - url: 'https://support.metamask.io/privacy-and-security/basic-safety-and-security-tips-for-metamask/', - title: strings('protect_wallet_modal.title'), + it('renders bottom (dismiss) button', () => { + const { getByText } = renderModal(); + + expect( + getByText(strings('protect_wallet_modal.bottom_button')), + ).toBeOnTheScreen(); + }); + + it('renders learn more link', () => { + const { getByTestId } = renderModal(); + + expect( + getByTestId(ProtectWalletModalSelectorsIDs.LEARN_MORE_BUTTON), + ).toBeOnTheScreen(); + }); + + it('hides modal when seedless onboarding login flow is active', () => { + const { queryByText } = renderWithProvider(, { + state: { + ...createInitialState(), + engine: { + backgroundState: { + SeedlessOnboardingController: { + vault: 'Vault string', + }, + }, + }, }, }); + + expect(queryByText(strings('protect_wallet_modal.title'))).toBeNull(); }); }); - it('render cancel button and onDismiss track event', async () => { - const { getByTestId } = render( - - - - - , - ); - - const cancelButton = getByTestId( - ProtectWalletModalSelectorsIDs.CANCEL_BUTTON, - ); - expect(cancelButton).toBeOnTheScreen(); - - await act(async () => { - fireEvent.press(cancelButton); - }); + describe('learn more button', () => { + it('navigates to support webview when pressed', async () => { + const { getByTestId } = renderModal(); - await waitFor(() => { - expect(mockTrackEvent).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'Wallet Security Reminder Engaged', - properties: { source: 'Modal', wallet_protection_required: false }, - saveDataRecording: true, - sensitiveProperties: {}, - }), + const learnMoreButton = getByTestId( + ProtectWalletModalSelectorsIDs.LEARN_MORE_BUTTON, ); + await act(async () => { + fireEvent.press(learnMoreButton); + }); + + await waitFor(() => { + expect(mockNavigation.navigate).toHaveBeenCalledWith('Webview', { + screen: 'SimpleWebview', + params: { + url: 'https://support.metamask.io/privacy-and-security/basic-safety-and-security-tips-for-metamask/', + title: strings('protect_wallet_modal.title'), + }, + }); + }); }); }); - it('navigates to set password flow when cancel button is pressed', async () => { - const { getByTestId } = render( - - - - - , - ); - - const cancelButton = getByTestId( - ProtectWalletModalSelectorsIDs.CANCEL_BUTTON, - ); - expect(cancelButton).toBeOnTheScreen(); - - await act(async () => { - fireEvent.press(cancelButton); + describe('protect button (cancel)', () => { + it('tracks Wallet Security Reminder event when pressed', async () => { + const { getByTestId } = renderModal(); + + const cancelButton = getByTestId( + ProtectWalletModalSelectorsIDs.CANCEL_BUTTON, + ); + await act(async () => { + fireEvent.press(cancelButton); + }); + + await waitFor(() => { + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Wallet Security Reminder Engaged', + properties: { source: 'Modal', wallet_protection_required: false }, + saveDataRecording: true, + sensitiveProperties: {}, + }), + ); + }); }); - await waitFor(() => { - expect(mockNavigation.navigate).toHaveBeenCalledWith('SetPasswordFlow', { - screen: 'AccountBackupStep1', + it('navigates to AccountBackupStep1 when passwordSet is true', async () => { + const { getByTestId } = renderModal(); + + const cancelButton = getByTestId( + ProtectWalletModalSelectorsIDs.CANCEL_BUTTON, + ); + await act(async () => { + fireEvent.press(cancelButton); }); - expect(mockTrackEvent).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'Wallet Security Reminder Engaged', - properties: { source: 'Modal', wallet_protection_required: false }, - saveDataRecording: true, - sensitiveProperties: {}, + await waitFor(() => { + expect(mockNavigation.navigate).toHaveBeenCalledWith( + 'SetPasswordFlow', + { screen: 'AccountBackupStep1' }, + ); + }); + }); + + it('navigates to SetPasswordFlow without screen param when passwordSet is false', async () => { + const store = mockStore( + createInitialState({ + user: { + protectWalletModalVisible: true, + passwordSet: false, + }, }), ); + const { getByTestId } = renderModal(store); + + const cancelButton = getByTestId( + ProtectWalletModalSelectorsIDs.CANCEL_BUTTON, + ); + await act(async () => { + fireEvent.press(cancelButton); + }); + + await waitFor(() => { + expect(mockNavigation.navigate).toHaveBeenCalledWith( + 'SetPasswordFlow', + undefined, + ); + }); }); }); - it('render matches snapshot when isSeedlessOnboardingLoginFlow is true', () => { - const { toJSON, queryByText } = renderWithProvider( - , - { - state: { - ...initialState, - engine: { - backgroundState: { - SeedlessOnboardingController: { - vault: 'Vault string', - }, - }, - }, - }, - }, - ); - expect(toJSON()).toMatchSnapshot(); - expect(queryByText(strings('protect_wallet_modal.title'))).toBeNull(); + describe('dismiss button (confirm)', () => { + it('dispatches protectWalletModalNotVisible action when pressed', async () => { + const store = mockStore(createInitialState()); + const mockDispatch = jest.fn(); + store.dispatch = mockDispatch; + const { getByTestId } = renderModal(store); + + const confirmButton = getByTestId( + ProtectWalletModalSelectorsIDs.CONFIRM_BUTTON, + ); + await act(async () => { + fireEvent.press(confirmButton); + }); + + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalled(); + }); + }); + + it('tracks analytics event when dismiss button is pressed', async () => { + const { getByTestId } = renderModal(); + + const confirmButton = getByTestId( + ProtectWalletModalSelectorsIDs.CONFIRM_BUTTON, + ); + await act(async () => { + fireEvent.press(confirmButton); + }); + + await waitFor(() => { + expect(mockTrackEvent).toHaveBeenCalled(); + }); + }); }); }); diff --git a/app/components/Views/AccountBackupStep1B/__snapshots__/index.test.tsx.snap b/app/components/Views/AccountBackupStep1B/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 7cf11a04f98..00000000000 --- a/app/components/Views/AccountBackupStep1B/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,788 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`AccountBackupStep1B render matches snapshot 1`] = ` - - - - - - - - - - 1 - - - - - - - 2 - - - - - - - 3 - - - - - - - - MetaMask password - - - - - Secure wallet - - - - - Confirm Secret Recovery Phrase - - - - - - - 🔒 - - - Secure your wallet - - - - Secure your wallet's - - - Secret Recovery Phrase. - - - - - -  - - - Why is it important? - - - - - - Manual - - - Write down your Secret Recovery Phrase on a piece of paper and store in a safe place. - - - Security level: Very strong - - - - - - - - Risks are: - - - • - You lose it - - - • - You forget where you put it - - - • - Someone else finds it - - - Other options: Doesn’t have to be paper! - - - Tips: - - - • - Store in bank vault - - - • - Store in a safe - - - • - Store in multiple secret places - - - - Start - - - - - - - - -`; diff --git a/app/components/Views/AccountBackupStep1B/index.test.tsx b/app/components/Views/AccountBackupStep1B/index.test.tsx index c71dd570f75..caa6cf33cec 100644 --- a/app/components/Views/AccountBackupStep1B/index.test.tsx +++ b/app/components/Views/AccountBackupStep1B/index.test.tsx @@ -3,12 +3,25 @@ import AccountBackupStep1B from './'; import renderWithProvider from '../../../util/test/renderWithProvider'; import { useNavigation } from '@react-navigation/native'; import { strings } from '../../../../locales/i18n'; -import { fireEvent } from '@testing-library/react-native'; +import { act, fireEvent, waitFor } from '@testing-library/react-native'; import AndroidBackHandler from '../AndroidBackHandler'; import Device from '../../../util/device'; import Routes from '../../../constants/navigation/Routes'; import { InteractionManager } from 'react-native'; +jest.mock('../../UI/ActionModal', () => { + const { View } = jest.requireActual('react-native'); + return ({ + children, + modalVisible, + }: { + children: React.ReactNode; + modalVisible: boolean; + }) => ( + {modalVisible ? children : null} + ); +}); + jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); return { @@ -38,25 +51,42 @@ jest .spyOn(InteractionManager, 'runAfterInteractions') .mockImplementation(mockRunAfterInteractions); -describe('AccountBackupStep1B', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); +const createMockNavigation = () => ({ + navigate: jest.fn(), + goBack: jest.fn(), + setOptions: jest.fn(), +}); + +const defaultState = { + engine: { + backgroundState: { + SeedlessOnboardingController: { + vault: undefined as string | undefined, + }, + }, + }, +}; +const seedlessState = { + engine: { + backgroundState: { + SeedlessOnboardingController: { + vault: 'encrypted-vault-data', + }, + }, + }, +}; + +describe('AccountBackupStep1B', () => { afterEach(() => { jest.clearAllMocks(); - jest.useFakeTimers({ legacyFakeTimers: true }); }); - const setupTest = () => { - const mockNavigate = jest.fn(); - const mockGoBack = jest.fn(); - const mockSetOptions = jest.fn(); + const setupTest = (stateOverride = defaultState) => { + const mockNav = createMockNavigation(); - const mockNavigation = (useNavigation as jest.Mock).mockReturnValue({ - navigate: mockNavigate, - goBack: mockGoBack, - setOptions: mockSetOptions, + const mockNavHook = (useNavigation as jest.Mock).mockReturnValue({ + ...mockNav, addListener: jest.fn(), removeListener: jest.fn(), isFocused: jest.fn(), @@ -64,118 +94,165 @@ describe('AccountBackupStep1B', () => { }); const wrapper = renderWithProvider( - , - { - state: { - engine: { - backgroundState: { - SeedlessOnboardingController: { - vault: 'encrypted-vault-data', - }, - }, - }, - }, - }, + , + { state: stateOverride }, ); - return { - wrapper, - mockNavigate, - mockGoBack, - mockSetOptions, - mockNavigation, - }; + return { wrapper, mockNav, mockNavHook }; }; - it('render matches snapshot', () => { - const { wrapper, mockNavigation } = setupTest(); - expect(wrapper).toMatchSnapshot(); - mockNavigation.mockRestore(); - }); + describe('rendering', () => { + it('renders title and SRP explanation link', () => { + const { wrapper } = setupTest(); - it('render title and srp link', () => { - const { wrapper, mockNavigation } = setupTest(); + const title = wrapper.getByText(strings('account_backup_step_1B.title')); + const srpLink = wrapper.getByText( + strings('account_backup_step_1B.subtitle_2'), + ); - const title = wrapper.getByText(strings('account_backup_step_1B.title')); - expect(title).toBeOnTheScreen(); + expect(title).toBeOnTheScreen(); + expect(srpLink).toBeOnTheScreen(); + }); - const srpLink = wrapper.getByText( - strings('account_backup_step_1B.subtitle_2'), - ); - expect(srpLink).toBeOnTheScreen(); - mockNavigation.mockRestore(); - }); + it('renders why-important info button', () => { + const { wrapper } = setupTest(); - it('opens the seed phrase modal on srp link press', () => { - const { wrapper, mockNavigate, mockNavigation } = setupTest(); + const whyImportantButton = wrapper.getByText( + strings('account_backup_step_1B.why_important'), + ); - const srpLink = wrapper.getByText( - strings('account_backup_step_1B.subtitle_2'), - ); - expect(srpLink).toBeOnTheScreen(); + expect(whyImportantButton).toBeOnTheScreen(); + }); + + it('renders start CTA button', () => { + const { wrapper } = setupTest(); + + const ctaButton = wrapper.getByText( + strings('account_backup_step_1B.cta_text'), + ); + + expect(ctaButton).toBeOnTheScreen(); + }); + + it('renders AndroidBackHandler on Android', () => { + (Device.isAndroid as jest.Mock).mockReturnValue(true); + const { wrapper } = setupTest(); + + const androidBackHandler = wrapper.UNSAFE_getByType(AndroidBackHandler); + + expect(androidBackHandler.props.customBackPress).toBeDefined(); + }); + + it('sets navigation header with empty left component', () => { + const { mockNav } = setupTest(); - fireEvent.press(srpLink); - expect(mockNavigate).toHaveBeenCalledWith(Routes.MODAL.ROOT_MODAL_FLOW, { - screen: Routes.SEEDPHRASE_MODAL, + expect(mockNav.setOptions).toHaveBeenCalled(); + const headerLeft = mockNav.setOptions.mock.calls[0][0].headerLeft(); + + expect(React.isValidElement(headerLeft)).toBe(true); }); - mockNavigation.mockRestore(); }); - it('render start button and on press it should navigate to ManualBackupStep1', () => { - const { wrapper, mockNavigate, mockNavigation } = setupTest(); - const ctaButton = wrapper.getByText( - strings('account_backup_step_1B.cta_text'), - ); - expect(ctaButton).toBeOnTheScreen(); - fireEvent.press(ctaButton); - expect(mockNavigate).toHaveBeenCalledWith( - Routes.ONBOARDING.MANUAL_BACKUP.STEP_1, - { + describe('navigation', () => { + it('navigates to SRP modal when explanation link is pressed', () => { + const { wrapper, mockNav } = setupTest(); + + const srpLink = wrapper.getByText( + strings('account_backup_step_1B.subtitle_2'), + ); + fireEvent.press(srpLink); + + expect(mockNav.navigate).toHaveBeenCalledWith( + Routes.MODAL.ROOT_MODAL_FLOW, + { screen: Routes.SHEET.SEEDPHRASE_MODAL }, + ); + }); + + it('navigates to ManualBackupStep1 with settingsBackup when CTA is pressed', () => { + const { wrapper, mockNav } = setupTest(); + + const ctaButton = wrapper.getByText( + strings('account_backup_step_1B.cta_text'), + ); + fireEvent.press(ctaButton); + + expect(mockNav.navigate).toHaveBeenCalledWith('ManualBackupStep1', { settingsBackup: true, - }, - ); - mockNavigation.mockRestore(); - }); + }); + }); - it('render AndroidBackHandler when on Android and on back press function is called with null', () => { - const mockIsAndroid = (Device.isAndroid as jest.Mock).mockReturnValue(true); + it('navigates to webview when learn more is pressed in why-secure modal', async () => { + const { wrapper, mockNav } = setupTest(); - const { wrapper, mockNavigation } = setupTest(); + await act(async () => { + fireEvent.press( + wrapper.getByText(strings('account_backup_step_1B.why_important')), + ); + }); - // Verify AndroidBackHandler is rendered - const androidBackHandler = wrapper.UNSAFE_getByType(AndroidBackHandler); - expect(androidBackHandler).toBeDefined(); + await waitFor(() => { + expect( + wrapper.getByText(strings('account_backup_step_1B.learn_more')), + ).toBeOnTheScreen(); + }); - // Verify customBackPress prop is passed - expect(androidBackHandler.props.customBackPress).toBeDefined(); + await act(async () => { + fireEvent.press( + wrapper.getByText(strings('account_backup_step_1B.learn_more')), + ); + }); - // Test that pressing back triggers the correct navigation - androidBackHandler.props.customBackPress(); - expect(null).toBe(null); - mockIsAndroid.mockRestore(); - mockNavigation.mockRestore(); + expect(mockNav.navigate).toHaveBeenCalledWith('Webview', { + screen: 'SimpleWebview', + params: { + url: 'https://support.metamask.io/privacy-and-security/basic-safety-and-security-tips-for-metamask/', + title: strings('drawer.metamask_support'), + }, + }); + }); }); - it('render header left button', () => { - const { mockSetOptions, mockNavigation } = setupTest(); + describe('why-secure modal', () => { + it('reveals modal content when why-important button is pressed', async () => { + const { wrapper } = setupTest(); + + await act(async () => { + fireEvent.press( + wrapper.getByText(strings('account_backup_step_1B.why_important')), + ); + }); + + await waitFor(() => { + expect( + wrapper.getByText(strings('account_backup_step_1B.why_secure_title')), + ).toBeOnTheScreen(); + }); + }); + + it('keeps modal hidden when seedless onboarding login flow is active', async () => { + const { wrapper } = setupTest(seedlessState); + + await act(async () => { + fireEvent.press( + wrapper.getByText(strings('account_backup_step_1B.why_important')), + ); + }); + + expect( + wrapper.queryByText(strings('account_backup_step_1B.why_secure_title')), + ).toBeNull(); + }); + }); - // Verify that setOptions was called with the correct configuration - expect(mockSetOptions).toHaveBeenCalled(); - const setOptionsCall = mockSetOptions.mock.calls[0][0]; + describe('android back handler', () => { + it('returns null when back press is triggered', () => { + (Device.isAndroid as jest.Mock).mockReturnValue(true); + const { wrapper } = setupTest(); - // Get the headerLeft function from the options - const headerLeftComponent = setOptionsCall.headerLeft(); + const androidBackHandler = wrapper.UNSAFE_getByType(AndroidBackHandler); + const result = androidBackHandler.props.customBackPress(); - // Verify the headerLeft component exists and is a valid React element - expect(headerLeftComponent).toBeDefined(); - expect(React.isValidElement(headerLeftComponent)).toBe(true); - mockNavigation.mockRestore(); + expect(result).toBeNull(); + }); }); }); diff --git a/app/components/Views/ManualBackupStep2/index.test.tsx b/app/components/Views/ManualBackupStep2/index.test.tsx index cfb6ba16d91..5152742fccf 100644 --- a/app/components/Views/ManualBackupStep2/index.test.tsx +++ b/app/components/Views/ManualBackupStep2/index.test.tsx @@ -4,7 +4,7 @@ import configureMockStore from 'redux-mock-store'; import { Provider } from 'react-redux'; import renderWithProvider from '../../../util/test/renderWithProvider'; import { CommonActions, useNavigation } from '@react-navigation/native'; -import { fireEvent, waitFor } from '@testing-library/react-native'; +import { act, fireEvent, waitFor } from '@testing-library/react-native'; import { ManualBackUpStepsSelectorsIDs } from '../ManualBackupStep1/ManualBackUpSteps.testIds'; import { strings } from '../../../../locales/i18n'; import Routes from '../../../constants/navigation/Routes'; @@ -99,6 +99,27 @@ describe('ManualBackupStep2', () => { 'cinnamon', ]; + const defaultRouteParams = { + words: mockWords, + backupFlow: false, + settingsBackup: false, + steps: ['one', 'two', 'three'], + }; + + const createMockNavigationProps = ( + overrides: Record = {}, + ) => ({ + navigate: jest.fn(), + goBack: jest.fn(), + setOptions: jest.fn(), + addListener: jest.fn(), + removeListener: jest.fn(), + isFocused: jest.fn(), + reset: jest.fn(), + dispatch: jest.fn(), + ...overrides, + }); + beforeEach(() => { jest.clearAllMocks(); global.Math = mockMath; @@ -114,49 +135,32 @@ describe('ManualBackupStep2', () => { }); const mockRoute = jest.fn().mockReturnValue({ - params: { - words: mockWords, - backupFlow: false, - settingsBackup: false, - steps: ['one', 'two', 'three'], - }, + params: { ...defaultRouteParams }, }); const setupTest = () => { const mockNavigate = jest.fn(); const mockNavigationDispatch = jest.fn(); - const mockGoBack = jest.fn(); const mockSetOptions = jest.fn(); const mockDispatch = jest.fn(); store.dispatch = mockDispatch; - const mockNavigation = (useNavigation as jest.Mock).mockReturnValue({ + const navProps = createMockNavigationProps({ navigate: mockNavigate, goBack: mockGoBack, setOptions: mockSetOptions, - addListener: jest.fn(), - removeListener: jest.fn(), - isFocused: jest.fn(), - reset: jest.fn(), dispatch: mockNavigationDispatch, }); + const mockNavigation = (useNavigation as jest.Mock).mockReturnValue( + navProps, + ); + const wrapper = renderWithProvider( - + , ); @@ -235,50 +239,40 @@ describe('ManualBackupStep2', () => { }; }; - it('render and handle word selection in grid', () => { + it('updates grid item style when a word is selected on Android', () => { Platform.OS = 'android'; const { wrapper, mockNavigation } = setupTest(); - const gridItems = wrapper.getByTestId( + + const gridItem = wrapper.getByTestId( `${ManualBackUpStepsSelectorsIDs.GRID_ITEM}-0`, ); + fireEvent.press(gridItem); - // Select a word - fireEvent.press(gridItems); - expect(gridItems).toHaveStyle({ backgroundColor: expect.any(String) }); + expect(gridItem).toHaveStyle({ backgroundColor: expect.any(String) }); mockNavigation.mockRestore(); Platform.OS = 'ios'; }); - it('render SuccessErrorSheet with type error when seed phrase is invalid', () => { + it('opens error sheet when seed phrase words are selected in wrong order', () => { const { wrapper, mockNavigate, mockNavigation } = setupTest(); + const getMissingWord = (index: number) => + wrapper.getByTestId( + `${ManualBackUpStepsSelectorsIDs.MISSING_WORDS}-${index}`, + ); - const missingWordOne = wrapper.getByTestId( - `${ManualBackUpStepsSelectorsIDs.MISSING_WORDS}-0`, - ); - const missingWordTwo = wrapper.getByTestId( - `${ManualBackUpStepsSelectorsIDs.MISSING_WORDS}-1`, - ); - const missingWordThree = wrapper.getByTestId( - `${ManualBackUpStepsSelectorsIDs.MISSING_WORDS}-2`, - ); - - fireEvent.press(missingWordOne); - fireEvent.press(missingWordTwo); - fireEvent.press(missingWordThree); - - fireEvent.press(missingWordOne); - fireEvent.press(missingWordTwo); - fireEvent.press(missingWordThree); - - fireEvent.press(missingWordOne); - fireEvent.press(missingWordTwo); - fireEvent.press(missingWordThree); + fireEvent.press(getMissingWord(0)); + fireEvent.press(getMissingWord(1)); + fireEvent.press(getMissingWord(2)); + fireEvent.press(getMissingWord(0)); + fireEvent.press(getMissingWord(1)); + fireEvent.press(getMissingWord(2)); + fireEvent.press(getMissingWord(0)); + fireEvent.press(getMissingWord(1)); + fireEvent.press(getMissingWord(2)); - // Press continue button const continueButton = wrapper.getByTestId( ManualBackUpStepsSelectorsIDs.CONTINUE_BUTTON, ); - fireEvent.press(continueButton); expect(mockNavigate).toHaveBeenCalledWith('RootModalFlow', { @@ -297,7 +291,7 @@ describe('ManualBackupStep2', () => { mockNavigation.mockRestore(); }); - it('render SuccessErrorSheet with type success when seed phrase is valid and navigate to HomeNav', async () => { + it('opens success sheet and navigates to onboarding success when words match', async () => { const { wrapper, mockNavigate, mockNavigationDispatch } = setupTest(); const missingWordOne = wrapper.getByTestId( @@ -392,16 +386,8 @@ describe('ManualBackupStep2', () => { }); }); - it('navigate to Optin Metrics for onboarding flow', async () => { - // configure onboarding scenario - mockRoute.mockReturnValue({ - params: { - words: mockWords, - backupFlow: false, - settingsBackup: false, - steps: ['one', 'two', 'three'], - }, - }); + it('navigates to OptinMetrics when analytics is disabled during onboarding', async () => { + mockRoute.mockReturnValue({ params: { ...defaultRouteParams } }); mockMetricsIsEnabled.mockReturnValue(false); // setup test @@ -422,16 +408,8 @@ describe('ManualBackupStep2', () => { }); }); - it('navigate to Onboarding Success flow for onboarding backup flow', async () => { - // configure onboarding scenario - mockRoute.mockReturnValue({ - params: { - words: mockWords, - backupFlow: false, - settingsBackup: false, - steps: ['one', 'two', 'three'], - }, - }); + it('navigates to onboarding success flow when analytics is enabled', async () => { + mockRoute.mockReturnValue({ params: { ...defaultRouteParams } }); mockMetricsIsEnabled.mockReturnValue(true); // setup test @@ -466,14 +444,9 @@ describe('ManualBackupStep2', () => { }); }); - it('navigate to HomeNav for reminder backup flow', async () => { + it('navigates to onboarding success with reminder backup flow', async () => { mockRoute.mockReturnValue({ - params: { - words: mockWords, - backupFlow: true, - settingsBackup: false, - steps: ['one', 'two', 'three'], - }, + params: { ...defaultRouteParams, backupFlow: true }, }); mockMetricsIsEnabled.mockReturnValue(true); @@ -509,14 +482,9 @@ describe('ManualBackupStep2', () => { }); }); - it('navigate to Onboarding Success with settings backup flow', async () => { + it('navigates to onboarding success with settings backup flow', async () => { mockRoute.mockReturnValue({ - params: { - words: mockWords, - backupFlow: false, - settingsBackup: true, - steps: ['one', 'two', 'three'], - }, + params: { ...defaultRouteParams, settingsBackup: true }, }); mockMetricsIsEnabled.mockReturnValue(true); @@ -549,32 +517,27 @@ describe('ManualBackupStep2', () => { expect(mockNavigationDispatch).toHaveBeenCalledWith(resetAction); }); - it('on click of the missing word, the empty slot should be selected', async () => { + it('highlights missing word with blue border after selecting it for an empty slot', async () => { const { wrapper } = setupTest(); - const missingWordOne = wrapper.getByTestId( `${ManualBackUpStepsSelectorsIDs.MISSING_WORDS}-0`, ); - - // Get all empty slots using GRID_ITEM_EMPTY test ID const emptySlots: ReactTestInstance[] = []; const nonEmptySlots: ReactTestInstance[] = []; - - // Try to find both types of slots for each index for (let i = 0; i < 12; i++) { try { const emptySlot = wrapper.getByTestId( `${ManualBackUpStepsSelectorsIDs.GRID_ITEM_EMPTY}-${i}`, ); emptySlots.push(emptySlot); - } catch (emptyError) { + } catch { try { const nonEmptySlot = wrapper.getByTestId( `${ManualBackUpStepsSelectorsIDs.GRID_ITEM}-${i}`, ); nonEmptySlots.push(nonEmptySlot); - } catch (nonEmptyError) { - // Skip if neither type is found (shouldn't happen) + } catch { + // index not present } } } @@ -583,39 +546,30 @@ describe('ManualBackupStep2', () => { expect(nonEmptySlots).toHaveLength(9); fireEvent.press(missingWordOne); - // Press each empty slot fireEvent.press(emptySlots[0]); - fireEvent.press(missingWordOne); - // Verify we found exactly 3 empty slots and 9 non-empty slots - expect(missingWordOne).toHaveStyle({ - borderColor: '#4459ff', - }); + expect(missingWordOne).toHaveStyle({ borderColor: '#4459ff' }); }); - it('on click of the empty slot, slot should be selected', async () => { + it('highlights empty slot with blue border when pressed', async () => { const { wrapper } = setupTest(); - - // Get all empty slots using GRID_ITEM_EMPTY test ID const emptySlots: ReactTestInstance[] = []; const nonEmptySlots: ReactTestInstance[] = []; - - // Try to find both types of slots for each index for (let i = 0; i < 12; i++) { try { const emptySlot = wrapper.getByTestId( `${ManualBackUpStepsSelectorsIDs.GRID_ITEM_EMPTY}-${i}`, ); emptySlots.push(emptySlot); - } catch (emptyError) { + } catch { try { const nonEmptySlot = wrapper.getByTestId( `${ManualBackUpStepsSelectorsIDs.GRID_ITEM}-${i}`, ); nonEmptySlots.push(nonEmptySlot); - } catch (nonEmptyError) { - // Skip if neither type is found (shouldn't happen) + } catch { + // index not present } } } @@ -625,18 +579,13 @@ describe('ManualBackupStep2', () => { fireEvent.press(emptySlots[0]); - expect(emptySlots[0]).toHaveStyle({ - borderColor: '#4459ff', - }); + expect(emptySlots[0]).toHaveStyle({ borderColor: '#4459ff' }); }); }); describe('with empty mockWords', () => { - const mockRoute = { - params: { - words: [], - steps: ['one', 'two', 'three'], - }, + const emptyRoute = { + params: { ...defaultRouteParams, words: [] }, }; const setupTest = () => { @@ -647,29 +596,19 @@ describe('ManualBackupStep2', () => { store.dispatch = mockDispatch; - const mockNavigation = (useNavigation as jest.Mock).mockReturnValue({ + const navProps = createMockNavigationProps({ navigate: mockNavigate, goBack: mockGoBack, setOptions: mockSetOptions, - addListener: jest.fn(), - removeListener: jest.fn(), - isFocused: jest.fn(), - reset: jest.fn(), }); + const mockNavigation = (useNavigation as jest.Mock).mockReturnValue( + navProps, + ); + const wrapper = renderWithProvider( - + , ); @@ -683,28 +622,227 @@ describe('ManualBackupStep2', () => { }; }; - it('check when words have empty array', async () => { + it('renders continue button when words array is empty', async () => { const { wrapper, mockNavigation } = setupTest(); - // Press continue button const continueButton = wrapper.getByTestId( ManualBackUpStepsSelectorsIDs.CONTINUE_BUTTON, ); - fireEvent.press(continueButton); - expect(continueButton).toBeTruthy(); + expect(continueButton).toBeOnTheScreen(); mockNavigation.mockRestore(); }); - it('shows header with back button for onboarding flow', () => { + it('configures navigation header with headerLeft component', () => { const { mockSetOptions } = setupTest(); expect(mockSetOptions).toHaveBeenCalled(); const setOptionsCall = mockSetOptions.mock.calls[0][0]; - expect(setOptionsCall.headerShown).toBeUndefined(); - expect(setOptionsCall.headerLeft).toBeDefined(); + expect(setOptionsCall.headerLeft).toEqual(expect.any(Function)); + }); + }); + + describe('headerLeft back button', () => { + it('triggers goBack when headerLeft back button is pressed', () => { + const mockGoBack = jest.fn(); + const mockSetOptions = jest.fn(); + + const navProps = createMockNavigationProps({ + goBack: mockGoBack, + setOptions: mockSetOptions, + }); + + (useNavigation as jest.Mock).mockReturnValue(navProps); + + renderWithProvider( + + + , + ); + + const headerLeftComponent = mockSetOptions.mock.calls[0][0].headerLeft; + expect(headerLeftComponent).toEqual(expect.any(Function)); + + const backButton = renderWithProvider(headerLeftComponent()); + const backButtonElement = backButton.getByTestId( + ManualBackUpStepsSelectorsIDs.BACK_BUTTON, + ); + fireEvent.press(backButtonElement); + + expect(mockGoBack).toHaveBeenCalled(); + }); + }); + + describe('error sheet callbacks', () => { + beforeEach(() => { + Platform.OS = 'ios'; + global.Math = mockMath; + }); + + const setupErrorSheet = () => { + const mockNavigate = jest.fn(); + const mockSetOptions = jest.fn(); + + const navProps = createMockNavigationProps({ + navigate: mockNavigate, + setOptions: mockSetOptions, + }); + + (useNavigation as jest.Mock).mockReturnValue(navProps); + + const wrapper = renderWithProvider( + + + , + ); + + const getMissingWords = (index: number) => + wrapper.getByTestId( + `${ManualBackUpStepsSelectorsIDs.MISSING_WORDS}-${index}`, + ); + + fireEvent.press(getMissingWords(0)); + fireEvent.press(getMissingWords(1)); + fireEvent.press(getMissingWords(2)); + fireEvent.press(getMissingWords(0)); + fireEvent.press(getMissingWords(1)); + fireEvent.press(getMissingWords(2)); + fireEvent.press(getMissingWords(0)); + fireEvent.press(getMissingWords(1)); + fireEvent.press(getMissingWords(2)); + + const continueButton = wrapper.getByTestId( + ManualBackUpStepsSelectorsIDs.CONTINUE_BUTTON, + ); + fireEvent.press(continueButton); + + const errorCall = mockNavigate.mock.calls.find( + (call) => + call[0] === 'RootModalFlow' && call[1]?.params?.type === 'error', + ); + + return { wrapper, errorCall }; + }; + + it('regenerates grid with 3 empty slots when error sheet primary button is pressed', () => { + const { wrapper, errorCall } = setupErrorSheet(); + expect(errorCall).not.toBeUndefined(); + + act(() => { + errorCall[1].params.onPrimaryButtonPress(); + }); + + const emptySlots: ReactTestInstance[] = []; + for (let i = 0; i < 12; i++) { + try { + emptySlots.push( + wrapper.getByTestId( + `${ManualBackUpStepsSelectorsIDs.GRID_ITEM_EMPTY}-${i}`, + ), + ); + } catch { + // filled slot — skip + } + } + expect(emptySlots).toHaveLength(3); + }); + + it('regenerates grid with 3 empty slots when error sheet onClose is called', () => { + const { wrapper, errorCall } = setupErrorSheet(); + expect(errorCall).not.toBeUndefined(); + + act(() => { + errorCall[1].params.onClose(); + }); + + const emptySlots: ReactTestInstance[] = []; + for (let i = 0; i < 12; i++) { + try { + emptySlots.push( + wrapper.getByTestId( + `${ManualBackUpStepsSelectorsIDs.GRID_ITEM_EMPTY}-${i}`, + ), + ); + } catch { + // filled slot — skip + } + } + expect(emptySlots).toHaveLength(3); + }); + }); + + describe('success sheet onClose callback', () => { + beforeEach(() => { + Platform.OS = 'ios'; + global.Math = mockMath; + mockMetricsIsEnabled.mockReturnValue(true); + }); + + it('dispatches navigation reset when success sheet onClose is called', () => { + const mockNavigate = jest.fn(); + const mockNavigationDispatch = jest.fn(); + const mockSetOptions = jest.fn(); + + const navProps = createMockNavigationProps({ + navigate: mockNavigate, + setOptions: mockSetOptions, + dispatch: mockNavigationDispatch, + }); + + (useNavigation as jest.Mock).mockReturnValue(navProps); + + const wrapper = renderWithProvider( + + + , + ); + + const getMissingWords = (index: number) => + wrapper.getByTestId( + `${ManualBackUpStepsSelectorsIDs.MISSING_WORDS}-${index}`, + ); + const getWordItem = (index: number) => + wrapper.getByTestId( + `${ManualBackUpStepsSelectorsIDs.WORD_ITEM_MISSING}-${index}`, + ); + + const missingWordOrder = [ + { click: getMissingWords(0), text: getWordItem(0).props.children }, + { click: getMissingWords(1), text: getWordItem(1).props.children }, + { click: getMissingWords(2), text: getWordItem(2).props.children }, + ]; + + missingWordOrder + .sort((a, b) => mockWords.indexOf(a.text) - mockWords.indexOf(b.text)) + .forEach(({ click }) => fireEvent.press(click)); + + const continueButton = wrapper.getByTestId( + ManualBackUpStepsSelectorsIDs.CONTINUE_BUTTON, + ); + fireEvent.press(continueButton); + + const successCall = mockNavigate.mock.calls.find( + (call) => + call[0] === 'RootModalFlow' && call[1]?.params?.type === 'success', + ); + expect(successCall).not.toBeUndefined(); + + const { onClose } = successCall[1].params; + expect(onClose).toEqual(expect.any(Function)); + onClose(); + + expect(mockNavigationDispatch).toHaveBeenCalled(); }); }); }); From bb5bfa87caf51bfd9255dac490d636f47120843f Mon Sep 17 00:00:00 2001 From: Nico MASSART Date: Wed, 18 Mar 2026 10:13:05 +0100 Subject: [PATCH 072/206] chore(deps): bump @metamask/profile-metrics-controller to 3.1.0 (#27486) ## **Description** - **Bump `@metamask/profile-metrics-controller` from `2.0.0` to `3.1.0`.** (initial issue was to update to 3.0.3 but we added a change in controller so better directly bump to 3.1.0 version, see controller changelog) - **Breaking change fix (v3.0.0):** Add `TransactionController:transactionSubmitted` event to the profile-metrics-controller messenger, as now required by the controller. - **Skip initial delay on PNA25 dismiss:** (mobile counterpart of Extension PR [#39388](https://github.com/MetaMask/metamask-extension/pull/39388) - **Anticipate delay update on mobile side for close events:** see [MCWP-400](https://consensyssoftware.atlassian.net/browse/MCWP-400) ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/MCWP-397 Fixes: https://consensyssoftware.atlassian.net/browse/MCWP-398 Related: https://consensyssoftware.atlassian.net/browse/MCWP-400 ## **Manual testing steps** see [MCWP-397](https://consensyssoftware.atlassian.net/browse/MCWP-397) and [MCWP-398](https://consensyssoftware.atlassian.net/browse/MCWP-398) ## **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. ## **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** > Medium risk because it upgrades a core analytics controller dependency and changes the Profile Metrics controller wiring (messenger events and init timing), which could affect metrics collection behavior and app startup interactions. > > **Overview** > Upgrades `@metamask/profile-metrics-controller` to `3.1.0` and updates lockfile resolutions to match new transitive dependencies. > > Updates Profile Metrics engine integration to satisfy new controller requirements by delegating `TransactionController:transactionSubmitted` through the restricted messenger, and passes a new `initialDelayDuration` (1 minute) during controller init. > > Adjusts the PNA25 notice bottom sheet so dismiss/accept/close actions trigger `ProfileMetricsController:skipInitialDelay` (guarded to fire only once), and expands unit tests to cover the new skip-delay behavior and prevent double-calls. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 4716765c6845089aefd0abb1cebf56c3947e75c7. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../Pna25BottomSheet.test.tsx | 81 +++++++++++++++++++ .../Pna25BottomSheet/Pna25BottomSheet.tsx | 15 ++++ .../profile-metrics-controller-init.test.ts | 1 + .../profile-metrics-controller-init.ts | 1 + ...ofile-metrics-controller-messenger.test.ts | 35 ++++++++ .../profile-metrics-controller-messenger.ts | 1 + package.json | 2 +- yarn.lock | 37 ++++----- 8 files changed, 154 insertions(+), 19 deletions(-) diff --git a/app/components/Views/Pna25BottomSheet/Pna25BottomSheet.test.tsx b/app/components/Views/Pna25BottomSheet/Pna25BottomSheet.test.tsx index e297d9c708e..acfb229596a 100644 --- a/app/components/Views/Pna25BottomSheet/Pna25BottomSheet.test.tsx +++ b/app/components/Views/Pna25BottomSheet/Pna25BottomSheet.test.tsx @@ -7,6 +7,7 @@ import Routes from '../../../constants/navigation/Routes'; import { Linking } from 'react-native'; import { createStackNavigator } from '@react-navigation/stack'; import { MetaMetricsEvents } from '../../../core/Analytics'; +import Engine from '../../../core/Engine'; const mockNavigate = jest.fn(); const mockGoBack = jest.fn(); @@ -53,6 +54,38 @@ jest.mock('react-native', () => ({ }, })); +jest.mock('../../../core/Engine', () => ({ + controllerMessenger: { + call: jest.fn(), + }, +})); + +jest.mock( + '../../../component-library/components/BottomSheets/BottomSheet', + () => { + const { forwardRef, useImperativeHandle } = + jest.requireActual('react'); + const { View } = + jest.requireActual('react-native'); + + const MockBottomSheet = forwardRef< + { onCloseBottomSheet: () => void }, + { children: React.ReactNode; onClose?: () => void } + >(({ children, onClose }, ref) => { + useImperativeHandle(ref, () => ({ + onCloseBottomSheet: () => onClose?.(), + })); + return {children}; + }); + MockBottomSheet.displayName = 'MockBottomSheet'; + + return { + __esModule: true, + default: MockBottomSheet, + }; + }, +); + const Stack = createStackNavigator(); const renderComponent = (state = {}) => @@ -206,4 +239,52 @@ describe('Pna25BottomSheet', () => { expect(mockDispatch).toHaveBeenCalled(); }); + + it('skips initial delay when confirm button is pressed', () => { + const { getByText } = renderComponent(); + const confirmButton = getByText( + strings('privacy_policy.pna25_confirm_button'), + ); + + fireEvent.press(confirmButton); + + expect(Engine.controllerMessenger.call).toHaveBeenCalledWith( + 'ProfileMetricsController:skipInitialDelay', + ); + }); + + it('does not skip initial delay on view', () => { + renderComponent(); + + expect(Engine.controllerMessenger.call).not.toHaveBeenCalled(); + }); + + it('does not skip initial delay when open settings button is pressed', () => { + const { getByText } = renderComponent(); + const openSettingsButton = getByText( + strings('privacy_policy.pna25_open_settings_button'), + ); + + fireEvent.press(openSettingsButton); + + expect(Engine.controllerMessenger.call).not.toHaveBeenCalledWith( + 'ProfileMetricsController:skipInitialDelay', + ); + }); + + it('calls skipInitialDelay exactly once when confirm button triggers both accept and close actions', () => { + const { getByText } = renderComponent(); + const confirmButton = getByText( + strings('privacy_policy.pna25_confirm_button'), + ); + + fireEvent.press(confirmButton); + + const skipDelayCalls = jest + .mocked(Engine.controllerMessenger.call) + .mock.calls.filter( + ([action]) => action === 'ProfileMetricsController:skipInitialDelay', + ); + expect(skipDelayCalls).toHaveLength(1); + }); }); diff --git a/app/components/Views/Pna25BottomSheet/Pna25BottomSheet.tsx b/app/components/Views/Pna25BottomSheet/Pna25BottomSheet.tsx index 1ab946c218b..e0868e1e765 100644 --- a/app/components/Views/Pna25BottomSheet/Pna25BottomSheet.tsx +++ b/app/components/Views/Pna25BottomSheet/Pna25BottomSheet.tsx @@ -24,6 +24,7 @@ import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../core/Analytics'; import Routes from '../../../constants/navigation/Routes'; import { storePna25Acknowledged } from '../../../actions/legalNotices'; +import Engine from '../../../core/Engine'; export enum Pna25BottomSheetAction { VIEWED = 'viewed', @@ -38,6 +39,7 @@ const Pna25BottomSheet = () => { const navigation = useNavigation(); const tw = useTailwind(); const sheetRef = useRef(null); + const hasSkippedDelay = useRef(false); const { trackEvent, createEventBuilder } = useAnalytics(); const handleAction = useCallback( @@ -46,6 +48,19 @@ const Pna25BottomSheet = () => { dispatch(storePna25Acknowledged()); } + const shouldSkipDelay = [ + Pna25BottomSheetAction.ACCEPT_AND_CLOSE, + Pna25BottomSheetAction.CLOSED, + Pna25BottomSheetAction.LEAVE, + ].includes(action); + + if (shouldSkipDelay && !hasSkippedDelay.current) { + hasSkippedDelay.current = true; + Engine.controllerMessenger.call( + 'ProfileMetricsController:skipInitialDelay', + ); + } + // Don't emit events for the default close action to avoid double tracking if (action === Pna25BottomSheetAction.LEAVE) { return; diff --git a/app/core/Engine/controllers/profile-metrics-controller-init.test.ts b/app/core/Engine/controllers/profile-metrics-controller-init.test.ts index f31f807dbf1..fc9a0eb8c60 100644 --- a/app/core/Engine/controllers/profile-metrics-controller-init.test.ts +++ b/app/core/Engine/controllers/profile-metrics-controller-init.test.ts @@ -133,6 +133,7 @@ describe.each([ state: undefined, assertUserOptedIn: expect.any(Function), getMetaMetricsId: expect.any(Function), + initialDelayDuration: 60_000, }); expect(controllerMock.mock.calls[0][0].assertUserOptedIn()).toBe( analyticsEnabled && remoteFeatureFlag && pna25Acknowledged, diff --git a/app/core/Engine/controllers/profile-metrics-controller-init.ts b/app/core/Engine/controllers/profile-metrics-controller-init.ts index ee2231cf87c..72255e1c849 100644 --- a/app/core/Engine/controllers/profile-metrics-controller-init.ts +++ b/app/core/Engine/controllers/profile-metrics-controller-init.ts @@ -48,6 +48,7 @@ export const profileMetricsControllerInit: ControllerInitFunction< state: persistedState.ProfileMetricsController, assertUserOptedIn, getMetaMetricsId: () => analyticsId, + initialDelayDuration: 60_000, // 1 minute delay }); return { diff --git a/app/core/Engine/messengers/profile-metrics-controller-messenger.test.ts b/app/core/Engine/messengers/profile-metrics-controller-messenger.test.ts index 8857354b260..9b495a8adca 100644 --- a/app/core/Engine/messengers/profile-metrics-controller-messenger.test.ts +++ b/app/core/Engine/messengers/profile-metrics-controller-messenger.test.ts @@ -14,4 +14,39 @@ describe('getProfileMetricsControllerMessenger', () => { expect(profileMetricsControllerMessenger).toBeInstanceOf(Messenger); }); + + it('delegates required actions to the messenger', () => { + const rootMessenger = getRootMessenger(); + const delegateSpy = jest.spyOn(rootMessenger, 'delegate'); + + getProfileMetricsControllerMessenger(rootMessenger); + + expect(delegateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + actions: expect.arrayContaining([ + 'AccountsController:getState', + 'ProfileMetricsService:submitMetrics', + ]), + }), + ); + }); + + it('delegates required events to the messenger', () => { + const rootMessenger = getRootMessenger(); + const delegateSpy = jest.spyOn(rootMessenger, 'delegate'); + + getProfileMetricsControllerMessenger(rootMessenger); + + expect(delegateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + events: expect.arrayContaining([ + 'AccountsController:accountAdded', + 'AccountsController:accountRemoved', + 'KeyringController:lock', + 'KeyringController:unlock', + 'TransactionController:transactionSubmitted', + ]), + }), + ); + }); }); diff --git a/app/core/Engine/messengers/profile-metrics-controller-messenger.ts b/app/core/Engine/messengers/profile-metrics-controller-messenger.ts index cc32b0a1f90..34a3a44391d 100644 --- a/app/core/Engine/messengers/profile-metrics-controller-messenger.ts +++ b/app/core/Engine/messengers/profile-metrics-controller-messenger.ts @@ -42,6 +42,7 @@ export function getProfileMetricsControllerMessenger(messenger: RootMessenger) { 'AccountsController:accountRemoved', 'KeyringController:lock', 'KeyringController:unlock', + 'TransactionController:transactionSubmitted', ], }); return profileMetricsControllerMessenger; diff --git a/package.json b/package.json index 0237533d8ac..35a28d7d378 100644 --- a/package.json +++ b/package.json @@ -272,7 +272,7 @@ "@metamask/post-message-stream": "^10.0.0", "@metamask/preferences-controller": "^23.0.0", "@metamask/preinstalled-example-snap": "^0.7.2", - "@metamask/profile-metrics-controller": "^2.0.0", + "@metamask/profile-metrics-controller": "^3.1.0", "@metamask/profile-sync-controller": "^28.0.0", "@metamask/ramps-controller": "^12.0.0", "@metamask/react-native-acm": "1.2.0", diff --git a/yarn.lock b/yarn.lock index 408124f237f..7c3516db761 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9194,7 +9194,7 @@ __metadata: languageName: node linkType: hard -"@metamask/polling-controller@npm:^16.0.0, @metamask/polling-controller@npm:^16.0.3": +"@metamask/polling-controller@npm:^16.0.3": version: 16.0.3 resolution: "@metamask/polling-controller@npm:16.0.3" dependencies: @@ -9238,24 +9238,25 @@ __metadata: languageName: node linkType: hard -"@metamask/profile-metrics-controller@npm:^2.0.0": - version: 2.0.0 - resolution: "@metamask/profile-metrics-controller@npm:2.0.0" +"@metamask/profile-metrics-controller@npm:^3.1.0": + version: 3.1.0 + resolution: "@metamask/profile-metrics-controller@npm:3.1.0" dependencies: - "@metamask/accounts-controller": "npm:^35.0.0" + "@metamask/accounts-controller": "npm:^37.0.0" "@metamask/base-controller": "npm:^9.0.0" - "@metamask/controller-utils": "npm:^11.16.0" - "@metamask/keyring-controller": "npm:^25.0.0" + "@metamask/controller-utils": "npm:^11.19.0" + "@metamask/keyring-controller": "npm:^25.1.0" "@metamask/messenger": "npm:^0.3.0" - "@metamask/polling-controller": "npm:^16.0.0" - "@metamask/profile-sync-controller": "npm:^27.0.0" - "@metamask/utils": "npm:^11.8.1" + "@metamask/polling-controller": "npm:^16.0.3" + "@metamask/profile-sync-controller": "npm:^28.0.0" + "@metamask/transaction-controller": "npm:^62.22.0" + "@metamask/utils": "npm:^11.9.0" async-mutex: "npm:^0.5.0" - checksum: 10/3d65a539a179e504b9a06f3fd744be087274ed3f93bdeea6c7965e208040c7d66bf3a2f0c99cbebf70187fb49b500fda29b62f82520b3f557c8973ebfdb1f8bd + checksum: 10/99fdcd81babd80bcbda6159cce74320ff9190aed4df3fe009bd0b6693ec7c694b5165538169da0d34672a4b2426ac7cabe976a062ee86491449e60707fc432e1 languageName: node linkType: hard -"@metamask/profile-sync-controller@npm:^27.0.0, @metamask/profile-sync-controller@npm:^27.1.0": +"@metamask/profile-sync-controller@npm:^27.1.0": version: 27.1.0 resolution: "@metamask/profile-sync-controller@npm:27.1.0" dependencies: @@ -10000,9 +10001,9 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@npm:^62.17.0, @metamask/transaction-controller@npm:^62.19.0, @metamask/transaction-controller@npm:^62.20.0, @metamask/transaction-controller@npm:^62.21.0": - version: 62.21.0 - resolution: "@metamask/transaction-controller@npm:62.21.0" +"@metamask/transaction-controller@npm:^62.17.0, @metamask/transaction-controller@npm:^62.19.0, @metamask/transaction-controller@npm:^62.20.0, @metamask/transaction-controller@npm:^62.21.0, @metamask/transaction-controller@npm:^62.22.0": + version: 62.22.0 + resolution: "@metamask/transaction-controller@npm:62.22.0" dependencies: "@ethereumjs/common": "npm:^4.4.0" "@ethereumjs/tx": "npm:^5.4.0" @@ -10015,7 +10016,7 @@ __metadata: "@metamask/approval-controller": "npm:^8.0.0" "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/core-backend": "npm:^6.1.0" + "@metamask/core-backend": "npm:^6.1.1" "@metamask/eth-query": "npm:^4.0.0" "@metamask/gas-fee-controller": "npm:^26.0.3" "@metamask/messenger": "npm:^0.3.0" @@ -10035,7 +10036,7 @@ __metadata: peerDependencies: "@babel/runtime": ^7.0.0 "@metamask/eth-block-tracker": ">=9" - checksum: 10/0dfc1853852f780911d14b8b7421cd946c10d8c8d144f19c6b4cd3ad8f8e412f48d8477cc1b4c1df497bab89bf5b9fd671c829fe5cb84397df09241e2b643a06 + checksum: 10/84d7fffb169bcb7b97844339f167972161f1f3ea14b396f6888e709269d4e126a4a896fcc526a32980a624b934a5afbf5e482a8263cf3be692d9ba6e159dca29 languageName: node linkType: hard @@ -35430,7 +35431,7 @@ __metadata: "@metamask/post-message-stream": "npm:^10.0.0" "@metamask/preferences-controller": "npm:^23.0.0" "@metamask/preinstalled-example-snap": "npm:^0.7.2" - "@metamask/profile-metrics-controller": "npm:^2.0.0" + "@metamask/profile-metrics-controller": "npm:^3.1.0" "@metamask/profile-sync-controller": "npm:^28.0.0" "@metamask/providers": "npm:^18.3.1" "@metamask/ramps-controller": "npm:^12.0.0" From 9f534c4232240ce223fdda8f365838c3a432d4f2 Mon Sep 17 00:00:00 2001 From: Gaurav Goel Date: Wed, 18 Mar 2026 15:05:03 +0530 Subject: [PATCH 073/206] feat: migrate OptinMetrics to new design system (#27529) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Migrates the OptinMetrics component from legacy styling patterns to the MetaMask design system and Tailwind CSS. Why: The component used deprecated component-library imports (Text, Button) and StyleSheet.create() which are flagged for migration to @metamask/design-system-react-native and useTailwind(). What changed: * View → Box from design system * TouchableOpacity → Pressable with tw.style() for press feedback * StyleSheet.create() → useTailwind() hook with twClassName / tw.style() * Deprecated Text → design system Text with updated enums (TextVariant.DisplayMd, TextColor.TextDefault, etc.) * Deprecated Button → design system Button with ButtonVariant.Primary and children instead of label * useTheme() removed — Tailwind tokens handle theming * OptinMetrics.styles.ts deleted entirely * Memoized illustrationSize and rootStyle to prevent unnecessary recalculation Jira: https://consensyssoftware.atlassian.net/browse/TO-591 ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Metrics Opt-In Screen Scenario: user views and interacts with metrics opt-in during onboarding Given the user is on the metrics opt-in screen during onboarding When user toggles the "Gather basic usage data" checkbox off Then the marketing checkbox should become disabled and unchecked When user toggles the "Gather basic usage data" checkbox back on Then the marketing checkbox should become enabled again When user taps "Continue" Then the user should proceed to the next onboarding step Screenshots/Recordings Before No visual change expected — this is a styling infrastructure migration only. After No visual change expected — same UI rendered with design system components and Tailwind classes instead of StyleSheet.create(). ``` ## **Screenshots/Recordings** ### **Before** Screenshot 2026-03-18 at 10 58 49 AM Screenshot 2026-03-18 at 11 00 47 AM ### **After** Screenshot 2026-03-17 at 5 28 21 PM Screenshot 2026-03-18 at 11 02 04 AM ## **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. ## **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** > Primarily a UI/styling refactor (design system + Tailwind) with no changes to metrics/consent business logic; main risk is unintended layout/pressable behavior differences on iOS/Android. > > **Overview** > Migrates the `OptinMetrics` onboarding screen to `@metamask/design-system-react-native` components and Tailwind styling via `useTailwind`, replacing legacy `StyleSheet` + component-library `Text`/`Button` usage. > > Reworks the interactive sections to use `Pressable` (with pressed/disabled opacity handling) and memoizes device-dependent illustration sizing and the root paddingTop (Android `StatusBar` height). Deletes `OptinMetrics.styles.ts` and updates the Jest snapshots to match the new rendered component tree/styles. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit e84c44a03df48a910f1c7a3691b02a1c496fc3c3. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../UI/OptinMetrics/OptinMetrics.styles.ts | 76 - .../__snapshots__/index.test.tsx.snap | 1377 ++++++++++++----- app/components/UI/OptinMetrics/index.tsx | 185 ++- 3 files changed, 1075 insertions(+), 563 deletions(-) delete mode 100644 app/components/UI/OptinMetrics/OptinMetrics.styles.ts diff --git a/app/components/UI/OptinMetrics/OptinMetrics.styles.ts b/app/components/UI/OptinMetrics/OptinMetrics.styles.ts deleted file mode 100644 index 2e4e7c712e1..00000000000 --- a/app/components/UI/OptinMetrics/OptinMetrics.styles.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { StyleSheet, Platform, StatusBar } from 'react-native'; -import { baseStyles } from '../../../styles/common'; -import Device from '../../../util/device'; -import type { Colors } from '../../../util/theme/models'; - -const createStyles = (colors: Colors) => - StyleSheet.create({ - root: { - ...baseStyles.flexGrow, - backgroundColor: colors.background.default, - paddingTop: - Platform.OS === 'android' ? StatusBar.currentHeight || 40 : 40, - }, - checkbox: { - display: 'flex', - flexDirection: 'row', - alignItems: 'flex-start', - justifyContent: 'space-between', - gap: 16, - }, - action: { - flex: 0, - flexDirection: 'row', - alignItems: 'flex-start', - gap: 16, - }, - description: { - flex: 1, - }, - wrapper: { - marginHorizontal: 20, - flex: 1, - flexDirection: 'column', - rowGap: 16, - paddingBottom: 80, - }, - actionContainer: { - flexDirection: 'row', - paddingHorizontal: 16, - paddingTop: 16, - }, - button: { - flex: 1, - }, - title: { - fontWeight: '700', - marginTop: 8, - }, - sectionContainer: { - backgroundColor: colors.background.section, - borderRadius: 12, - padding: 16, - marginBottom: 16, - }, - imageContainer: { - alignItems: 'center', - marginVertical: Device.isMediumDevice() ? 8 : 12, - }, - illustration: { - width: Device.isMediumDevice() ? 160 : 200, - height: Device.isMediumDevice() ? 120 : 180, - alignSelf: 'center', - }, - flexContainer: { - flex: 1, - }, - descriptionText: { - marginTop: 4, - marginLeft: 0, - }, - disabledContainer: { - opacity: 0.5, - }, - }); - -export default createStyles; diff --git a/app/components/UI/OptinMetrics/__snapshots__/index.test.tsx.snap b/app/components/UI/OptinMetrics/__snapshots__/index.test.tsx.snap index 8fbe0760b37..187720aa95a 100644 --- a/app/components/UI/OptinMetrics/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/OptinMetrics/__snapshots__/index.test.tsx.snap @@ -212,7 +212,9 @@ exports[`OptinMetrics Snapshots android render matches snapshot 1`] = ` style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, "paddingTop": 40, } } @@ -224,9 +226,9 @@ exports[`OptinMetrics Snapshots android render matches snapshot 1`] = ` scrollEventThrottle={150} style={ { - "backgroundColor": "#ffffff", - "flex": 1, - "paddingTop": 40, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, } } testID="meta-metrics-container" @@ -234,21 +236,32 @@ exports[`OptinMetrics Snapshots android render matches snapshot 1`] = ` @@ -283,60 +299,116 @@ exports[`OptinMetrics Snapshots android render matches snapshot 1`] = ` We’d like to request these permissions. You can opt out or delete your usage data at any time. - - + Gather basic usage data @@ -394,15 +466,18 @@ exports[`OptinMetrics Snapshots android render matches snapshot 1`] = ` We'll collect basic product usage data like general location, clicks, and views. No other information will be stored. @@ -410,63 +485,106 @@ exports[`OptinMetrics Snapshots android render matches snapshot 1`] = ` accessibilityRole="text" onPress={[Function]} style={ - { - "color": "#4459ff", - "fontFamily": "Geist-Regular", - "fontSize": 14, - "letterSpacing": 0, - "lineHeight": 22, - } + [ + { + "color": "#4459ff", + "fontFamily": "Geist-Regular", + "fontSize": 14, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 22, + }, + undefined, + ] } > Learn more - - + Marketing updates @@ -506,71 +624,132 @@ exports[`OptinMetrics Snapshots android render matches snapshot 1`] = ` We'll use this data to learn how you interact with our marketing communications. We may share relevant news (like product features). - + - Continue - + @@ -903,7 +1082,9 @@ exports[`OptinMetrics Snapshots android render matches snapshot with status bar style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, "paddingTop": 40, } } @@ -915,9 +1096,9 @@ exports[`OptinMetrics Snapshots android render matches snapshot with status bar scrollEventThrottle={150} style={ { - "backgroundColor": "#ffffff", - "flex": 1, - "paddingTop": 40, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, } } testID="meta-metrics-container" @@ -925,21 +1106,32 @@ exports[`OptinMetrics Snapshots android render matches snapshot with status bar @@ -974,60 +1169,116 @@ exports[`OptinMetrics Snapshots android render matches snapshot with status bar We’d like to request these permissions. You can opt out or delete your usage data at any time. - - + Gather basic usage data @@ -1085,15 +1336,18 @@ exports[`OptinMetrics Snapshots android render matches snapshot with status bar We'll collect basic product usage data like general location, clicks, and views. No other information will be stored. @@ -1101,63 +1355,106 @@ exports[`OptinMetrics Snapshots android render matches snapshot with status bar accessibilityRole="text" onPress={[Function]} style={ - { - "color": "#4459ff", - "fontFamily": "Geist-Regular", - "fontSize": 14, - "letterSpacing": 0, - "lineHeight": 22, - } + [ + { + "color": "#4459ff", + "fontFamily": "Geist-Regular", + "fontSize": 14, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 22, + }, + undefined, + ] } > Learn more - - + Marketing updates @@ -1197,71 +1494,132 @@ exports[`OptinMetrics Snapshots android render matches snapshot with status bar We'll use this data to learn how you interact with our marketing communications. We may share relevant news (like product features). - + - Continue - + @@ -1706,7 +2064,9 @@ exports[`OptinMetrics Snapshots iOS renders correctly 1`] = ` style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, "paddingTop": 40, } } @@ -1718,9 +2078,9 @@ exports[`OptinMetrics Snapshots iOS renders correctly 1`] = ` scrollEventThrottle={150} style={ { - "backgroundColor": "#ffffff", - "flex": 1, - "paddingTop": 40, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, } } testID="meta-metrics-container" @@ -1728,21 +2088,32 @@ exports[`OptinMetrics Snapshots iOS renders correctly 1`] = ` @@ -1777,60 +2151,116 @@ exports[`OptinMetrics Snapshots iOS renders correctly 1`] = ` We’d like to request these permissions. You can opt out or delete your usage data at any time. - - + Gather basic usage data @@ -1888,15 +2318,18 @@ exports[`OptinMetrics Snapshots iOS renders correctly 1`] = ` We'll collect basic product usage data like general location, clicks, and views. No other information will be stored. @@ -1904,63 +2337,106 @@ exports[`OptinMetrics Snapshots iOS renders correctly 1`] = ` accessibilityRole="text" onPress={[Function]} style={ - { - "color": "#4459ff", - "fontFamily": "Geist-Regular", - "fontSize": 14, - "letterSpacing": 0, - "lineHeight": 22, - } + [ + { + "color": "#4459ff", + "fontFamily": "Geist-Regular", + "fontSize": 14, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 22, + }, + undefined, + ] } > Learn more - - + Marketing updates @@ -2000,71 +2476,132 @@ exports[`OptinMetrics Snapshots iOS renders correctly 1`] = ` We'll use this data to learn how you interact with our marketing communications. We may share relevant news (like product features). - + - Continue - + diff --git a/app/components/UI/OptinMetrics/index.tsx b/app/components/UI/OptinMetrics/index.tsx index a4f8d611d13..63871a546da 100644 --- a/app/components/UI/OptinMetrics/index.tsx +++ b/app/components/UI/OptinMetrics/index.tsx @@ -1,17 +1,31 @@ -import React, { useState, useCallback, useEffect } from 'react'; +import React, { useState, useCallback, useEffect, useMemo } from 'react'; import { - View, ScrollView, BackHandler, Alert, - TouchableOpacity, + Pressable, Platform, Image, + StatusBar, NativeScrollEvent, NativeSyntheticEvent, LayoutChangeEvent, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + Text, + Button, + BoxFlexDirection, + BoxAlignItems, + BoxJustifyContent, + TextVariant, + TextColor, + FontWeight, + ButtonVariant, + ButtonSize, +} from '@metamask/design-system-react-native'; import { strings } from '../../../../locales/i18n'; import { useDispatch, useSelector } from 'react-redux'; import { clearOnboardingEvents } from '../../../actions/onboarding'; @@ -19,22 +33,13 @@ import { selectOnboardingAccountType } from '../../../selectors/onboarding'; import { setDataCollectionForMarketing } from '../../../actions/security'; import { MetaMetricsEvents, useMetrics } from '../../hooks/useMetrics'; import { markMetricsOptInUISeen } from '../../../util/metrics/metricsOptInUIUtils'; -import { useTheme } from '../../../util/theme'; import { MetaMetricsOptInSelectorsIDs } from './MetaMetricsOptIn.testIds'; import Checkbox from '../../../component-library/components/Checkbox'; -import Button, { - ButtonVariants, - ButtonSize, -} from '../../../component-library/components/Buttons/Button'; import Routes from '../../../constants/navigation/Routes'; import generateDeviceAnalyticsMetaData, { UserSettingsAnalyticsMetaData as generateUserSettingsAnalyticsMetaData, } from '../../../util/metrics'; import { UserProfileProperty } from '../../../util/metrics/UserSettingsAnalyticsMetaData/UserProfileAnalyticsMetaData.types'; -import Text, { - TextColor, - TextVariant, -} from '../../../component-library/components/Texts/Text'; import { getConfiguredCaipChainIds } from '../../../util/metrics/MultichainAPI/networkMetricUtils'; import { updateCachedConsent, @@ -44,7 +49,7 @@ import { import { setupSentry } from '../../../util/sentry/utils'; import PrivacyIllustration from '../../../images/privacy_metrics_illustration.png'; import { selectIsPna25FlagEnabled } from '../../../selectors/featureFlagController/legalNotices'; -import createStyles from './OptinMetrics.styles'; +import Device from '../../../util/device'; import type { OptinMetricsRouteParams } from './OptinMetrics.types'; import { useNavigation, @@ -67,7 +72,7 @@ const OptinMetrics = () => { 'OptinMetrics' > >(); - const { colors } = useTheme(); + const tw = useTailwind(); const metrics = useMetrics(); // Redux state selectors @@ -86,7 +91,14 @@ const OptinMetrics = () => { const [isMarketingChecked, setIsMarketingChecked] = useState(false); const [isBasicUsageChecked, setIsBasicUsageChecked] = useState(true); - const styles = createStyles(colors); + const isMediumDevice = useMemo(() => Device.isMediumDevice(), []); + const illustrationSize = useMemo( + () => + isMediumDevice + ? { width: 160, height: 120 } + : { width: 200, height: 180 }, + [isMediumDevice], + ); /** * Temporary disabling the back button so users can't go back @@ -266,18 +278,19 @@ const OptinMetrics = () => { const renderActionButtons = useCallback( () => ( - + + ), - [styles, onConfirm], + [onConfirm, tw], ); /** @@ -326,70 +339,96 @@ const OptinMetrics = () => { [isEndReached], ); + const rootStyle = useMemo( + () => + tw.style('flex-1 bg-default', { + paddingTop: + Platform.OS === 'android' ? StatusBar.currentHeight || 40 : 40, + }), + [tw], + ); + return ( - + - - + + - + {strings('privacy_policy.description_title')} {strings('privacy_policy.description_content_1')} - - + + tw.style( + 'bg-background-alternative rounded-xl p-4 mb-4', + pressed && 'opacity-70', + ) + } onPress={handleBasicUsageToggle} testID={ MetaMetricsOptInSelectorsIDs.OPTIN_METRICS_METRICS_CHECKBOX } - activeOpacity={0.7} > - - + + {strings('privacy_policy.gather_basic_usage_title')} - + - + {isPna25FlagEnabled ? strings( @@ -398,8 +437,8 @@ const OptinMetrics = () => { : strings('privacy_policy.gather_basic_usage_description') + ' '} { e?.stopPropagation?.(); openLearnMore(); @@ -408,27 +447,37 @@ const OptinMetrics = () => { {strings('privacy_policy.gather_basic_usage_learn_more')} - - + + tw.style( + 'bg-background-alternative rounded-xl p-4 mb-4', + isMarketingDisabled && 'opacity-50', + pressed && !isMarketingDisabled && 'opacity-70', + ) + } onPress={handleMarketingToggle} - activeOpacity={isMarketingDisabled ? 1 : 0.7} disabled={isMarketingDisabled} > - - + + {strings('privacy_policy.checkbox_marketing')} - + { accessible disabled={isMarketingDisabled} /> - + {strings('privacy_policy.checkbox')} - - - + + + {renderActionButtons()} From 76a819d50347eca89ba76c9c54da4ae5865b77f7 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Wed, 18 Mar 2026 15:35:48 +0530 Subject: [PATCH 074/206] chore: add flag to disable STX for MMPay transactions (#26997) ## **Description** Add feature flag to disable STX for MMPay transactions. ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/7002 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** 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. ## **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 the transaction publish path for MetaMask Pay by gating Smart Transactions behind a new remote flag; misconfiguration could route pay transactions through the wrong submission path. Scope is small and covered by unit tests. > > **Overview** > Adds a new `confirmations_pay.stxDisabled` remote feature flag (default `false`) to explicitly disable Smart Transactions for MetaMask Pay. > > Updates `TransactionControllerInit` so `TransactionPayPublishHook` receives `isSmartTransaction` that returns `false` when this flag is enabled, and extends selector + unit tests to cover the new flag and hook behavior. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 8f1f14619172124d38f96015154c0d764cbcf356. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../transaction-controller-init.test.ts | 45 +++++++++++++++++++ .../transaction-controller-init.ts | 5 ++- .../confirmations/index.test.ts | 20 +++++++++ .../confirmations/index.ts | 6 +++ 4 files changed, 75 insertions(+), 1 deletion(-) diff --git a/app/core/Engine/controllers/transaction-controller/transaction-controller-init.test.ts b/app/core/Engine/controllers/transaction-controller/transaction-controller-init.test.ts index 9aefd2770d6..df7f7e065cb 100644 --- a/app/core/Engine/controllers/transaction-controller/transaction-controller-init.test.ts +++ b/app/core/Engine/controllers/transaction-controller/transaction-controller-init.test.ts @@ -14,6 +14,7 @@ import { ORIGIN_METAMASK, toHex } from '@metamask/controller-utils'; import { MOCK_ANY_NAMESPACE, MockAnyNamespace } from '@metamask/messenger'; import { Hex } from '@metamask/utils'; import { selectShouldUseSmartTransaction } from '../../../../selectors/smartTransactionsController'; +import { selectMetaMaskPayFlags } from '../../../../selectors/featureFlagController/confirmations'; import { getGlobalChainId } from '../../../../util/networks/global-network'; import { submitSmartTransactionHook } from '../../../../util/smart-transactions/smart-publish-hook'; import { Delegation7702PublishHook } from '../../../../util/transactions/hooks/delegation-7702-publish'; @@ -41,6 +42,7 @@ jest.mock('./event-handlers/metrics'); jest.mock('../../../../util/transactions/hooks/delegation-7702-publish'); jest.mock('../../../../util/transactions/sentinel-api'); jest.mock('@metamask/transaction-pay-controller'); +jest.mock('../../../../selectors/featureFlagController/confirmations'); jest.mock('../../../../util/transactions', () => ({ getTransactionById: jest.fn((_id) => ({ @@ -140,6 +142,7 @@ describe('Transaction Controller Init', () => { handleTransactionAddedEventForMetrics, ); const isSendBundleSupportedMock = jest.mocked(isSendBundleSupported); + const selectMetaMaskPayFlagsMock = jest.mocked(selectMetaMaskPayFlags); const payHookClassMock = jest.mocked(TransactionPayPublishHook); const payHookMock: jest.MockedFn = jest.fn(); @@ -172,6 +175,14 @@ describe('Transaction Controller Init', () => { selectShouldUseSmartTransactionMock.mockReturnValue(true); getGlobalChainIdMock.mockReturnValue('0x1'); isSendBundleSupportedMock.mockResolvedValue(true); + selectMetaMaskPayFlagsMock.mockReturnValue({ + attemptsMax: 2, + bufferInitial: 0.025, + bufferStep: 0.025, + bufferSubsequent: 0.05, + slippage: 0.005, + stxDisabled: false, + }); payHookClassMock.mockReturnValue({ getHook: () => payHookMock, @@ -315,6 +326,40 @@ describe('Transaction Controller Init', () => { expect(payHookMock).toHaveBeenCalledTimes(1); }); + + it('passes isSmartTransaction returning false to pay hook when stxDisabled is true', async () => { + selectMetaMaskPayFlagsMock.mockReturnValue({ + attemptsMax: 2, + bufferInitial: 0.025, + bufferStep: 0.025, + bufferSubsequent: 0.05, + slippage: 0.005, + stxDisabled: true, + }); + + const hooks = testConstructorOption('hooks'); + await hooks?.publish?.(MOCK_TRANSACTION_META); + + const { isSmartTransaction } = payHookClassMock.mock.calls[0][0]; + expect(isSmartTransaction('0x1')).toBe(false); + }); + + it('passes isSmartTransaction returning true to pay hook when stxDisabled is false', async () => { + selectMetaMaskPayFlagsMock.mockReturnValue({ + attemptsMax: 2, + bufferInitial: 0.025, + bufferStep: 0.025, + bufferSubsequent: 0.05, + slippage: 0.005, + stxDisabled: false, + }); + + const hooks = testConstructorOption('hooks'); + await hooks?.publish?.(MOCK_TRANSACTION_META); + + const { isSmartTransaction } = payHookClassMock.mock.calls[0][0]; + expect(isSmartTransaction('0x1')).toBe(true); + }); }); describe('publishBatch hook', () => { diff --git a/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts b/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts index 26de36b390d..b61e47c745c 100644 --- a/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts +++ b/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts @@ -50,6 +50,7 @@ import { TransactionPayControllerMessenger, TransactionPayPublishHook, } from '@metamask/transaction-pay-controller'; +import { selectMetaMaskPayFlags } from '../../../../selectors/featureFlagController/confirmations'; import { trace } from '../../../../util/trace'; import { Delegation7702PublishHook } from '../../../../util/transactions/hooks/delegation-7702-publish'; import { isSendBundleSupported } from '../../../../util/transactions/sentinel-api'; @@ -210,8 +211,10 @@ async function publishHook({ transactionMeta.chainId, ); + const { stxDisabled } = selectMetaMaskPayFlags(state); + const payResult = await new TransactionPayPublishHook({ - isSmartTransaction: () => shouldUseSmartTransaction, + isSmartTransaction: () => shouldUseSmartTransaction && !stxDisabled, messenger: initMessenger as TransactionPayControllerMessenger, }).getHook()(transactionMeta, signedTransactionInHex); diff --git a/app/selectors/featureFlagController/confirmations/index.test.ts b/app/selectors/featureFlagController/confirmations/index.test.ts index 885731f4b8f..4aca6f1cd0d 100644 --- a/app/selectors/featureFlagController/confirmations/index.test.ts +++ b/app/selectors/featureFlagController/confirmations/index.test.ts @@ -7,6 +7,7 @@ import { ATTEMPTS_MAX_DEFAULT, SLIPPAGE_DEFAULT, BUFFER_SUBSEQUENT_DEFAULT, + STX_DISABLED_DEFAULT, selectNonZeroUnusedApprovalsAllowList, selectGasFeeTokenFlags, GasFeeTokenFlags, @@ -120,6 +121,25 @@ describe('MetaMask Pay Feature Flags', () => { expect(selectMetaMaskPayFlags(state).slippage).toEqual(0.123); }); + + it('returns default stxDisabled if not in feature flags', () => { + expect(selectMetaMaskPayFlags(mockedEmptyFlagsState).stxDisabled).toEqual( + STX_DISABLED_DEFAULT, + ); + }); + + it('returns stxDisabled from feature flag', () => { + const state = cloneDeep(mockedEmptyFlagsState); + + state.engine.backgroundState.RemoteFeatureFlagController.remoteFeatureFlags = + { + confirmations_pay: { + stxDisabled: true, + }, + }; + + expect(selectMetaMaskPayFlags(state).stxDisabled).toEqual(true); + }); }); describe('Non-Zero Unused Approvals Allow List', () => { diff --git a/app/selectors/featureFlagController/confirmations/index.ts b/app/selectors/featureFlagController/confirmations/index.ts index cfd28f846ab..4c6c7c3d9b0 100644 --- a/app/selectors/featureFlagController/confirmations/index.ts +++ b/app/selectors/featureFlagController/confirmations/index.ts @@ -9,6 +9,7 @@ export const BUFFER_STEP_DEFAULT = 0.025; export const BUFFER_SUBSEQUENT_DEFAULT = 0.05; export const PAY_FIAT_ENABLED_DEFAULT = false; export const SLIPPAGE_DEFAULT = 0.005; +export const STX_DISABLED_DEFAULT = false; export interface PreferredToken { address: string; @@ -42,6 +43,7 @@ export interface MetaMaskPayFlags { bufferStep: number; bufferSubsequent: number; slippage: number; + stxDisabled: boolean; } export interface MetaMaskPayTokensFlags { @@ -98,12 +100,16 @@ export const selectMetaMaskPayFlags = createSelector( const slippage = (metaMaskPayFlags?.slippage as number) ?? SLIPPAGE_DEFAULT; + const stxDisabled = + (metaMaskPayFlags?.stxDisabled as boolean) ?? STX_DISABLED_DEFAULT; + return { attemptsMax, bufferInitial, bufferStep, bufferSubsequent, slippage, + stxDisabled, }; }, ); From 39c509dc913a46f2b7449f0e88ba2a1f9b620f7e Mon Sep 17 00:00:00 2001 From: Gaurav Goel Date: Wed, 18 Mar 2026 15:56:00 +0530 Subject: [PATCH 075/206] fix: remove dead error branch in handlePostSocialLogin (#27578) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Removes dead code in handlePostSocialLogin where the else branch for result.type !== 'success' was empty with only a placeholder comment. This branch is unreachable because OAuthService.handleOAuthLogin() throws on failure, and the error is caught by the try/catch in onPressContinueWithSocialLogin which routes to handleLoginError → handleOAuthLoginError → captureException (Sentry). Replaced the empty else block with an early return guard and a comment documenting the error handling flow, improving code clarity and preventing silent failures if the flow is ever refactored ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TO-594 ## **Manual testing steps** ```gherkin Feature: Social login error handling Scenario: user completes social login successfully Given the user is on the Onboarding screen When user taps Google or Apple login and completes authentication Then the user is navigated to the appropriate next screen (ChoosePassword, AccountAlreadyExists, etc.) Scenario: user encounters an error during social login Given the user is on the Onboarding screen When user taps Google or Apple login and an error occurs Then the error is caught by handleLoginError and reported to Sentry And the user sees an error toast and remains on the Onboarding screen ``` ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/0073d035-a502-4fdd-abdb-afb9bbe5cc47 ## **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. ## **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** > Low risk refactor in onboarding social login flow: it removes an unreachable branch and adds an explicit guard, without changing the success-path navigation or state updates. > > **Overview** > `handlePostSocialLogin` now *explicitly guards* against `result.type !== 'success'` with an early return and documents that OAuth failures are handled upstream via `onPressContinueWithSocialLogin`’s try/catch and `handleOAuthLoginError`/Sentry. > > The success-path logic (account type dispatch, metrics event, and navigation for create/import + existing/new users across iOS/Android) is preserved, but is no longer nested under an `if (result.type === 'success')` block. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 6c98ea95170fe65b79c9e3e80ad2fa09c50daf16. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../Views/Onboarding/index.test.tsx | 50 +++++++ app/components/Views/Onboarding/index.tsx | 130 +++++++++--------- 2 files changed, 116 insertions(+), 64 deletions(-) diff --git a/app/components/Views/Onboarding/index.test.tsx b/app/components/Views/Onboarding/index.test.tsx index a52f199efae..6fea61b1973 100644 --- a/app/components/Views/Onboarding/index.test.tsx +++ b/app/components/Views/Onboarding/index.test.tsx @@ -1310,6 +1310,56 @@ describe('Onboarding', () => { ); }); + it('does not navigate when OAuth login result type is not success', async () => { + mockCreateLoginHandler.mockReturnValue('mockGoogleHandler'); + mockOAuthService.handleOAuthLogin.mockResolvedValue({ + type: 'error', + existingUser: false, + }); + + const { getByTestId } = renderScreen( + Onboarding, + { name: 'Onboarding' }, + { + state: mockInitialState, + }, + ); + + const createWalletButton = getByTestId( + OnboardingSelectorIDs.NEW_WALLET_BUTTON, + ); + await act(async () => { + fireEvent.press(createWalletButton); + }); + + const navCall = mockNavigate.mock.calls.find( + (call) => + call[0] === Routes.MODAL.ROOT_MODAL_FLOW && + call[1]?.screen === Routes.SHEET.ONBOARDING_SHEET, + ); + + const googleOAuthFunction = navCall[1].params.onPressContinueWithGoogle; + + mockNavigate.mockClear(); + + await act(async () => { + await googleOAuthFunction(true); + }); + + expect(mockNavigate).not.toHaveBeenCalledWith( + 'ChoosePassword', + expect.anything(), + ); + expect(mockNavigate).not.toHaveBeenCalledWith( + Routes.ONBOARDING.SOCIAL_LOGIN_SUCCESS_NEW_USER, + expect.anything(), + ); + expect(mockNavigate).not.toHaveBeenCalledWith( + 'AccountAlreadyExists', + expect.anything(), + ); + }); + it('attempts browser fallback when no credential is available in Android', async () => { Platform.OS = 'android'; const noCredentialError = new OAuthError( diff --git a/app/components/Views/Onboarding/index.tsx b/app/components/Views/Onboarding/index.tsx index 8c5c157d026..abac9f39591 100644 --- a/app/components/Views/Onboarding/index.tsx +++ b/app/components/Views/Onboarding/index.tsx @@ -412,83 +412,85 @@ const Onboarding = () => { socialLoginTraceCtx.current = undefined; } - if (result.type === 'success') { - const accountType = getSocialAccountType(provider, result.existingUser); - dispatch(setAccountType(accountType)); + // Error case (result.type !== 'success') is not handled here because + // OAuthService.handleOAuthLogin() throws on failure, and the error is + // caught by the try/catch in onPressContinueWithSocialLogin, which calls + // handleLoginError → handleOAuthLoginError → captureException (Sentry). + if (result.type !== 'success') { + return; + } - track(MetaMetricsEvents.SOCIAL_LOGIN_COMPLETED, { - account_type: accountType, - }); - if (createWallet) { - if (result.existingUser) { - navigation.navigate('AccountAlreadyExists', { - accountName: result.accountName, - oauthLoginSuccess: true, - onboardingTraceCtx: onboardingTraceCtx.current, - provider, - }); - } else { - trace({ - name: TraceName.OnboardingNewSocialCreateWallet, - op: TraceOperation.OnboardingUserJourney, - tags: getTraceTags(store.getState()), - parentContext: onboardingTraceCtx.current, - }); + const accountType = getSocialAccountType(provider, result.existingUser); + dispatch(setAccountType(accountType)); - if (isIOS) { - // Navigate to SocialLoginSuccess screen first, then ChoosePassword - navigation.navigate( - Routes.ONBOARDING.SOCIAL_LOGIN_SUCCESS_NEW_USER, - { - accountName: result.accountName, - oauthLoginSuccess: true, - onboardingTraceCtx: onboardingTraceCtx.current, - provider, - }, - ); - } else { - // Direct navigation to ChoosePassword for Android - navigation.navigate('ChoosePassword', { - [PREVIOUS_SCREEN]: ONBOARDING, + track(MetaMetricsEvents.SOCIAL_LOGIN_COMPLETED, { + account_type: accountType, + }); + if (createWallet) { + if (result.existingUser) { + navigation.navigate('AccountAlreadyExists', { + accountName: result.accountName, + oauthLoginSuccess: true, + onboardingTraceCtx: onboardingTraceCtx.current, + provider, + }); + } else { + trace({ + name: TraceName.OnboardingNewSocialCreateWallet, + op: TraceOperation.OnboardingUserJourney, + tags: getTraceTags(store.getState()), + parentContext: onboardingTraceCtx.current, + }); + + if (isIOS) { + // Navigate to SocialLoginSuccess screen first, then ChoosePassword + navigation.navigate( + Routes.ONBOARDING.SOCIAL_LOGIN_SUCCESS_NEW_USER, + { + accountName: result.accountName, oauthLoginSuccess: true, onboardingTraceCtx: onboardingTraceCtx.current, provider, - }); - } - } - } else if (!createWallet) { - if (result.existingUser) { - trace({ - name: TraceName.OnboardingExistingSocialLogin, - op: TraceOperation.OnboardingUserJourney, - tags: getTraceTags(store.getState()), - parentContext: onboardingTraceCtx.current, - }); - isIOS - ? navigation.navigate( - Routes.ONBOARDING.SOCIAL_LOGIN_SUCCESS_EXISTING_USER, - { - [PREVIOUS_SCREEN]: ONBOARDING, - oauthLoginSuccess: true, - onboardingTraceCtx: onboardingTraceCtx.current, - }, - ) - : navigation.navigate('Rehydrate', { - [PREVIOUS_SCREEN]: ONBOARDING, - oauthLoginSuccess: true, - onboardingTraceCtx: onboardingTraceCtx.current, - }); + }, + ); } else { - navigation.navigate('AccountNotFound', { - accountName: result.accountName, + // Direct navigation to ChoosePassword for Android + navigation.navigate('ChoosePassword', { + [PREVIOUS_SCREEN]: ONBOARDING, oauthLoginSuccess: true, onboardingTraceCtx: onboardingTraceCtx.current, provider, }); } } + } else if (result.existingUser) { + trace({ + name: TraceName.OnboardingExistingSocialLogin, + op: TraceOperation.OnboardingUserJourney, + tags: getTraceTags(store.getState()), + parentContext: onboardingTraceCtx.current, + }); + isIOS + ? navigation.navigate( + Routes.ONBOARDING.SOCIAL_LOGIN_SUCCESS_EXISTING_USER, + { + [PREVIOUS_SCREEN]: ONBOARDING, + oauthLoginSuccess: true, + onboardingTraceCtx: onboardingTraceCtx.current, + }, + ) + : navigation.navigate('Rehydrate', { + [PREVIOUS_SCREEN]: ONBOARDING, + oauthLoginSuccess: true, + onboardingTraceCtx: onboardingTraceCtx.current, + }); } else { - // handle error: show error message in the UI + navigation.navigate('AccountNotFound', { + accountName: result.accountName, + oauthLoginSuccess: true, + onboardingTraceCtx: onboardingTraceCtx.current, + provider, + }); } }, [navigation, track, dispatch], From 6556ecd37f30c796a3e4353c78c35c9f649841aa Mon Sep 17 00:00:00 2001 From: Gaurav Goel Date: Wed, 18 Mar 2026 15:59:24 +0530 Subject: [PATCH 076/206] feat: restore wallet tailwind migration (#27575) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Migrates the RestoreWallet component from legacy React Native styling (StyleSheet, View, StyledButton) to the MetaMask design system using @metamask/design-system-react-native components (Box, Text, Button) and Tailwind CSS via useTailwind() Why: Legacy StyleSheet.create(), raw View/Text, and deprecated StyledButton are being phased out across the app in favor of the design system for consistency, accessibility, and maintainability. What changed: * Replaced View with Box (design system) * Replaced deprecated Text with design system Text (updated variants: HeadingLG → HeadingLg, BodyMD → BodyMd) * Replaced deprecated StyledButton + manual ActivityIndicator with design system Button (isLoading prop handles spinner internally) * Replaced useAppThemeFromContext() + createStyles() with useTailwind() hook * Removed ActivityIndicator import (no longer needed) * Updated unit test to assert loading state via spinner-container testID instead of UNSAFE_getByType(ActivityIndicator) styles.ts is not deleted as it is still shared by WalletRestored and WalletResetNeeded Jira: https://consensyssoftware.atlassian.net/browse/TO-596 ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: RestoreWallet screen Tailwind migration Feature: RestoreWallet screen Tailwind migration Scenario: user sees the RestoreWallet screen during vault recovery Given the app detects a corrupted vault on startup or login When user is navigated to the RestoreWallet screen Then the screen displays the device image, "Restore needed" title, description text, and a full-width "Restore wallet" button Scenario: user taps Restore wallet button Given the user is on the RestoreWallet screen When user taps "Restore wallet" Then a loading spinner appears on the button And the app attempts to restore the vault from backup ``` ## **Screenshots/Recordings** ### **Before** Screenshot 2026-03-18 at 12 19 37 PM Screenshot 2026-03-18 at 12 18 25 PM ### **After** Screenshot 2026-03-18 at 12 28 36 PM Screenshot 2026-03-18 at 12 27 01 PM ## **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. ## **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** > Low risk UI refactor that swaps legacy styling/components for design-system primitives and changes how the loading spinner is rendered/queried in tests. > > **Overview** > Migrates `RestoreWallet` from legacy `StyleSheet`/`View`/`StyledButton`/`ActivityIndicator` to MetaMask design-system components (`Box`, `Text`, `Button`) styled via `useTailwind()`. > > Updates the restore CTA to use `Button`’s `isLoading` state (instead of manually rendering an `ActivityIndicator`) and adjusts the unit test to assert the new loading UI via the design-system spinner test id. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 00ea3993492fa44e6c625d74a7630bed787db6b3. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../RestoreWallet/RestoreWallet.test.tsx | 16 +- .../Views/RestoreWallet/RestoreWallet.tsx | 68 +++--- .../__snapshots__/RestoreWallet.test.tsx.snap | 202 ++++++++++++++++++ 3 files changed, 254 insertions(+), 32 deletions(-) create mode 100644 app/components/Views/RestoreWallet/__snapshots__/RestoreWallet.test.tsx.snap diff --git a/app/components/Views/RestoreWallet/RestoreWallet.test.tsx b/app/components/Views/RestoreWallet/RestoreWallet.test.tsx index 3cc8931a8f5..0c68a222d44 100644 --- a/app/components/Views/RestoreWallet/RestoreWallet.test.tsx +++ b/app/components/Views/RestoreWallet/RestoreWallet.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Image, ActivityIndicator } from 'react-native'; +import { Image } from 'react-native'; import { fireEvent, waitFor } from '@testing-library/react-native'; import RestoreWallet from './RestoreWallet'; import Routes from '../../../constants/navigation/Routes'; @@ -93,6 +93,12 @@ describe('RestoreWallet', () => { const imageElement = UNSAFE_getByType(Image); expect(imageElement).toBeTruthy(); }); + + it('renders component tree correctly', () => { + const { toJSON } = renderWithProvider(); + + expect(toJSON()).toMatchSnapshot(); + }); }); describe('analytics tracking', () => { @@ -150,7 +156,7 @@ describe('RestoreWallet', () => { }); }); - it('shows loading indicator while restoring', async () => { + it('triggers vault restore on button press', async () => { let resolveRestore: (value: { success: boolean }) => void; const restorePromise = new Promise<{ success: boolean }>((resolve) => { resolveRestore = resolve; @@ -158,15 +164,13 @@ describe('RestoreWallet', () => { (EngineService.initializeVaultFromBackup as jest.Mock).mockReturnValue( restorePromise, ); - const { getByText, UNSAFE_getByType } = renderWithProvider( - , - ); + const { getByText } = renderWithProvider(); fireEvent.press( getByText(strings('restore_wallet.restore_needed_action')), ); - expect(UNSAFE_getByType(ActivityIndicator)).toBeTruthy(); + expect(EngineService.initializeVaultFromBackup).toHaveBeenCalled(); // @ts-expect-error resolveRestore is assigned in Promise constructor resolveRestore({ success: true }); diff --git a/app/components/Views/RestoreWallet/RestoreWallet.tsx b/app/components/Views/RestoreWallet/RestoreWallet.tsx index c3af43d18fe..a9d1d557535 100644 --- a/app/components/Views/RestoreWallet/RestoreWallet.tsx +++ b/app/components/Views/RestoreWallet/RestoreWallet.tsx @@ -1,12 +1,19 @@ /* eslint-disable import/no-commonjs */ import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { View, Image, ActivityIndicator } from 'react-native'; +import { Image } from 'react-native'; import { strings } from '../../../../locales/i18n'; -import { createStyles } from './styles'; -import Text, { +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + Text, + Button, + BoxAlignItems, + BoxJustifyContent, TextVariant, -} from '../../../component-library/components/Texts/Text'; -import StyledButton from '../../UI/StyledButton'; + TextColor, + ButtonVariant, + ButtonSize, +} from '@metamask/design-system-react-native'; import { createNavigationDetails, useParams, @@ -15,7 +22,6 @@ import Routes from '../../../constants/navigation/Routes'; import EngineService from '../../../core/EngineService'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useNavigation, StackActions } from '@react-navigation/native'; -import { useAppThemeFromContext } from '../../../util/theme'; import { createWalletResetNeededNavDetails } from './WalletResetNeeded'; import { createWalletRestoredNavDetails } from './WalletRestored'; import { MetaMetricsEvents } from '../../../core/Analytics'; @@ -44,8 +50,7 @@ export const createRestoreWalletNavDetailsNested = const RestoreWallet = () => { const { trackEvent, createEventBuilder } = useMetrics(); - const { colors } = useAppThemeFromContext(); - const styles = createStyles(colors); + const tw = useTailwind(); const [loading, setLoading] = useState(false); @@ -89,31 +94,42 @@ const RestoreWallet = () => { }, [deviceMetaData, navigation, trackEvent, createEventBuilder]); return ( - - - + + + - - + + {strings('restore_wallet.restore_needed_title')} - + {strings('restore_wallet.restore_needed_description')} - - - + + + ); }; diff --git a/app/components/Views/RestoreWallet/__snapshots__/RestoreWallet.test.tsx.snap b/app/components/Views/RestoreWallet/__snapshots__/RestoreWallet.test.tsx.snap new file mode 100644 index 00000000000..afabd9495b5 --- /dev/null +++ b/app/components/Views/RestoreWallet/__snapshots__/RestoreWallet.test.tsx.snap @@ -0,0 +1,202 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RestoreWallet rendering renders component tree correctly 1`] = ` + + + + + + + Restore needed + + + Something went wrong, but don’t worry! Let’s try to restore your wallet. + + + + + + Restore wallet + + + + +`; From 3bf82cf4a997672631bc5f9a84102786782d978a Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Wed, 18 Mar 2026 11:18:24 +0000 Subject: [PATCH 077/206] feat: support relay execute in metamask pay (#27430) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Add support for entirely gasless Relay execute flow in MetaMask Pay. - Add `SourceHashSummaryLine` component for displaying outgoing transaction when no local transactions. - Add `TransactionPayController:getDelegationTransaction` permission. ## **Changelog** CHANGELOG entry: null ## **Related issues** ## **Manual testing steps** ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Adds conditional transaction-summary UI based on `metamaskPay.sourceHash` and expands messenger action permissions for `TransactionPayController`, which could affect transaction display and controller interactions if mis-gated. > > **Overview** > **MetaMask Pay relay execute support:** `TransactionDetailsSummary` now conditionally prepends a new `SourceHashSummaryLine` when `metamaskPay.sourceHash` is present and there are *no* deposit-related transactions (no `requiredTransactionIds` and no batch txs), enabling an outgoing tx reference even when nothing is stored locally. > > `SourceHashSummaryLine` is added to format a “bridge send” title using the source token/network and delegates rendering/navigation to `TransactionSummaryLine` with `txHash=sourceHash`; unit tests cover rendering and block-explorer navigation, and `TransactionDetailsSummary` tests cover the new gating behavior. > > **Engine wiring:** `TransactionControllerInit` messenger permissions are extended to allow `TransactionPayController:getDelegationTransaction`, and `@metamask/transaction-controller` / `@metamask/transaction-pay-controller` dependencies are bumped to `^62.22.0` and `^17.0.0` respectively. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit ee119fca1561672780472c36fb780f426b4b7253. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../source-hash-summary-line.test.tsx | 121 ++++++++++++++++++ .../source-hash-summary-line.tsx | 43 +++++++ .../transaction-details-summary.test.tsx | 70 ++++++++++ .../transaction-details-summary.tsx | 13 ++ .../transaction-controller-messenger.ts | 3 + package.json | 4 +- yarn.lock | 15 ++- 7 files changed, 260 insertions(+), 9 deletions(-) create mode 100644 app/components/Views/confirmations/components/activity/transaction-details-summary/source-hash-summary-line.test.tsx create mode 100644 app/components/Views/confirmations/components/activity/transaction-details-summary/source-hash-summary-line.tsx diff --git a/app/components/Views/confirmations/components/activity/transaction-details-summary/source-hash-summary-line.test.tsx b/app/components/Views/confirmations/components/activity/transaction-details-summary/source-hash-summary-line.test.tsx new file mode 100644 index 00000000000..696513c3528 --- /dev/null +++ b/app/components/Views/confirmations/components/activity/transaction-details-summary/source-hash-summary-line.test.tsx @@ -0,0 +1,121 @@ +import React from 'react'; +import { fireEvent } from '@testing-library/react-native'; +import { TransactionMeta } from '@metamask/transaction-controller'; +import { Hex } from '@metamask/utils'; +import renderWithProvider from '../../../../../../util/test/renderWithProvider'; +import { strings } from '../../../../../../../locales/i18n'; +import { useMultichainBlockExplorerTxUrl } from '../../../../../UI/Bridge/hooks/useMultichainBlockExplorerTxUrl'; +import Routes from '../../../../../../constants/navigation/Routes'; +import { useNetworkName } from '../../../hooks/useNetworkName'; +import { useTokenWithBalance } from '../../../hooks/tokens/useTokenWithBalance'; +import { selectBridgeHistoryForAccount } from '../../../../../../selectors/bridgeStatusController'; +import { useBridgeTxHistoryData } from '../../../../../../util/bridge/hooks/useBridgeTxHistoryData'; +import { useTokenAmount } from '../../../hooks/useTokenAmount'; +import { useTransactionDetails } from '../../../hooks/activity/useTransactionDetails'; +import { SourceHashSummaryLine } from './source-hash-summary-line'; + +const mockNavigate = jest.fn(); + +jest.mock('../../../../../UI/Bridge/hooks/useMultichainBlockExplorerTxUrl'); +jest.mock('../../../hooks/useNetworkName'); +jest.mock('../../../hooks/tokens/useTokenWithBalance'); +jest.mock('../../../../../../selectors/bridgeStatusController'); +jest.mock('../../../../../../util/bridge/hooks/useBridgeTxHistoryData'); +jest.mock('../../../hooks/useTokenAmount'); +jest.mock('../../../hooks/activity/useTransactionDetails'); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ + navigate: mockNavigate, + }), +})); + +function render() { + return renderWithProvider( + , + { + state: { + engine: { + backgroundState: { + TransactionController: { transactions: [] }, + }, + }, + }, + }, + ); +} + +describe('SourceHashSummaryLine', () => { + const useMultichainBlockExplorerTxUrlMock = jest.mocked( + useMultichainBlockExplorerTxUrl, + ); + const useNetworkNameMock = jest.mocked(useNetworkName); + const useTokenWithBalanceMock = jest.mocked(useTokenWithBalance); + + beforeEach(() => { + jest.resetAllMocks(); + + useMultichainBlockExplorerTxUrlMock.mockReturnValue({ + explorerTxUrl: 'https://explorer.example', + explorerName: 'Explorer', + } as ReturnType); + + useNetworkNameMock.mockReturnValue('Ethereum'); + useTokenWithBalanceMock.mockReturnValue({ symbol: 'USDC' } as ReturnType< + typeof useTokenWithBalance + >); + + jest.mocked(selectBridgeHistoryForAccount).mockReturnValue({}); + jest.mocked(useBridgeTxHistoryData).mockReturnValue({ + bridgeTxHistoryItem: undefined, + isBridgeComplete: null, + }); + jest + .mocked(useTokenAmount) + .mockReturnValue({} as ReturnType); + jest.mocked(useTransactionDetails).mockReturnValue({ + transactionMeta: {} as TransactionMeta, + }); + }); + + it('renders send title', () => { + const { getByText } = render(); + + expect( + getByText( + strings('transaction_details.summary_title.bridge_send', { + sourceSymbol: 'USDC', + sourceChain: 'Ethereum', + }), + ), + ).toBeDefined(); + }); + + it('navigates to block explorer when button is pressed', () => { + const { getByTestId } = render(); + + fireEvent.press(getByTestId('block-explorer-button')); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.WEBVIEW.MAIN, { + screen: Routes.WEBVIEW.SIMPLE, + params: { + url: 'https://explorer.example', + title: 'Explorer', + }, + }); + }); +}); diff --git a/app/components/Views/confirmations/components/activity/transaction-details-summary/source-hash-summary-line.tsx b/app/components/Views/confirmations/components/activity/transaction-details-summary/source-hash-summary-line.tsx new file mode 100644 index 00000000000..87733f43eab --- /dev/null +++ b/app/components/Views/confirmations/components/activity/transaction-details-summary/source-hash-summary-line.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { TransactionMeta } from '@metamask/transaction-controller'; +import { Hex } from '@metamask/utils'; +import { strings } from '../../../../../../../locales/i18n'; +import { useNetworkName } from '../../../hooks/useNetworkName'; +import { useTokenWithBalance } from '../../../hooks/tokens/useTokenWithBalance'; +import { TransactionSummaryLine } from './transaction-summary-line'; + +export function SourceHashSummaryLine({ + parentTransaction, + sourceHash, +}: { + parentTransaction: TransactionMeta; + sourceHash: Hex; +}) { + const tokenAddress = parentTransaction.metamaskPay?.tokenAddress; + const tokenChainId = parentTransaction.metamaskPay?.chainId; + + const sourceToken = useTokenWithBalance( + tokenAddress ?? '0x0', + tokenChainId ?? '0x0', + ); + + const sourceNetworkName = useNetworkName(tokenChainId); + const chainId = tokenChainId ?? parentTransaction.chainId; + + const title = + sourceToken?.symbol && sourceNetworkName + ? strings('transaction_details.summary_title.bridge_send', { + sourceSymbol: sourceToken.symbol, + sourceChain: sourceNetworkName, + }) + : strings('transaction_details.summary_title.bridge_send_loading'); + + return ( + + ); +} diff --git a/app/components/Views/confirmations/components/activity/transaction-details-summary/transaction-details-summary.test.tsx b/app/components/Views/confirmations/components/activity/transaction-details-summary/transaction-details-summary.test.tsx index 5c580803da6..0ddbfb64d03 100644 --- a/app/components/Views/confirmations/components/activity/transaction-details-summary/transaction-details-summary.test.tsx +++ b/app/components/Views/confirmations/components/activity/transaction-details-summary/transaction-details-summary.test.tsx @@ -43,6 +43,14 @@ jest.mock('./default-summary-line', () => ({ }, })); +jest.mock('./source-hash-summary-line', () => ({ + SourceHashSummaryLine: () => { + const ReactNative = require('react-native'); + + return SourceHashSummaryLine; + }, +})); + function render({ transactions, }: { @@ -191,6 +199,68 @@ describe('TransactionDetailsSummary', () => { expect(getAllByText('DefaultSummaryLine')).toHaveLength(2); }); + it('renders SourceHashSummaryLine when sourceHash exists and no deposit transactions', () => { + useTransactionDetailsMock.mockReturnValue({ + transactionMeta: { + id: transactionIdMock, + chainId: '0x1', + type: TransactionType.perpsDeposit, + metamaskPay: { + sourceHash: '0xabc', + tokenAddress: '0x123', + chainId: '0x1', + }, + } as unknown as TransactionMeta, + }); + + const { getByText } = render({ + transactions: [ + { + id: transactionIdMock, + chainId: '0x1', + type: TransactionType.perpsDeposit, + }, + ], + }); + + expect(getByText('SourceHashSummaryLine')).toBeDefined(); + }); + + it('does not render SourceHashSummaryLine when deposit transactions exist', () => { + const depositId = 'deposit-id'; + + useTransactionDetailsMock.mockReturnValue({ + transactionMeta: { + id: transactionIdMock, + chainId: '0x1', + type: TransactionType.perpsDeposit, + requiredTransactionIds: [depositId], + metamaskPay: { + sourceHash: '0xabc', + tokenAddress: '0x123', + chainId: '0x1', + }, + } as unknown as TransactionMeta, + }); + + const { queryByText } = render({ + transactions: [ + { + id: depositId, + chainId: '0x1', + type: TransactionType.relayDeposit, + }, + { + id: transactionIdMock, + chainId: '0x1', + type: TransactionType.perpsDeposit, + }, + ], + }); + + expect(queryByText('SourceHashSummaryLine')).toBeNull(); + }); + it('skips non-relay child transactions for mUSD conversion parent', () => { const sendId = 'send-id'; const receiveId = 'receive-id'; diff --git a/app/components/Views/confirmations/components/activity/transaction-details-summary/transaction-details-summary.tsx b/app/components/Views/confirmations/components/activity/transaction-details-summary/transaction-details-summary.tsx index 80e0939ad37..0fc9b203a0b 100644 --- a/app/components/Views/confirmations/components/activity/transaction-details-summary/transaction-details-summary.tsx +++ b/app/components/Views/confirmations/components/activity/transaction-details-summary/transaction-details-summary.tsx @@ -18,6 +18,7 @@ import { import { hasTransactionType } from '../../../utils/transaction'; import { RELAY_DEPOSIT_TYPES } from '../../../constants/confirmations'; import { ProgressList } from '../../progress-list'; +import { SourceHashSummaryLine } from './source-hash-summary-line'; import { DepositSummaryLine } from './deposit-summary-line'; import { ApprovalSummaryLine } from './approval-summary-line'; import { ReceiveSummaryLine } from './receive-summary-line'; @@ -28,6 +29,7 @@ export function TransactionDetailsSummary() { const { batchId, id: transactionId, + metamaskPay, requiredTransactionIds, } = transactionMeta; @@ -62,10 +64,21 @@ export function TransactionDetailsSummary() { transaction.id === transactionId, ); + const hasDepositTransactions = + (requiredTransactionIds?.length ?? 0) > 0 || batchTransactionIds.length > 0; + + const { sourceHash } = metamaskPay ?? {}; + return ( Summary + {!hasDepositTransactions && sourceHash ? ( + + ) : null} {transactions.map((transaction) => ( Date: Wed, 18 Mar 2026 04:24:18 -0700 Subject: [PATCH 078/206] test: Add Component View Tests for Send Flow (#26094) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Sev1 issues addressed in this PR: Test | Issue | What it covers -- | -- | -- TRON send: selecting destination account updates selection and flow moves forward | #22789 #23251| TRON send: choose recipient from list, flow advances (no stuck on recipient list). ERC-721 send: Amount screen shows enabled Continue and user can proceed to Recipient | #12317 | ERC-721 send: Continue enabled on Amount, can go to Recipient. ERC-721 selected from Asset screen navigates to Recipient not Amount | #19002 | Starting Send from Asset with ERC-721 opens Recipient (not Amount). Solana send Recipient screen does not show EVM contacts | #22205 | Non-EVM (Solana) send: Recipient screen has no EVM contacts. Recipient list renders each contact with avatar | #22806 | Each Recipient list row (contacts) has the expected avatar. ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [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 - [ ] 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. ## **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** > Low risk: changes are primarily new/updated tests and test scaffolding, plus additive `testID` props used for automation with no functional logic changes. > > **Overview** > Adds new component-view regression tests for the redesigned `Send` flow, covering TRON recipient selection advancing the flow, ERC-721 navigation/Continue enablement, non-EVM recipient filtering (no EVM contacts), and per-recipient avatar rendering. > > To support these tests, introduces centralized Send `testID` constants/helpers, adds `testID`s to NFT rows (now includes `tokenId` for uniqueness) and recipient avatars, and expands component-view test mocks/presets (Engine controller stubs, Snap `onAmountInput` validation response, and reusable Send/TRON state overrides). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit ecc307be26494dab1e251e9ba31a732a8c570ea9. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../confirmations/components/UI/nft/nft.tsx | 3 + .../components/UI/recipient/recipient.tsx | 5 +- .../components/nft-list/nft-list.test.tsx | 44 +-- .../send/RedesignedSendView.testIds.ts | 21 +- .../components/send/send.view.test.tsx | 338 ++++++++++++++++++ tests/component-view/mocks.ts | 27 +- tests/component-view/presets/send.ts | 196 ++++++++++ 7 files changed, 607 insertions(+), 27 deletions(-) create mode 100644 app/components/Views/confirmations/components/send/send.view.test.tsx create mode 100644 tests/component-view/presets/send.ts diff --git a/app/components/Views/confirmations/components/UI/nft/nft.tsx b/app/components/Views/confirmations/components/UI/nft/nft.tsx index 2ef484065bd..31ce67f5f0f 100644 --- a/app/components/Views/confirmations/components/UI/nft/nft.tsx +++ b/app/components/Views/confirmations/components/UI/nft/nft.tsx @@ -29,8 +29,11 @@ export function Nft({ asset, onPress }: NftProps) { onPress(asset); }, [asset, onPress]); + const testID = `nft-${asset.name || asset.collectionName || 'NFT'}-${asset.tokenId}`; + return ( tw.style( 'w-full flex-row items-center justify-between py-2', diff --git a/app/components/Views/confirmations/components/UI/recipient/recipient.tsx b/app/components/Views/confirmations/components/UI/recipient/recipient.tsx index 13b7d4ca873..b8555735c99 100644 --- a/app/components/Views/confirmations/components/UI/recipient/recipient.tsx +++ b/app/components/Views/confirmations/components/UI/recipient/recipient.tsx @@ -89,7 +89,10 @@ export function Recipient({ accessibilityRole="button" > - + { onPress: (asset: Nft) => void; }) => ( onPress(asset)} > {asset.name || asset.tokenId} @@ -95,22 +95,22 @@ describe('NftList', () => { it('renders nfts when provided', () => { const { getByTestId } = render(); - expect(getByTestId('nft-Cool NFT #1')).toBeOnTheScreen(); - expect(getByTestId('nft-Awesome NFT #2')).toBeOnTheScreen(); + expect(getByTestId('nft-Cool NFT #1-1')).toBeOnTheScreen(); + expect(getByTestId('nft-Awesome NFT #2-2')).toBeOnTheScreen(); }); it('renders empty list when no nfts provided', () => { const { queryByTestId } = render(); - expect(queryByTestId('nft-Cool NFT #1')).toBeNull(); - expect(queryByTestId('nft-Awesome NFT #2')).toBeNull(); + expect(queryByTestId('nft-Cool NFT #1-1')).toBeNull(); + expect(queryByTestId('nft-Awesome NFT #2-2')).toBeNull(); }); it('renders single nft correctly', () => { const singleNft = [mockNfts[0]]; const { getByTestId } = render(); - expect(getByTestId('nft-Cool NFT #1')).toBeOnTheScreen(); + expect(getByTestId('nft-Cool NFT #1-1')).toBeOnTheScreen(); }); }); @@ -118,7 +118,7 @@ describe('NftList', () => { it('calls updateAsset and navigates to recipient screen when ERC721 nft is pressed', () => { const { getByTestId } = render(); - fireEvent.press(getByTestId('nft-Cool NFT #1')); + fireEvent.press(getByTestId('nft-Cool NFT #1-1')); expect(mockUpdateAsset).toHaveBeenCalledWith(mockNfts[0]); expect(mockGotToSendScreen).toHaveBeenCalledWith(Routes.SEND.RECIPIENT); @@ -134,14 +134,14 @@ describe('NftList', () => { const { getByTestId } = render(); - fireEvent.press(getByTestId('nft-Cool NFT #1')); + fireEvent.press(getByTestId('nft-Cool NFT #1-1')); expect(mockUpdateTo).toHaveBeenCalledWith(''); }); it('calls updateAsset and navigates to amount screen when ERC1155 nft is pressed', () => { const { getByTestId } = render(); - fireEvent.press(getByTestId('nft-Awesome NFT #2')); + fireEvent.press(getByTestId('nft-Awesome NFT #2-2')); expect(mockUpdateAsset).toHaveBeenCalledWith(mockNfts[1]); expect(mockGotToSendScreen).toHaveBeenCalledWith(Routes.SEND.AMOUNT); @@ -158,7 +158,7 @@ describe('NftList', () => { const { getByTestId } = render(); - fireEvent.press(getByTestId('nft-Cool NFT #1')); + fireEvent.press(getByTestId('nft-Cool NFT #1-1')); expect(mockCaptureAssetSelected).toHaveBeenCalledWith(mockNfts[0], '0'); }); @@ -174,7 +174,7 @@ describe('NftList', () => { const { getByTestId } = render(); - fireEvent.press(getByTestId('nft-Awesome NFT #2')); + fireEvent.press(getByTestId('nft-Awesome NFT #2-2')); expect(mockCaptureAssetSelected).toHaveBeenCalledWith(mockNfts[1], '1'); }); @@ -184,10 +184,10 @@ describe('NftList', () => { it('shows only first 5 nfts initially', () => { const { queryByTestId } = render(); - expect(queryByTestId('nft-NFT 0')).toBeOnTheScreen(); - expect(queryByTestId('nft-NFT 4')).toBeOnTheScreen(); - expect(queryByTestId('nft-NFT 5')).toBeNull(); - expect(queryByTestId('nft-NFT 11')).toBeNull(); + expect(queryByTestId('nft-NFT 0-0')).toBeOnTheScreen(); + expect(queryByTestId('nft-NFT 4-4')).toBeOnTheScreen(); + expect(queryByTestId('nft-NFT 5-5')).toBeNull(); + expect(queryByTestId('nft-NFT 11-11')).toBeNull(); }); it('shows "Show more NFTs" button when there are more than 5 nfts', () => { @@ -205,12 +205,12 @@ describe('NftList', () => { it('shows more nfts when "Show more NFTs" is pressed', () => { const { getByText, queryByTestId } = render(); - expect(queryByTestId('nft-NFT 5')).toBeNull(); + expect(queryByTestId('nft-NFT 5-5')).toBeNull(); fireEvent.press(getByText('Show more NFTs')); - expect(queryByTestId('nft-NFT 5')).toBeOnTheScreen(); - expect(queryByTestId('nft-NFT 9')).toBeOnTheScreen(); + expect(queryByTestId('nft-NFT 5-5')).toBeOnTheScreen(); + expect(queryByTestId('nft-NFT 9-9')).toBeOnTheScreen(); }); it('hides "Show more NFTs" button when all nfts are visible', () => { @@ -236,13 +236,13 @@ describe('NftList', () => { fireEvent.press(getByText('Show more NFTs')); - expect(queryByTestId('nft-NFT 9')).toBeOnTheScreen(); - expect(queryByTestId('nft-NFT 10')).toBeNull(); + expect(queryByTestId('nft-NFT 9-9')).toBeOnTheScreen(); + expect(queryByTestId('nft-NFT 10-10')).toBeNull(); fireEvent.press(getByText('Show more NFTs')); - expect(queryByTestId('nft-NFT 14')).toBeOnTheScreen(); - expect(queryByTestId('nft-NFT 15')).toBeNull(); + expect(queryByTestId('nft-NFT 14-14')).toBeOnTheScreen(); + expect(queryByTestId('nft-NFT 15-15')).toBeNull(); }); }); }); diff --git a/app/components/Views/confirmations/components/send/RedesignedSendView.testIds.ts b/app/components/Views/confirmations/components/send/RedesignedSendView.testIds.ts index c19adffa899..7d047936d7b 100644 --- a/app/components/Views/confirmations/components/send/RedesignedSendView.testIds.ts +++ b/app/components/Views/confirmations/components/send/RedesignedSendView.testIds.ts @@ -1,4 +1,23 @@ export const RedesignedSendViewSelectorsIDs = { + SEND_AMOUNT: 'send_amount', + EDIT_AMOUNT_KEYBOARD: 'edit-amount-keyboard', + PERCENTAGE_BUTTON_100: 'percentage-button-100', RECIPIENT_ADDRESS_INPUT: 'recipient-address-input', REVIEW_BUTTON: 'review-button', -}; +} as const; + +/** TestID for recipient row (unselected). */ +export const getRecipientRowTestId = (address: string) => + `recipient-${address}`; + +/** TestID for selected recipient row. */ +export const getSelectedRecipientTestId = (address: string) => + `selected-${address}`; + +/** TestID for recipient row avatar. */ +export const getRecipientAvatarTestId = (address: string) => + `recipient-avatar-${address}`; + +/** TestID for NFT row in asset list. Includes tokenId for uniqueness. */ +export const getNftRowTestId = (name: string, tokenId: string) => + `nft-${name}-${tokenId}`; diff --git a/app/components/Views/confirmations/components/send/send.view.test.tsx b/app/components/Views/confirmations/components/send/send.view.test.tsx new file mode 100644 index 00000000000..b8fc54fab6b --- /dev/null +++ b/app/components/Views/confirmations/components/send/send.view.test.tsx @@ -0,0 +1,338 @@ +import '../../../../../../tests/component-view/mocks'; +import React from 'react'; +import { fireEvent, screen, waitFor } from '@testing-library/react-native'; +import { renderScreenWithRoutes } from '../../../../../../tests/component-view/render'; +import { + buildAddressBookOverridesWithEvmContact, + buildTronSendFixture, + sendViewOverrides, +} from '../../../../../../tests/component-view/presets/send'; +import { initialStateWallet } from '../../../../../../tests/component-view/presets/wallet'; +import { describeForPlatforms } from '../../../../../../app/util/test/platform'; +import Routes from '../../../../../constants/navigation/Routes'; +import { TokenStandard } from '../../types/token'; +import { + getNftRowTestId, + getRecipientAvatarTestId, + getRecipientRowTestId, + getSelectedRecipientTestId, + RedesignedSendViewSelectorsIDs, +} from './RedesignedSendView.testIds'; +import { Send } from './send'; + +describeForPlatforms('Send', () => { + describe('Non-EVM', () => { + /** + * Regression test for Issue #22789 and related to #23251 + * TRON send flow: selecting a destination account must move the flow forward + * (previously it stayed on the recipient list and did not navigate). + */ + it('TRON send: selecting destination account updates selection', async () => { + const { tronOverrides, recipientAddresses } = buildTronSendFixture(); + + const state = initialStateWallet().withOverrides(tronOverrides).build(); + + const TRON_MAINNET_CHAIN_ID = 'tron:728126428'; + + const tronAsset = { + address: `${TRON_MAINNET_CHAIN_ID}/native`, + chainId: TRON_MAINNET_CHAIN_ID, + symbol: 'TRX', + decimals: 6, + balance: '100', + rawBalance: '0x64', + accountId: 'tron-acc-1', + }; + + const { getByTestId, getByRole, findByTestId } = renderScreenWithRoutes( + Send as unknown as React.ComponentType, + { name: Routes.SEND.DEFAULT }, + [], + { state }, + { screen: Routes.SEND.AMOUNT, params: { asset: tronAsset } }, + ); + + expect( + getByTestId(RedesignedSendViewSelectorsIDs.SEND_AMOUNT), + ).toBeOnTheScreen(); + + fireEvent.press( + getByTestId(RedesignedSendViewSelectorsIDs.PERCENTAGE_BUTTON_100), + ); + fireEvent.press(getByRole('button', { name: 'Continue' })); + + expect( + await findByTestId( + RedesignedSendViewSelectorsIDs.RECIPIENT_ADDRESS_INPUT, + ), + ).toBeOnTheScreen(); + + const recipientItem = await findByTestId( + getRecipientRowTestId(recipientAddresses[0]), + {}, + { timeout: 10000 }, + ); + fireEvent.press(recipientItem); + + expect( + await findByTestId( + getSelectedRecipientTestId(recipientAddresses[0]), + {}, + { timeout: 10000 }, + ), + ).toBeOnTheScreen(); + }); + + /** + * Regression test for issue #22205 + * EVM contacts must not appear in non-EVM (e.g. Solana, BTC) send flow Recipient screen. + * Only contacts for the current chain/protocol should be shown. + */ + it('Solana send Recipient screen does not show EVM contacts', async () => { + const SOLANA_CHAIN_ID = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; + const EVM_CONTACT_ADDRESS = '0x1234567890123456789012345678901234567890'; + + const addressBookOverrides = + buildAddressBookOverridesWithEvmContact(EVM_CONTACT_ADDRESS); + + const solanaAsset = { + address: `${SOLANA_CHAIN_ID}/native`, + chainId: SOLANA_CHAIN_ID, + symbol: 'SOL', + decimals: 9, + balance: '100', + rawBalance: '100', + }; + + const state = initialStateWallet() + .withOverrides(sendViewOverrides) + .withOverrides(addressBookOverrides) + .build(); + + const { findByTestId, queryByTestId } = renderScreenWithRoutes( + Send as unknown as React.ComponentType, + { name: Routes.SEND.DEFAULT }, + [], + { state }, + { screen: Routes.SEND.RECIPIENT, params: { asset: solanaAsset } }, + ); + + expect( + await findByTestId( + RedesignedSendViewSelectorsIDs.RECIPIENT_ADDRESS_INPUT, + ), + ).toBeOnTheScreen(); + + const evmContactRow = queryByTestId( + getRecipientRowTestId(EVM_CONTACT_ADDRESS), + ); + expect(evmContactRow).not.toBeOnTheScreen(); + }); + }); + + describe('ERC-721', () => { + /** + * Regression test for issue #12317 + * When sending an ERC-721 token, the Next/Continue button must be enabled so the user + * can proceed from Amount to Recipient (and not get stuck with "Fiat conversions not available"). + */ + it('Amount screen shows enabled Continue button and user can proceed to Recipient', async () => { + const erc721Asset = { + address: '0x4B3E2eD66631FE2dE488CB0c23eF3A91A41601f7', + chainId: '0x1', + symbol: 'NFT', + name: 'Test NFT', + standard: TokenStandard.ERC721, + tokenId: '42', + balance: '1', + }; + + const state = initialStateWallet() + .withOverrides(sendViewOverrides) + .build(); + + const { getByTestId, getByRole, getByText, findByTestId } = + renderScreenWithRoutes( + Send as unknown as React.ComponentType, + { name: Routes.SEND.DEFAULT }, + [], + { state }, + { screen: Routes.SEND.AMOUNT, params: { asset: erc721Asset } }, + ); + + expect( + getByTestId(RedesignedSendViewSelectorsIDs.SEND_AMOUNT), + ).toBeOnTheScreen(); + + fireEvent.press(getByText('1')); + + const continueButton = getByRole('button', { name: 'Continue' }); + expect(continueButton).toBeOnTheScreen(); + expect(continueButton).toBeEnabled(); + + fireEvent.press(continueButton); + + expect( + await findByTestId( + RedesignedSendViewSelectorsIDs.RECIPIENT_ADDRESS_INPUT, + ), + ).toBeOnTheScreen(); + }); + + /** + * Regression test for issue #19002 + * When starting Send from home and selecting an ERC-721 NFT in the asset picker, + * the flow must go to Recipient (not Amount). ERC721 must not be treated as ERC1155. + */ + it('Asset screen navigates to Recipient not Amount', async () => { + const accountAddress = '0x0000000000000000000000000000000000000001'; + const erc721InState = { + address: '0x4B3E2eD66631FE2dE488CB0c23eF3A91A41601f7', + tokenId: '42', + standard: 'ERC721', + name: 'Test ERC721 NFT', + favorite: false, + isCurrentlyOwned: true, + }; + + const nftOverrides = { + engine: { + backgroundState: { + NftController: { + allNfts: { + [accountAddress]: { + '0x1': [erc721InState], + }, + }, + allNftContracts: {}, + }, + }, + }, + } as unknown as Record; + + const state = initialStateWallet() + .withOverrides(sendViewOverrides) + .withOverrides(nftOverrides) + .build(); + + const { findByTestId } = renderScreenWithRoutes( + Send as unknown as React.ComponentType, + { name: Routes.SEND.DEFAULT }, + [], + { state }, + { screen: Routes.SEND.ASSET }, + ); + + const nftRow = await findByTestId( + getNftRowTestId('Test ERC721 NFT', '42'), + {}, + { timeout: 5000 }, + ); + expect(nftRow).toBeOnTheScreen(); + fireEvent.press(nftRow); + + expect( + await findByTestId( + RedesignedSendViewSelectorsIDs.RECIPIENT_ADDRESS_INPUT, + ), + ).toBeOnTheScreen(); + }); + }); + + describe('Recipient list', () => { + /** + * Regression test for issue #22806 + * Recipient list (accounts or contacts) must render each entry with the expected avatar. + * Uses address-book contacts to avoid dependency on multichain account tree/feature flags. + */ + it('renders each contact with avatar', async () => { + const contactAddresses = [ + '0x0000000000000000000000000000000000000002', + '0x0000000000000000000000000000000000000003', + ]; + + const contactOverrides = { + engine: { + backgroundState: { + AddressBookController: { + addressBook: { + '0x1': { + [contactAddresses[0].toLowerCase()]: { + name: 'Contact One', + address: contactAddresses[0], + }, + [contactAddresses[1].toLowerCase()]: { + name: 'Contact Two', + address: contactAddresses[1], + }, + }, + }, + }, + }, + }, + } as unknown as Record; + + const state = initialStateWallet() + .withOverrides(sendViewOverrides) + .withOverrides(contactOverrides) + .build(); + + const { getByTestId, getByRole, findByTestId } = renderScreenWithRoutes( + Send as unknown as React.ComponentType, + { name: Routes.SEND.DEFAULT }, + [], + { state }, + { + screen: Routes.SEND.AMOUNT, + params: { + asset: { + chainId: '0x1', + symbol: 'ETH', + decimals: 18, + balance: '1', + }, + }, + }, + ); + + expect( + getByTestId(RedesignedSendViewSelectorsIDs.SEND_AMOUNT), + ).toBeOnTheScreen(); + + fireEvent.press( + getByTestId(RedesignedSendViewSelectorsIDs.PERCENTAGE_BUTTON_100), + ); + fireEvent.press(getByRole('button', { name: 'Continue' })); + + expect( + await findByTestId( + RedesignedSendViewSelectorsIDs.RECIPIENT_ADDRESS_INPUT, + ), + ).toBeOnTheScreen(); + + const avatarElements: ReturnType[] = []; + for (const address of contactAddresses) { + const recipientRow = await waitFor( + () => screen.getByTestId(getRecipientRowTestId(address)), + { timeout: 5000 }, + ); + expect(recipientRow).toBeOnTheScreen(); + const avatar = getByTestId(getRecipientAvatarTestId(address)); + expect(avatar).toBeOnTheScreen(); + avatarElements.push(avatar); + } + + // Regression guard for #22806: all contacts rendered the same avatar. + // Extract the accountAddress fed to each Avatar and verify all are unique. + const avatarAddresses = avatarElements.map((el) => { + const nodes = el.findAll((node) => 'accountAddress' in node.props); + return nodes[0]?.props.accountAddress; + }); + for (const addr of avatarAddresses) { + expect(addr).toBeDefined(); + } + const uniqueAddresses = new Set(avatarAddresses); + expect(uniqueAddresses.size).toBe(avatarAddresses.length); + }); + }); +}); diff --git a/tests/component-view/mocks.ts b/tests/component-view/mocks.ts index d75b43cce65..f5b7b6cc6ff 100644 --- a/tests/component-view/mocks.ts +++ b/tests/component-view/mocks.ts @@ -12,6 +12,9 @@ jest.mock('../../app/core/Engine', () => { keyrings: [], }, }, + AccountsController: { + listAccounts: jest.fn().mockReturnValue([]), + }, AccountTrackerController: { refresh() { return undefined; @@ -26,6 +29,9 @@ jest.mock('../../app/core/Engine', () => { }, }, PreferencesController: { + state: { + securityAlertsEnabled: true, + }, setTokenNetworkFilter() { return undefined; }, @@ -119,6 +125,15 @@ jest.mock('../../app/core/Engine', () => { AuthenticationController: { getBearerToken: jest.fn().mockResolvedValue('mock-bearer-token'), }, + TransactionController: { + state: { + transactions: [], + }, + addTransaction: jest.fn().mockResolvedValue({}), + getNonceLock: jest + .fn() + .mockResolvedValue({ nextNonce: 0, releaseLock: jest.fn() }), + }, NetworkController: { state: { networksMetadata: {} }, findNetworkClientIdByChainId() { @@ -233,9 +248,15 @@ jest.mock('../../app/core/Engine', () => { unsubscribe() { return undefined; }, - call(_action: string, ..._args: unknown[]) { - // Analytics calls are side effects - return resolved promise to prevent errors - // but don't execute actual analytics tracking in tests + call(action: string, ...args: unknown[]) { + // Non-EVM (e.g. TRON) amount validation calls SnapController:handleRequest with onAmountInput + const params = args[0] as { request?: { method?: string } } | undefined; + if ( + action === 'SnapController:handleRequest' && + params?.request?.method === 'onAmountInput' + ) { + return Promise.resolve({ valid: true, errors: [] }); + } return Promise.resolve(undefined); }, }, diff --git a/tests/component-view/presets/send.ts b/tests/component-view/presets/send.ts new file mode 100644 index 00000000000..49148262f2f --- /dev/null +++ b/tests/component-view/presets/send.ts @@ -0,0 +1,196 @@ +import type { DeepPartial } from 'app/util/test/renderWithProvider'; +import type { RootState } from 'app/reducers'; + +/** + * Base state overrides for Send view component tests. + * Use with initialStateWallet(): initialStateWallet().withOverrides(sendViewOverrides).build() + * or spread and extend for scenario-specific state (e.g. TRON send). + */ +export const sendViewOverrides = { + settings: { + basicFunctionalityEnabled: true, + }, + engine: { + backgroundState: { + AddressBookController: { + addressBook: {}, + }, + MultichainNetworkController: { + isEvmSelected: true, + }, + SnapController: { + snaps: {}, + }, + PermissionController: { + subjects: {}, + }, + NftController: { + allNfts: {}, + allNftContracts: {}, + }, + SignatureController: { + signatureRequests: {}, + unapprovedPersonalMsgs: {}, + unapprovedTypedMessages: {}, + unapprovedPersonalMsgCount: 0, + unapprovedTypedMessagesCount: 0, + }, + }, + }, +} as unknown as DeepPartial; + +const TRON_SEND_GROUP_ID = 'entropy:wallet1/0'; +const TRON_SEND_WALLETS_KEY = 'entropy:wallet1'; +const TRON_SEND_ACCOUNT_IDS = ['tron-acc-1', 'tron-acc-2', 'tron-acc-3']; + +export interface TronSendFixtureOptions { + senderAddress?: string; + recipientAddresses?: [string, string]; +} + +export interface TronSendFixture { + tronOverrides: DeepPartial; + recipientAddresses: [string, string]; +} + +/** + * Builds state overrides and recipient addresses for TRON send view tests. + * Use with initialStateWallet().withOverrides(tronOverrides).build(). + */ +export function buildTronSendFixture( + options: TronSendFixtureOptions = {}, +): TronSendFixture { + const senderAddress = + options.senderAddress ?? 'TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7'; + const recipientAddresses = + options.recipientAddresses ?? + ([ + 'TA9vN2KmER9cuVBaHxQjzzRtXnBCdF7D4u', + 'TLv2f6VPqDgRE67v1736s7bJ8Ray5wYjU8', + ] as [string, string]); + + const TRON_MAINNET_SCOPE = 'tron:728126428'; + + const tronAccountEntries = Object.fromEntries([ + [ + TRON_SEND_ACCOUNT_IDS[0], + { + id: TRON_SEND_ACCOUNT_IDS[0], + address: senderAddress, + metadata: { name: 'Tron Account 1', importTime: Date.now() }, + options: {}, + methods: [], + type: 'tron:eoa', + scopes: [TRON_MAINNET_SCOPE], + }, + ], + [ + TRON_SEND_ACCOUNT_IDS[1], + { + id: TRON_SEND_ACCOUNT_IDS[1], + address: recipientAddresses[0], + metadata: { name: 'Tron Account 2', importTime: Date.now() }, + options: {}, + methods: [], + type: 'tron:eoa', + scopes: [TRON_MAINNET_SCOPE], + }, + ], + [ + TRON_SEND_ACCOUNT_IDS[2], + { + id: TRON_SEND_ACCOUNT_IDS[2], + address: recipientAddresses[1], + metadata: { name: 'Tron Account 3', importTime: Date.now() }, + options: {}, + methods: [], + type: 'tron:eoa', + scopes: [TRON_MAINNET_SCOPE], + }, + ], + ]); + + const baseEngine = (sendViewOverrides as Record).engine as + | { backgroundState?: Record } + | undefined; + const tronOverrides = { + ...sendViewOverrides, + engine: { + backgroundState: { + ...(baseEngine?.backgroundState ?? {}), + MultichainNetworkController: { + isEvmSelected: false, + }, + AccountsController: { + internalAccounts: { + accounts: tronAccountEntries, + selectedAccount: TRON_SEND_ACCOUNT_IDS[0], + }, + }, + AccountTreeController: { + accountTree: { + selectedAccountGroup: TRON_SEND_GROUP_ID, + wallets: { + [TRON_SEND_WALLETS_KEY]: { + id: TRON_SEND_WALLETS_KEY, + type: 'Entropy', + metadata: { name: 'Wallet 1', entropy: { id: 'wallet1' } }, + groups: { + [TRON_SEND_GROUP_ID]: { + id: TRON_SEND_GROUP_ID, + type: 'MultipleAccount', + metadata: { + name: 'Group 1', + pinned: false, + hidden: false, + }, + accounts: TRON_SEND_ACCOUNT_IDS, + }, + }, + }, + }, + }, + }, + RemoteFeatureFlagController: { + remoteFeatureFlags: { + enableMultichainAccounts: { + enabled: true, + featureVersion: '1', + minimumVersion: '0.0.0', + }, + }, + }, + }, + }, + } as unknown as DeepPartial; + + return { tronOverrides, recipientAddresses }; +} + +/** + * Builds state overrides for non-EVM send tests that need an EVM contact in the address book. + * Use to assert that EVM contacts do not appear on non-EVM (e.g. Solana) Recipient screen. + */ +export function buildAddressBookOverridesWithEvmContact( + evmContactAddress: string, +): DeepPartial { + return { + engine: { + backgroundState: { + AddressBookController: { + addressBook: { + '0x1': { + [evmContactAddress.toLowerCase()]: { + name: 'EVM Contact', + address: evmContactAddress, + }, + }, + }, + }, + MultichainNetworkController: { + isEvmSelected: false, + }, + }, + }, + } as unknown as DeepPartial; +} From ed0638c6f17f3600c945e54a826114c8acbbbf77 Mon Sep 17 00:00:00 2001 From: tommasini <46944231+tommasini@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:54:21 +0000 Subject: [PATCH 079/206] fix(ci): namespace node_modules artifact names by build_name to prevent conflicts (#27599) ## **Description** When multiple `build.yml` invocations run in parallel within the same workflow run (e.g. nightly builds triggering `main-exp` and `main-rc` simultaneously), the `setup-dependencies` job uploads node_modules tarballs as artifacts named `node-modules-{platform}`. Since GitHub Actions requires unique artifact names per workflow run, the second upload fails with a `409 Conflict`. This PR namespaces the artifact name by including `build_name`, changing from `node-modules-{platform}` to `node-modules-{build_name}-{platform}`. This produces unique names like `node-modules-main-exp-android` and `node-modules-main-rc-android`, allowing parallel invocations to coexist. The tarball filename inside the artifact remains unchanged (`node-modules-{platform}.tar.gz`), so the extract step requires no modification. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** N/A -- CI-only change. Verified by triggering a nightly build that invokes `build.yml` twice in parallel (main-exp + main-rc) and confirming both setup-dependencies jobs succeed without artifact name conflicts. ## **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. ## **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. Made with [Cursor](https://cursor.com) --- > [!NOTE] > **Low Risk** > Low risk CI-only change that just adjusts artifact naming to avoid collisions when multiple `build.yml` invocations run in the same workflow run. > > **Overview** > Prevents GitHub Actions artifact upload/download name collisions when `build.yml` runs concurrently for different `build_name` values. > > The `setup-dependencies` job now uploads `node_modules` tarballs with `build_name` included (e.g., `node-modules-${{ inputs.build_name }}-${{ matrix.platform }}`), and the `build` job downloads artifacts using the same names; the tarball filename inside the artifact remains unchanged. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 149822efa106b39b2661eb3a348e730c5d4e2d76. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2afc290ec0d..66495dbea6d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -115,7 +115,7 @@ jobs: build_name: ${{ inputs.build_name }} use-tarball: true upload-artifact: true - artifact-name: node-modules-${{ matrix.platform }} + artifact-name: node-modules-${{ inputs.build_name }}-${{ matrix.platform }} artifact-retention-days: 1 # Build @@ -165,7 +165,7 @@ jobs: - name: Download node_modules tarball uses: actions/download-artifact@v4 with: - name: node-modules-${{ matrix.platform }} + name: node-modules-${{ inputs.build_name }}-${{ matrix.platform }} - name: Extract tarball (preserves symlinks) run: | From 1470836e69b646edab246ad1a0f7110db9e70b8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20=C5=81ucka?= <5708018+PatrykLucka@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:37:21 +0100 Subject: [PATCH 080/206] fix: correct Spanish translation for obtaining mUSD in es.json cp-7.70.0 (#27606) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Fixes Spanish translation. ## **Changelog** CHANGELOG entry: Fixes "get mUSD" transaction in Spanish ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TMCU-581 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Low risk: changes a single Spanish localization string and does not affect application logic or data handling. > > **Overview** > Corrects the Spanish (`es.json`) translation for the `get_musd` label, changing it from “Obtener USDC” to “Obtener mUSD” to match the intended mUSD flow and align with other locales. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 29f1bcdc6f6592c4295a9e529b39bc4d5910da20. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- locales/languages/es.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/locales/languages/es.json b/locales/languages/es.json index 9af31300080..0aed1f163aa 100644 --- a/locales/languages/es.json +++ b/locales/languages/es.json @@ -6049,7 +6049,7 @@ "secondary_button": "Ahora no" }, "buy_musd": "Comprar mUSD", - "get_musd": "Obtener USDC", + "get_musd": "Obtener mUSD", "bonus_title": "Obtén un {{percentage}} % en tus monedas estables", "bonus_description": "Convierte tus stablecoins a mUSD y obtén un {{percentage}} % de bono anualizado.", "powered_by_relay": "Desarrollado por Relay", From 0dde4d00e74fe00376da2d9355e3f4e3e312639d Mon Sep 17 00:00:00 2001 From: Juanmi <95381763+juanmigdr@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:42:45 +0100 Subject: [PATCH 081/206] chore: remove deprecated payment request (#27519) ## **Description** Remove deprecated payment request code ## **Changelog** CHANGELOG entry: remove deprecated payment request ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-2943 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Moderate risk because it removes an entire user flow and navigation routes; any remaining deep links or external callers to `PaymentRequestView`/`ReceiveRequest` would now break at runtime. > > **Overview** > Removes the deprecated **payment request / request token** feature end-to-end. > > This deletes the `PaymentRequest`, `PaymentRequestSuccess`, and `ReceiveRequest` UI components (plus their unit snapshots/tests and Detox page objects/spec), and strips the `PaymentRequestView` stack from `MainNavigator` and related navbar option helpers/test IDs. It also simplifies token list selectors by removing the unused `selectTokenListArray` export. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 3f886c3096b5c5865251c6c35116d9e9bfe7cc8c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/components/Nav/Main/MainNavigator.js | 18 - .../__snapshots__/MainNavigator.test.tsx.snap | 12 - app/components/UI/Navbar/index.js | 116 -- .../__snapshots__/index.test.tsx.snap | 36 - .../PaymentRequest/AssetList/index.test.tsx | 29 - .../UI/PaymentRequest/AssetList/index.tsx | 157 -- .../__snapshots__/index.test.tsx.snap | 945 ---------- app/components/UI/PaymentRequest/index.js | 968 ---------- .../UI/PaymentRequest/index.test.tsx | 814 --------- .../UI/PaymentRequestSuccess/index.js | 421 ----- .../RequestPaymentModal.testIds.ts | 3 - .../RequestPaymentView.testIds.ts | 7 - .../UI/ReceiveRequest/SendLinkView.testIds.ts | 7 - .../__snapshots__/index.test.tsx.snap | 1561 ----------------- app/components/UI/ReceiveRequest/index.js | 231 --- .../UI/ReceiveRequest/index.test.tsx | 255 --- app/selectors/tokenListController.ts | 10 - .../Receive/PaymentRequestQrBottomSheet.ts | 23 - .../Receive/RequestPaymentModal.ts | 21 - .../Receive/RequestPaymentView.ts | 60 - tests/page-objects/Receive/SendLinkView.ts | 39 - .../wallet/request-token-flow.spec.ts | 69 - 22 files changed, 5802 deletions(-) delete mode 100644 app/components/UI/PaymentRequest/AssetList/__snapshots__/index.test.tsx.snap delete mode 100644 app/components/UI/PaymentRequest/AssetList/index.test.tsx delete mode 100644 app/components/UI/PaymentRequest/AssetList/index.tsx delete mode 100644 app/components/UI/PaymentRequest/__snapshots__/index.test.tsx.snap delete mode 100644 app/components/UI/PaymentRequest/index.js delete mode 100644 app/components/UI/PaymentRequest/index.test.tsx delete mode 100644 app/components/UI/PaymentRequestSuccess/index.js delete mode 100644 app/components/UI/ReceiveRequest/RequestPaymentModal.testIds.ts delete mode 100644 app/components/UI/ReceiveRequest/RequestPaymentView.testIds.ts delete mode 100644 app/components/UI/ReceiveRequest/SendLinkView.testIds.ts delete mode 100644 app/components/UI/ReceiveRequest/__snapshots__/index.test.tsx.snap delete mode 100644 app/components/UI/ReceiveRequest/index.js delete mode 100644 app/components/UI/ReceiveRequest/index.test.tsx delete mode 100644 tests/page-objects/Receive/PaymentRequestQrBottomSheet.ts delete mode 100644 tests/page-objects/Receive/RequestPaymentModal.ts delete mode 100644 tests/page-objects/Receive/RequestPaymentView.ts delete mode 100644 tests/page-objects/Receive/SendLinkView.ts delete mode 100644 tests/regression/wallet/request-token-flow.spec.ts diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index 7d698a596c6..9a66f54afd2 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -47,8 +47,6 @@ import AccountBackupStep1B from '../../Views/AccountBackupStep1B'; import ManualBackupStep1 from '../../Views/ManualBackupStep1'; import ManualBackupStep2 from '../../Views/ManualBackupStep2'; import ManualBackupStep3 from '../../Views/ManualBackupStep3'; -import PaymentRequest from '../../UI/PaymentRequest'; -import PaymentRequestSuccess from '../../UI/PaymentRequestSuccess'; import ContactForm from '../../Views/Settings/Contacts/ContactForm'; import ActivityView from '../../Views/ActivityView'; import RewardsNavigator from '../../UI/Rewards/RewardsNavigator'; @@ -849,21 +847,6 @@ const OfflineModeView = () => ( ); -const PaymentRequestView = () => ( - - - - -); - /* eslint-disable react/prop-types */ const NotificationsModeView = (props) => ( @@ -1067,7 +1050,6 @@ const MainNavigator = () => { component={NftFullView} options={{ headerShown: false, ...slideFromRightAnimation }} /> - - - - ( - - {title} - - ), - headerLeft: () => - goBack ? ( - // eslint-disable-next-line react/jsx-no-bind - - - - ) : ( - - ), - headerRight: () => ( - navigation.pop()} - style={innerStyles.headerCloseButton} - testID={RequestPaymentViewSelectors.BACK_BUTTON_ID} - /> - ), - headerStyle: innerStyles.headerStyle, - headerTintColor: themeColors.primary.default, - }; -} - -/** - * Function that returns the navigation options - * This is used by payment request view showing close button - * - * @returns {Object} - Corresponding navbar options containing title, and headerRight - */ -export function getPaymentRequestSuccessOptionsTitle(navigation, themeColors) { - const innerStyles = StyleSheet.create({ - headerStyle: { - backgroundColor: themeColors.background.default, - shadowColor: importedColors.transparent, - elevation: 0, - }, - headerIcon: { - color: themeColors.primary.default, - }, - }); - - return { - headerStyle: innerStyles.headerStyle, - title: null, - headerLeft: () => , - headerRight: () => ( - navigation.pop()} - style={styles.closeButton} - {...generateTestId( - Platform, - SendLinkViewSelectorsIDs.CLOSE_SEND_LINK_VIEW_BUTTON, - )} - > - - - ), - headerTintColor: themeColors.primary.default, - }; -} - /** * Function that returns the navigation options * This is used by views that confirms transactions, showing current network diff --git a/app/components/UI/PaymentRequest/AssetList/__snapshots__/index.test.tsx.snap b/app/components/UI/PaymentRequest/AssetList/__snapshots__/index.test.tsx.snap deleted file mode 100644 index d267a0cf6cf..00000000000 --- a/app/components/UI/PaymentRequest/AssetList/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,36 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`AssetList should render correctly 1`] = ` - - - -`; diff --git a/app/components/UI/PaymentRequest/AssetList/index.test.tsx b/app/components/UI/PaymentRequest/AssetList/index.test.tsx deleted file mode 100644 index 02a3a1c73ea..00000000000 --- a/app/components/UI/PaymentRequest/AssetList/index.test.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react'; -import { shallow } from 'enzyme'; -import AssetList from './'; -import configureMockStore from 'redux-mock-store'; -import { Provider } from 'react-redux'; -import { backgroundState } from '../../../../util/test/initial-root-state'; - -const mockStore = configureMockStore(); -const initialState = { - engine: { - backgroundState, - }, -}; -const store = mockStore(initialState); - -describe('AssetList', () => { - it('should render correctly', () => { - const wrapper = shallow( - - null} - emptyMessage={'Enpty Message'} - searchResults={[]} - /> - , - ); - expect(wrapper).toMatchSnapshot(); - }); -}); diff --git a/app/components/UI/PaymentRequest/AssetList/index.tsx b/app/components/UI/PaymentRequest/AssetList/index.tsx deleted file mode 100644 index 8c2ecefaf32..00000000000 --- a/app/components/UI/PaymentRequest/AssetList/index.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import React, { useCallback } from 'react'; -import { Text, View, StyleSheet } from 'react-native'; -import StyledButton from '../../StyledButton'; -import AssetIcon from '../../AssetIcon'; -import { fontStyles } from '../../../../styles/common'; -import Identicon from '../../Identicon'; -import NetworkMainAssetLogo from '../../NetworkMainAssetLogo'; -import { useSelector } from 'react-redux'; -import { useTheme } from '../../../../util/theme'; -import { selectTokenList } from '../../../../selectors/tokenListController'; -import { ImportTokenViewSelectorsIDs } from '../../../Views/AddAsset/ImportAssetView.testIds'; -import { toChecksumAddress } from '../../../../util/address'; - -// TODO: Replace "any" with type -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const createStyles = (colors: any) => - StyleSheet.create({ - item: { - borderWidth: 1, - borderColor: colors.border.default, - padding: 8, - marginBottom: 8, - borderRadius: 8, - }, - assetListElement: { - flex: 1, - flexDirection: 'row', - alignItems: 'flex-start', - }, - text: { - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ...(fontStyles.normal as any), - color: colors.text.default, - }, - textSymbol: { - ...fontStyles.normal, - paddingBottom: 4, - fontSize: 16, - color: colors.text.default, - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any, - assetInfo: { - flex: 1, - flexDirection: 'column', - alignSelf: 'center', - padding: 4, - }, - assetIcon: { - flexDirection: 'column', - alignSelf: 'center', - marginRight: 12, - }, - ethLogo: { - width: 50, - height: 50, - }, - listContainer: { - flex: 1, - }, - }); - -interface Props { - /** - * Array of assets objects returned from the search - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - searchResults: any; - /** - * Callback triggered when a token is selected - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - handleSelectAsset: any; - /** - * Message string to display when searchResults is empty - */ - emptyMessage: string; -} - -const AssetList = ({ - searchResults, - handleSelectAsset, - emptyMessage, -}: Props) => { - const tokenList = useSelector(selectTokenList); - const { colors } = useTheme(); - const styles = createStyles(colors); - - /** - * Render logo according to asset. Could be ETH, Identicon or contractMap logo - * - * @param {object} asset - Asset to generate the logo to render - */ - const renderLogo = useCallback( - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (asset: any) => { - const { address, isETH } = asset; - if (isETH) { - return ; - } - const token = - tokenList?.[toChecksumAddress(address)] || - tokenList?.[address.toLowerCase()]; - const iconUrl = token?.iconUrl; - if (!iconUrl) { - return ; - } - return ; - }, - [tokenList, styles], - ); - - if (searchResults.length === 0) { - return {emptyMessage}; - } - - return ( - - {/* Use simple rendering like token import for better performance */} - {searchResults - .slice(0, 6) - .map( - ( - item: { symbol?: string; name?: string; address?: string }, - index: number, - ) => { - const { symbol, name } = item || {}; - return ( - handleSelectAsset(item)} - > - - {renderLogo(item)} - - {symbol} - {!!name && {name}} - - - - ); - }, - )} - - ); -}; - -export default AssetList; diff --git a/app/components/UI/PaymentRequest/__snapshots__/index.test.tsx.snap b/app/components/UI/PaymentRequest/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 86cc6ba76a9..00000000000 --- a/app/components/UI/PaymentRequest/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,945 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`PaymentRequest renders correctly 1`] = ` - - - - - - - - - - - - - - - - - - Choose an asset to request - - - - -  - - - - - - Top picks - - - - - - - - ETH - - - Ether - - - - - - - - - - - - - - - - - - - SAI - - - Sai Stablecoin v1.0 - - - - - - - - - - -`; - -exports[`PaymentRequest renders correctly with network picker when feature flag is enabled 1`] = ` - - - - - - - - - - - - - - - - - - Choose an asset to request - - - - -  - - - - - - Top picks - - - - - - - - ETH - - - Ether - - - - - - - - - - - - - - - - - - - SAI - - - Sai Stablecoin v1.0 - - - - - - - - - - -`; diff --git a/app/components/UI/PaymentRequest/index.js b/app/components/UI/PaymentRequest/index.js deleted file mode 100644 index fb79e5614d8..00000000000 --- a/app/components/UI/PaymentRequest/index.js +++ /dev/null @@ -1,968 +0,0 @@ -import React, { PureComponent } from 'react'; -import { - SafeAreaView, - TextInput, - Text, - StyleSheet, - View, - TouchableOpacity, - KeyboardAvoidingView, - InteractionManager, -} from 'react-native'; -import { connect } from 'react-redux'; -import { fontStyles, baseStyles } from '../../../styles/common'; -import { getPaymentRequestOptionsTitle } from '../../UI/Navbar'; -import FeatherIcon from 'react-native-vector-icons/Feather'; -import Fuse from 'fuse.js'; -import AssetList from './AssetList'; -import PropTypes from 'prop-types'; -import { debounce } from 'lodash'; -import { - weiToFiat, - toWei, - balanceToFiat, - renderFromWei, - fiatNumberToWei, - fromWei, - isDecimal, - fiatNumberToTokenMinimalUnit, - renderFromTokenMinimalUnit, - fromTokenMinimalUnit, - toTokenMinimalUnit, -} from '../../../util/number'; -import { strings } from '../../../../locales/i18n'; -import FontAwesome from 'react-native-vector-icons/FontAwesome'; -import StyledButton from '../StyledButton'; -import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'; -import { - generateETHLink, - generateERC20Link, - generateUniversalLinkRequest, -} from '../../../util/payment-link-generator'; -import Device from '../../../util/device'; -import currencySymbols from '../../../util/currency-symbols.json'; -import { ChainId } from '@metamask/controller-utils'; -import { getTicker } from '../../../util/transactions'; -import { toLowerCaseEquals } from '../../../util/general'; -import { utils as ethersUtils } from 'ethers'; -import { ThemeContext, mockTheme } from '../../../util/theme'; -import { isTestNet, getDecimalChainId } from '../../../util/networks'; -import { isTokenDetectionSupportedForNetwork } from '@metamask/assets-controllers'; -import { - selectChainId, - selectEvmTicker, - selectNetworkConfigurations, -} from '../../../selectors/networkController'; -import { selectNetworkImageSource } from '../../../selectors/networkInfos'; -import { - selectConversionRate, - selectCurrentCurrency, -} from '../../../selectors/currencyRateController'; -import { selectTokenListArray } from '../../../selectors/tokenListController'; -import { selectTokens } from '../../../selectors/tokensController'; -import { selectContractExchangeRates } from '../../../selectors/tokenRatesController'; -import { selectSelectedInternalAccountFormattedAddress } from '../../../selectors/accountsController'; -import PickerNetwork from '../../../component-library/components/Pickers/PickerNetwork/PickerNetwork'; -import Routes from '../../../constants/navigation/Routes'; -import { RequestPaymentViewSelectors } from '../ReceiveRequest/RequestPaymentView.testIds'; -import { MetaMetricsEvents } from '../../../core/Analytics'; -import { analytics } from '../../../util/analytics/analytics'; -import { AnalyticsEventBuilder } from '../../../util/analytics/AnalyticsEventBuilder'; - -const KEYBOARD_OFFSET = 120; -const createStyles = (colors) => - StyleSheet.create({ - wrapper: { - backgroundColor: colors.background.default, - flex: 1, - }, - title: { - ...fontStyles.normal, - fontSize: 16, - color: colors.text.default, - }, - amountWrapper: { - marginVertical: 8, - }, - searchWrapper: { - marginVertical: 8, - borderColor: colors.border.default, - borderWidth: 1, - borderRadius: 8, - flexDirection: 'row', - backgroundColor: colors.background.default, - }, - searchInput: { - paddingTop: Device.isAndroid() ? 12 : 0, - paddingLeft: 8, - fontSize: 16, - height: 40, - flex: 1, - color: colors.text.default, - ...fontStyles.normal, - }, - searchIcon: { - textAlignVertical: 'center', - marginLeft: 12, - alignSelf: 'center', - }, - clearButton: { paddingHorizontal: 12, justifyContent: 'center' }, - input: { - ...fontStyles.normal, - backgroundColor: colors.background.default, - borderWidth: 0, - fontSize: 24, - paddingBottom: 0, - paddingRight: 0, - paddingLeft: 0, - paddingTop: 0, - color: colors.text.default, - }, - eth: { - ...fontStyles.normal, - fontSize: 24, - paddingTop: Device.isAndroid() ? 3 : 0, - paddingLeft: 10, - textTransform: 'uppercase', - color: colors.text.default, - }, - testNetEth: { - ...fontStyles.normal, - fontSize: 24, - paddingTop: Device.isAndroid() ? 3 : 0, - paddingLeft: 10, - color: colors.text.default, - }, - fiatValue: { - ...fontStyles.normal, - fontSize: 18, - color: colors.text.default, - }, - split: { - flex: 1, - flexDirection: 'row', - }, - ethContainer: { - flex: 1, - flexDirection: 'row', - paddingLeft: 6, - paddingRight: 10, - }, - container: { - flex: 1, - flexDirection: 'row', - paddingRight: 10, - paddingVertical: 10, - paddingLeft: 14, - position: 'relative', - backgroundColor: colors.background.default, - borderColor: colors.border.default, - borderRadius: 4, - borderWidth: 1, - }, - amounts: { - maxWidth: '70%', - }, - switchContainer: { - flex: 1, - flexDirection: 'column', - alignSelf: 'center', - right: 0, - }, - switchTouchable: { - flexDirection: 'row', - alignSelf: 'flex-end', - right: 0, - }, - enterAmountWrapper: { - flex: 1, - flexDirection: 'column', - }, - button: { - marginBottom: 16, - }, - buttonsWrapper: { - flex: 1, - flexDirection: 'row', - alignSelf: 'center', - }, - buttonsContainer: { - flex: 1, - flexDirection: 'column', - alignSelf: 'flex-end', - }, - scrollViewContainer: { - padding: 24, - }, - errorWrapper: { - backgroundColor: colors.error.muted, - borderRadius: 4, - marginTop: 8, - }, - errorText: { - color: colors.text.default, - alignSelf: 'center', - }, - assetsWrapper: { - marginTop: 16, - }, - assetsTitle: { - ...fontStyles.normal, - fontSize: 16, - marginBottom: 8, - color: colors.text.default, - }, - secondaryAmount: { - flexDirection: 'row', - }, - currencySymbol: { - ...fontStyles.normal, - fontSize: 24, - color: colors.text.default, - }, - currencySymbolSmall: { - ...fontStyles.normal, - fontSize: 18, - color: colors.text.default, - }, - }); - -const fuse = new Fuse([], { - shouldSort: true, - threshold: 0.45, - location: 0, - distance: 100, - maxPatternLength: 32, - minMatchCharLength: 1, - keys: [ - { name: 'name', weight: 0.5 }, - { name: 'symbol', weight: 0.5 }, - ], -}); - -const defaultEth = { - symbol: 'ETH', - name: 'Ether', - isETH: true, -}; -const defaultAssets = [ - defaultEth, - { - address: '0x89d24A6b4CcB1B6fAA2625fE562bDD9a23260359', - decimals: 18, - erc20: true, - logo: 'sai.svg', - name: 'Sai Stablecoin v1.0', - symbol: 'SAI', - }, -]; - -const MODE_SELECT = 'select'; -const MODE_AMOUNT = 'amount'; - -/** - * View to generate a payment request link - */ -class PaymentRequest extends PureComponent { - static propTypes = { - /** - * Object that represents the navigator - */ - navigation: PropTypes.object, - /** - * ETH-to-current currency conversion rate from CurrencyRateController - */ - conversionRate: PropTypes.number, - /** - * Currency code for currently-selected currency from CurrencyRateController - */ - currentCurrency: PropTypes.string, - /** - * Object containing token exchange rates in the format address => exchangeRate - */ - contractExchangeRates: PropTypes.object, - /** - * Primary currency, either ETH or Fiat - */ - primaryCurrency: PropTypes.string, - /** - * A string that represents the selected address - */ - selectedAddress: PropTypes.string, - /** - * Array of ERC20 assets - */ - tokens: PropTypes.array, - /** - * A string representing the chainId - */ - chainId: PropTypes.string, - /** - * Current provider ticker - */ - ticker: PropTypes.string, - /** - * List of tokens from TokenListController (Formatted into array) - */ - tokenList: PropTypes.array, - /** - * Object that represents the current route info like params passed to it - */ - route: PropTypes.object, - /** - * Network configurations - */ - networkConfigurations: PropTypes.object, - /** - * Network image source - */ - networkImageSource: PropTypes.string, - }; - - amountInput = React.createRef(); - searchInput = React.createRef(); - - state = { - searchInputValue: '', - results: [], - selectedAsset: undefined, - mode: MODE_SELECT, - internalPrimaryCurrency: '', - cryptoAmount: undefined, - amount: undefined, - secondaryAmount: undefined, - symbol: undefined, - showError: false, - inputWidth: { width: '99%' }, - }; - - /** - * Handle token search based on user input - * debounced by 300ms to prevent calls on every keystroke - * - * @param {string} searchInputValue - String containing assets query - */ - debouncedTokenSearch = debounce((searchInputValue) => { - const { tokenList } = this.props; - if (typeof searchInputValue !== 'string') { - searchInputValue = this.state.searchInputValue; - } - - const fuseSearchResult = fuse.search(searchInputValue); - const addressSearchResult = tokenList.filter((token) => - toLowerCaseEquals(token.address, searchInputValue), - ); - const results = [...addressSearchResult, ...fuseSearchResult]; - this.setState({ results }); - }, 300); - - updateNavBar = () => { - const { navigation, route } = this.props; - const colors = this.context.colors || mockTheme.colors; - navigation.setOptions( - getPaymentRequestOptionsTitle( - strings('payment_request.title'), - navigation, - route, - colors, - ), - ); - }; - - /** - * Set chainId, internalPrimaryCurrency and receiveAssets, if there is an asset set to this payment request chose it automatically, to state - */ - componentDidMount = () => { - const { primaryCurrency, route, tokenList } = this.props; - this.updateNavBar(); - const receiveAsset = route?.params?.receiveAsset; - this.setState({ - internalPrimaryCurrency: primaryCurrency, - inputWidth: { width: '100%' }, - }); - if (receiveAsset) { - this.goToAmountInput(receiveAsset); - } - // TODO: Fuse will only be updated once on mount. When we convert this component to hooks, we can utilize useEffect to update fuse. - // Update fuse collection with token list - fuse.setCollection(tokenList); - }; - - componentDidUpdate = () => { - this.updateNavBar(); - InteractionManager.runAfterInteractions(() => { - this.amountInput.current && this.amountInput.current.focus(); - }); - }; - - componentWillUnmount = () => { - // Cancel any pending debounced search - this.debouncedTokenSearch.cancel(); - }; - - /** - * Go to asset selection view and modify navbar accordingly - */ - goToAssetSelection = () => { - const { navigation } = this.props; - navigation && - navigation.setParams({ mode: MODE_SELECT, dispatch: undefined }); - this.setState({ - mode: MODE_SELECT, - amount: undefined, - cryptoAmount: undefined, - secondaryAmount: undefined, - symbol: undefined, - }); - }; - - /** - * Go to enter amount view, with selectedAsset and modify navbar accordingly - * - * @param {object} selectedAsset - Asset selected to build the payment request - */ - goToAmountInput = async (selectedAsset) => { - const { navigation } = this.props; - navigation && - navigation.setParams({ - mode: MODE_AMOUNT, - dispatch: this.goToAssetSelection, - }); - await this.setState({ selectedAsset, mode: MODE_AMOUNT }); - this.updateAmount(); - }; - - handleSearchTokenList = (searchInputValue) => { - if (typeof searchInputValue !== 'string') { - searchInputValue = this.state.searchInputValue; - } - this.setState({ searchInputValue }); - - this.debouncedTokenSearch(searchInputValue); - }; - - /** Clear search input and focus */ - clearSearchInput = () => { - // Cancel any pending debounced search - this.debouncedTokenSearch.cancel(); - this.setState({ searchInputValue: '', results: [] }); - this.searchInput.current?.focus?.(); - }; - - /** - * Renders a view that allows user to select assets to build the payment request - * Either top picks and user's assets are available to select - */ - renderSelectAssets = () => { - const { tokens, chainId, ticker, tokenList } = this.props; - const { inputWidth } = this.state; - let results; - const colors = this.context.colors || mockTheme.colors; - const themeAppearance = this.context.themeAppearance || 'light'; - const styles = createStyles(colors); - const isTDSupportedForNetwork = - isTokenDetectionSupportedForNetwork(chainId); - - if (isTDSupportedForNetwork) { - const defaults = - chainId === ChainId.mainnet - ? defaultAssets - : [{ ...defaultEth, symbol: getTicker(ticker), name: '' }]; - results = this.state.searchInputValue ? this.state.results : defaults; - } else if ( - //Check to see if it is not a test net ticker symbol - Object.values(ChainId).find((value) => value === chainId) && - !(parseInt(chainId, 10) > 1 && parseInt(chainId, 10) < 6) - ) { - results = [defaultEth]; - } else { - results = [{ ...defaultEth, symbol: getTicker(ticker), name: '' }]; - } - - const userTokens = tokens.map((token) => { - const contract = tokenList.find( - (contractToken) => contractToken.address === token.address, - ); - if (contract) return contract; - return token; - }); - return ( - - - - {strings('payment_request.choose_asset')} - - - {isTDSupportedForNetwork && ( - - - - {this.state.searchInputValue ? ( - - - - ) : null} - - )} - - - {this.state.searchInputValue - ? strings('payment_request.search_results') - : strings('payment_request.search_top_picks')} - - - - {userTokens.length > 0 && ( - - - {strings('payment_request.your_tokens')} - - - - )} - - ); - }; - - /** - * Handles payment request parameters for ETH as primaryCurrency - * - * @param {string} amount - String containing amount number from input, as token value - * @returns {object} - Object containing respective symbol, secondaryAmount and cryptoAmount according to amount and selectedAsset - */ - handleETHPrimaryCurrency = (amount) => { - const { conversionRate, currentCurrency, contractExchangeRates } = - this.props; - const { selectedAsset } = this.state; - let secondaryAmount; - const symbol = selectedAsset.symbol; - const undefAmount = - isDecimal(amount) && !ethersUtils.isHexString(amount) ? amount : 0; - const cryptoAmount = amount; - const exchangeRate = - selectedAsset && - selectedAsset.address && - contractExchangeRates?.[selectedAsset.address]?.price; - if (selectedAsset.symbol !== 'ETH') { - secondaryAmount = exchangeRate - ? balanceToFiat( - undefAmount, - conversionRate, - exchangeRate, - currentCurrency, - ) - : undefined; - } else { - secondaryAmount = weiToFiat( - toWei(undefAmount), - conversionRate, - currentCurrency, - ); - } - return { symbol, secondaryAmount, cryptoAmount }; - }; - - /** - * Handles payment request parameters for Fiat as primaryCurrency - * - * @param {string} amount - String containing amount number from input, as fiat value - * @returns {object} - Object containing respective symbol, secondaryAmount and cryptoAmount according to amount and selectedAsset - */ - handleFiatPrimaryCurrency = (amount) => { - const { conversionRate, currentCurrency, contractExchangeRates } = - this.props; - const { selectedAsset } = this.state; - const symbol = currentCurrency; - const exchangeRate = - selectedAsset && - selectedAsset.address && - contractExchangeRates && - contractExchangeRates[selectedAsset.address]?.price; - const undefAmount = (isDecimal(amount) && amount) || 0; - let secondaryAmount, cryptoAmount; - if (selectedAsset.symbol !== 'ETH' && exchangeRate && exchangeRate !== 0) { - const secondaryMinimalUnit = fiatNumberToTokenMinimalUnit( - undefAmount, - conversionRate, - exchangeRate, - selectedAsset.decimals, - ); - secondaryAmount = - renderFromTokenMinimalUnit( - secondaryMinimalUnit, - selectedAsset.decimals, - ) + - ' ' + - selectedAsset.symbol; - cryptoAmount = fromTokenMinimalUnit( - secondaryMinimalUnit, - selectedAsset.decimals, - ); - } else { - secondaryAmount = - renderFromWei(fiatNumberToWei(undefAmount, conversionRate)) + - ' ' + - strings('unit.eth'); - cryptoAmount = fromWei(fiatNumberToWei(undefAmount, conversionRate)); - } - return { symbol, secondaryAmount, cryptoAmount }; - }; - - /** - * Handles amount update, setting amount related state parameters, it handles state according to internalPrimaryCurrency - * - * @param {string} amount - String containing amount number from input - */ - updateAmount = (amount) => { - const { internalPrimaryCurrency, selectedAsset } = this.state; - const { conversionRate, contractExchangeRates, currentCurrency } = - this.props; - const currencySymbol = currencySymbols[currentCurrency]; - // Normalize amount: trim whitespace and replace comma with period - amount = amount?.replace(',', '.')?.trim(); - const exchangeRate = - selectedAsset && - selectedAsset.address && - contractExchangeRates && - contractExchangeRates[selectedAsset.address]?.price; - let res; - // If primary currency is not crypo we need to know if there are conversion and exchange rates to handle0, - // fiat conversion for the payment request - if ( - internalPrimaryCurrency !== 'ETH' && - conversionRate && - (exchangeRate || selectedAsset.isETH) - ) { - res = this.handleFiatPrimaryCurrency(amount); - } else { - res = this.handleETHPrimaryCurrency(amount); - } - const { cryptoAmount, symbol } = res; - if (amount && amount[0] === currencySymbol) amount = amount.substr(1); - if (res.secondaryAmount && res.secondaryAmount[0] === currencySymbol) - res.secondaryAmount = res.secondaryAmount.substr(1); - if (amount && amount === '0') amount = undefined; - this.setState({ - amount, - cryptoAmount, - secondaryAmount: res.secondaryAmount, - symbol, - showError: false, - }); - }; - - /** - * Updates internalPrimaryCurrency - */ - switchPrimaryCurrency = async () => { - const { internalPrimaryCurrency, secondaryAmount } = this.state; - const primarycurrencies = { - ETH: 'Fiat', - Fiat: 'ETH', - }; - await this.setState({ - internalPrimaryCurrency: primarycurrencies[internalPrimaryCurrency], - }); - this.updateAmount(secondaryAmount.split(' ')[0]); - }; - - /** - * Resets amount on payment request - */ - onReset = () => { - this.updateAmount(); - }; - - /** - * Generates payment request link and redirects to PaymentRequestSuccess view with it - * If there is an error, an error message will be set to display on the view - */ - onNext = () => { - const { selectedAddress, navigation, chainId } = this.props; - const { cryptoAmount, selectedAsset } = this.state; - - try { - if (cryptoAmount && cryptoAmount > '0') { - let eth_link; - if (selectedAsset.isETH) { - const amount = toWei(cryptoAmount).toString(); - eth_link = generateETHLink(selectedAddress, amount, chainId); - } else { - const amount = toTokenMinimalUnit( - cryptoAmount, - selectedAsset.decimals, - ).toString(); - eth_link = generateERC20Link( - selectedAddress, - selectedAsset.address, - amount, - chainId, - ); - } - - // Convert to universal link / app link - const link = generateUniversalLinkRequest(eth_link); - - navigation && - navigation.replace('PaymentRequestSuccess', { - link, - qrLink: eth_link, - amount: cryptoAmount, - symbol: selectedAsset.symbol, - }); - } else { - this.setState({ showError: true }); - } - } catch (e) { - this.setState({ showError: true }); - } - }; - - /** - * Renders a view that allows user to set payment request amount - */ - renderEnterAmount = () => { - const { conversionRate, contractExchangeRates, currentCurrency } = - this.props; - const { - amount, - secondaryAmount, - symbol, - cryptoAmount, - showError, - selectedAsset, - internalPrimaryCurrency, - chainId, - } = this.state; - const currencySymbol = currencySymbols[currentCurrency]; - const exchangeRate = - selectedAsset && - selectedAsset.address && - contractExchangeRates && - contractExchangeRates[selectedAsset.address]?.price; - let switchable = true; - const colors = this.context.colors || mockTheme.colors; - const themeAppearance = this.context.themeAppearance || 'light'; - const styles = createStyles(colors); - - if (!conversionRate) { - switchable = false; - } else if (selectedAsset.symbol !== 'ETH' && !exchangeRate) { - switchable = false; - } - return ( - - - - {strings('payment_request.enter_amount')} - - - - - - - - {internalPrimaryCurrency !== 'ETH' && ( - {currencySymbol} - )} - - - {symbol} - - - - {secondaryAmount && internalPrimaryCurrency === 'ETH' && ( - - {currencySymbol} - - )} - {secondaryAmount && ( - - {secondaryAmount} - - )} - - - {switchable && ( - - - - - - )} - - - {showError && ( - - - {strings('payment_request.request_error')} - - - )} - - - - - {strings('payment_request.reset')} - - - {strings('payment_request.next')} - - - - - ); - }; - - handleNetworkPickerPress = () => { - this.props.navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { - screen: Routes.SHEET.NETWORK_SELECTOR, - }); - analytics.trackEvent( - AnalyticsEventBuilder.createEventBuilder( - MetaMetricsEvents.NETWORK_SELECTOR_PRESSED, - ) - .addProperties({ - chain_id: getDecimalChainId(this.props.chainId), - }) - .build(), - ); - }; - - render() { - const { mode } = this.state; - const colors = this.context.colors || mockTheme.colors; - const styles = createStyles(colors); - const networkName = - this.props.networkConfigurations?.[this.props.chainId]?.name; - const networkImageSource = this.props.networkImageSource; - - return ( - - - - - - {mode === MODE_SELECT - ? this.renderSelectAssets() - : this.renderEnterAmount()} - - - ); - } -} - -PaymentRequest.contextType = ThemeContext; - -const mapStateToProps = (state) => ({ - conversionRate: selectConversionRate(state), - currentCurrency: selectCurrentCurrency(state), - contractExchangeRates: selectContractExchangeRates(state), - searchEngine: state.settings.searchEngine, - tokens: selectTokens(state), - selectedAddress: selectSelectedInternalAccountFormattedAddress(state), - primaryCurrency: state.settings.primaryCurrency, - ticker: selectEvmTicker(state), - chainId: selectChainId(state), - tokenList: selectTokenListArray(state), - networkConfigurations: selectNetworkConfigurations(state), - networkImageSource: selectNetworkImageSource(state), -}); - -export default connect(mapStateToProps)(PaymentRequest); diff --git a/app/components/UI/PaymentRequest/index.test.tsx b/app/components/UI/PaymentRequest/index.test.tsx deleted file mode 100644 index e8804deb5ce..00000000000 --- a/app/components/UI/PaymentRequest/index.test.tsx +++ /dev/null @@ -1,814 +0,0 @@ -import React from 'react'; -import { - render, - fireEvent, - act, - userEvent, - waitFor, -} from '@testing-library/react-native'; -import PaymentRequestConnected from './index'; - -// Workaround: source is a .js file so TypeScript can't infer PropTypes; -// connect() produces a narrow type that rejects valid ownProps like chainId. -const PaymentRequest = PaymentRequestConnected as React.ComponentType< - Record ->; -import { Provider } from 'react-redux'; -import configureMockStore from 'redux-mock-store'; -import { SolScope } from '@metamask/keyring-api'; -import { ThemeContext, mockTheme } from '../../../util/theme'; -import { MOCK_ACCOUNTS_CONTROLLER_STATE } from '../../../util/test/accountsControllerTestUtils'; -import Routes from '../../../constants/navigation/Routes'; -import { WalletViewSelectorsIDs } from '../../Views/Wallet/WalletView.testIds'; -import ethLogo from '../../../assets/images/eth-logo.png'; - -jest.mock('react', () => ({ - ...jest.requireActual('react'), - useState: jest.fn(), -})); - -const mockTrackEvent = jest.fn(); -jest.mock('../../../util/analytics/analytics', () => ({ - analytics: { - trackEvent: (event: Record) => mockTrackEvent(event), - }, -})); - -// Enable fake timers globally for this test file -jest.useFakeTimers(); - -const mockStore = configureMockStore(); - -const initialState = { - engine: { - backgroundState: { - CurrencyRateController: { - conversionRate: 1, - currentCurrency: 'USD', - }, - TokenRatesController: { - contractExchangeRates: {}, - marketData: { - '0x1': { - '0x0d8775f59023cbe76e541b6497bbed3cd21acbdc': { - price: 1, - }, - }, - }, - }, - TokensController: { - marketData: { - '0x1': { - '0x0d8775f59023cbe76e541b6497bbed3cd21acbdc': { - price: 1, - }, - }, - }, - allTokens: { - '0x1': { - '0xc4955c0d639d99699bfd7ec54d9fafee40e4d272': [], - }, - }, - }, - NetworkController: { - provider: { - ticker: 'ETH', - chainId: '1', - }, - }, - MultichainNetworkController: { - isEvmSelected: true, - selectedMultichainNetworkChainId: SolScope.Mainnet, - - multichainNetworkConfigurationsByChainId: {}, - }, - AccountsController: { - ...MOCK_ACCOUNTS_CONTROLLER_STATE, - internalAccounts: { - ...MOCK_ACCOUNTS_CONTROLLER_STATE.internalAccounts, - selectedAccount: '30786334-3935-4563-b064-363339643939', - }, - }, - TokenListController: { - tokensChainsCache: { - '0x1': { - data: [ - { - address: '0x0d8775f59023cbe76e541b6497bbed3cd21acbdc', - symbol: 'BAT', - decimals: 18, - name: 'Basic Attention Token', - iconUrl: - 'https://assets.coingecko.com/coins/images/677/thumb/basic-attention-token.png?1547034427', - type: 'erc20', - }, - { - address: '0x89d24A6b4CcB1B6fAA2625fE562bDD9a23260359', - symbol: 'SAI', - decimals: 18, - name: 'Sai Stablecoin v1.0', - iconUrl: 'sai.svg', - type: 'erc20', - }, - ], - }, - }, - }, - PreferencesController: { - ipfsGateway: {}, - }, - }, - }, - settings: { - primaryCurrency: 'ETH', - }, -}; - -let mockSetShowError: jest.Mock; -let mockShowError = false; - -beforeEach(() => { - mockTrackEvent.mockClear(); - mockSetShowError = jest.fn((value) => { - mockShowError = value; - }); - (React.useState as jest.Mock).mockImplementation((state) => [ - state, - mockSetShowError, - ]); -}); - -afterEach(() => { - act(() => { - jest.runOnlyPendingTimers(); - }); -}); - -const store = mockStore(initialState); - -const mockNavigation = { - setOptions: jest.fn(), - setParams: jest.fn(), - navigate: jest.fn(), - goBack: jest.fn(), -}; - -const mockRoute = { - params: { - dispatch: jest.fn(), - }, -}; - -const renderComponent = (props = {}) => - render( - - - - - , - ); - -describe('PaymentRequest', () => { - it('renders correctly', () => { - const { toJSON } = renderComponent(); - expect(toJSON()).toMatchSnapshot(); - }); - - it('renders correctly with network picker when feature flag is enabled', () => { - const { toJSON } = renderComponent({ - chainId: '0x1', - networkImageSource: ethLogo, - }); - expect(toJSON()).toMatchSnapshot(); - }); - - it('displays the correct title for asset selection', () => { - const { getByText } = renderComponent(); - expect(getByText('Choose an asset to request')).toBeTruthy(); - }); - - it('allows searching for assets', () => { - const { getByPlaceholderText } = renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - fireEvent.changeText(searchInput, 'ETH'); - expect(searchInput.props.value).toBe('ETH'); - }); - - it('switches to amount input mode when an asset is selected', async () => { - const { getByText } = renderComponent({ navigation: mockNavigation }); - - await userEvent.press(getByText('ETH')); - - expect(getByText('Enter amount')).toBeTruthy(); - expect(mockNavigation.setParams).toHaveBeenCalledWith({ - mode: 'amount', - dispatch: expect.any(Function), - }); - }); - - it('updates amount when input changes', async () => { - const { getByText, getByPlaceholderText } = renderComponent(); - - // First, select an asset - await userEvent.press(getByText('ETH')); - - const amountInput = getByPlaceholderText('0.00'); - await userEvent.type(amountInput, '1.5'); - - expect(amountInput.props.value).toBe('1.5'); - }); - - it('trims leading and trailing spaces from amount input', async () => { - const { getByText, getByPlaceholderText } = renderComponent(); - - await userEvent.press(getByText('ETH')); - - const amountInput = getByPlaceholderText('0.00'); - fireEvent.changeText(amountInput, ' 1.5 '); - - expect(amountInput.props.value).toBe('1.5'); - }); - - it('handles whitespace-only input without throwing', async () => { - const { getByText, getByPlaceholderText } = renderComponent(); - - await userEvent.press(getByText('ETH')); - - const amountInput = getByPlaceholderText('0.00'); - - expect(() => { - fireEvent.changeText(amountInput, ' '); - }).not.toThrow(); - }); - - it('displays an error when an invalid amount is entered', async () => { - const { getByText, getByPlaceholderText, queryByText } = renderComponent(); - - (React.useState as jest.Mock).mockImplementation(() => [ - mockShowError, - mockSetShowError, - ]); - - mockSetShowError(true); - - await userEvent.press(getByText('ETH')); - - const amountInput = getByPlaceholderText('0.00'); - const nextButton = getByText('Next'); - - await act(async () => { - fireEvent.changeText(amountInput, '0'); - fireEvent.press(nextButton); - }); - - expect(mockSetShowError).toHaveBeenCalledWith(true); - expect(queryByText('Invalid request, please try again')).toBeTruthy(); - }); - - describe('handleNetworkPickerPress', () => { - it('should navigate to network selector modal when feature flag is enabled', () => { - const { getByTestId } = renderComponent({ - chainId: '0x1', - networkImageSource: ethLogo, - }); - - const networkPicker = getByTestId( - WalletViewSelectorsIDs.NAVBAR_NETWORK_PICKER, - ); - - act(() => { - fireEvent.press(networkPicker); - }); - - expect(mockNavigation.navigate).toHaveBeenCalledWith( - Routes.MODAL.ROOT_MODAL_FLOW, - { - screen: Routes.SHEET.NETWORK_SELECTOR, - }, - ); - }); - }); - - describe('Clear Search Input Functionality', () => { - it('clears search input and resets results when clear button is pressed', async () => { - // Given a PaymentRequest component with search input - const { getByPlaceholderText, getByText, queryByText, getByTestId } = - renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - - // When user types and then presses clear button - fireEvent.changeText(searchInput, 'BAT'); - - // Wait for debounce to complete - act(() => { - jest.advanceTimersByTime(300); - }); - - await waitFor(() => { - expect(getByText('Search results')).toBeTruthy(); - expect(getByText('BAT')).toBeTruthy(); - }); - - // Find and press clear button using testID - const clearButton = getByTestId('clear-search-input-button'); - fireEvent.press(clearButton); - - // Then input should be cleared and results reset - expect(searchInput.props.value).toBe(''); - expect(getByText('Top picks')).toBeTruthy(); - expect(queryByText('BAT')).toBeNull(); - }); - - it('focuses search input after clearing', async () => { - // Given a PaymentRequest component with search input - const { getByPlaceholderText } = renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - - // When user types and clears - fireEvent.changeText(searchInput, 'BAT'); - fireEvent.changeText(searchInput, ''); - - // Then search input should maintain focus - expect(searchInput.props.value).toBe(''); - }); - }); - - describe('Component Lifecycle and Cleanup', () => { - it('cancels debounced search on component unmount', async () => { - // Given a PaymentRequest component with active search - const { getByPlaceholderText, unmount } = renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - - // When user types and component unmounts before debounce completes - fireEvent.changeText(searchInput, 'ETH'); - - // Then unmount the component - unmount(); - - // When advancing timers after unmount - act(() => { - jest.advanceTimersByTime(300); - }); - - // Then no errors should occur (debounced function should be cancelled) - expect(() => { - act(() => { - jest.runOnlyPendingTimers(); - }); - }).not.toThrow(); - }); - - it('initializes with correct state on mount', () => { - // Given a PaymentRequest component - const { getByText } = renderComponent(); - - // Then it should show top picks by default - expect(getByText('Top picks')).toBeTruthy(); - expect(getByText('Choose an asset to request')).toBeTruthy(); - }); - - it('handles route params with receiveAsset on mount', () => { - // Given a route with receiveAsset parameter - const mockRouteWithAsset = { - params: { - receiveAsset: { - symbol: 'ETH', - name: 'Ether', - isETH: true, - }, - }, - }; - - // When component mounts with receiveAsset - const { getByText } = renderComponent({ route: mockRouteWithAsset }); - - // Then it should switch to amount input mode - expect(getByText('Enter amount')).toBeTruthy(); - }); - }); - - describe('Search Edge Cases', () => { - it('handles search with non-string input gracefully', async () => { - // Given a PaymentRequest component - const { getByPlaceholderText, getByText } = renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - - // When user types invalid input (simulating edge case) - fireEvent.changeText(searchInput, '123'); - - // Then it should handle the search without errors - act(() => { - jest.advanceTimersByTime(300); - }); - - await waitFor(() => { - expect(getByText('Search results')).toBeTruthy(); - }); - }); - - it('handles search with special characters', async () => { - // Given a PaymentRequest component - const { getByPlaceholderText, getByText } = renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - - // When user types special characters - fireEvent.changeText(searchInput, '!@#$%'); - - // Then it should handle the search gracefully - act(() => { - jest.advanceTimersByTime(300); - }); - - await waitFor(() => { - expect(getByText('Search results')).toBeTruthy(); - expect(getByText('No tokens found')).toBeTruthy(); - }); - }); - - it('handles search with very long input', async () => { - // Given a PaymentRequest component - const { getByPlaceholderText, getByText } = renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - - // When user types a very long search term - const longSearchTerm = 'a'.repeat(100); - fireEvent.changeText(searchInput, longSearchTerm); - - // Then it should handle the search without performance issues - act(() => { - jest.advanceTimersByTime(300); - }); - - await waitFor(() => { - expect(getByText('Search results')).toBeTruthy(); - expect(getByText('No tokens found')).toBeTruthy(); - }); - }); - }); - - describe('Network Configuration Tests', () => { - it('shows correct assets for mainnet', () => { - // Given a PaymentRequest component on mainnet - const { getByText } = renderComponent({ chainId: '0x1' }); - - // Then it should show default assets including SAI - expect(getByText('ETH')).toBeTruthy(); - expect(getByText('SAI')).toBeTruthy(); - }); - - it('shows correct assets for non-mainnet networks', () => { - // Given a PaymentRequest component on non-mainnet - const { getByText } = renderComponent({ chainId: '0x5' }); // Goerli - - // Then it should show only ETH with network-specific ticker - expect(getByText('ETH')).toBeTruthy(); - }); - - it('handles networks without token detection support', () => { - // Given a PaymentRequest component on network without token detection - const { getByText } = renderComponent({ chainId: '0x2' }); - - // Then it should show only ETH - expect(getByText('ETH')).toBeTruthy(); - }); - }); - - describe('Debounced Search Functionality', () => { - it('debounces search input to reduce excessive calls', async () => { - // Given a PaymentRequest component with search functionality - const { getByPlaceholderText, getByText } = renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - - // Initially should show top picks - expect(getByText('Top picks')).toBeTruthy(); - - // When user types rapidly - fireEvent.changeText(searchInput, 'E'); - fireEvent.changeText(searchInput, 'ET'); - fireEvent.changeText(searchInput, 'ETH'); - - // Then the input value should update immediately - expect(searchInput.props.value).toBe('ETH'); - - // The search should execute after debounce delay - act(() => { - jest.advanceTimersByTime(300); - }); - - // Then the search should execute and show search results - await waitFor(() => { - expect(getByText('Search results')).toBeTruthy(); - // Should show "No tokens found" for ETH search since it's not in the test data - expect(getByText('No tokens found')).toBeTruthy(); - }); - }); - - it('cancels pending search when user clears input', async () => { - // Given a PaymentRequest component with search input - const { getByPlaceholderText, getByText } = renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - - // When user types and then clears immediately - fireEvent.changeText(searchInput, 'ETH'); - fireEvent.changeText(searchInput, ''); - - // Then the input should be cleared immediately - expect(searchInput.props.value).toBe(''); - - // And the search should not execute even after delay - act(() => { - jest.advanceTimersByTime(300); - }); - - // Then it should show top picks instead of search results - expect(getByText('Top picks')).toBeTruthy(); - }); - - it('updates search results after debounce delay', async () => { - // Given a PaymentRequest component - const { getByPlaceholderText, getByText } = renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - - // Initially should show top picks - expect(getByText('Top picks')).toBeTruthy(); - - // When user types a search term - fireEvent.changeText(searchInput, 'BAT'); - - // Then the input value should update immediately - expect(searchInput.props.value).toBe('BAT'); - - // The search should execute after debounce delay - act(() => { - jest.advanceTimersByTime(300); - }); - - // Then search results should appear with specific token details - await waitFor(() => { - expect(getByText('Search results')).toBeTruthy(); - expect(getByText('BAT')).toBeTruthy(); - expect(getByText('Basic Attention Token')).toBeTruthy(); - }); - }); - - it('handles multiple rapid search inputs correctly', async () => { - // Given a PaymentRequest component - const { getByPlaceholderText, getByText } = renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - - // When user types rapidly with different terms - fireEvent.changeText(searchInput, 'E'); - fireEvent.changeText(searchInput, 'ET'); - fireEvent.changeText(searchInput, 'ETH'); - - // Then only the final search should execute - act(() => { - jest.advanceTimersByTime(300); - }); - - // Then it should search for the final term - await waitFor(() => { - expect(getByText('Search results')).toBeTruthy(); - // Should show "No tokens found" for ETH search since it's not in the test data - expect(getByText('No tokens found')).toBeTruthy(); - }); - }, 10000); - - it('cancels debounced search on component unmount', async () => { - // Given a PaymentRequest component with active search - const { getByPlaceholderText, unmount } = renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - - // When user types and component unmounts before debounce completes - fireEvent.changeText(searchInput, 'ETH'); - - // Then unmount the component - unmount(); - - // When advancing timers after unmount - act(() => { - jest.advanceTimersByTime(300); - }); - - // Then no errors should occur (debounced function should be cancelled) - expect(() => { - act(() => { - jest.runOnlyPendingTimers(); - }); - }).not.toThrow(); - }); - - it('handles empty search input correctly', async () => { - // Given a PaymentRequest component - const { getByPlaceholderText, getByText } = renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - - // When user types and then clears to empty - fireEvent.changeText(searchInput, 'ETH'); - fireEvent.changeText(searchInput, ''); - - // Then it should show top picks immediately - expect(getByText('Top picks')).toBeTruthy(); - - // And should not show search results after delay - act(() => { - jest.advanceTimersByTime(300); - }); - - expect(getByText('Top picks')).toBeTruthy(); - }); - - it('debounces handleSearchTokenList calls', async () => { - // Given a PaymentRequest component - const { getByPlaceholderText, getByText } = renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - - // When user types and submits (which calls handleSearchTokenList) - fireEvent.changeText(searchInput, 'BAT'); - fireEvent(searchInput, 'submitEditing'); - - // Then the search should not execute immediately - expect(searchInput.props.value).toBe('BAT'); - - // When the debounce delay passes - act(() => { - jest.advanceTimersByTime(300); - }); - - // Then the search should execute with specific token details - await waitFor(() => { - expect(getByText('Search results')).toBeTruthy(); - expect(getByText('BAT')).toBeTruthy(); - expect(getByText('Basic Attention Token')).toBeTruthy(); - }); - }); - - it('filters search results based on different search terms', async () => { - // Given a PaymentRequest component - const { getByPlaceholderText, getByText, queryByText } = - renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - - // When searching for token symbol - fireEvent.changeText(searchInput, 'BAT'); - act(() => { - jest.advanceTimersByTime(300); - }); - - // Then should find BAT token - await waitFor(() => { - expect(getByText('Search results')).toBeTruthy(); - expect(getByText('BAT')).toBeTruthy(); - expect(getByText('Basic Attention Token')).toBeTruthy(); - }); - - // When searching for token name - fireEvent.changeText(searchInput, 'Basic Attention'); - act(() => { - jest.advanceTimersByTime(300); - }); - - // Then should still find BAT token - await waitFor(() => { - expect(getByText('Search results')).toBeTruthy(); - expect(getByText('BAT')).toBeTruthy(); - expect(getByText('Basic Attention Token')).toBeTruthy(); - }); - - // When searching for non-existent token - fireEvent.changeText(searchInput, 'NONEXISTENT'); - act(() => { - jest.advanceTimersByTime(300); - }); - - // Then should show no results - await waitFor(() => { - expect(getByText('Search results')).toBeTruthy(); - expect(getByText('No tokens found')).toBeTruthy(); - expect(queryByText('BAT')).toBeNull(); - }); - }); - - it('shows correct search results for partial matches', async () => { - // Given a PaymentRequest component - const { getByPlaceholderText, getByText } = renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - - // When searching with partial symbol - fireEvent.changeText(searchInput, 'BA'); - act(() => { - jest.advanceTimersByTime(300); - }); - - // Then should find BAT token - await waitFor(() => { - expect(getByText('Search results')).toBeTruthy(); - expect(getByText('BAT')).toBeTruthy(); - expect(getByText('Basic Attention Token')).toBeTruthy(); - }); - - // When searching with partial name - fireEvent.changeText(searchInput, 'Basic'); - act(() => { - jest.advanceTimersByTime(300); - }); - - // Then should still find BAT token - await waitFor(() => { - expect(getByText('Search results')).toBeTruthy(); - expect(getByText('BAT')).toBeTruthy(); - expect(getByText('Basic Attention Token')).toBeTruthy(); - }); - }); - - it('maintains search results state during rapid typing', async () => { - // Given a PaymentRequest component - const { getByPlaceholderText, getByText, queryByText } = - renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - - // When typing rapidly with valid search terms - fireEvent.changeText(searchInput, 'B'); - fireEvent.changeText(searchInput, 'BA'); - fireEvent.changeText(searchInput, 'BAT'); - - // Then should not show results immediately - expect(queryByText('BAT')).toBeNull(); - - // When debounce delay passes - act(() => { - jest.advanceTimersByTime(300); - }); - - // Then should show final search results - await waitFor(() => { - expect(getByText('Search results')).toBeTruthy(); - expect(getByText('BAT')).toBeTruthy(); - expect(getByText('Basic Attention Token')).toBeTruthy(); - }); - - // When typing rapidly with invalid then valid terms - fireEvent.changeText(searchInput, 'INVALID'); - fireEvent.changeText(searchInput, 'BAT'); - - // When debounce delay passes - act(() => { - jest.advanceTimersByTime(300); - }); - - // Then should show valid results - await waitFor(() => { - expect(getByText('Search results')).toBeTruthy(); - expect(getByText('BAT')).toBeTruthy(); - expect(getByText('Basic Attention Token')).toBeTruthy(); - }); - }); - - it('handles address-based search correctly', async () => { - // Given a PaymentRequest component - const { getByPlaceholderText, getByText } = renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - - // When searching by token address - fireEvent.changeText( - searchInput, - '0x0d8775f59023cbe76e541b6497bbed3cd21acbdc', - ); - act(() => { - jest.advanceTimersByTime(300); - }); - - // Then should find BAT token by address - await waitFor(() => { - expect(getByText('Search results')).toBeTruthy(); - expect(getByText('BAT')).toBeTruthy(); - expect(getByText('Basic Attention Token')).toBeTruthy(); - }); - }); - - it('handles case-insensitive search', async () => { - // Given a PaymentRequest component - const { getByPlaceholderText, getByText } = renderComponent(); - const searchInput = getByPlaceholderText('Search assets'); - - // When searching with different cases - fireEvent.changeText(searchInput, 'bat'); - act(() => { - jest.advanceTimersByTime(300); - }); - - // Then should find BAT token regardless of case - await waitFor(() => { - expect(getByText('Search results')).toBeTruthy(); - expect(getByText('BAT')).toBeTruthy(); - expect(getByText('Basic Attention Token')).toBeTruthy(); - }); - }); - }); -}); diff --git a/app/components/UI/PaymentRequestSuccess/index.js b/app/components/UI/PaymentRequestSuccess/index.js deleted file mode 100644 index cc861fb083a..00000000000 --- a/app/components/UI/PaymentRequestSuccess/index.js +++ /dev/null @@ -1,421 +0,0 @@ -import React, { PureComponent } from 'react'; -import { - Dimensions, - SafeAreaView, - View, - ScrollView, - Text, - StyleSheet, - InteractionManager, - TouchableOpacity, - Platform, -} from 'react-native'; -import { connect } from 'react-redux'; -import { fontStyles } from '../../../styles/common'; -import { getPaymentRequestSuccessOptionsTitle } from '../../UI/Navbar'; -import PropTypes from 'prop-types'; -import EvilIcons from 'react-native-vector-icons/EvilIcons'; -import StyledButton from '../StyledButton'; -import FontAwesome from 'react-native-vector-icons/FontAwesome'; -import IonicIcon from 'react-native-vector-icons/Ionicons'; -import { showAlert } from '../../../actions/alert'; -import Logger from '../../../util/Logger'; -import Share from 'react-native-share'; // eslint-disable-line import/default -import Modal from 'react-native-modal'; -import QRCode from 'react-native-qrcode-svg'; -import { renderNumber } from '../../../util/number'; -import Device from '../../../util/device'; -import { strings } from '../../../../locales/i18n'; -import { protectWalletModalVisible } from '../../../actions/user'; -import ClipboardManager from '../../../core/ClipboardManager'; -import { ThemeContext, mockTheme } from '../../../util/theme'; -import generateTestId from '../../../../wdio/utils/generateTestId'; -import { SendLinkViewSelectorsIDs } from '../ReceiveRequest/SendLinkView.testIds'; - -const isIos = Device.isIos(); - -const createStyles = (theme) => - StyleSheet.create({ - wrapper: { - backgroundColor: theme.colors.background.default, - flex: 1, - }, - contentWrapper: { - padding: 24, - }, - button: { - marginBottom: 16, - }, - titleText: { - ...fontStyles.bold, - fontSize: 24, - marginVertical: 16, - alignSelf: 'center', - color: theme.colors.text.default, - }, - descriptionText: { - ...fontStyles.normal, - fontSize: 14, - alignSelf: 'center', - textAlign: 'center', - marginVertical: 8, - color: theme.colors.text.default, - }, - linkText: { - ...fontStyles.normal, - fontSize: 14, - color: theme.colors.primary.default, - alignSelf: 'center', - textAlign: 'center', - marginVertical: 16, - }, - buttonsWrapper: { - flex: 1, - flexDirection: 'row', - alignSelf: 'center', - }, - buttonsContainer: { - flex: 1, - flexDirection: 'column', - alignSelf: 'flex-end', - }, - scrollViewContainer: { - flexGrow: 1, - }, - icon: { - color: theme.colors.primary.default, - marginBottom: 16, - }, - blueIcon: { - color: theme.colors.primary.inverse, - }, - iconWrapper: { - alignItems: 'center', - }, - buttonText: { - ...fontStyles.bold, - color: theme.colors.primary.default, - fontSize: 14, - marginLeft: 8, - }, - blueButtonText: { - ...fontStyles.bold, - color: theme.colors.primary.inverse, - fontSize: 14, - marginLeft: 8, - }, - buttonContent: { - flexDirection: 'row', - alignSelf: 'center', - }, - buttonIconWrapper: { - flexDirection: 'column', - alignSelf: 'center', - }, - buttonTextWrapper: { - flexDirection: 'column', - alignSelf: 'center', - }, - detailsWrapper: { - padding: 10, - alignItems: 'center', - }, - addressTitle: { - fontSize: 16, - ...fontStyles.normal, - color: theme.colors.text.default, - }, - informationWrapper: { - paddingHorizontal: 40, - }, - linkWrapper: { - paddingHorizontal: 24, - }, - titleQr: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: isIos ? 8 : 10, - }, - closeIcon: { - right: isIos ? -20 : -40, - alignItems: 'center', - paddingHorizontal: 10, - }, - qrCode: { - marginBottom: 16, - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: 36, - paddingBottom: 24, - paddingTop: 16, - backgroundColor: theme.colors.background.default, - borderRadius: 8, - }, - qrCodeWrapper: { - marginVertical: 8, - padding: 8, - backgroundColor: theme.brandColors.white, - }, - }); - -/** - * View to interact with a previously generated payment request link - */ -class PaymentRequestSuccess extends PureComponent { - static propTypes = { - /** - * Navigation object - */ - navigation: PropTypes.object, - /** - * Object that represents the current route info like params passed to it - */ - route: PropTypes.object, - /** - /* Triggers global alert - */ - showAlert: PropTypes.func, - /** - /* Prompts protect wallet modal - */ - protectWalletModalVisible: PropTypes.func, - }; - - state = { - link: '', - qrLink: '', - amount: '', - symbol: '', - qrModalVisible: false, - }; - - updateNavBar = () => { - const { navigation } = this.props; - const colors = this.context.colors || mockTheme.colors; - navigation.setOptions( - getPaymentRequestSuccessOptionsTitle(navigation, colors), - ); - }; - - /** - * Sets payment request link, amount and symbol of the asset to state - */ - componentDidMount = () => { - const { route } = this.props; - this.updateNavBar(); - const link = route?.params?.link ?? ''; - const qrLink = route?.params?.qrLink ?? ''; - const amount = route?.params?.amount ?? ''; - const symbol = route?.params?.symbol ?? ''; - this.setState({ link, qrLink, amount, symbol }); - }; - - componentDidUpdate = () => { - this.updateNavBar(); - }; - - componentWillUnmount = () => { - this.props.protectWalletModalVisible(); - }; - - /** - * Copies payment request link to clipboard - */ - copyAccountToClipboard = async () => { - const { link } = this.state; - await ClipboardManager.setString(link); - InteractionManager.runAfterInteractions(() => { - this.props.showAlert({ - isVisible: true, - autodismiss: 1500, - content: 'clipboard-alert', - data: { msg: strings('payment_request.link_copied') }, - }); - }); - }; - - /** - * Shows share native UI - */ - onShare = () => { - const { link } = this.state; - Share.open({ - message: link, - }).catch((err) => { - Logger.log('Error while trying to share payment request', err); - }); - }; - - /** - * Toggles payment request QR code modal on top - */ - showQRModal = () => { - this.setState({ qrModalVisible: true }); - }; - - /** - * Closes payment request QR code modal - */ - closeQRModal = () => { - this.setState({ qrModalVisible: false }); - }; - - render() { - const { link, amount, symbol, qrModalVisible } = this.state; - const theme = this.context || mockTheme; - const colors = theme.colors; - const styles = createStyles(theme); - - return ( - - - - - - - - {strings('payment_request.send_link')} - - - {strings('payment_request.description_1')} - - - {strings('payment_request.description_2')} - - {' ' + renderNumber(amount) + ' ' + symbol} - - - - - {link} - - - - - - - - - - - - {strings('payment_request.copy_to_clipboard')} - - - - - - - - - - - - {strings('payment_request.qr_code')} - - - - - - - - - - - - {strings('payment_request.send_link')} - - - - - - - - - - - - - {strings('payment_request.request_qr_code')} - - - - - - - - - - - - - - ); - } -} - -PaymentRequestSuccess.contextType = ThemeContext; - -const mapDispatchToProps = (dispatch) => ({ - showAlert: (config) => dispatch(showAlert(config)), - protectWalletModalVisible: () => dispatch(protectWalletModalVisible()), -}); - -export default connect(null, mapDispatchToProps)(PaymentRequestSuccess); diff --git a/app/components/UI/ReceiveRequest/RequestPaymentModal.testIds.ts b/app/components/UI/ReceiveRequest/RequestPaymentModal.testIds.ts deleted file mode 100644 index 1e8b2aa9a8c..00000000000 --- a/app/components/UI/ReceiveRequest/RequestPaymentModal.testIds.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const RequestPaymentModalSelectorsIDs = { - REQUEST_BUTTON: 'request-payment-button', -} as const; diff --git a/app/components/UI/ReceiveRequest/RequestPaymentView.testIds.ts b/app/components/UI/ReceiveRequest/RequestPaymentView.testIds.ts deleted file mode 100644 index 0fc4ad7c77e..00000000000 --- a/app/components/UI/ReceiveRequest/RequestPaymentView.testIds.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const RequestPaymentViewSelectors = { - BACK_BUTTON_ID: 'request-search-asset-back-button', - REQUEST_PAYMENT_CONTAINER_ID: 'request-screen', - REQUEST_ASSET_LIST_ID: 'searched-asset-results', - REQUEST_AMOUNT_INPUT_BOX_ID: 'request-amount-input', - TOKEN_SEARCH_INPUT_BOX: 'request-search-asset-input', -} as const; diff --git a/app/components/UI/ReceiveRequest/SendLinkView.testIds.ts b/app/components/UI/ReceiveRequest/SendLinkView.testIds.ts deleted file mode 100644 index 27dbfe20855..00000000000 --- a/app/components/UI/ReceiveRequest/SendLinkView.testIds.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const SendLinkViewSelectorsIDs = { - CONTAINER_ID: 'send-link-screen', - QR_CODE_BUTTON: 'request-qrcode-button', - QR_MODAL: 'payment-request-qrcode', - CLOSE_QR_MODAL_BUTTON: 'payment-request-qrcode-close-button', - CLOSE_SEND_LINK_VIEW_BUTTON: 'send-link-close-button', -} as const; diff --git a/app/components/UI/ReceiveRequest/__snapshots__/index.test.tsx.snap b/app/components/UI/ReceiveRequest/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 49565ff43a3..00000000000 --- a/app/components/UI/ReceiveRequest/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,1561 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`ReceiveRequest render matches snapshot 1`] = ` - - - - - - - - - - - - - ReceiveRequest - - - - - - - - - - - - - - - - - - - - - - QR: ethereum:0xC4966c0D659D99699BFD7EB54D8fafEE40e4a756@0x1 - - - - - - 0xC4966c0D659D99699BFD7EB54D8fafEE40e4a756 - - - - - - - - - Request payment - - - - - - - - - - - - - - - -`; - -exports[`ReceiveRequest render with different ticker matches snapshot 1`] = ` - - - - - - - - - - - - - ReceiveRequest - - - - - - - - - - - - - - - - - - - - - - QR: ethereum:0xC4966c0D659D99699BFD7EB54D8fafEE40e4a756@0x1 - - - - - - 0xC4966c0D659D99699BFD7EB54D8fafEE40e4a756 - - - - - - - - - Request payment - - - - - - - - - - - - - - - -`; - -exports[`ReceiveRequest render without buy matches snapshot 1`] = ` - - - - - - - - - - - - - ReceiveRequest - - - - - - - - - - - - - - - - - - - - - - QR: ethereum:0xC4966c0D659D99699BFD7EB54D8fafEE40e4a756@0x1 - - - - - - 0xC4966c0D659D99699BFD7EB54D8fafEE40e4a756 - - - - - - - - - Request payment - - - - - - - - - - - - - - - -`; diff --git a/app/components/UI/ReceiveRequest/index.js b/app/components/UI/ReceiveRequest/index.js deleted file mode 100644 index 0f91e586dee..00000000000 --- a/app/components/UI/ReceiveRequest/index.js +++ /dev/null @@ -1,231 +0,0 @@ -import React, { PureComponent } from 'react'; -import PropTypes from 'prop-types'; -import { SafeAreaView, Dimensions, Alert } from 'react-native'; -import { - Box, - BoxFlexDirection, - BoxAlignItems, - BoxJustifyContent, - Button, - ButtonVariant, - ButtonSize, -} from '@metamask/design-system-react-native'; -import QRCode from 'react-native-qrcode-svg'; -import { connect } from 'react-redux'; - -import { MetaMetricsEvents } from '../../../core/Analytics'; -import { strings } from '../../../../locales/i18n'; -import { showAlert } from '../../../actions/alert'; -import { protectWalletModalVisible } from '../../../actions/user'; - -import GlobalAlert from '../GlobalAlert'; -import ClipboardManager from '../../../core/ClipboardManager'; -import { ThemeContext, mockTheme } from '../../../util/theme'; -import { selectChainId } from '../../../selectors/networkController'; -import { isNetworkRampSupported } from '../Ramp/Aggregator/utils'; -import { withRampNavigation } from '../Ramp/hooks/withRampNavigation'; -import { selectSelectedInternalAccountFormattedAddress } from '../../../selectors/accountsController'; -import { getRampNetworks } from '../../../reducers/fiatOrders'; -import { RequestPaymentModalSelectorsIDs } from './RequestPaymentModal.testIds'; -import { analytics } from '../../../util/analytics/analytics'; -import { AnalyticsEventBuilder } from '../../../util/analytics/AnalyticsEventBuilder'; -import QRAccountDisplay from '../../Views/QRAccountDisplay'; -import PNG_MM_LOGO_PATH from '../../../images/branding/fox.png'; -import { isEthAddress } from '../../../util/address'; -import { selectIsEvmNetworkSelected } from '../../../selectors/multichainNetworkController'; - -const { height: windowHeight } = Dimensions.get('window'); - -const createStyles = (theme) => ({ - wrapper: { - backgroundColor: theme.colors.background.default, - borderTopLeftRadius: 10, - borderTopRightRadius: 10, - marginTop: windowHeight * 0.05 + 160, - marginBottom: 20, - marginHorizontal: 0, - paddingHorizontal: 0, - height: windowHeight * 0.95 - 180, - width: '100%', - }, -}); - -/** - * PureComponent that renders receive options - */ -class ReceiveRequest extends PureComponent { - static propTypes = { - /** - * The navigator object - */ - navigation: PropTypes.object, - /** - * Selected address as string - */ - selectedAddress: PropTypes.string, - /** - * Asset to receive, could be not defined - */ - receiveAsset: PropTypes.object, - /** - /* Triggers global alert - */ - showAlert: PropTypes.func, - /** - * Function to navigate to ramp flows - */ - goToBuy: PropTypes.func, - /** - * Network provider chain id - */ - chainId: PropTypes.string, - /** - * Prompts protect wallet modal - */ - protectWalletModalVisible: PropTypes.func, - /** - * Hides the modal that contains the component - */ - hideModal: PropTypes.func, - /** - * redux flag that indicates if the user - * completed the seed phrase backup flow - */ - seedphraseBackedUp: PropTypes.bool, - /** - * Boolean that indicates if the network supports buy - */ - isNetworkBuySupported: PropTypes.bool, - /** - * Boolean that indicates if the evm network is selected - */ - isEvmNetworkSelected: PropTypes.bool, - }; - - state = { - qrModalVisible: false, - buyModalVisible: false, - }; - - /** - * Shows an alert message with a coming soon message - */ - onBuy = async () => { - const { isNetworkBuySupported, goToBuy } = this.props; - if (!isNetworkBuySupported) { - Alert.alert( - strings('fiat_on_ramp.network_not_supported'), - strings('fiat_on_ramp.switch_network'), - ); - } else { - goToBuy(); - // TODO: Add RAMPS_BUTTON_CLICKED analytics tracking when this component is refactored to a functional component - // This will allow access to the useRampsButtonClickData hook for the expanded analytics payload - } - }; - - copyAccountToClipboard = async () => { - const { selectedAddress } = this.props; - ClipboardManager.setString(selectedAddress); - this.props.showAlert({ - isVisible: true, - autodismiss: 1500, - content: 'clipboard-alert', - data: { msg: strings('account_details.account_copied_to_clipboard') }, - }); - if (!this.props.seedphraseBackedUp) { - setTimeout(() => this.props.hideModal(), 1000); - setTimeout(() => this.props.protectWalletModalVisible(), 1500); - } - }; - - onReceive = () => { - this.props.navigation.navigate('PaymentRequestView', { - screen: 'PaymentRequest', - params: { receiveAsset: this.props.receiveAsset }, - }); - - analytics.trackEvent( - AnalyticsEventBuilder.createEventBuilder( - MetaMetricsEvents.RECEIVE_OPTIONS_PAYMENT_REQUEST, - ) - .addProperties({ action: 'Receive Options', name: 'Payment Request' }) - .build(), - ); - }; - - render() { - const theme = this.context || mockTheme; - const styles = createStyles(theme); - - const qrValue = isEthAddress(this.props.selectedAddress) - ? `ethereum:${this.props.selectedAddress}@${this.props.chainId}` - : this.props.selectedAddress; - - return ( - - - - - - - - - - - - - - {this.props.isEvmNetworkSelected && ( - - - - )} - - - - ); - } -} - -ReceiveRequest.contextType = ThemeContext; - -const mapStateToProps = (state) => ({ - chainId: selectChainId(state), - selectedAddress: selectSelectedInternalAccountFormattedAddress(state), - receiveAsset: state.modals.receiveAsset, - seedphraseBackedUp: state.user.seedphraseBackedUp, - isNetworkBuySupported: isNetworkRampSupported( - selectChainId(state), - getRampNetworks(state), - ), - isEvmNetworkSelected: selectIsEvmNetworkSelected(state), -}); - -const mapDispatchToProps = (dispatch) => ({ - showAlert: (config) => dispatch(showAlert(config)), - protectWalletModalVisible: () => dispatch(protectWalletModalVisible()), -}); - -export default connect( - mapStateToProps, - mapDispatchToProps, -)(withRampNavigation(ReceiveRequest)); diff --git a/app/components/UI/ReceiveRequest/index.test.tsx b/app/components/UI/ReceiveRequest/index.test.tsx deleted file mode 100644 index 9202bed008b..00000000000 --- a/app/components/UI/ReceiveRequest/index.test.tsx +++ /dev/null @@ -1,255 +0,0 @@ -import React from 'react'; -import { cloneDeep } from 'lodash'; -import { RpcEndpointType } from '@metamask/network-controller'; -import ReceiveRequest from './'; -import { renderScreen } from '../../../util/test/renderWithProvider'; -import { backgroundState } from '../../../util/test/initial-root-state'; -import { MOCK_ACCOUNTS_CONTROLLER_STATE } from '../../../util/test/accountsControllerTestUtils'; -import { mockNetworkState } from '../../../util/test/network'; -import { RequestPaymentModalSelectorsIDs } from './RequestPaymentModal.testIds'; -import { fireEvent } from '@testing-library/react-native'; - -const initialState = { - engine: { - backgroundState: { - ...backgroundState, - NetworkController: { - ...mockNetworkState({ - id: 'mainnet', - nickname: 'Ethereum', - ticker: 'ETH', - chainId: '0x1', - type: RpcEndpointType.Infura, - }), - }, - AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE, - }, - }, - fiatOrders: { - networks: [ - { - active: true, - chainId: '1', - nativeTokenSupported: true, - }, - ], - }, -}; - -jest.mock('../../../util/address', () => ({ - ...jest.requireActual('../../../util/address'), - renderAccountName: jest.fn(), -})); - -jest.mock('react-native-share', () => ({ - open: jest.fn(), -})); - -jest.mock('../../../core/ClipboardManager', () => ({ - setString: jest.fn(), -})); - -jest.mock('../../../util/analytics/analytics', () => ({ - analytics: { - trackEvent: jest.fn(), - }, -})); - -jest.mock('../../../util/analytics/AnalyticsEventBuilder', () => ({ - AnalyticsEventBuilder: { - createEventBuilder: jest.fn(() => ({ - addProperties: jest.fn(() => ({ build: jest.fn() })), - build: jest.fn(), - })), - }, -})); - -// Mock QRCode component to test props -jest.mock('react-native-qrcode-svg', () => { - const actualReact = jest.requireActual('react'); - const { Text } = jest.requireActual('react-native'); - return function MockQRCode({ - value, - size, - logoSize, - logoBorderRadius, - }: { - value: string; - size?: number; - logoSize?: number; - logoBorderRadius?: number; - }) { - return actualReact.createElement( - Text, - { - testID: 'receive-request-qr-code', - accessibilityLabel: `QR Code: ${value}, size: ${size}, logoSize: ${logoSize}, logoBorderRadius: ${logoBorderRadius}`, - }, - `QR: ${value}`, - ); - }; -}); - -// Mock QRAccountDisplay to test integration -jest.mock('../../Views/QRAccountDisplay', () => { - const actualReact = jest.requireActual('react'); - const { View, Text } = jest.requireActual('react-native'); - return function MockQRAccountDisplay({ - accountAddress, - }: { - accountAddress: string; - }) { - return actualReact.createElement( - View, - { testID: 'receive-request-qr-account-display' }, - actualReact.createElement( - Text, - { testID: 'qr-account-address' }, - accountAddress, - ), - ); - }; -}); - -describe('ReceiveRequest', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('render matches snapshot', () => { - const { toJSON } = renderScreen( - ReceiveRequest, - { name: 'ReceiveRequest' }, - { state: initialState }, - ); - expect(toJSON()).toMatchSnapshot(); - }); - - it('renders QR code with correct properties', () => { - // Arrange & Act - const { getByTestId } = renderScreen( - ReceiveRequest, - { name: 'ReceiveRequest' }, - { state: initialState }, - ); - const qrCode = getByTestId('receive-request-qr-code'); - - // Assert - expect(qrCode).toBeOnTheScreen(); - expect(qrCode.props.accessibilityLabel).toContain('size: 200'); - expect(qrCode.props.accessibilityLabel).toContain('logoSize: 32'); - expect(qrCode.props.accessibilityLabel).toContain('logoBorderRadius: 8'); - }); - - it('displays account address in QR account display', () => { - // Arrange & Act - const { getByTestId } = renderScreen( - ReceiveRequest, - { name: 'ReceiveRequest' }, - { state: initialState }, - ); - - // Assert - expect(getByTestId('receive-request-qr-account-display')).toBeOnTheScreen(); - expect(getByTestId('qr-account-address')).toBeOnTheScreen(); - }); - - it('render with different ticker matches snapshot', () => { - const state = cloneDeep(initialState); - state.engine.backgroundState.NetworkController.networkConfigurationsByChainId[ - '0x1' - ].nativeCurrency = 'DIFF'; - const { toJSON } = renderScreen( - ReceiveRequest, - { name: 'ReceiveRequest' }, - { state }, - ); - expect(toJSON()).toMatchSnapshot(); - }); - - it('render without buy matches snapshot', () => { - const state = { - ...initialState, - fiatOrders: undefined, - }; - const { toJSON } = renderScreen( - ReceiveRequest, - { name: 'ReceiveRequest' }, - { state }, - ); - expect(toJSON()).toMatchSnapshot(); - }); - - it('renders request payment button when EVM network is selected', () => { - // Arrange & Act - const { getByTestId } = renderScreen( - ReceiveRequest, - { name: 'ReceiveRequest' }, - { state: initialState }, - ); - const requestButton = getByTestId( - RequestPaymentModalSelectorsIDs.REQUEST_BUTTON, - ); - - // Assert - expect(requestButton).toBeOnTheScreen(); - }); - - it('does not render request button when EVM network is not selected', () => { - // Arrange - const state = { - ...initialState, - engine: { - backgroundState: { - ...initialState.engine.backgroundState, - MultichainNetworkController: { - isEvmSelected: false, - }, - }, - }, - }; - - // Act - const { queryByTestId } = renderScreen( - ReceiveRequest, - { name: 'ReceiveRequest' }, - { state }, - ); - - // Assert - expect( - queryByTestId(RequestPaymentModalSelectorsIDs.REQUEST_BUTTON), - ).toBeNull(); - }); - - it('navigates to payment request when request button is pressed', () => { - // Arrange - const mockNavigate = jest.fn(); - const receiveAsset = { symbol: 'ETH' }; - - // Act - const { getByTestId } = renderScreen( - () => - React.createElement(ReceiveRequest, { - navigation: { navigate: mockNavigate }, - selectedAddress: '0x123', - receiveAsset, - }), - { name: 'ReceiveRequest' }, - { state: initialState }, - ); - const requestButton = getByTestId( - RequestPaymentModalSelectorsIDs.REQUEST_BUTTON, - ); - fireEvent.press(requestButton); - - // Assert - expect(mockNavigate).toHaveBeenCalledWith( - 'PaymentRequestView', - expect.objectContaining({ - screen: 'PaymentRequest', - params: expect.any(Object), - }), - ); - }); -}); diff --git a/app/selectors/tokenListController.ts b/app/selectors/tokenListController.ts index 787de3fdd35..448cbb5afb3 100644 --- a/app/selectors/tokenListController.ts +++ b/app/selectors/tokenListController.ts @@ -1,7 +1,6 @@ import { createSelector } from 'reselect'; import { TokenListState } from '@metamask/assets-controllers'; import { RootState } from '../reducers'; -import { tokenListToArray } from '../util/tokens'; import { createDeepEqualSelector } from '../selectors/util'; import { selectEvmChainId } from './networkController'; @@ -19,15 +18,6 @@ export const selectTokenList = createSelector( tokenListControllerState?.tokensChainsCache?.[chainId]?.data || [], ); -/** - * Return token list array from TokenListController. - * Can pass directly into useSelector. - */ -export const selectTokenListArray = createDeepEqualSelector( - selectTokenList, - tokenListToArray, -); - const selectERC20TokensByChainInternal = createDeepEqualSelector( selectTokenListConstrollerState, (tokenListControllerState) => tokenListControllerState?.tokensChainsCache, diff --git a/tests/page-objects/Receive/PaymentRequestQrBottomSheet.ts b/tests/page-objects/Receive/PaymentRequestQrBottomSheet.ts deleted file mode 100644 index 941bca08ebc..00000000000 --- a/tests/page-objects/Receive/PaymentRequestQrBottomSheet.ts +++ /dev/null @@ -1,23 +0,0 @@ -import Matchers from '../../framework/Matchers'; -import Gestures from '../../framework/Gestures'; -import { SendLinkViewSelectorsIDs } from '../../../app/components/UI/ReceiveRequest/SendLinkView.testIds'; - -class PaymentRequestQrBottomSheet { - get container(): DetoxElement { - return Matchers.getElementByID(SendLinkViewSelectorsIDs.QR_MODAL); - } - - get closeButton(): DetoxElement { - return Matchers.getElementByID( - SendLinkViewSelectorsIDs.CLOSE_QR_MODAL_BUTTON, - ); - } - - async tapCloseButton(): Promise { - await Gestures.waitAndTap(this.closeButton, { - elemDescription: 'Close Button in Payment Request QR Bottom Sheet', - }); - } -} - -export default new PaymentRequestQrBottomSheet(); diff --git a/tests/page-objects/Receive/RequestPaymentModal.ts b/tests/page-objects/Receive/RequestPaymentModal.ts deleted file mode 100644 index 4d4ad17cb52..00000000000 --- a/tests/page-objects/Receive/RequestPaymentModal.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { RequestPaymentModalSelectorsIDs } from '../../../app/components/UI/ReceiveRequest/RequestPaymentModal.testIds'; -import Matchers from '../../framework/Matchers'; -import Gestures from '../../framework/Gestures'; - -class RequestPaymentModal { - get requestPaymentButton(): DetoxElement { - return device.getPlatform() === 'android' - ? Matchers.getElementByLabel( - RequestPaymentModalSelectorsIDs.REQUEST_BUTTON, - ) - : Matchers.getElementByID(RequestPaymentModalSelectorsIDs.REQUEST_BUTTON); - } - - async tapRequestPaymentButton(): Promise { - await Gestures.waitAndTap(this.requestPaymentButton, { - elemDescription: 'Request Payment Button in Request Payment Modal', - }); - } -} - -export default new RequestPaymentModal(); diff --git a/tests/page-objects/Receive/RequestPaymentView.ts b/tests/page-objects/Receive/RequestPaymentView.ts deleted file mode 100644 index efc805f36c9..00000000000 --- a/tests/page-objects/Receive/RequestPaymentView.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { RequestPaymentViewSelectors } from '../../../app/components/UI/ReceiveRequest/RequestPaymentView.testIds'; -import Matchers from '../../framework/Matchers'; -import Gestures from '../../framework/Gestures'; - -class RequestPaymentView { - get backButton(): DetoxElement { - return Matchers.getElementByID(RequestPaymentViewSelectors.BACK_BUTTON_ID); - } - - get tokenSearchInput(): DetoxElement { - return Matchers.getElementByID( - RequestPaymentViewSelectors.TOKEN_SEARCH_INPUT_BOX, - ); - } - - get requestAmountInput(): DetoxElement { - return Matchers.getElementByID( - RequestPaymentViewSelectors.REQUEST_AMOUNT_INPUT_BOX_ID, - ); - } - - get requestPaymentContainer(): DetoxElement { - return Matchers.getElementByID( - RequestPaymentViewSelectors.REQUEST_PAYMENT_CONTAINER_ID, - ); - } - - get requestAssetList(): DetoxElement { - return Matchers.getElementByID( - RequestPaymentViewSelectors.REQUEST_ASSET_LIST_ID, - ); - } - - async tapBackButton(): Promise { - await Gestures.waitAndTap(this.backButton); - } - - async searchForToken(token: string): Promise { - await Gestures.typeText(this.tokenSearchInput, token, { - elemDescription: 'Token Search Input', - hideKeyboard: true, - }); - } - - async tapOnToken(token: string) { - const tokenElement = await Matchers.getElementByText(token, 0); - await Gestures.waitAndTap(Promise.resolve(tokenElement), { - elemDescription: `Token "${token}" in Request Payment View`, - }); - } - - async typeInTokenAmount(amount: number | string): Promise { - await Gestures.typeText(this.requestAmountInput, String(amount), { - elemDescription: 'Request Amount Input', - hideKeyboard: true, - }); - } -} - -export default new RequestPaymentView(); diff --git a/tests/page-objects/Receive/SendLinkView.ts b/tests/page-objects/Receive/SendLinkView.ts deleted file mode 100644 index c56653faa55..00000000000 --- a/tests/page-objects/Receive/SendLinkView.ts +++ /dev/null @@ -1,39 +0,0 @@ -import Matchers from '../../framework/Matchers'; -import Gestures from '../../framework/Gestures'; -import { SendLinkViewSelectorsIDs } from '../../../app/components/UI/ReceiveRequest/SendLinkView.testIds'; - -class SendLinkView { - get container(): DetoxElement { - return Matchers.getElementByID(SendLinkViewSelectorsIDs.CONTAINER_ID); - } - - get qrModal(): DetoxElement { - return Matchers.getElementByID(SendLinkViewSelectorsIDs.QR_MODAL); - } - - get closeSendLinkButton(): DetoxElement { - return Matchers.getElementByID( - SendLinkViewSelectorsIDs.CLOSE_SEND_LINK_VIEW_BUTTON, - ); - } - - get qrCodeButton(): DetoxElement { - return device.getPlatform() === 'android' - ? Matchers.getElementByLabel(SendLinkViewSelectorsIDs.QR_CODE_BUTTON) - : Matchers.getElementByID(SendLinkViewSelectorsIDs.QR_CODE_BUTTON); - } - - async tapQRCodeButton(): Promise { - await Gestures.waitAndTap(this.qrCodeButton, { - elemDescription: 'QR Code Button in Send Link View', - }); - } - - async tapCloseSendLinkButton(): Promise { - await Gestures.waitAndTap(this.closeSendLinkButton, { - elemDescription: 'Close Send Link Button in Send Link View', - }); - } -} - -export default new SendLinkView(); diff --git a/tests/regression/wallet/request-token-flow.spec.ts b/tests/regression/wallet/request-token-flow.spec.ts deleted file mode 100644 index 9ea53f03b57..00000000000 --- a/tests/regression/wallet/request-token-flow.spec.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { RegressionWalletPlatform } from '../../tags'; -import RequestPaymentModal from '../../page-objects/Receive/RequestPaymentModal'; -import SendLinkView from '../../page-objects/Receive/SendLinkView'; -import PaymentRequestQrBottomSheet from '../../page-objects/Receive/PaymentRequestQrBottomSheet'; -import RequestPaymentView from '../../page-objects/Receive/RequestPaymentView'; -import WalletView from '../../page-objects/wallet/WalletView'; -import ProtectYourWalletModal from '../../page-objects/Onboarding/ProtectYourWalletModal'; -import { loginToApp } from '../../flows/wallet.flow'; -import { withFixtures } from '../../framework/fixtures/FixtureHelper'; -import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; -import Assertions from '../../framework/Assertions'; - -const SAI_CONTRACT_ADDRESS: string = - '0x89d24a6b4ccb1b6faa2625fe562bdd9a23260359'; - -describe.skip( - RegressionWalletPlatform('Request Token Flow with Unprotected Wallet'), - (): void => { - it('should complete request token flow from action button to wallet protection modal', async (): Promise => { - await withFixtures( - { - fixture: new FixtureBuilder() - .withKeyringController() - .withSeedphraseBackedUpDisabled() - .build(), - restartDevice: true, - }, - async (): Promise => { - await loginToApp(); - await device.disableSynchronization(); - await Assertions.expectElementToBeVisible(WalletView.container); - // Request asset from main Receive button - await WalletView.tapWalletReceiveButton(); - await RequestPaymentModal.tapRequestPaymentButton(); - await Assertions.expectElementToBeVisible( - RequestPaymentView.requestPaymentContainer, - ); - - // Search for SAI by contract - await RequestPaymentView.searchForToken(SAI_CONTRACT_ADDRESS); - await Assertions.expectTextDisplayed('SAI'); - - // Search DAI - await RequestPaymentView.searchForToken('D'); - await RequestPaymentView.tapOnToken('Dai Stablecoin'); - - // Request DAI amount - await RequestPaymentView.typeInTokenAmount(5.5); - await Assertions.expectElementToBeVisible(SendLinkView.container); - - // See DAI request QR code - await SendLinkView.tapQRCodeButton(); - await Assertions.expectElementToBeVisible( - PaymentRequestQrBottomSheet.container, - ); - - // Close request - await PaymentRequestQrBottomSheet.tapCloseButton(); - await SendLinkView.tapCloseSendLinkButton(); - - // See protect your wallet modal - await Assertions.expectElementToBeVisible( - ProtectYourWalletModal.container, - ); - }, - ); - }); - }, -); From feb42687409a91d9bb9d6f73f452727073b421f5 Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Wed, 18 Mar 2026 20:47:35 +0800 Subject: [PATCH 082/206] fix(agentic): store password in SecureKeychain for Android auto-unlock (#27576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** On Android, after the agentic service sets up a wallet with device authentication enabled, the app fails to auto-unlock on reload because the password was never stored in `SecureKeychain`. iOS handles this natively, but Android requires an explicit call to `SecureKeychain.setGenericPassword`. This fix stores the password in `SecureKeychain` with the `DEVICE_AUTHENTICATION` type during agentic wallet setup on Android, enabling auto-unlock on reload. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: N/A ## **Manual testing steps** ```gherkin Feature: Android auto-unlock after agentic wallet setup Scenario: App auto-unlocks on reload with device auth enabled Given the app is running on Android with agentic service And a wallet fixture with deviceAuthEnabled: true is loaded When the app reloads Then the wallet auto-unlocks without prompting for password ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Touches credential storage by writing the wallet password to `SecureKeychain` (Android-only) when device auth is enabled; while scoped to the __DEV__ agentic setup flow, regressions could affect unlock behavior or leak sensitive logs if misused. > > **Overview** > Fixes Android agentic wallet setup so device-auth wallets can auto-unlock after reload by **storing the fixture password in `SecureKeychain`** under `DEVICE_AUTHENTICATION` when `settings.deviceAuthEnabled` is true. > > Adds `DevLogger` instrumentation around the new keychain write, and updates the unit test harness to mock `SecureKeychain`, `AUTHENTICATION_TYPE`, and `DevLogger`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 71a4e8c7d521f7d9eeb12e3b67a94f729c5462fc. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). Co-authored-by: Arthur Breton Co-authored-by: Claude Opus 4.6 (1M context) --- app/core/AgenticService/AgenticService.test.ts | 10 ++++++++++ app/core/AgenticService/AgenticService.ts | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/app/core/AgenticService/AgenticService.test.ts b/app/core/AgenticService/AgenticService.test.ts index 2fc46f7ad68..9fd496d3998 100644 --- a/app/core/AgenticService/AgenticService.test.ts +++ b/app/core/AgenticService/AgenticService.test.ts @@ -103,6 +103,16 @@ jest.mock('../NavigationService', () => ({ jest.mock('../../constants/navigation/Routes', () => ({ ONBOARDING: { HOME_NAV: 'HomeNav' }, })); +jest.mock('../SecureKeychain', () => ({ + setGenericPassword: jest.fn().mockResolvedValue(undefined), +})); +jest.mock('../../constants/userProperties', () => ({ + __esModule: true, + default: { DEVICE_AUTHENTICATION: 'device_authentication' }, +})); +jest.mock('../SDKConnect/utils/DevLogger', () => ({ + log: jest.fn(), +})); const MockEngine = jest.mocked(Engine); diff --git a/app/core/AgenticService/AgenticService.ts b/app/core/AgenticService/AgenticService.ts index b8f1fb81435..568833c4aa8 100644 --- a/app/core/AgenticService/AgenticService.ts +++ b/app/core/AgenticService/AgenticService.ts @@ -37,6 +37,9 @@ import { setLockTime } from '../../actions/settings'; import AccountTreeInitService from '../../multichain-accounts/AccountTreeInitService'; import NavigationService from '../NavigationService'; import Routes from '../../constants/navigation/Routes'; +import SecureKeychain from '../SecureKeychain'; +import AUTHENTICATION_TYPE from '../../constants/userProperties'; +import DevLogger from '../SDKConnect/utils/DevLogger'; // ─── Fiber tree types ────────────────────────────────────────────────────── @@ -460,6 +463,21 @@ const AgenticService = { ReduxService.store.dispatch(setOsAuthEnabled(true)); } + // 8b. Store password in SecureKeychain for device-auth auto-unlock on reload (Android only — iOS already handles this) + if ( + settings.deviceAuthEnabled === true && + Platform.OS === 'android' + ) { + DevLogger.log('[AUTO-UNLOCK] Storing password in SecureKeychain', { + authType: AUTHENTICATION_TYPE.DEVICE_AUTHENTICATION, + }); + await SecureKeychain.setGenericPassword( + fixture.password, + AUTHENTICATION_TYPE.DEVICE_AUTHENTICATION, + ); + DevLogger.log('[AUTO-UNLOCK] SecureKeychain password stored'); + } + // 9. Configure MetaMetrics if specified if (settings.metametrics === false) { await analytics.optOut(); From 3e74af4866b2b38d8c45730aff7908d0d2532214 Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Wed, 18 Mar 2026 13:48:24 +0100 Subject: [PATCH 083/206] fix: improve hardware wallet icon colors and copy (#27597) ## **Description** ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** no manual testing steps ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Low risk UI/UX-only changes affecting hardware-wallet error presentation (icon color selection and one localized string), with tests updated to match. > > **Overview** > Updates hardware-wallet error presentation to reduce overly-severe styling: most `ErrorCode`s now use `IconColor.Default`, while `Unknown` (and unmapped codes via fallback) use `IconColor.Warning` instead of `Error`. > > Updates unit tests to assert the new icon-color behavior, and tweaks the English copy for `hardware_wallet.errors.bluetooth_connection_failed` to a more accurate connection-failure message. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit d522ba68dc0b092b8dcb4f94247349a76cab6e39. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../HardwareWallet/errors/helpers.test.ts | 58 ++++++++++--------- app/core/HardwareWallet/errors/helpers.ts | 2 +- app/core/HardwareWallet/errors/mappings.ts | 44 +++++++------- locales/languages/en.json | 2 +- 4 files changed, 55 insertions(+), 51 deletions(-) diff --git a/app/core/HardwareWallet/errors/helpers.test.ts b/app/core/HardwareWallet/errors/helpers.test.ts index 21d95bb82b4..6d1c992b2a1 100644 --- a/app/core/HardwareWallet/errors/helpers.test.ts +++ b/app/core/HardwareWallet/errors/helpers.test.ts @@ -132,42 +132,46 @@ describe('error helpers', () => { }); describe('getIconColorForErrorCode', () => { - it('returns Error color for DeviceDisconnected', () => { - const color = getIconColorForErrorCode(ErrorCode.DeviceDisconnected); - - expect(color).toBe(IconColor.Error); - }); - - it('returns Info color for UserRejected', () => { - const color = getIconColorForErrorCode(ErrorCode.UserRejected); - - expect(color).toBe(IconColor.Info); - }); - - it('returns Info color for UserCancelled', () => { - const color = getIconColorForErrorCode(ErrorCode.UserCancelled); - - expect(color).toBe(IconColor.Info); - }); - - it('returns Warning color for UserConfirmationRequired', () => { - const color = getIconColorForErrorCode( + it('returns Default for most errors', () => { + const defaultCodes = [ + ErrorCode.AuthenticationDeviceLocked, + ErrorCode.AuthenticationDeviceBlocked, + ErrorCode.DeviceStateEthAppClosed, + ErrorCode.DeviceDisconnected, + ErrorCode.DeviceNotFound, + ErrorCode.DeviceMissingCapability, + ErrorCode.DeviceStateBlindSignNotSupported, + ErrorCode.DeviceUnresponsive, + ErrorCode.ConnectionClosed, + ErrorCode.ConnectionTimeout, + ErrorCode.UserRejected, + ErrorCode.UserCancelled, ErrorCode.UserConfirmationRequired, - ); - - expect(color).toBe(IconColor.Warning); + ErrorCode.PermissionBluetoothDenied, + ErrorCode.PermissionLocationDenied, + ErrorCode.PermissionNearbyDevicesDenied, + ErrorCode.BluetoothDisabled, + ErrorCode.BluetoothScanFailed, + ErrorCode.BluetoothConnectionFailed, + ErrorCode.DeviceNotReady, + ErrorCode.MobileNotSupported, + ]; + + for (const code of defaultCodes) { + expect(getIconColorForErrorCode(code)).toBe(IconColor.Default); + } }); - it('returns Error color for Unknown', () => { + it('returns Warning color for Unknown', () => { const color = getIconColorForErrorCode(ErrorCode.Unknown); - expect(color).toBe(IconColor.Error); + expect(color).toBe(IconColor.Warning); }); - it('returns default Error color for unmapped codes', () => { + it('returns default Warning color for unmapped codes', () => { const color = getIconColorForErrorCode(999 as ErrorCode); - expect(color).toBe(IconColor.Error); + expect(color).toBe(IconColor.Warning); }); }); diff --git a/app/core/HardwareWallet/errors/helpers.ts b/app/core/HardwareWallet/errors/helpers.ts index 88b43c867a9..6f519a12dd1 100644 --- a/app/core/HardwareWallet/errors/helpers.ts +++ b/app/core/HardwareWallet/errors/helpers.ts @@ -43,7 +43,7 @@ export function getIconForErrorCode(errorCode: ErrorCode): IconName { */ export function getIconColorForErrorCode(errorCode: ErrorCode): IconColor { const ext = MOBILE_ERROR_EXTENSIONS[errorCode]; - return ext?.iconColor ?? IconColor.Error; + return ext?.iconColor ?? IconColor.Warning; } /** diff --git a/app/core/HardwareWallet/errors/mappings.ts b/app/core/HardwareWallet/errors/mappings.ts index 8976cd02b8e..acb3fb7d69c 100644 --- a/app/core/HardwareWallet/errors/mappings.ts +++ b/app/core/HardwareWallet/errors/mappings.ts @@ -26,7 +26,7 @@ export const MOBILE_ERROR_EXTENSIONS: Partial< [ErrorCode.AuthenticationDeviceLocked]: { recoveryAction: RecoveryAction.RETRY, icon: IconName.Lock, - iconColor: IconColor.Error, + iconColor: IconColor.Default, getLocalizedTitle: (walletType) => strings('hardware_wallet.error.device_locked_title', { device: getHardwareWalletTypeName(walletType), @@ -39,7 +39,7 @@ export const MOBILE_ERROR_EXTENSIONS: Partial< [ErrorCode.AuthenticationDeviceBlocked]: { recoveryAction: RecoveryAction.RETRY, icon: IconName.Lock, - iconColor: IconColor.Error, + iconColor: IconColor.Default, getLocalizedTitle: (walletType) => strings('hardware_wallet.error.device_locked_title', { device: getHardwareWalletTypeName(walletType), @@ -54,14 +54,14 @@ export const MOBILE_ERROR_EXTENSIONS: Partial< [ErrorCode.DeviceStateEthAppClosed]: { recoveryAction: RecoveryAction.RETRY, icon: IconName.Setting, - iconColor: IconColor.Error, + iconColor: IconColor.Default, getLocalizedTitle: () => strings('hardware_wallet.error.app_not_open'), getLocalizedMessage: () => strings('hardware_wallet.errors.app_not_open'), }, [ErrorCode.DeviceDisconnected]: { recoveryAction: RecoveryAction.RETRY, icon: IconName.Plug, - iconColor: IconColor.Error, + iconColor: IconColor.Default, getLocalizedTitle: (walletType) => strings('hardware_wallet.error.device_disconnected_title', { device: getHardwareWalletTypeName(walletType), @@ -74,7 +74,7 @@ export const MOBILE_ERROR_EXTENSIONS: Partial< [ErrorCode.DeviceNotFound]: { recoveryAction: RecoveryAction.RETRY, icon: IconName.Search, - iconColor: IconColor.Error, + iconColor: IconColor.Default, getLocalizedTitle: (walletType) => strings('hardware_wallet.error.device_not_found_title', { device: getHardwareWalletTypeName(walletType), @@ -87,7 +87,7 @@ export const MOBILE_ERROR_EXTENSIONS: Partial< [ErrorCode.DeviceNotReady]: { recoveryAction: RecoveryAction.RETRY, icon: IconName.Clock, - iconColor: IconColor.Error, + iconColor: IconColor.Default, getLocalizedTitle: () => strings('hardware_wallet.error.something_went_wrong'), getLocalizedMessage: () => @@ -96,14 +96,14 @@ export const MOBILE_ERROR_EXTENSIONS: Partial< [ErrorCode.DeviceMissingCapability]: { recoveryAction: RecoveryAction.RETRY, icon: IconName.Setting, - iconColor: IconColor.Error, + iconColor: IconColor.Default, getLocalizedTitle: () => strings('hardware_wallet.error.app_not_open'), getLocalizedMessage: () => strings('hardware_wallet.errors.app_not_open'), }, [ErrorCode.DeviceStateBlindSignNotSupported]: { recoveryAction: RecoveryAction.ACKNOWLEDGE, icon: IconName.Eye, - iconColor: IconColor.Error, + iconColor: IconColor.Default, getLocalizedTitle: () => strings('hardware_wallet.error.blind_signing_disabled'), getLocalizedMessage: () => strings('hardware_wallet.errors.blind_signing'), @@ -111,7 +111,7 @@ export const MOBILE_ERROR_EXTENSIONS: Partial< [ErrorCode.DeviceUnresponsive]: { recoveryAction: RecoveryAction.RETRY, icon: IconName.Clock, - iconColor: IconColor.Error, + iconColor: IconColor.Default, getLocalizedTitle: () => strings('hardware_wallet.error.connection_timeout'), getLocalizedMessage: () => @@ -122,7 +122,7 @@ export const MOBILE_ERROR_EXTENSIONS: Partial< [ErrorCode.ConnectionClosed]: { recoveryAction: RecoveryAction.RETRY, icon: IconName.Close, - iconColor: IconColor.Error, + iconColor: IconColor.Default, getLocalizedTitle: () => strings('hardware_wallet.error.connection_closed'), getLocalizedMessage: () => strings('hardware_wallet.errors.connection_closed'), @@ -130,7 +130,7 @@ export const MOBILE_ERROR_EXTENSIONS: Partial< [ErrorCode.ConnectionTimeout]: { recoveryAction: RecoveryAction.RETRY, icon: IconName.Clock, - iconColor: IconColor.Error, + iconColor: IconColor.Default, getLocalizedTitle: () => strings('hardware_wallet.error.connection_timeout'), getLocalizedMessage: () => @@ -141,21 +141,21 @@ export const MOBILE_ERROR_EXTENSIONS: Partial< [ErrorCode.UserRejected]: { recoveryAction: RecoveryAction.ACKNOWLEDGE, icon: IconName.Close, - iconColor: IconColor.Info, + iconColor: IconColor.Default, getLocalizedTitle: () => strings('hardware_wallet.error.user_cancelled'), getLocalizedMessage: () => strings('hardware_wallet.errors.user_cancelled'), }, [ErrorCode.UserCancelled]: { recoveryAction: RecoveryAction.ACKNOWLEDGE, icon: IconName.Close, - iconColor: IconColor.Info, + iconColor: IconColor.Default, getLocalizedTitle: () => strings('hardware_wallet.error.user_cancelled'), getLocalizedMessage: () => strings('hardware_wallet.errors.user_cancelled'), }, [ErrorCode.UserConfirmationRequired]: { recoveryAction: RecoveryAction.RETRY, icon: IconName.SecurityTick, - iconColor: IconColor.Warning, + iconColor: IconColor.Default, getLocalizedTitle: () => strings('hardware_wallet.error.pending_confirmation'), getLocalizedMessage: () => @@ -166,7 +166,7 @@ export const MOBILE_ERROR_EXTENSIONS: Partial< [ErrorCode.PermissionBluetoothDenied]: { recoveryAction: RecoveryAction.RETRY, icon: IconName.Connect, - iconColor: IconColor.Error, + iconColor: IconColor.Default, getLocalizedTitle: () => strings('hardware_wallet.error.bluetooth_permission_denied'), getLocalizedMessage: () => @@ -175,7 +175,7 @@ export const MOBILE_ERROR_EXTENSIONS: Partial< [ErrorCode.PermissionLocationDenied]: { recoveryAction: RecoveryAction.RETRY, icon: IconName.Location, - iconColor: IconColor.Error, + iconColor: IconColor.Default, getLocalizedTitle: () => strings('hardware_wallet.error.location_permission_denied'), getLocalizedMessage: () => @@ -184,7 +184,7 @@ export const MOBILE_ERROR_EXTENSIONS: Partial< [ErrorCode.PermissionNearbyDevicesDenied]: { recoveryAction: RecoveryAction.RETRY, icon: IconName.Connect, - iconColor: IconColor.Error, + iconColor: IconColor.Default, getLocalizedTitle: () => strings('hardware_wallet.error.nearby_devices_permission_denied'), getLocalizedMessage: () => @@ -193,7 +193,7 @@ export const MOBILE_ERROR_EXTENSIONS: Partial< [ErrorCode.BluetoothDisabled]: { recoveryAction: RecoveryAction.RETRY, icon: IconName.Connect, - iconColor: IconColor.Error, + iconColor: IconColor.Default, getLocalizedTitle: () => strings('hardware_wallet.error.bluetooth_required'), getLocalizedMessage: () => strings('hardware_wallet.errors.bluetooth_off'), @@ -201,7 +201,7 @@ export const MOBILE_ERROR_EXTENSIONS: Partial< [ErrorCode.BluetoothScanFailed]: { recoveryAction: RecoveryAction.RETRY, icon: IconName.Connect, - iconColor: IconColor.Error, + iconColor: IconColor.Default, getLocalizedTitle: () => strings('hardware_wallet.error.scan_failed'), getLocalizedMessage: () => strings('hardware_wallet.errors.bluetooth_scan_failed'), @@ -209,7 +209,7 @@ export const MOBILE_ERROR_EXTENSIONS: Partial< [ErrorCode.BluetoothConnectionFailed]: { recoveryAction: RecoveryAction.RETRY, icon: IconName.Connect, - iconColor: IconColor.Error, + iconColor: IconColor.Default, getLocalizedTitle: () => strings('hardware_wallet.error.connection_closed'), getLocalizedMessage: () => strings('hardware_wallet.errors.bluetooth_connection_failed'), @@ -219,7 +219,7 @@ export const MOBILE_ERROR_EXTENSIONS: Partial< [ErrorCode.MobileNotSupported]: { recoveryAction: RecoveryAction.ACKNOWLEDGE, icon: IconName.Danger, - iconColor: IconColor.Error, + iconColor: IconColor.Default, getLocalizedTitle: () => strings('hardware_wallet.error.something_went_wrong'), getLocalizedMessage: () => strings('hardware_wallet.errors.not_supported'), @@ -229,7 +229,7 @@ export const MOBILE_ERROR_EXTENSIONS: Partial< [ErrorCode.Unknown]: { recoveryAction: RecoveryAction.RETRY, icon: IconName.Danger, - iconColor: IconColor.Error, + iconColor: IconColor.Warning, getLocalizedTitle: () => strings('hardware_wallet.error.something_went_wrong'), getLocalizedMessage: (walletType) => diff --git a/locales/languages/en.json b/locales/languages/en.json index e3b3349d2e8..056f0976028 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -8043,7 +8043,7 @@ "nearby_permission_denied": "Nearby devices permission is required", "bluetooth_off": "Please turn on Bluetooth to connect to your device", "bluetooth_scan_failed": "Failed to scan for devices. Please try again", - "bluetooth_connection_failed": "Enable Bluetooth on your device to continue", + "bluetooth_connection_failed": "The connection to your device failed. Please try again", "not_supported": "This operation is not supported", "unknown_error": "Make sure your {{device}} is set up with the Secret Recovery Phrase or passphrase for this account" }, From 1bf5d785381031f824a1abc652368a7ba7d6da15 Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Wed, 18 Mar 2026 20:55:48 +0800 Subject: [PATCH 084/206] fix(perps): fix stale data and missing price change after reconnection (#27530) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Two fixes for perps foreground reconnection: 1. **Stale data after background** — `connect()` returned early when `isConnected=true` (grace period kept state alive) without checking if the WebSocket was dead. Fixed by adding `ensureConnected()` that always forces disconnect + reconnect on foreground return. 2. **Price change "–%" persists after reconnect** — Prewarm called `subscribeToPrices()` without `includeMarketData`, so `assetCtxs` subscriptions (which provide `prevDayPx` for `percentChange24h`) were never re-established. Fixed by moving the `assetCtxs` subscription out of the `includeMarketData` guard in `subscribeToPrices()`. This is safe because `assetCtxs` is 1 subscription per DEX (2-3 total), not per-symbol. The expensive per-symbol `activeAssetCtx` subscriptions remain gated behind `includeMarketData`. ## **Changelog** CHANGELOG entry: Fixed stale perps data and missing 24h price change after returning from background ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Perps foreground reconnection Scenario: user returns after short background (grace period still active) Given user is on Perps screen with live data When user backgrounds app for 10s and returns Then data refreshes with live prices and positions And 24h price change % displays correctly (not "--%" ) Scenario: user returns after long background (grace period already fired) Given user is on Perps screen with live data When user backgrounds app for 60s and returns Then data refreshes with live prices and positions And 24h price change % displays correctly (not "--%" ) Scenario: initial mount unchanged Given user opens app fresh When user navigates to Perps Then connection establishes normally via connect() And 24h price change % displays correctly ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Touches perps WebSocket lifecycle and subscription behavior; regressions could cause extra reconnects or missed/duplicated subscriptions, though changes are scoped and covered by updated tests. > > **Overview** > Fixes perps reconnection reliability by switching foreground handling from `connect()` to a new `PerpsConnectionManager.ensureConnected()` that **cancels any grace period, force-disconnects, resets ref-count, and reconnects**, deduplicating concurrent calls. > > Restores 24h % change after reconnection/prewarm by ensuring `HyperLiquidSubscriptionService.subscribeToPrices()` always establishes lightweight per-DEX `assetCtxs` subscriptions even when `includeMarketData` is false; price prewarm explicitly passes `includeMarketData: false` and documents the N² connection risk. > > Updates unit tests and architecture docs to reflect `ensureConnected()` usage and the new subscription expectations. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 1549f85b775097e5f10768f093f6548ba92e4253. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../providers/PerpsAlwaysOnProvider.test.tsx | 20 ++-- .../Perps/providers/PerpsAlwaysOnProvider.tsx | 2 +- .../providers/PerpsStreamManager.test.tsx | 4 +- .../UI/Perps/providers/PerpsStreamManager.tsx | 6 +- .../services/PerpsConnectionManager.test.ts | 112 ++++++++++++++++++ .../Perps/services/PerpsConnectionManager.ts | 60 +++++++++- .../HyperLiquidSubscriptionService.test.ts | 4 +- .../HyperLiquidSubscriptionService.ts | 59 +++++---- docs/perps/perps-connection-architecture.md | 19 +-- 9 files changed, 229 insertions(+), 57 deletions(-) diff --git a/app/components/UI/Perps/providers/PerpsAlwaysOnProvider.test.tsx b/app/components/UI/Perps/providers/PerpsAlwaysOnProvider.test.tsx index c2c760bbcad..ce59bb17707 100644 --- a/app/components/UI/Perps/providers/PerpsAlwaysOnProvider.test.tsx +++ b/app/components/UI/Perps/providers/PerpsAlwaysOnProvider.test.tsx @@ -41,6 +41,7 @@ jest.mock('../index', () => ({ const mockUseSelector = useSelector as jest.MockedFunction; const mockConnect = PerpsConnectionManager.connect as jest.Mock; const mockDisconnect = PerpsConnectionManager.disconnect as jest.Mock; +const mockEnsureConnected = PerpsConnectionManager.ensureConnected as jest.Mock; describe('PerpsAlwaysOnProvider', () => { let mockAppStateListener: ((state: string) => void) | null = null; @@ -53,6 +54,7 @@ describe('PerpsAlwaysOnProvider', () => { mockConnect.mockResolvedValue(undefined); mockDisconnect.mockResolvedValue(undefined); + mockEnsureConnected.mockResolvedValue(undefined); mockSubscriptionRemove = jest.fn(); addEventListenerSpy = jest @@ -167,7 +169,7 @@ describe('PerpsAlwaysOnProvider', () => { expect(mockDisconnect).toHaveBeenCalledTimes(1); }); - it('calls connect after delay when app returns to foreground', () => { + it('calls ensureConnected after delay when app returns to foreground', () => { render( child @@ -175,7 +177,7 @@ describe('PerpsAlwaysOnProvider', () => { ); // Clear the initial mount connect call - mockConnect.mockClear(); + mockEnsureConnected.mockClear(); act(() => { mockAppStateListener?.('background'); @@ -185,13 +187,13 @@ describe('PerpsAlwaysOnProvider', () => { }); // Should not reconnect immediately — uses a timer delay - expect(mockConnect).not.toHaveBeenCalled(); + expect(mockEnsureConnected).not.toHaveBeenCalled(); act(() => { jest.runAllTimers(); }); - expect(mockConnect).toHaveBeenCalledTimes(1); + expect(mockEnsureConnected).toHaveBeenCalledTimes(1); }); it('cancels pending reconnect timer if app goes background before timer fires', () => { @@ -201,7 +203,7 @@ describe('PerpsAlwaysOnProvider', () => { , ); - mockConnect.mockClear(); + mockEnsureConnected.mockClear(); // Goes active — schedules reconnect timer act(() => { @@ -217,8 +219,8 @@ describe('PerpsAlwaysOnProvider', () => { jest.runAllTimers(); }); - // connect should NOT have been called (timer was cancelled) - expect(mockConnect).not.toHaveBeenCalled(); + // ensureConnected should NOT have been called (timer was cancelled) + expect(mockEnsureConnected).not.toHaveBeenCalled(); expect(mockDisconnect).toHaveBeenCalledTimes(1); }); @@ -250,7 +252,7 @@ describe('PerpsAlwaysOnProvider', () => { , ); - mockConnect.mockClear(); + mockEnsureConnected.mockClear(); mockDisconnect.mockClear(); // Pull-down: active → inactive → active @@ -266,7 +268,7 @@ describe('PerpsAlwaysOnProvider', () => { }); expect(mockDisconnect).toHaveBeenCalledTimes(1); - expect(mockConnect).toHaveBeenCalledTimes(1); + expect(mockEnsureConnected).toHaveBeenCalledTimes(1); }); it('calls disconnect and removes AppState subscription on unmount', () => { diff --git a/app/components/UI/Perps/providers/PerpsAlwaysOnProvider.tsx b/app/components/UI/Perps/providers/PerpsAlwaysOnProvider.tsx index 0573d565a90..138e0dfad95 100644 --- a/app/components/UI/Perps/providers/PerpsAlwaysOnProvider.tsx +++ b/app/components/UI/Perps/providers/PerpsAlwaysOnProvider.tsx @@ -58,7 +58,7 @@ export const PerpsAlwaysOnProvider: React.FC<{ children: React.ReactNode }> = ({ } else if (nextState === 'active') { // Small delay to allow system to stabilize after background reconnectTimer = setTimeout(() => { - PerpsConnectionManager.connect().catch((err) => { + PerpsConnectionManager.ensureConnected().catch((err) => { Logger.error(ensureError(err, 'PerpsAlwaysOnProvider.reconnect'), { tags: { feature: PERPS_CONSTANTS.FeatureName }, context: { diff --git a/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx b/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx index 32c0363acf4..16a8dae1cb6 100644 --- a/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx +++ b/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx @@ -1046,9 +1046,10 @@ describe('PerpsStreamManager', () => { await Promise.resolve(); }); - // Now subscribeToPrices should have been called + // Now subscribeToPrices should have been called without includeMarketData expect(mockSubscribeToPrices).toHaveBeenCalledWith({ symbols: ['BTC-PERP', 'ETH-PERP'], + includeMarketData: false, callback: expect.any(Function), }); @@ -1237,6 +1238,7 @@ describe('PerpsStreamManager', () => { expect(mockSubscribeToPrices).toHaveBeenCalledTimes(1); expect(mockSubscribeToPrices).toHaveBeenCalledWith({ symbols: ['ETH-PERP'], + includeMarketData: false, callback: expect.any(Function), }); diff --git a/app/components/UI/Perps/providers/PerpsStreamManager.tsx b/app/components/UI/Perps/providers/PerpsStreamManager.tsx index af12dc4c846..a904007ec50 100644 --- a/app/components/UI/Perps/providers/PerpsStreamManager.tsx +++ b/app/components/UI/Perps/providers/PerpsStreamManager.tsx @@ -520,9 +520,13 @@ class PriceStreamChannel extends StreamChannel> { }, ); - // Subscribe to all market prices + // WARNING: Do NOT set includeMarketData: true here. It triggers + // per-symbol activeAssetCtx subscriptions (N symbols × N DEXs = N² + // WebSocket connections). assetCtxs (1 per DEX) is always established + // by the subscription service regardless of this flag. const unsub = controller.subscribeToPrices({ symbols: this.allMarketSymbols, + includeMarketData: false, callback: (updates: PriceUpdate[]) => { const priceMap: Record = {}; updates.forEach((update) => { diff --git a/app/components/UI/Perps/services/PerpsConnectionManager.test.ts b/app/components/UI/Perps/services/PerpsConnectionManager.test.ts index 1f42dec8338..4cbcbeba7be 100644 --- a/app/components/UI/Perps/services/PerpsConnectionManager.test.ts +++ b/app/components/UI/Perps/services/PerpsConnectionManager.test.ts @@ -123,6 +123,7 @@ const resetManager = (manager: unknown) => { initPromise: Promise | null; disconnectPromise: Promise | null; pendingReconnectPromise: Promise | null; + ensureConnectedPromise: Promise | null; unsubscribeFromStore: (() => void) | null; previousAddress: string | undefined; previousPerpsNetwork: 'mainnet' | 'testnet' | undefined; @@ -157,6 +158,7 @@ const resetManager = (manager: unknown) => { m.initPromise = null; m.disconnectPromise = null; m.pendingReconnectPromise = null; + m.ensureConnectedPromise = null; m.unsubscribeFromStore = null; m.previousAddress = undefined; m.previousPerpsNetwork = undefined; @@ -1275,6 +1277,116 @@ describe('PerpsConnectionManager', () => { }); }); + describe('ensureConnected', () => { + it('cancels grace period, disconnects, and reconnects when connected', async () => { + // Establish connection first + await PerpsConnectionManager.connect(); + expect(PerpsConnectionManager.getConnectionState().isConnected).toBe( + true, + ); + + // Simulate the state after AlwaysOnProvider calls disconnect() which + // decrements refCount to 0 and starts grace period + const m = PerpsConnectionManager as unknown as { + isInGracePeriod: boolean; + gracePeriodTimer: number | null; + connectionRefCount: number; + }; + m.connectionRefCount = 0; + m.isInGracePeriod = true; + m.gracePeriodTimer = 123; + + // Clear mocks to track ensureConnected calls + (Engine.context.PerpsController.disconnect as jest.Mock).mockClear(); + (Engine.context.PerpsController.init as jest.Mock).mockClear(); + + await PerpsConnectionManager.ensureConnected(); + + // Grace period should be cancelled + expect(m.isInGracePeriod).toBe(false); + + // Should have disconnected then reconnected + expect(Engine.context.PerpsController.disconnect).toHaveBeenCalled(); + expect(Engine.context.PerpsController.init).toHaveBeenCalled(); + expect(PerpsConnectionManager.getConnectionState().isConnected).toBe( + true, + ); + }); + + it('reconnects after long background when grace period already fired', async () => { + // Establish connection, then simulate grace period already fired + await PerpsConnectionManager.connect(); + + // Simulate performActualDisconnection already ran (grace period fired) + const m = PerpsConnectionManager as unknown as { + isConnected: boolean; + isInitialized: boolean; + hasPreloaded: boolean; + isPreloading: boolean; + connectionRefCount: number; + }; + m.isConnected = false; + m.isInitialized = false; + m.hasPreloaded = false; + m.isPreloading = false; + // Grace period fired → refCount was already 0 when disconnect ran + m.connectionRefCount = 0; + + (Engine.context.PerpsController.init as jest.Mock).mockClear(); + + await PerpsConnectionManager.ensureConnected(); + + // Should have reconnected (connect() runs full init path since isConnected=false) + expect(Engine.context.PerpsController.init).toHaveBeenCalled(); + expect(PerpsConnectionManager.getConnectionState().isConnected).toBe( + true, + ); + }); + + it('connects when not previously connected', async () => { + // Manager starts in disconnected state (from resetManager in beforeEach) + (Engine.context.PerpsController.init as jest.Mock).mockClear(); + + await PerpsConnectionManager.ensureConnected(); + + expect(Engine.context.PerpsController.init).toHaveBeenCalled(); + expect(PerpsConnectionManager.getConnectionState().isConnected).toBe( + true, + ); + }); + + it('resets connectionRefCount to 1 after ensureConnected', async () => { + // Simulate refCount drift: connect() twice so refCount = 2 + await PerpsConnectionManager.connect(); + await PerpsConnectionManager.connect(); + + const m = PerpsConnectionManager as unknown as { + connectionRefCount: number; + }; + expect(m.connectionRefCount).toBe(2); + + await PerpsConnectionManager.ensureConnected(); + + // ensureConnected resets to 0 then connect() brings it to 1 + expect(m.connectionRefCount).toBe(1); + }); + + it('deduplicates concurrent ensureConnected calls', async () => { + (Engine.context.PerpsController.init as jest.Mock).mockClear(); + + // Fire two calls concurrently + const [result1, result2] = await Promise.all([ + PerpsConnectionManager.ensureConnected(), + PerpsConnectionManager.ensureConnected(), + ]); + + expect(result1).toBeUndefined(); + expect(result2).toBeUndefined(); + // init should only be called once since second call reuses the promise + expect(Engine.context.PerpsController.init).toHaveBeenCalledTimes(1); + }); + }); + describe('getActiveProviderName', () => { it('returns activeProvider from PerpsController state', () => { // Arrange diff --git a/app/components/UI/Perps/services/PerpsConnectionManager.ts b/app/components/UI/Perps/services/PerpsConnectionManager.ts index d24d66c988c..96e5b5f3305 100644 --- a/app/components/UI/Perps/services/PerpsConnectionManager.ts +++ b/app/components/UI/Perps/services/PerpsConnectionManager.ts @@ -50,6 +50,7 @@ class PerpsConnectionManagerClass { private connectionRefCount = 0; private initPromise: Promise | null = null; private disconnectPromise: Promise | null = null; + private ensureConnectedPromise: Promise | null = null; private hasPreloaded = false; private isPreloading = false; private prewarmCleanups: (() => void)[] = []; @@ -470,9 +471,12 @@ class PerpsConnectionManagerClass { } /** - * Perform the actual disconnection after grace period expires + * Perform the actual disconnection after grace period expires. + * @param options.force - Bypass refCount guard (used by ensureConnected). */ - private async performActualDisconnection(): Promise { + private async performActualDisconnection( + options: { force?: boolean } = {}, + ): Promise { DevLogger.log( `PerpsConnectionManager: Grace period expired, performing disconnection (refCount: ${this.connectionRefCount})`, ); @@ -481,8 +485,8 @@ class PerpsConnectionManagerClass { this.gracePeriodTimer = null; this.isInGracePeriod = false; - // Only disconnect if we still have no references - if (this.connectionRefCount <= 0) { + // Only disconnect if we still have no references (unless forced) + if (options.force || this.connectionRefCount <= 0) { if (this.isConnected || this.isInitialized) { // Track that we're disconnecting this.isDisconnecting = true; @@ -1061,6 +1065,54 @@ class PerpsConnectionManagerClass { } } + /** + * Called on foreground return. Always forces a full reconnect. + * + * The grace period timer handles battery savings (disconnects after 30s + * in background). But regardless of whether the timer fired or not, + * we cannot trust the WebSocket state after backgrounding: + * - Grace period fired: already disconnected, need fresh connect + * - Grace period didn't fire (iOS suspends JS timers): WebSocket + * likely dead but isConnected still true, need fresh connect + * + * By always doing disconnect + connect, behavior is identical on both + * iOS and Android regardless of how long the app was backgrounded. + */ + async ensureConnected(): Promise { + // Guard against concurrent calls (e.g. rapid foreground transitions) + if (this.ensureConnectedPromise) { + return this.ensureConnectedPromise; + } + + this.ensureConnectedPromise = this.performEnsureConnected(); + try { + await this.ensureConnectedPromise; + } finally { + this.ensureConnectedPromise = null; + } + } + + private async performEnsureConnected(): Promise { + // Cancel grace period if still pending — we're taking over + this.cancelGracePeriod(); + + // Force clean state so connect() runs the full init → ping → preload path. + // Uses force: true to bypass the refCount guard — ensureConnected must + // always tear down, regardless of how many components hold references. + if (this.isConnected || this.isInitialized) { + await this.performActualDisconnection({ force: true }); + } + + // Reset refCount so connect() brings it to exactly 1. + // Without this, repeated background/foreground cycles would drift + // refCount upward (1→2→3…), eventually preventing grace-period + // disconnects from firing (they require refCount ≤ 0). + this.connectionRefCount = 0; + + // Full reconnect: init → ping → preload + await this.connect(); + } + async disconnect(): Promise { this.connectionRefCount--; DevLogger.log( diff --git a/app/controllers/perps/services/HyperLiquidSubscriptionService.test.ts b/app/controllers/perps/services/HyperLiquidSubscriptionService.test.ts index a1e2877498a..95b4801741d 100644 --- a/app/controllers/perps/services/HyperLiquidSubscriptionService.test.ts +++ b/app/controllers/perps/services/HyperLiquidSubscriptionService.test.ts @@ -3275,8 +3275,8 @@ describe('HyperLiquidSubscriptionService', () => { await jest.runAllTimersAsync(); - // Should not call meta/metaAndAssetCtxs when market data not requested - expect(mockInfoClient.meta).not.toHaveBeenCalled(); + // assetCtxs subscription is always established (lightweight, 1 per DEX) + // so meta may be called for the assetCtxs mapping, but metaAndAssetCtxs should not expect(mockInfoClient.metaAndAssetCtxs).not.toHaveBeenCalled(); unsubscribe(); diff --git a/app/controllers/perps/services/HyperLiquidSubscriptionService.ts b/app/controllers/perps/services/HyperLiquidSubscriptionService.ts index 7c7cc8dda64..2b3e64732e1 100644 --- a/app/controllers/perps/services/HyperLiquidSubscriptionService.ts +++ b/app/controllers/perps/services/HyperLiquidSubscriptionService.ts @@ -1031,32 +1031,39 @@ export class HyperLiquidSubscriptionService { // Ensure global subscriptions are established this.#ensureGlobalAllMidsSubscription(); - // HIP-3: Establish assetCtxs subscriptions ONLY for DEXs with requested symbols - // Performance: Avoid unnecessary WebSocket connections for unused DEXs - if (includeMarketData) { - // Extract unique DEXs from requested symbols - const dexsNeeded = new Set(); - symbols.forEach((symbol) => { - const { dex } = parseAssetName(symbol); - dexsNeeded.add(dex); + // Extract unique DEXs from requested symbols + const dexsNeeded = new Set(); + symbols.forEach((symbol) => { + const { dex } = parseAssetName(symbol); + dexsNeeded.add(dex); + }); + + // Always ensure assetCtxs subscriptions (1 per DEX, lightweight). + // Provides prevDayPx for percentChange24h even without includeMarketData + // (e.g., prewarm after reconnection). Uses incrementRefCount: false when + // not explicitly requested so lifecycle is managed by component subscriptions. + dexsNeeded.forEach((dex) => { + const dexName = dex ?? ''; + this.#ensureAssetCtxsSubscription(dexName, { + incrementRefCount: includeMarketData, + }).catch((error) => { + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.subscribeToPrices', + ), + this.#getErrorContext( + 'subscribeToPrices.ensureAssetCtxsSubscription', + { dex: dexName }, + ), + ); }); + }); - // Only subscribe to DEXs that have requested symbols + // dexAllMids and activeAssetCtx only when market data explicitly requested + if (includeMarketData) { dexsNeeded.forEach((dex) => { const dexName = dex ?? ''; - this.#ensureAssetCtxsSubscription(dexName).catch((error) => { - this.#logErrorUnlessClearing( - ensureError( - error, - 'HyperLiquidSubscriptionService.subscribeToPrices', - ), - this.#getErrorContext( - 'subscribeToPrices.ensureAssetCtxsSubscription', - { dex: dexName }, - ), - ); - }); - this.#ensureDexAllMidsSubscription(dexName).catch((error) => { this.#logErrorUnlessClearing( ensureError( @@ -1110,14 +1117,6 @@ export class HyperLiquidSubscriptionService { // Cleanup DEX-level assetCtxs subscriptions if (includeMarketData) { - // Extract unique DEXs from requested symbols - const dexsNeeded = new Set(); - symbols.forEach((symbol) => { - const { dex } = parseAssetName(symbol); - dexsNeeded.add(dex); - }); - - // Cleanup assetCtxs subscription for each DEX dexsNeeded.forEach((dex) => { const dexName = dex ?? ''; this.#cleanupAssetCtxsSubscription(dexName); diff --git a/docs/perps/perps-connection-architecture.md b/docs/perps/perps-connection-architecture.md index 300bfe6ef2d..397dd2621a5 100644 --- a/docs/perps/perps-connection-architecture.md +++ b/docs/perps/perps-connection-architecture.md @@ -67,7 +67,7 @@ graph TD - Call `connect()` on mount (when `isPerpsEnabled`) - Call `disconnect()` when app goes to background (triggers 20s grace period in Manager) -- Call `connect()` when app returns to foreground (with `ReconnectionDelayAndroidMs` stabilization delay) +- Call `ensureConnected()` when app returns to foreground (forces disconnect + fresh reconnect after stabilization delay) - Call `disconnect()` on unmount **Does NOT**: @@ -362,13 +362,14 @@ The stream hooks used by PerpsHomeView gracefully handle the not-yet-connected s ### Manager Layer Methods -| Method | Signature | Purpose | -| --------------------------- | ----------------------------------------------- | ----------------------------------------------- | -| `connect()` | `() => Promise` | Initialize connection if first provider | -| `disconnect()` | `() => Promise` | Disconnect if last provider (with grace period) | -| `reconnectWithNewContext()` | `(options?: ReconnectOptions) => Promise` | Coordinate full reconnection | -| `getConnectionState()` | `() => ConnectionState` | Get current connection state (for polling) | -| `resetError()` | `() => void` | Clear error state | +| Method | Signature | Purpose | +| --------------------------- | ----------------------------------------------- | ----------------------------------------------------- | +| `connect()` | `() => Promise` | Initialize connection if first provider | +| `disconnect()` | `() => Promise` | Disconnect if last provider (with grace period) | +| `ensureConnected()` | `() => Promise` | Foreground return: force disconnect + fresh reconnect | +| `reconnectWithNewContext()` | `(options?: ReconnectOptions) => Promise` | Coordinate full reconnection | +| `getConnectionState()` | `() => ConnectionState` | Get current connection state (for polling) | +| `resetError()` | `() => void` | Clear error state | ### Controller Layer Methods @@ -518,7 +519,7 @@ The Manager's `pendingReconnectPromise` ensures only one reconnection happens at | Account switch | `reconnectWithNewContext()` | default | Manager (automatic via Redux subscription) | Clears caches immediately before reconnection | | Network switch | `reconnectWithNewContext()` | default | Manager (automatic via Redux subscription) | Same as account switch | | App background | `disconnect()` | - | PerpsAlwaysOnProvider → Manager | Grace period (20s) before actual disconnect | -| App foreground | `connect()` | - | PerpsAlwaysOnProvider → Manager | ReconnectionDelayAndroidMs stabilization | +| App foreground | `ensureConnected()` | - | PerpsAlwaysOnProvider → Manager | Forces disconnect + reconnect after delay | --- From a300f0a8f27d9602f55db4d2b1d8c7d790808744 Mon Sep 17 00:00:00 2001 From: Juanmi <95381763+juanmigdr@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:03:36 +0100 Subject: [PATCH 085/206] chore: add press opacity feedback to NFT grid item (#27488) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adds visual press feedback to NFT grid items by applying 50% opacity when a user taps on them. Previously, the Pressable component had no visual response on press, making interactions feel unresponsive. ## **Changelog** CHANGELOG entry: added press opacity feedback to NFT grid items ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-2924 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Low risk UI-only change that adjusts `Pressable` styling to provide pressed-state feedback, without affecting navigation or data handling. > > **Overview** > Adds visual tap feedback to NFT tiles by updating `NftGridItem`’s `Pressable` to apply `opacity-50` while pressed, making grid interactions feel responsive. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 12c14d92dc804e0453575c67c2ef88d45bc922f0. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/components/UI/NftGrid/NftGridItem.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/components/UI/NftGrid/NftGridItem.tsx b/app/components/UI/NftGrid/NftGridItem.tsx index 02e4834e94d..8a72d757051 100644 --- a/app/components/UI/NftGrid/NftGridItem.tsx +++ b/app/components/UI/NftGrid/NftGridItem.tsx @@ -53,7 +53,9 @@ const NftGridItem = ({ return ( + tw.style('self-stretch mb-3', pressed && 'opacity-50') + } onPress={onPress} onLongPress={() => onLongPress(item)} testID={`collectible-${item.name}-${item.tokenId}`} From 8158b8e3097130235d8b83ac086057c78fb640b1 Mon Sep 17 00:00:00 2001 From: Juanmi <95381763+juanmigdr@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:07:53 +0100 Subject: [PATCH 086/206] fix: nfts not showing when non-evm is chosen cp-7.70.0 (#27514) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** When a non-EVM account such as Bitcoin is the active account, selectSelectedInternalAccountAddress returns a non-EVM address (e.g. bc1q...). NftGrid was using that address directly to look up NFTs, but NFTs are always stored under EVM addresses — resulting in 0 NFTs shown even though "All Popular Networks" appeared selected. Fixed by passing addressesOverride (all addresses in the selected account group) to multichainCollectiblesByEnabledNetworksSelector in NftGrid, mirroring the same approach already used in useOwnedNfts. ## **Changelog** CHANGELOG entry: Fixed NFTs not showing when non-EVM account is chosen ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/27516 & https://consensyssoftware.atlassian.net/browse/ASSETS-2944 ## **Manual testing steps** ```gherkin Feature: NFT Display with Non-EVM Accounts Scenario: user views NFTs when Bitcoin account is active Given user has imported a Bitcoin account And user has NFTs associated with EVM addresses in the same account group And user has selected the Bitcoin account as active When user navigates to the NFTs section Then user should see their NFTs displayed correctly And "All Popular Networks" should appear selected ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/1302b216-98b5-4613-9822-7227dce34968 ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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] > [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) is generating a summary for commit 52a47544a87a8f1c9b987cae749e8107ddfcf02b. Configure [here](https://cursor.com/dashboard?tab=bugbot).
Open in Web Open in Cursor 
--------- Co-authored-by: Cursor Agent Co-authored-by: Juanmi --- app/components/UI/NftGrid/NftGrid.test.tsx | 163 +++++++++++++++++++-- app/components/UI/NftGrid/NftGrid.tsx | 23 ++- 2 files changed, 176 insertions(+), 10 deletions(-) diff --git a/app/components/UI/NftGrid/NftGrid.test.tsx b/app/components/UI/NftGrid/NftGrid.test.tsx index 4f1fbaa74dc..615f4f6dcd8 100644 --- a/app/components/UI/NftGrid/NftGrid.test.tsx +++ b/app/components/UI/NftGrid/NftGrid.test.tsx @@ -7,11 +7,9 @@ import { backgroundState } from '../../../util/test/initial-root-state'; import { Nft } from '@metamask/assets-controllers'; import { useMetrics } from '../../hooks/useMetrics'; import { MetricsEventBuilder } from '../../../core/Analytics/MetricsEventBuilder'; -import { - isNftFetchingProgressSelector, - multichainCollectiblesByEnabledNetworksSelector, -} from '../../../reducers/collectibles'; +import { isNftFetchingProgressSelector } from '../../../reducers/collectibles'; import { selectHomepageRedesignV1Enabled } from '../../../selectors/featureFlagController/homepage'; +import { selectSelectedAccountGroupInternalAccounts } from '../../../selectors/multichainAccounts/accountTreeController'; const mockStore = configureMockStore(); const mockNavigate = jest.fn(); @@ -21,7 +19,7 @@ const mockUseSelector = useSelector as jest.MockedFunction; // Mock navigation jest.mock('@react-navigation/native', () => { - const React = jest.requireActual('react'); + const ReactActual = jest.requireActual('react'); return { useNavigation: () => ({ navigate: mockNavigate, @@ -29,7 +27,7 @@ jest.mock('@react-navigation/native', () => { }), useFocusEffect: (callback: () => void | (() => void)) => { // Use real useEffect to ensure cleanup runs on unmount - React.useEffect(() => callback(), [callback]); + ReactActual.useEffect(() => callback(), [callback]); }, }; }); @@ -344,21 +342,28 @@ describe('NftGrid', () => { isHomepageRedesignEnabled = false, collectibles = {}, isNftFetching = false, + selectedGroupAccounts = [], }: { isHomepageRedesignEnabled?: boolean; collectibles?: Record; isNftFetching?: boolean; + selectedGroupAccounts?: { address: string }[]; }) => { mockUseSelector.mockImplementation((selector) => { if (selector === selectHomepageRedesignV1Enabled) { return isHomepageRedesignEnabled; } - if (selector === multichainCollectiblesByEnabledNetworksSelector) { - return collectibles; - } if (selector === isNftFetchingProgressSelector) { return isNftFetching; } + if (selector === selectSelectedAccountGroupInternalAccounts) { + return selectedGroupAccounts; + } + // For the custom selector function that calls multichainCollectiblesByEnabledNetworksSelector + if (typeof selector === 'function') { + // This handles the inline function in NftGrid that calls multichainCollectiblesByEnabledNetworksSelector + return collectibles; + } return undefined; }); }; @@ -1239,4 +1244,144 @@ describe('NftGrid', () => { expect(positionScreenViewedCalls).toHaveLength(0); }); }); + + describe('non-EVM account group selection (addressesOverride)', () => { + it('updates NFT display when selectedGroupAccounts changes from empty to populated', async () => { + const mockCollectibles = { '0x1': [mockNft] }; + + // Start with no group accounts (EVM case) + setupSelectorMocks({ + isHomepageRedesignEnabled: false, + collectibles: mockCollectibles, + isNftFetching: false, + selectedGroupAccounts: [], + }); + const store = mockStore(initialState); + + const { getByTestId, rerender } = render( + + + , + ); + + act(() => { + jest.advanceTimersByTime(100); + }); + + await waitFor(() => { + expect(getByTestId('collectible-Test NFT-456')).toBeOnTheScreen(); + }); + + // Switch to a non-EVM group with accounts + const nonEvmAccounts = [{ address: '0xabc111', id: 'non-evm-acc-1' }]; + setupSelectorMocks({ + isHomepageRedesignEnabled: false, + collectibles: mockCollectibles, + isNftFetching: false, + selectedGroupAccounts: nonEvmAccounts as { address: string }[], + }); + + rerender( + + + , + ); + + act(() => { + jest.advanceTimersByTime(100); + }); + + await waitFor(() => { + expect(getByTestId('collectible-Test NFT-456')).toBeOnTheScreen(); + }); + }); + + it('updates NFT display when selectedGroupAccounts changes from populated to empty', async () => { + const nonEvmAccounts = [{ address: '0xabc111', id: 'non-evm-acc-1' }]; + const mockCollectibles = { '0x1': [mockNft] }; + + // Start with non-EVM group accounts + setupSelectorMocks({ + isHomepageRedesignEnabled: false, + collectibles: mockCollectibles, + isNftFetching: false, + selectedGroupAccounts: nonEvmAccounts as { address: string }[], + }); + const store = mockStore(initialState); + + const { getByTestId, rerender } = render( + + + , + ); + + act(() => { + jest.advanceTimersByTime(100); + }); + + await waitFor(() => { + expect(getByTestId('collectible-Test NFT-456')).toBeOnTheScreen(); + }); + + // Switch back to EVM (empty group accounts) + setupSelectorMocks({ + isHomepageRedesignEnabled: false, + collectibles: mockCollectibles, + isNftFetching: false, + selectedGroupAccounts: [], + }); + + rerender( + + + , + ); + + act(() => { + jest.advanceTimersByTime(100); + }); + + await waitFor(() => { + expect(getByTestId('collectible-Test NFT-456')).toBeOnTheScreen(); + }); + }); + + it('renders NFTs across multiple non-EVM accounts in the same group', async () => { + const nonEvmAccounts = [ + { address: '0xabc111', id: 'non-evm-acc-1' }, + { address: '0xdef222', id: 'non-evm-acc-2' }, + ]; + const nft2: typeof mockNft = { + ...mockNft, + tokenId: '789', + name: 'Second NFT', + }; + const mockCollectibles = { + '0x1': [mockNft], + '0x89': [nft2], + }; + setupSelectorMocks({ + isHomepageRedesignEnabled: false, + collectibles: mockCollectibles, + isNftFetching: false, + selectedGroupAccounts: nonEvmAccounts as { address: string }[], + }); + const store = mockStore(initialState); + + const { getByTestId } = render( + + + , + ); + + act(() => { + jest.advanceTimersByTime(100); + }); + + await waitFor(() => { + expect(getByTestId('collectible-Test NFT-456')).toBeOnTheScreen(); + expect(getByTestId('collectible-Second NFT-789')).toBeOnTheScreen(); + }); + }); + }); }); diff --git a/app/components/UI/NftGrid/NftGrid.tsx b/app/components/UI/NftGrid/NftGrid.tsx index 460f674e5a4..ecfc6b89879 100644 --- a/app/components/UI/NftGrid/NftGrid.tsx +++ b/app/components/UI/NftGrid/NftGrid.tsx @@ -12,6 +12,7 @@ import type { TabRefreshHandle } from '../../Views/Wallet/types'; import { useNftRefresh } from './useNftRefresh'; import { FlashList } from '@shopify/flash-list'; import { useSelector } from 'react-redux'; +import { RootState } from '../../../reducers'; import { RefreshTestId } from './constants'; import { endTrace, trace, TraceName } from '../../../util/trace'; import { Nft } from '@metamask/assets-controllers'; @@ -19,6 +20,7 @@ import { isNftFetchingProgressSelector, multichainCollectiblesByEnabledNetworksSelector, } from '../../../reducers/collectibles'; +import { selectSelectedAccountGroupInternalAccounts } from '../../../selectors/multichainAccounts/accountTreeController'; import NftGridItem from './NftGridItem'; import ActionSheet from '@metamask/react-native-actionsheet'; import NftGridItemActionSheet from './NftGridItemActionSheet'; @@ -108,8 +110,27 @@ const NftGrid = forwardRef( const nftSource = isFullView ? 'mobile-nft-list-page' : 'mobile-nft-list'; + const selectedGroupAccounts = useSelector( + selectSelectedAccountGroupInternalAccounts, + ); + + const addressesOverride = useMemo( + () => + selectedGroupAccounts?.length > 0 + ? selectedGroupAccounts.map((a) => a.address) + : undefined, + [selectedGroupAccounts], + ); + const collectiblesByEnabledNetworks: Record = useSelector( - multichainCollectiblesByEnabledNetworksSelector, + (state: RootState) => + ( + multichainCollectiblesByEnabledNetworksSelector as ( + s: RootState, + preferredChainIds?: string[], + addressesOverride?: string[], + ) => Record + )(state, undefined, addressesOverride), ); const { detectNfts, abortDetection, chainIdsToDetectNftsFor } = From b23e56d88f0f948a5684a035fbb50e27e4f2d9ec Mon Sep 17 00:00:00 2001 From: Christopher Ferreira <104831203+christopherferreira9@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:14:08 +0000 Subject: [PATCH 087/206] test: updates tests docs (#27551) ## **Description** This PR updates the docs with a comprehensive table with the current available testing tools. ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Low risk documentation-only change; no production code or test logic is modified. > > **Overview** > Adds a new **"Existing tooling"** section to `tests/docs/README.md` with a comparison table of current testing tools (Component View Tests, Detox, Maestro, Appium), outlining their testing type, intended usage, and key limitations. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit c65fc87704c64cd6be8d783c667de0ce57540fed. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- tests/docs/README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/docs/README.md b/tests/docs/README.md index c8fa43acc2c..52d4c264958 100644 --- a/tests/docs/README.md +++ b/tests/docs/README.md @@ -313,3 +313,12 @@ await Utilities.executeWithRetry( - [ ] Tests work on both iOS and Android platforms - [ ] Test names are descriptive without 'should' prefix - [ ] Uses FixtureBuilder for test data setup + +## Existing tooling + +| Tool | Type | Current use | When to use | Notes and Limitations | +| -------------------- | ----------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Component View Tests | White box testing | UI integration tests | - When following a full user flow is not needed
- When we want to test individual component and view rendering | - Does **not** require builds
- Low cost (faster runtime)
- Fast feedback loop | +| Detox | Grey box testing | E2E | - When we want to test user flows
- Run on PR basis | - High cost
- Low tool (detox) maintenance
- JS/TS based test files
- Handles deeply nested elements better (easier to find and locate elements)
- Uses emulators/simulators
- Allows runs with local Builds
- Can't be used with real devices (cloud included) | +| Maestro | Grey box testing | TBD | TBD | - **Still in experimentation phase (!)**
- Struggles with deeply nested elements
- YAML based spec files
- Allows runs with local builds
- Can run on real devices (cloud) but can't be used with real devices | +| Appium | Black box testing | Performance tests | - When we want to test user flows as a end user
- When we want to measure and report performance stats | - High cost
- Struggles with deeply nested
- Uses a Cloud provider for real device testing elements
- Does not allow runs with local builds | From db4c00d1056c0116079aaf2dea16220ecdf0e05e Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Wed, 18 Mar 2026 21:42:19 +0800 Subject: [PATCH 088/206] chore(metro): make resetCache conditional via METRO_RESET_CACHE env var (#27588) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** `resetCache: true` is hardcoded in `metro.config.js`, destroying ~380MB of transform cache on every Metro start. This forces a cold bundle on every restart, causing heavy CPU/RAM spikes that make the system unresponsive during the agentic dev loop (edit -> reload -> validate via CDP). This PR gates `resetCache` on the `METRO_RESET_CACHE` env var. Default behavior is unchanged (`resetCache: true`). Set `METRO_RESET_CACHE=false` in `.js.env` to preserve cache. The `lockdownSerializer` (LavaMoat) and `wrapWithReanimatedMetroConfig` wrappers hook into serializer/symbolicator — they have no cache dependency. Disabling `resetCache` is safe for dev. ### Benchmark **Machine**: AMD Ryzen 7 6800H (8C/16T), 32GB RAM, Radeon 680M iGPU **Emulator**: headless Android 14 (x86_64), `METRO_MAX_WORKERS=4` #### Software rendering (`swiftshader_indirect` — no GPU passthrough) | Scenario | Wallet route | System settled (load < 3.0) | Peak load | |---|---|---|---| | **Cold start** (`METRO_RESET_CACHE=true`) | 105s | **218s** (~3m38s) | 11.2 | | **Warm start** (`METRO_RESET_CACHE=false`) | 102s | **173s** (~2m53s) | 9.3 | | **Warm reload** (edit -> reload) | 69s | **131s** (~2m11s) | 8.3 | #### GPU passthrough (`-gpu host` — Radeon 680M via Vulkan/EGL, requires X11 session) | Scenario | Wallet route | System settled (load < 3.0) | Peak load | |---|---|---|---| | **Cold start** (`METRO_RESET_CACHE=true`) | 89s | **89s** (~1m29s) | 2.5 | | **Warm start** (`METRO_RESET_CACHE=false`) | 104s | **114s** (~1m54s) | 3.3 | | **Warm reload** (edit -> reload) | 43s | **43s** (~0m43s) | 2.7 | #### Summary | Scenario | swiftshader (settled) | GPU host (settled) | Improvement | |---|---|---|---| | **Cold start** | 218s, peak 11.2 | **89s**, peak 2.5 | **59% faster** | | **Warm start** | 173s, peak 9.3 | **114s**, peak 3.3 | **34% faster** | | **Warm reload** | 131s, peak 8.3 | **43s**, peak 2.7 | **67% faster** | GPU passthrough requires an X11 session (Wayland EGL passthrough is not supported by the Android emulator). The warm reload cycle drops from 131s to 43s and system load never exceeds 3.3, keeping CDP WebSocket connections stable. ## **Changelog** CHANGELOG entry: null ## **Related issues** N/A — developer experience improvement for agentic dev loop. ## **Manual testing steps** ```gherkin Feature: Metro conditional cache reset Scenario: Default behavior unchanged Given METRO_RESET_CACHE is not set When developer starts Metro Then Metro resets cache on every start (existing behavior) Scenario: Developer opts into cached restarts Given METRO_RESET_CACHE=false in .js.env When developer starts Metro a second time Then Metro preserves transform cache and starts faster Scenario: Force cache reset when needed Given METRO_RESET_CACHE=false in .js.env When developer runs METRO_RESET_CACHE=true npx react-native start Then Metro resets cache for that invocation ``` ## **Screenshots/Recordings** ### **Before** N/A — no UI changes. ### **After** N/A — no UI changes. ## **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. ## **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** > Low risk developer-experience change limited to Metro startup behavior; only potential impact is serving stale transforms when cache resets are disabled. > > **Overview** > Makes Metro’s `resetCache` behavior configurable via the `METRO_RESET_CACHE` environment variable, defaulting to the current behavior (cache reset unless explicitly set to `'false'`). > > Updates `.js.env.example` to document the new `METRO_RESET_CACHE` toggle (and fixes the missing newline at EOF). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit e07727b9f258699ff3c326891c77964618f10f6a. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). Co-authored-by: Arthur Breton --- .js.env.example | 5 ++++- metro.config.js | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.js.env.example b/.js.env.example index 4b384bcdcd6..36407f4771d 100644 --- a/.js.env.example +++ b/.js.env.example @@ -208,4 +208,7 @@ export MM_PREDICT_ENABLED="true" export MM_CARD_BAANX_API_CLIENT_KEY_DEV="" ## PNA25 (Privacy Notice) -export MM_EXTENSION_UX_PNA25="" \ No newline at end of file +export MM_EXTENSION_UX_PNA25="" + +## Metro +export METRO_RESET_CACHE="true" diff --git a/metro.config.js b/metro.config.js index 4f292d66714..4f925023d42 100644 --- a/metro.config.js +++ b/metro.config.js @@ -213,7 +213,7 @@ module.exports = function (baseConfig) { getPolyfills, }, ), - resetCache: true, + resetCache: process.env.METRO_RESET_CACHE !== 'false', maxWorkers, }), ); From cf02cadfe5981cbacae9b22ce9d9bf563245c364 Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Wed, 18 Mar 2026 22:26:24 +0800 Subject: [PATCH 089/206] fix(perps): abstract breadcrumb calls and remove external dependencies (#27574) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Abstracts direct `@sentry/react-native` `addBreadcrumb` calls behind the `PerpsTracer` interface and removes the `AppConstants` external dependency from `MYXClientService`, replacing it with a local `ZERO_ADDRESS` constant. Also fixes eslint glob patterns in `validate-core-sync.sh` for the updated core ESLint configuration. These changes improve core-sync parity by ensuring no platform-specific imports leak into controller code. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: N/A (internal cleanup) ## **Manual testing steps** ```gherkin Feature: Perps trading functionality Scenario: user places a perps order after breadcrumb abstraction Given the user has an active perps account with funds When user places a market order on any symbol Then the order executes successfully and breadcrumbs are recorded in Sentry Scenario: user initiates a deposit after breadcrumb abstraction Given the user is on the perps deposit screen When user submits a deposit transaction Then the deposit processes and Sentry breadcrumb shows "Deposit action started" ``` ## **Screenshots/Recordings** ### **Before** N/A — no UI changes ### **After** N/A — no UI changes ## **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. --- > [!NOTE] > **Low Risk** > Low risk refactor focused on observability and constants; main behavior change is how breadcrumbs are emitted, so any risk is limited to missing/incorrect Sentry logging if adapters aren’t wired correctly. > > **Overview** > **Decouples Perps controller/services from platform-specific Sentry imports** by adding `addBreadcrumb` to the `PerpsTracer` interface and routing breadcrumb calls (e.g., order execution and deposit start) through injected `infrastructure.tracer` instead of `@sentry/react-native`. > > Moves `ZERO_ADDRESS`/`ZERO_BALANCE` into controller-portable `perpsConfig` and updates `MYXClientService` to use the local `ZERO_ADDRESS` rather than `AppConstants`, reducing external/mobile-only dependencies. > > Updates `scripts/perps/validate-core-sync.sh` eslint invocations to use the `packages/perps-controller/src/**/*.ts` glob for `--fix`, `--suppress-all`, `--prune-suppressions`, and lint. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 25d62e71b0f39fc499ed15677eda0a38fae506f4. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/components/UI/Perps/__mocks__/serviceMocks.ts | 1 + .../UI/Perps/adapters/mobileInfrastructure.ts | 14 +++++++++++++- app/controllers/perps/PerpsController.ts | 3 +-- .../perps/constants/hyperLiquidConfig.ts | 2 -- app/controllers/perps/constants/perpsConfig.ts | 3 +++ app/controllers/perps/services/MYXClientService.ts | 12 ++++-------- app/controllers/perps/services/TradingService.ts | 3 +-- app/controllers/perps/types/index.ts | 7 +++++++ app/controllers/perps/utils/myxAdapter.ts | 10 ++++------ scripts/perps/validate-core-sync.sh | 8 ++++---- 10 files changed, 38 insertions(+), 25 deletions(-) diff --git a/app/components/UI/Perps/__mocks__/serviceMocks.ts b/app/components/UI/Perps/__mocks__/serviceMocks.ts index ae4edc0217f..890e77d1560 100644 --- a/app/components/UI/Perps/__mocks__/serviceMocks.ts +++ b/app/components/UI/Perps/__mocks__/serviceMocks.ts @@ -61,6 +61,7 @@ export const createMockInfrastructure = trace: jest.fn(() => undefined), endTrace: jest.fn(), setMeasurement: jest.fn(), + addBreadcrumb: jest.fn(), }, // === Platform Services === diff --git a/app/components/UI/Perps/adapters/mobileInfrastructure.ts b/app/components/UI/Perps/adapters/mobileInfrastructure.ts index b2709f513e1..5dbf7f24858 100644 --- a/app/components/UI/Perps/adapters/mobileInfrastructure.ts +++ b/app/components/UI/Perps/adapters/mobileInfrastructure.ts @@ -11,7 +11,11 @@ import { MetaMetricsEvents } from '../../../../core/Analytics'; import { AnalyticsEventBuilder } from '../../../../util/analytics/AnalyticsEventBuilder'; import { analytics } from '../../../../util/analytics/analytics'; import { trace, endTrace, TraceName } from '../../../../util/trace'; -import { setMeasurement } from '@sentry/react-native'; +import { + setMeasurement, + addBreadcrumb, + type SeverityLevel, +} from '@sentry/react-native'; import performance from 'react-native-performance'; import { getStreamManagerInstance } from '../providers/PerpsStreamManager'; import Engine from '../../../../core/Engine'; @@ -217,6 +221,14 @@ export function createMobileInfrastructure(): PerpsPlatformDependencies { setMeasurement(name: string, value: number, unit: string): void { setMeasurement(name, value, unit); }, + addBreadcrumb(breadcrumb: { + category: string; + message: string; + level: SeverityLevel; + data?: Record; + }): void { + addBreadcrumb(breadcrumb); + }, }, // === Platform Services === diff --git a/app/controllers/perps/PerpsController.ts b/app/controllers/perps/PerpsController.ts index 6cabef5957c..ae8a20d8e2f 100644 --- a/app/controllers/perps/PerpsController.ts +++ b/app/controllers/perps/PerpsController.ts @@ -8,7 +8,6 @@ import type { StateChangeListener } from '@metamask/base-controller'; import { ORIGIN_METAMASK } from '@metamask/controller-utils'; import type { Messenger } from '@metamask/messenger'; import type { Json } from '@metamask/utils'; -import { addBreadcrumb } from '@sentry/react-native'; import { v4 as uuidv4 } from 'uuid'; import { CandlePeriod } from './constants/chartConfig'; @@ -2019,7 +2018,7 @@ export class PerpsController extends BaseController< skipInitialGasEstimate: true, }; - addBreadcrumb({ + this.#options.infrastructure.tracer.addBreadcrumb({ category: 'perps', message: 'Deposit action started', level: 'info', diff --git a/app/controllers/perps/constants/hyperLiquidConfig.ts b/app/controllers/perps/constants/hyperLiquidConfig.ts index 9e56137b13b..4e463c222d5 100644 --- a/app/controllers/perps/constants/hyperLiquidConfig.ts +++ b/app/controllers/perps/constants/hyperLiquidConfig.ts @@ -30,8 +30,6 @@ export const USDC_SYMBOL = 'USDC'; export const USDC_NAME = 'USD Coin'; export const USDC_DECIMALS = 6; export const TOKEN_DECIMALS = 18; -export const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; -export const ZERO_BALANCE = '0x0'; // Network constants export const ARBITRUM_SEPOLIA_CHAIN_ID = '0x66eee'; // 421614 in decimal diff --git a/app/controllers/perps/constants/perpsConfig.ts b/app/controllers/perps/constants/perpsConfig.ts index 32b95b447d8..b1b76be7e71 100644 --- a/app/controllers/perps/constants/perpsConfig.ts +++ b/app/controllers/perps/constants/perpsConfig.ts @@ -8,6 +8,9 @@ * UI-only constants (layout, display, navigation) live in: * app/components/UI/Perps/constants/perpsConfig.ts */ +export const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; +export const ZERO_BALANCE = '0x0'; + export const PERPS_CONSTANTS = { FeatureFlagKey: 'perpsEnabled', FeatureName: 'perps', // Constant for Sentry error filtering - enables "feature:perps" dashboard queries diff --git a/app/controllers/perps/services/MYXClientService.ts b/app/controllers/perps/services/MYXClientService.ts index 0a2e2b804b5..e8bbbc47328 100644 --- a/app/controllers/perps/services/MYXClientService.ts +++ b/app/controllers/perps/services/MYXClientService.ts @@ -15,13 +15,12 @@ import type { } from '@myx-trade/sdk'; import { MyxClient } from '@myx-trade/sdk'; -import AppConstants from '../../../core/AppConstants'; import { MYX_PRICE_POLLING_INTERVAL_MS, getMYXChainId, getMYXHttpEndpoint, } from '../constants/myxConfig'; -import { PERPS_CONSTANTS } from '../constants/perpsConfig'; +import { PERPS_CONSTANTS, ZERO_ADDRESS } from '../constants/perpsConfig'; import type { PerpsPlatformDependencies } from '../types'; import type { MYXAuthConfig, @@ -113,10 +112,9 @@ export class MYXClientService { brokerAddress: '', }; - const brokerAddress = - this.#authConfig.brokerAddress || AppConstants.ZERO_ADDRESS; + const brokerAddress = this.#authConfig.brokerAddress || ZERO_ADDRESS; - if (brokerAddress === AppConstants.ZERO_ADDRESS) { + if (brokerAddress === ZERO_ADDRESS) { this.#deps.debugLogger.log( '[MYXClientService] brokerAddress not configured, using zero address', ); @@ -139,9 +137,7 @@ export class MYXClientService { chainId: this.#chainId, wsConnected: true, brokerAddress: - brokerAddress === AppConstants.ZERO_ADDRESS - ? 'zero (not configured)' - : 'configured', + brokerAddress === ZERO_ADDRESS ? 'zero (not configured)' : 'configured', }); } diff --git a/app/controllers/perps/services/TradingService.ts b/app/controllers/perps/services/TradingService.ts index a87894212d2..f865ee51cb2 100644 --- a/app/controllers/perps/services/TradingService.ts +++ b/app/controllers/perps/services/TradingService.ts @@ -1,4 +1,3 @@ -import { addBreadcrumb } from '@sentry/react-native'; import { v4 as uuidv4 } from 'uuid'; import type { RewardsIntegrationService } from './RewardsIntegrationService'; @@ -370,7 +369,7 @@ export class TradingService { : 'perps_balance'; try { - addBreadcrumb({ + this.#deps.tracer.addBreadcrumb({ category: 'perps', message: 'Order execution started', level: 'info', diff --git a/app/controllers/perps/types/index.ts b/app/controllers/perps/types/index.ts index a8bd656aa31..7b096606d1c 100644 --- a/app/controllers/perps/types/index.ts +++ b/app/controllers/perps/types/index.ts @@ -1405,6 +1405,13 @@ export type PerpsTracer = { }): void; setMeasurement(name: string, value: number, unit: string): void; + + addBreadcrumb(breadcrumb: { + category: string; + message: string; + level: 'fatal' | 'error' | 'warning' | 'log' | 'info' | 'debug'; + data?: Record; + }): void; }; // ============================================================================ diff --git a/app/controllers/perps/utils/myxAdapter.ts b/app/controllers/perps/utils/myxAdapter.ts index aaeee8a92fd..ee9b348ad9b 100644 --- a/app/controllers/perps/utils/myxAdapter.ts +++ b/app/controllers/perps/utils/myxAdapter.ts @@ -455,12 +455,10 @@ export function adaptAccountStateFromMYX( // accountInfo structure varies; extract what we can // TODO: Verify SDK semantics — if totalCollateral already includes unrealizedPnl, // the totalBalance formula below double-counts. Needs SDK documentation check. - const marginUsed = accountInfo - ? fromMYXCollateral(String(accountInfo.totalCollateral ?? '0')) - : 0; - const unrealizedPnl = accountInfo - ? fromMYXCollateral(String(accountInfo.unrealizedPnl ?? '0')) - : 0; + const rawCollateral = accountInfo?.totalCollateral ?? '0'; + const rawPnl = accountInfo?.unrealizedPnl ?? '0'; + const marginUsed = accountInfo ? fromMYXCollateral(String(rawCollateral)) : 0; + const unrealizedPnl = accountInfo ? fromMYXCollateral(String(rawPnl)) : 0; const balance = walletBalance ? fromMYXCollateral(walletBalance) : 0; const totalBalance = balance + marginUsed + unrealizedPnl; diff --git a/scripts/perps/validate-core-sync.sh b/scripts/perps/validate-core-sync.sh index 659a29c918a..5001b97a02a 100755 --- a/scripts/perps/validate-core-sync.sh +++ b/scripts/perps/validate-core-sync.sh @@ -336,13 +336,13 @@ step_eslint_fix() { fi progress " ├─ Running --fix" - yarn eslint packages/perps-controller/src/ --ext .ts --fix || true + yarn eslint 'packages/perps-controller/src/**/*.ts' --fix || true progress " ├─ Running --suppress-all" - yarn eslint packages/perps-controller/src/ --ext .ts --suppress-all || true + yarn eslint 'packages/perps-controller/src/**/*.ts' --suppress-all || true progress " └─ Running --prune-suppressions" - yarn eslint packages/perps-controller/src/ --ext .ts --prune-suppressions || true + yarn eslint 'packages/perps-controller/src/**/*.ts' --prune-suppressions || true # Count suppressions if [[ -f "$supp_file" ]]; then @@ -378,7 +378,7 @@ step_lint() { cd "$CORE_PATH" # No workspace-level lint script exists; run eslint directly to verify # all violations are either fixed or suppressed (exit 0 = clean). - yarn eslint packages/perps-controller/src/ --ext .ts + yarn eslint 'packages/perps-controller/src/**/*.ts' cd "$MOBILE_ROOT" } From 0d326cb35251b16e452a599f8f4778c2d6d29157 Mon Sep 17 00:00:00 2001 From: Daniel <80175477+dan437@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:27:31 +0100 Subject: [PATCH 090/206] feat: Default predict withdraw token from last used selection or from feature flags (#27532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR updates Predict withdraw token auto-selection to prefer the user’s most recent withdraw destination token before falling back to the remote preferred token configuration. ## **Changelog** CHANGELOG entry: Updated Predict withdraw to default to the user’s last used destination token before falling back to the remote preferred token. ## **Related issues** Fixes: [MetaMask-planning#7081](https://consensyssoftware.atlassian.net/browse/CONF-972) ## **Manual testing steps** ```gherkin Feature: Predict withdraw default destination token Scenario: user sees the last used withdraw token preselected Given the user has previously completed a Predict withdraw to BNB And the user has a valid preferred withdraw token configured in remote feature flags When the user opens the Predict withdraw flow again Then the withdraw screen preselects BNB instead of the remote preferred token Scenario: user falls back to the preferred token from feature flags Given the user has not completed any previous Predict withdraw with metamaskPay destination metadata And the remote feature flag `confirmations_pay_tokens.preferredTokens.overrides.predictWithdraw` is set to mUSD on Ethereum When the user opens the Predict withdraw flow Then the withdraw screen preselects mUSD on Ethereum ``` ## **Screenshots/Recordings** ### **Before** N/A ### **After** Pre-selected token from the last withdraw: image Pre-selected token from feature flags: image ## **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. ## **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. ``` Tests I ran: - `npx jest app/selectors/transactionController.test.ts --no-coverage` - `npx jest app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.test.ts --no-coverage` --- > [!NOTE] > **Medium Risk** > Changes automatic pay-token selection for withdraw flows by introducing transaction-history based defaults and new filtering, which could affect which token is preselected for users in post-quote transactions. > > **Overview** > Updates `useAutomaticTransactionPayToken` to treat post-quote/withdraw transactions differently: it now uses `getPostQuoteTransactionType`, applies a withdraw-specific token filter (`useWithdrawTokenFilter`), and prefers the **last used withdraw destination token** (from transaction history) before falling back to feature-flag preferred tokens. It also avoids repeatedly auto-setting the token by keying the update guard to `transactionId` and skipping when `payToken` is already set. > > Adds `selectLastWithdrawTokenByType` to `transactionController` selectors to extract the most recent `metamaskPay` `tokenAddress`/`chainId` for a given (including nested) transaction type, and extends unit tests to cover nested `predictWithdraw` history selection and selector behavior. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit f90f5c36f551b03fe076abc9fb6d5bb62dc02e8d. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Signed-off-by: dan437 <80175477+dan437@users.noreply.github.com> --- .../useAutomaticTransactionPayToken.test.ts | 100 +++++++++++++- .../pay/useAutomaticTransactionPayToken.ts | 90 ++++++++++--- app/selectors/transactionController.test.ts | 125 ++++++++++++++++++ app/selectors/transactionController.ts | 68 +++++++++- 4 files changed, 364 insertions(+), 19 deletions(-) diff --git a/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.test.ts b/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.test.ts index db31676315a..774c3fd151b 100644 --- a/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.test.ts +++ b/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.test.ts @@ -5,7 +5,10 @@ import { SetPayTokenRequest, } from './useAutomaticTransactionPayToken'; import { useTransactionPayToken } from './useTransactionPayToken'; -import { simpleSendTransactionControllerMock } from '../../__mocks__/controllers/transaction-controller-mock'; +import { + simpleSendTransactionControllerMock, + transactionIdMock, +} from '../../__mocks__/controllers/transaction-controller-mock'; import { transactionApprovalControllerMock } from '../../__mocks__/controllers/approval-controller-mock'; import { MetaMaskPayTokensFlags, @@ -18,12 +21,14 @@ import { Hex } from '@metamask/utils'; import { useTransactionPayRequiredTokens } from './useTransactionPayData'; import { useTransactionPayAvailableTokens } from './useTransactionPayAvailableTokens'; import { AssetType } from '../../types/token'; +import { useWithdrawTokenFilter } from './useWithdrawTokenFilter'; jest.mock('./useTransactionPayToken'); jest.mock('../../../../../util/address'); jest.mock('../../../../../selectors/transactionPayController'); jest.mock('./useTransactionPayData'); jest.mock('./useTransactionPayAvailableTokens'); +jest.mock('./useWithdrawTokenFilter'); jest.mock( '../../../../../selectors/featureFlagController/confirmations', () => ({ @@ -82,6 +87,7 @@ describe('useAutomaticTransactionPayToken', () => { const useTransactionPayAvailableTokensMock = jest.mocked( useTransactionPayAvailableTokens, ); + const useWithdrawTokenFilterMock = jest.mocked(useWithdrawTokenFilter); const isHardwareAccountMock = jest.mocked(isHardwareAccount); const useTransactionPayRequiredTokensMock = jest.mocked( useTransactionPayRequiredTokens, @@ -110,6 +116,7 @@ describe('useAutomaticTransactionPayToken', () => { ]); isHardwareAccountMock.mockReturnValue(false); + useWithdrawTokenFilterMock.mockReturnValue((tokens) => tokens); selectMetaMaskPayTokensFlagsMock.mockReturnValue({ preferredTokens: { default: [], overrides: {} }, @@ -561,6 +568,97 @@ describe('useAutomaticTransactionPayToken', () => { }); }); + it('selects last used token for predict withdraw from nested transaction history', () => { + const predictWithdrawStateMock = merge( + {}, + simpleSendTransactionControllerMock, + transactionApprovalControllerMock, + { + engine: { + backgroundState: { + TransactionController: { + transactions: [ + { + id: transactionIdMock, + nestedTransactions: [ + { type: TransactionType.predictWithdraw }, + ], + status: 'unapproved', + time: 200, + txParams: { from: '0x123' }, + type: TransactionType.batch, + }, + { + id: 'previous-predict-withdraw', + metamaskPay: { + chainId: CHAIN_ID_2_MOCK, + tokenAddress: TOKEN_ADDRESS_2_MOCK, + }, + nestedTransactions: [ + { type: TransactionType.predictWithdraw }, + ], + status: 'confirmed', + time: 100, + txParams: { from: '0x123' }, + type: TransactionType.batch, + }, + ], + }, + }, + }, + }, + ); + + useTransactionPayAvailableTokensMock.mockReturnValue({ + availableTokens: [ + { + address: TOKEN_ADDRESS_2_MOCK, + balance: '1', + chainId: CHAIN_ID_2_MOCK, + symbol: 'BNB', + }, + { + address: PREFERRED_TOKEN_ADDRESS_MOCK, + balance: '1', + chainId: PREFERRED_CHAIN_ID_MOCK, + symbol: 'MUSD', + }, + ] as AssetType[], + hasTokens: true, + }); + selectMetaMaskPayTokensFlagsMock.mockReturnValue({ + preferredTokens: { + default: [], + overrides: { + predictWithdraw: [ + { + address: PREFERRED_TOKEN_ADDRESS_MOCK, + chainId: PREFERRED_CHAIN_ID_MOCK, + successRate: 1, + }, + ], + }, + }, + minimumRequiredTokenBalance: 0, + blockedTokens: { + default: { + chainIds: [], + tokens: [], + }, + overrides: {}, + }, + } as MetaMaskPayTokensFlags); + + renderHookWithProvider(() => useAutomaticTransactionPayToken(), { + state: predictWithdrawStateMock, + }); + + expect(setPayTokenMock).toHaveBeenCalledWith({ + address: TOKEN_ADDRESS_2_MOCK, + chainId: CHAIN_ID_2_MOCK, + }); + }); + it('treats missing fiat balance as 0 for minimum balance check', () => { selectMetaMaskPayTokensFlagsMock.mockReturnValue({ preferredTokens: { diff --git a/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.ts b/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.ts index fa5b427e67d..1a2951603a4 100644 --- a/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.ts +++ b/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.ts @@ -8,13 +8,19 @@ import { TransactionMeta } from '@metamask/transaction-controller'; import { useTransactionPayRequiredTokens } from './useTransactionPayData'; import { useTransactionPayAvailableTokens } from './useTransactionPayAvailableTokens'; import { AssetType } from '../../types/token'; -import { isTransactionPayWithdraw } from '../../utils/transaction'; +import { + getPostQuoteTransactionType, + isTransactionPayWithdraw, +} from '../../utils/transaction'; import { useSelector } from 'react-redux'; import { selectMetaMaskPayTokensFlags, PreferredToken, getPreferredTokensForTransactionType, } from '../../../../../selectors/featureFlagController/confirmations'; +import { RootState } from '../../../../../reducers'; +import { selectLastWithdrawTokenByType } from '../../../../../selectors/transactionController'; +import { useWithdrawTokenFilter } from './useWithdrawTokenFilter'; export interface SetPayTokenRequest { address: Hex; @@ -30,22 +36,19 @@ export function useAutomaticTransactionPayToken({ disable?: boolean; preferredToken?: SetPayTokenRequest; } = {}) { - const isUpdated = useRef(false); - const { setPayToken } = useTransactionPayToken(); + const isUpdated = useRef(); + const { payToken, setPayToken } = useTransactionPayToken(); const requiredTokens = useTransactionPayRequiredTokens(); - const { availableTokens: tokens } = useTransactionPayAvailableTokens(); + const { availableTokens } = useTransactionPayAvailableTokens(); const payTokensFlags = useSelector(selectMetaMaskPayTokensFlags); - const tokensWithBalance = useMemo( - () => tokens.filter((t) => !t.disabled), - [tokens], - ); - const transactionMetaRequest = useTransactionMetadataRequest(); const transactionMeta = useMemo( () => transactionMetaRequest ?? ({ txParams: {} } as TransactionMeta), [transactionMetaRequest], ); + const transactionId = transactionMeta.id; + const postQuoteTransactionType = getPostQuoteTransactionType(transactionMeta); const { txParams: { from }, @@ -65,24 +68,45 @@ export function useAutomaticTransactionPayToken({ () => getPreferredTokensForTransactionType( payTokensFlags.preferredTokens, - transactionMeta.type, + postQuoteTransactionType ?? transactionMeta.type, ), - [transactionMeta.type, payTokensFlags.preferredTokens], + [ + transactionMeta.type, + postQuoteTransactionType, + payTokensFlags.preferredTokens, + ], ); - // For withdrawals, skip auto-selection — the default token is derived - // from required tokens and shown via PayWithRow const isWithdraw = isTransactionPayWithdraw(transactionMeta); + const lastWithdrawToken = useSelector((state: RootState) => + selectLastWithdrawTokenByType(state, postQuoteTransactionType), + ); + const withdrawTokenFilter = useWithdrawTokenFilter(); + + const tokens = useMemo( + () => + isWithdraw + ? withdrawTokenFilter(availableTokens) + : availableTokens.filter((t) => !t.disabled), + [availableTokens, isWithdraw, withdrawTokenFilter], + ); useEffect(() => { - if (disable || isWithdraw || isUpdated.current) { + if ( + disable || + payToken || + !transactionId || + isUpdated.current === transactionId + ) { return; } const automaticToken = getBestToken({ isHardwareWallet, + isWithdraw, + lastWithdrawToken, targetToken, - tokens: tokensWithBalance, + tokens, preferredToken, preferredTokensFromFlags, minimumRequiredTokenBalance: payTokensFlags.minimumRequiredTokenBalance, @@ -98,25 +122,30 @@ export function useAutomaticTransactionPayToken({ chainId: automaticToken.chainId, }); - isUpdated.current = true; + isUpdated.current = transactionId; log('Automatically selected pay token', automaticToken); }, [ disable, isHardwareWallet, isWithdraw, + lastWithdrawToken, payTokensFlags.minimumRequiredTokenBalance, + payToken, preferredToken, preferredTokensFromFlags, requiredTokens, setPayToken, targetToken, - tokensWithBalance, + tokens, + transactionId, ]); } function getBestToken({ isHardwareWallet, + isWithdraw, + lastWithdrawToken, preferredToken, preferredTokensFromFlags, minimumRequiredTokenBalance, @@ -124,6 +153,8 @@ function getBestToken({ tokens, }: { isHardwareWallet: boolean; + isWithdraw: boolean; + lastWithdrawToken?: SetPayTokenRequest; preferredToken?: SetPayTokenRequest; preferredTokensFromFlags: PreferredToken[]; minimumRequiredTokenBalance: number; @@ -141,6 +172,20 @@ function getBestToken({ return targetTokenFallback; } + if (isWithdraw && lastWithdrawToken) { + const lastWithdrawTokenAvailable = tokens.some( + (token) => + token.address.toLowerCase() === + lastWithdrawToken.address.toLowerCase() && + token.chainId?.toLowerCase() === + lastWithdrawToken.chainId.toLowerCase(), + ); + + if (lastWithdrawTokenAvailable) { + return lastWithdrawToken; + } + } + if (preferredToken) { const preferredTokenAvailable = tokens.some( (token) => @@ -166,6 +211,13 @@ function getBestToken({ ); if (matchingToken) { + if (isWithdraw) { + return { + address: matchingToken.address as Hex, + chainId: matchingToken.chainId as Hex, + }; + } + const fiatBalance = matchingToken.fiat?.balance ?? 0; if (fiatBalance >= minimumRequiredTokenBalance) { @@ -179,6 +231,10 @@ function getBestToken({ } if (tokens?.length) { + if (isWithdraw) { + return undefined; + } + return { address: tokens[0].address as Hex, chainId: tokens[0].chainId as Hex, diff --git a/app/selectors/transactionController.test.ts b/app/selectors/transactionController.test.ts index 43d033fa496..2725e20d6d2 100644 --- a/app/selectors/transactionController.test.ts +++ b/app/selectors/transactionController.test.ts @@ -1,7 +1,9 @@ import { SmartTransaction } from '@metamask/smart-transactions-controller'; import { RootState } from '../components/UI/BasicFunctionality/BasicFunctionalityModal/BasicFunctionalityModal.test'; +import { TransactionType } from '@metamask/transaction-controller'; import { selectTransactions, + selectLastWithdrawTokenByType, selectNonReplacedTransactions, selectSwapsTransactions, selectTransactionMetadataById, @@ -217,6 +219,129 @@ describe('TransactionController Selectors', () => { }); }); + describe('selectLastWithdrawTokenByType', () => { + it('returns token from latest nested predictWithdraw transaction', () => { + const state = { + engine: { + backgroundState: { + TransactionController: { + transactions: [ + { + id: 'older', + metamaskPay: { + chainId: '0x89', + tokenAddress: '0xolder', + }, + nestedTransactions: [ + { type: TransactionType.predictWithdraw }, + ], + time: 100, + type: TransactionType.batch, + }, + { + id: 'latest', + metamaskPay: { + chainId: '0x38', + tokenAddress: '0xlatest', + }, + nestedTransactions: [ + { type: TransactionType.predictWithdraw }, + ], + time: 200, + type: TransactionType.batch, + }, + ], + }, + }, + }, + pendingSmartTransactions: [], + } as unknown as RootState; + + const result = selectLastWithdrawTokenByType( + state, + TransactionType.predictWithdraw, + ); + + expect(result).toStrictEqual({ + address: '0xlatest', + chainId: '0x38', + }); + }); + + it('ignores nested predictWithdraw transactions without metamaskPay and uses the latest one with metamaskPay', () => { + const state = { + engine: { + backgroundState: { + TransactionController: { + transactions: [ + { + id: 'newer-without-metamask-pay', + nestedTransactions: [ + { type: TransactionType.predictWithdraw }, + ], + time: 300, + type: TransactionType.batch, + }, + { + id: 'latest-with-metamask-pay', + metamaskPay: { + chainId: '0x38', + tokenAddress: '0xlatest', + }, + nestedTransactions: [ + { type: TransactionType.predictWithdraw }, + ], + time: 200, + type: TransactionType.batch, + }, + ], + }, + }, + }, + pendingSmartTransactions: [], + } as unknown as RootState; + + const result = selectLastWithdrawTokenByType( + state, + TransactionType.predictWithdraw, + ); + + expect(result).toStrictEqual({ + address: '0xlatest', + chainId: '0x38', + }); + }); + + it('returns undefined when matching nested predictWithdraw transaction has no metamaskPay token', () => { + const state = { + engine: { + backgroundState: { + TransactionController: { + transactions: [ + { + id: 'latest', + nestedTransactions: [ + { type: TransactionType.predictWithdraw }, + ], + time: 200, + type: TransactionType.batch, + }, + ], + }, + }, + }, + pendingSmartTransactions: [], + } as unknown as RootState; + + const result = selectLastWithdrawTokenByType( + state, + TransactionType.predictWithdraw, + ); + + expect(result).toBeUndefined(); + }); + }); + describe('selectSortedEVMTransactionsForSelectedAccountGroup', () => { it('merges non-replaced transactions and pending smart transactions for selected group and sorts descending by time', () => { const transactions = [ diff --git a/app/selectors/transactionController.ts b/app/selectors/transactionController.ts index 91a873233fe..0c34e5e5c65 100644 --- a/app/selectors/transactionController.ts +++ b/app/selectors/transactionController.ts @@ -5,7 +5,42 @@ import { selectPendingSmartTransactionsBySender, selectPendingSmartTransactionsForSelectedAccountGroup, } from './smartTransactionsController'; -import { TransactionMeta } from '@metamask/transaction-controller'; +import { + TransactionMeta, + TransactionType, +} from '@metamask/transaction-controller'; +import { Hex } from '@metamask/utils'; + +interface MetaMaskPayToken { + address: Hex; + chainId: Hex; +} + +function getNestedTransactionTypes( + transaction: TransactionMeta, +): TransactionType[] { + if (!transaction.nestedTransactions) { + return []; + } + + return transaction.nestedTransactions + .map((nestedTransaction) => nestedTransaction.type) + .filter((type): type is TransactionType => Boolean(type)); +} + +function matchesTransactionType( + transaction: TransactionMeta, + transactionType: string, +): boolean { + return ( + transaction.type === transactionType || + transaction.originalType === transactionType || + (Boolean(transaction.metamaskPay) && + getNestedTransactionTypes(transaction).some( + (nestedTransactionType) => nestedTransactionType === transactionType, + )) + ); +} const selectTransactionControllerState = (state: RootState) => state.engine.backgroundState.TransactionController; @@ -47,6 +82,37 @@ export const selectSortedTransactions = createDeepEqualSelector( ), ); +export const selectLastWithdrawTokenByType = createSelector( + selectNonReplacedTransactions, + (_state: RootState, transactionType?: string) => transactionType, + (transactions, transactionType): MetaMaskPayToken | undefined => { + if (!transactionType) { + return undefined; + } + + const latestTransaction = [...transactions] + .reverse() + .find( + (transaction) => + matchesTransactionType(transaction, transactionType) && + transaction.metamaskPay?.tokenAddress && + transaction.metamaskPay?.chainId, + ); + + const tokenAddress = latestTransaction?.metamaskPay?.tokenAddress; + const chainId = latestTransaction?.metamaskPay?.chainId; + + if (!tokenAddress || !chainId) { + return undefined; + } + + return { + address: tokenAddress, + chainId, + }; + }, +); + export const selectSortedEVMTransactionsForSelectedAccountGroup = createDeepEqualSelector( [ From 36e01b98959f5e90b4739c5e13c2d4d1c0762417 Mon Sep 17 00:00:00 2001 From: CW Date: Wed, 18 Mar 2026 07:57:59 -0700 Subject: [PATCH 091/206] test: delete onramp-limits.failing e2e test (already covered) [MMQA-1522] (#27101) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Deletes the `onramp-limits.failing.ts` e2e test after confirming all behaviors are already covered by existing component tests. The e2e test was fully `.skip`ped and quarantined. ### Coverage Analysis | E2E Behavior | Existing Coverage | |---|---| | Min limit error message | `BuildQuote.test.tsx` line 742 — "validates the min limit" | | Max limit error message | `BuildQuote.test.tsx` line 729 — "validates the max limit" | No coverage gaps exist. A separate component-view test is not needed because the existing `BuildQuote.test.tsx` already uses `renderScreen()`, which renders the full component within the navigation stack with Redux state — making it functionally equivalent to a component-view test. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/MMQA-1522 ## **Manual testing steps** ```gherkin Feature: Onramp limits unit test coverage Scenario: Existing component tests cover onramp limits e2e behaviors Given the onramp-limits.failing.ts e2e test validates min/max limit error messages When existing component tests in BuildQuote.test.tsx are reviewed Then both min and max limit validations are already covered by component-level tests ``` ## **Screenshots/Recordings** ### **Before** - `onramp-limits.failing.ts` e2e test existed but was fully skipped and quarantined - Min/max limit validation already covered in `BuildQuote.test.tsx` ### **After** - `onramp-limits.failing.ts` e2e test deleted — all behaviors confirmed covered by component tests - No new tests needed — zero coverage gaps ## **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. ## **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** > Low risk: this PR only deletes a quarantined, fully skipped smoke test and does not affect app/runtime behavior. The only impact is reduced test surface area if equivalent coverage is not actually present elsewhere. > > **Overview** > Removes the quarantined `tests/smoke/ramps/onramp-limits.failing.ts` on-ramp limits smoke test, which was entirely `it.skip`ped. > > No new tests or product code are added; this change simply drops the disabled e2e coverage for min/max limit error messaging. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 84e67f75285921c905dc77a50ad1cf3c7b37f563. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). Co-authored-by: Claude Opus 4.6 --- tests/smoke/ramps/onramp-limits.failing.ts | 56 ---------------------- 1 file changed, 56 deletions(-) delete mode 100644 tests/smoke/ramps/onramp-limits.failing.ts diff --git a/tests/smoke/ramps/onramp-limits.failing.ts b/tests/smoke/ramps/onramp-limits.failing.ts deleted file mode 100644 index 7f3b26effc5..00000000000 --- a/tests/smoke/ramps/onramp-limits.failing.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { loginToApp } from '../../flows/wallet.flow'; -import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; -import { withFixtures } from '../../framework/fixtures/FixtureHelper'; -import { SmokeTrade } from '../../tags'; -import BuildQuoteView from '../../page-objects/Ramps/BuildQuoteView'; -import Assertions from '../../framework/Assertions'; -import WalletView from '../../page-objects/wallet/WalletView'; -import FundActionMenu from '../../page-objects/UI/FundActionMenu'; -import BuyGetStartedView from '../../page-objects/Ramps/BuyGetStartedView'; -import { RampsRegions, RampsRegionsEnum } from '../../framework/Constants'; -import { setupRegionAwareOnRampMocks } from '../../api-mocking/mock-responses/ramps/ramps-mocks'; -import { Mockttp } from 'mockttp'; -import { remoteFeatureFlagRampsUnifiedEnabled } from '../../api-mocking/mock-responses/feature-flags-mocks'; -import { setupRemoteFeatureFlagsMock } from '../../api-mocking/helpers/remoteFeatureFlagsHelper'; - -/** - * TODO: - * Moving to quaratine since all tests are being skipped. - * When this test is fixed we need to add a second shard to CI. - */ -describe(SmokeTrade('On-Ramp Limits'), () => { - const selectedRegion = RampsRegions[RampsRegionsEnum.FRANCE]; - it.skip('should check order min and maxlimits', async () => { - await withFixtures( - { - fixture: new FixtureBuilder() - .withRampsSelectedRegion(selectedRegion) - .withRampsSelectedPaymentMethod() - .build(), - restartDevice: true, - testSpecificMock: async (mockServer: Mockttp) => { - await setupRemoteFeatureFlagsMock(mockServer, { - ...remoteFeatureFlagRampsUnifiedEnabled(true), - }); - await setupRegionAwareOnRampMocks(mockServer, selectedRegion); - }, - }, - async () => { - await loginToApp(); - await WalletView.tapWalletBuyButton(); - await FundActionMenu.tapBuyButton(); - await BuyGetStartedView.tapGetStartedButton(); - await BuildQuoteView.enterAmount('1'); - await Assertions.expectElementToBeVisible( - BuildQuoteView.minLimitErrorMessage, - ); - await BuildQuoteView.tapKeypadDeleteButton(1); - await BuildQuoteView.enterAmount('55555'); - await Assertions.expectElementToBeVisible( - BuildQuoteView.maxLimitErrorMessage, - ); - await BuildQuoteView.tapCancelButton(); - }, - ); - }); -}); From f55954e6955347c56eb537c9618a4d8e5b1a3d3b Mon Sep 17 00:00:00 2001 From: Bryan Fullam Date: Wed, 18 Mar 2026 16:02:49 +0100 Subject: [PATCH 092/206] chore: bump bridge controllers (#27607) ## **Description** Bumps bridge controllers to allow for a/b test passthrough. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Dependency bumps to `@metamask/bridge-controller` (and related controllers) plus a new messenger action can affect quote fetching/bridge execution behavior at runtime. UI changes are small but touch price-impact gating logic used to warn/block swaps. > > **Overview** > Updates Bridge dependencies (notably `@metamask/bridge-controller` to `^69.1.1` and `@metamask/gas-fee-controller` to `^26.1.0`) and refreshes the lockfile to match. > > Removes temporary TypeScript suppression around `priceImpactThreshold` feature flags and continues to use flag-provided `warning`/`error` thresholds with `AppConstants` fallbacks across Bridge UI (`QuoteDetailsCard`, `SwapsConfirmButton`, `useBridgeQuoteData`, `usePriceImpactViewData`). > > Extends the Bridge controller messenger delegation to allow `AssetsController:getExchangeRatesForBridge`, enabling updated controller behavior that relies on bridge-specific exchange rates. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 1a838f12ec69808d282d31c4ef4c5f40cf9db960. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../QuoteDetailsCard/QuoteDetailsCard.tsx | 1 - .../components/SwapsConfirmButton/index.tsx | 1 - .../Bridge/hooks/useBridgeQuoteData/index.ts | 1 - .../hooks/usePriceImpactViewData/index.ts | 2 - .../bridge-controller-messenger/index.ts | 1 + package.json | 6 +- yarn.lock | 268 ++++++++++++------ 7 files changed, 183 insertions(+), 97 deletions(-) diff --git a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx index d04f7956846..ce9b529f690 100644 --- a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx +++ b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx @@ -96,7 +96,6 @@ const QuoteDetailsCard: React.FC = ({ const priceImpactIsSafe = !activeQuote?.quote.priceData?.priceImpact || Number(activeQuote.quote.priceData.priceImpact) <= - // @ts-expect-error TODO: remove comment after changes to core are published. (bridgeFeatureFlags?.priceImpactThreshold?.warning ?? AppConstants.BRIDGE.PRICE_IMPACT_WARNING_THRESHOLD); diff --git a/app/components/UI/Bridge/components/SwapsConfirmButton/index.tsx b/app/components/UI/Bridge/components/SwapsConfirmButton/index.tsx index 93e55555a97..be12f21f417 100644 --- a/app/components/UI/Bridge/components/SwapsConfirmButton/index.tsx +++ b/app/components/UI/Bridge/components/SwapsConfirmButton/index.tsx @@ -157,7 +157,6 @@ export const SwapsConfirmButton = ({ if ( Number.isFinite(priceImpact) && priceImpact >= - // @ts-expect-error TODO: remove comment after changes to core are published. (bridgeFeatureFlags?.priceImpactThreshold?.error ?? AppConstants.BRIDGE.PRICE_IMPACT_ERROR_THRESHOLD) ) { diff --git a/app/components/UI/Bridge/hooks/useBridgeQuoteData/index.ts b/app/components/UI/Bridge/hooks/useBridgeQuoteData/index.ts index 57d4dd7a818..9d5bb981f57 100644 --- a/app/components/UI/Bridge/hooks/useBridgeQuoteData/index.ts +++ b/app/components/UI/Bridge/hooks/useBridgeQuoteData/index.ts @@ -260,7 +260,6 @@ export const useBridgeQuoteData = ({ activeQuote?.quote.priceData?.priceImpact !== undefined && bridgeFeatureFlags?.priceImpactThreshold && Number(activeQuote?.quote.priceData?.priceImpact) >= - // @ts-expect-error TODO: remove comment after changes to core are published. (bridgeFeatureFlags.priceImpactThreshold.warning ?? AppConstants.BRIDGE.PRICE_IMPACT_WARNING_THRESHOLD), ); diff --git a/app/components/UI/Bridge/hooks/usePriceImpactViewData/index.ts b/app/components/UI/Bridge/hooks/usePriceImpactViewData/index.ts index 7401d4c62f1..07bf8b83c1d 100644 --- a/app/components/UI/Bridge/hooks/usePriceImpactViewData/index.ts +++ b/app/components/UI/Bridge/hooks/usePriceImpactViewData/index.ts @@ -13,11 +13,9 @@ export const usePriceImpactViewData = (priceImpact?: string) => { priceImpactValue: priceImpact, threshold: { error: - // @ts-expect-error TODO: remove comment after changes to core are published. bridgeFeatureFlags?.priceImpactThreshold?.error ?? AppConstants.BRIDGE.PRICE_IMPACT_ERROR_THRESHOLD, warning: - // @ts-expect-error TODO: remove comment after changes to core are published. bridgeFeatureFlags?.priceImpactThreshold?.warning ?? AppConstants.BRIDGE.PRICE_IMPACT_WARNING_THRESHOLD, }, diff --git a/app/core/Engine/messengers/bridge-controller-messenger/index.ts b/app/core/Engine/messengers/bridge-controller-messenger/index.ts index 46f8a945f1e..ac8235ae594 100644 --- a/app/core/Engine/messengers/bridge-controller-messenger/index.ts +++ b/app/core/Engine/messengers/bridge-controller-messenger/index.ts @@ -36,6 +36,7 @@ export function getBridgeControllerMessenger( 'CurrencyRateController:getState', 'RemoteFeatureFlagController:getState', 'AuthenticationController:getBearerToken', + 'AssetsController:getExchangeRatesForBridge', ], events: [], messenger, diff --git a/package.json b/package.json index dbd2b25de66..5174aadb955 100644 --- a/package.json +++ b/package.json @@ -212,8 +212,8 @@ "@metamask/assets-controllers": "^100.2.0", "@metamask/base-controller": "^9.0.0", "@metamask/bitcoin-wallet-snap": "^1.10.0", - "@metamask/bridge-controller": "^68.0.0", - "@metamask/bridge-status-controller": "^68.0.0", + "@metamask/bridge-controller": "^69.1.1", + "@metamask/bridge-status-controller": "^68.1.0", "@metamask/chain-agnostic-permission": "^1.3.0", "@metamask/connectivity-controller": "^0.1.0", "@metamask/controller-utils": "^11.18.0", @@ -240,7 +240,7 @@ "@metamask/ethjs-contract": "^0.4.1", "@metamask/ethjs-query": "^0.7.1", "@metamask/ethjs-unit": "^0.3.0", - "@metamask/gas-fee-controller": "^25.0.0", + "@metamask/gas-fee-controller": "^26.1.0", "@metamask/gator-permissions-controller": "^0.3.0", "@metamask/geolocation-controller": "^0.1.1", "@metamask/hw-wallet-sdk": "^0.4.0", diff --git a/yarn.lock b/yarn.lock index 22925ba6265..ce0fb4844f3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7675,42 +7675,55 @@ __metadata: languageName: node linkType: hard -"@metamask/assets-controller@npm:^2.0.0, @metamask/assets-controller@npm:^2.3.0": - version: 2.3.0 - resolution: "@metamask/assets-controller@npm:2.3.0" +"@metamask/approval-controller@npm:^9.0.0": + version: 9.0.0 + resolution: "@metamask/approval-controller@npm:9.0.0" + dependencies: + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/rpc-errors": "npm:^7.0.2" + "@metamask/utils": "npm:^11.9.0" + nanoid: "npm:^3.3.8" + checksum: 10/3eea0d1f291c159f096ed74d029531af529dc1e94bf1246ce3718bf91c11510fb3a52348eae5547b18af799213beee48f3cfe7d701909e9e527d6d4fe33e0152 + languageName: node + linkType: hard + +"@metamask/assets-controller@npm:^2.0.0, @metamask/assets-controller@npm:^2.3.0, @metamask/assets-controller@npm:^2.4.0": + version: 2.4.0 + resolution: "@metamask/assets-controller@npm:2.4.0" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/account-tree-controller": "npm:^5.0.0" - "@metamask/assets-controllers": "npm:^100.2.0" + "@metamask/account-tree-controller": "npm:^5.0.1" + "@metamask/assets-controllers": "npm:^101.0.0" "@metamask/base-controller": "npm:^9.0.0" "@metamask/client-controller": "npm:^1.0.0" "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/core-backend": "npm:^6.1.0" + "@metamask/core-backend": "npm:^6.1.1" "@metamask/keyring-api": "npm:^21.5.0" "@metamask/keyring-controller": "npm:^25.1.0" "@metamask/keyring-internal-api": "npm:^10.0.0" "@metamask/keyring-snap-client": "npm:^8.2.0" "@metamask/messenger": "npm:^0.3.0" "@metamask/network-controller": "npm:^30.0.0" - "@metamask/network-enablement-controller": "npm:^4.2.0" - "@metamask/permission-controller": "npm:^12.2.0" + "@metamask/network-enablement-controller": "npm:^5.0.0" + "@metamask/permission-controller": "npm:^12.2.1" "@metamask/polling-controller": "npm:^16.0.3" "@metamask/preferences-controller": "npm:^23.0.0" "@metamask/snaps-controllers": "npm:^17.2.0" "@metamask/snaps-utils": "npm:^11.7.0" - "@metamask/transaction-controller": "npm:^62.21.0" + "@metamask/transaction-controller": "npm:^63.0.0" "@metamask/utils": "npm:^11.9.0" async-mutex: "npm:^0.5.0" bignumber.js: "npm:^9.1.2" lodash: "npm:^4.17.21" p-limit: "npm:^3.1.0" - checksum: 10/b1ef8cffec8e648356063517af2c18322ac5891c83f436bc28fc0387598a85e3bdd94a24e3d593746cb00c821b2c659c918644433d05edd8b8c7a42890476c9c + checksum: 10/7c9d489736617508de3464539f1d2370b720c83d0b62e17ddd9593f8de0fbe2f1f97c65ff86c76793114b79f209c25d975ab169adf1a256ac5116d9f26e86db0 languageName: node linkType: hard -"@metamask/assets-controllers@npm:^100.0.3, @metamask/assets-controllers@npm:^100.2.0, @metamask/assets-controllers@npm:^100.2.1": +"@metamask/assets-controllers@npm:^100.2.0, @metamask/assets-controllers@npm:^100.2.1": version: 100.2.1 resolution: "@metamask/assets-controllers@npm:100.2.1" dependencies: @@ -7766,6 +7779,62 @@ __metadata: languageName: node linkType: hard +"@metamask/assets-controllers@npm:^101.0.0": + version: 101.0.0 + resolution: "@metamask/assets-controllers@npm:101.0.0" + dependencies: + "@ethereumjs/util": "npm:^9.1.0" + "@ethersproject/abi": "npm:^5.7.0" + "@ethersproject/address": "npm:^5.7.0" + "@ethersproject/bignumber": "npm:^5.7.0" + "@ethersproject/contracts": "npm:^5.7.0" + "@ethersproject/providers": "npm:^5.7.0" + "@metamask/abi-utils": "npm:^2.0.3" + "@metamask/account-tree-controller": "npm:^5.0.1" + "@metamask/accounts-controller": "npm:^37.0.0" + "@metamask/approval-controller": "npm:^9.0.0" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/contract-metadata": "npm:^2.4.0" + "@metamask/controller-utils": "npm:^11.19.0" + "@metamask/core-backend": "npm:^6.1.1" + "@metamask/eth-query": "npm:^4.0.0" + "@metamask/keyring-api": "npm:^21.5.0" + "@metamask/keyring-controller": "npm:^25.1.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/metamask-eth-abis": "npm:^3.1.1" + "@metamask/multichain-account-service": "npm:^7.1.0" + "@metamask/network-controller": "npm:^30.0.0" + "@metamask/network-enablement-controller": "npm:^5.0.0" + "@metamask/permission-controller": "npm:^12.2.1" + "@metamask/phishing-controller": "npm:^17.0.0" + "@metamask/polling-controller": "npm:^16.0.3" + "@metamask/preferences-controller": "npm:^23.0.0" + "@metamask/profile-sync-controller": "npm:^28.0.0" + "@metamask/rpc-errors": "npm:^7.0.2" + "@metamask/snaps-controllers": "npm:^17.2.0" + "@metamask/snaps-sdk": "npm:^10.3.0" + "@metamask/snaps-utils": "npm:^11.7.0" + "@metamask/storage-service": "npm:^1.0.0" + "@metamask/transaction-controller": "npm:^63.0.0" + "@metamask/utils": "npm:^11.9.0" + "@types/bn.js": "npm:^5.1.5" + "@types/uuid": "npm:^8.3.0" + async-mutex: "npm:^0.5.0" + bitcoin-address-validation: "npm:^2.2.3" + bn.js: "npm:^5.2.1" + immer: "npm:^9.0.6" + lodash: "npm:^4.17.21" + multiformats: "npm:^9.9.0" + reselect: "npm:^5.1.1" + single-call-balance-checker-abi: "npm:^1.0.0" + uuid: "npm:^8.3.2" + peerDependencies: + "@metamask/providers": ^22.0.0 + webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 + checksum: 10/8ef31a94c5666aafb93b8ace0fbc2b8de642f03b798e2a7d3d25df924d3548da8a251fc240e09d5cd89cf8bfd67b2344567b49a130d158fe132a1a63d363019a + languageName: node + linkType: hard + "@metamask/auth-network-utils@npm:^0.3.0": version: 0.3.1 resolution: "@metamask/auth-network-utils@npm:0.3.1" @@ -7835,41 +7904,9 @@ __metadata: languageName: node linkType: hard -"@metamask/bridge-controller@npm:^68.0.0": - version: 68.0.0 - resolution: "@metamask/bridge-controller@npm:68.0.0" - dependencies: - "@ethersproject/address": "npm:^5.7.0" - "@ethersproject/bignumber": "npm:^5.7.0" - "@ethersproject/constants": "npm:^5.7.0" - "@ethersproject/contracts": "npm:^5.7.0" - "@ethersproject/providers": "npm:^5.7.0" - "@metamask/accounts-controller": "npm:^36.0.1" - "@metamask/assets-controllers": "npm:^100.0.3" - "@metamask/base-controller": "npm:^9.0.0" - "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/gas-fee-controller": "npm:^26.0.3" - "@metamask/keyring-api": "npm:^21.5.0" - "@metamask/messenger": "npm:^0.3.0" - "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/multichain-network-controller": "npm:^3.0.4" - "@metamask/network-controller": "npm:^30.0.0" - "@metamask/polling-controller": "npm:^16.0.3" - "@metamask/profile-sync-controller": "npm:^27.1.0" - "@metamask/remote-feature-flag-controller": "npm:^4.1.0" - "@metamask/snaps-controllers": "npm:^17.2.0" - "@metamask/transaction-controller": "npm:^62.19.0" - "@metamask/utils": "npm:^11.9.0" - bignumber.js: "npm:^9.1.2" - reselect: "npm:^5.1.1" - uuid: "npm:^8.3.2" - checksum: 10/e92d96c7e421efeadcccdd58c87e3f5b171f3a8eff035dae8fff602b162902652b2738ae4aec62e48b432fefcab1736f69f7a2026ba7686151fafe73601c6a9b - languageName: node - linkType: hard - -"@metamask/bridge-controller@npm:^69.1.0": - version: 69.1.0 - resolution: "@metamask/bridge-controller@npm:69.1.0" +"@metamask/bridge-controller@npm:^69.1.0, @metamask/bridge-controller@npm:^69.1.1": + version: 69.1.1 + resolution: "@metamask/bridge-controller@npm:69.1.1" dependencies: "@ethersproject/address": "npm:^5.7.0" "@ethersproject/bignumber": "npm:^5.7.0" @@ -7877,11 +7914,11 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/accounts-controller": "npm:^37.0.0" - "@metamask/assets-controller": "npm:^2.3.0" - "@metamask/assets-controllers": "npm:^100.2.1" + "@metamask/assets-controller": "npm:^2.4.0" + "@metamask/assets-controllers": "npm:^101.0.0" "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/gas-fee-controller": "npm:^26.0.3" + "@metamask/gas-fee-controller": "npm:^26.1.0" "@metamask/keyring-api": "npm:^21.5.0" "@metamask/messenger": "npm:^0.3.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" @@ -7891,16 +7928,16 @@ __metadata: "@metamask/profile-sync-controller": "npm:^28.0.0" "@metamask/remote-feature-flag-controller": "npm:^4.1.0" "@metamask/snaps-controllers": "npm:^17.2.0" - "@metamask/transaction-controller": "npm:^62.21.0" + "@metamask/transaction-controller": "npm:^63.0.0" "@metamask/utils": "npm:^11.9.0" bignumber.js: "npm:^9.1.2" reselect: "npm:^5.1.1" uuid: "npm:^8.3.2" - checksum: 10/9bcd140206f718e8589aaba91870243ffe589ce6ea43127d624e675cb5c4afde1aee6e1a852efc5141e460d78acb50a916a179bbe5bc21683c9cacc5e3272e86 + checksum: 10/fac684a9caac65c336464affd8647d34ab0e4ccdddb3db95ffc741c454732df2eae52db6d9d6980ee04e38bd696d09e4c40fce30911bdea6c51fc5b91cf06320 languageName: node linkType: hard -"@metamask/bridge-status-controller@npm:^68.0.0, @metamask/bridge-status-controller@npm:^68.1.0": +"@metamask/bridge-status-controller@npm:^68.1.0": version: 68.1.0 resolution: "@metamask/bridge-status-controller@npm:68.1.0" dependencies: @@ -8000,7 +8037,7 @@ __metadata: languageName: node linkType: hard -"@metamask/controller-utils@npm:^11.0.0, @metamask/controller-utils@npm:^11.14.1, @metamask/controller-utils@npm:^11.15.0, @metamask/controller-utils@npm:^11.16.0, @metamask/controller-utils@npm:^11.17.0, @metamask/controller-utils@npm:^11.18.0, @metamask/controller-utils@npm:^11.19.0, @metamask/controller-utils@npm:^11.3.0": +"@metamask/controller-utils@npm:^11.0.0, @metamask/controller-utils@npm:^11.14.1, @metamask/controller-utils@npm:^11.15.0, @metamask/controller-utils@npm:^11.16.0, @metamask/controller-utils@npm:^11.18.0, @metamask/controller-utils@npm:^11.19.0, @metamask/controller-utils@npm:^11.3.0": version: 11.19.0 resolution: "@metamask/controller-utils@npm:11.19.0" dependencies: @@ -8542,30 +8579,9 @@ __metadata: languageName: node linkType: hard -"@metamask/gas-fee-controller@npm:^25.0.0": - version: 25.0.0 - resolution: "@metamask/gas-fee-controller@npm:25.0.0" - dependencies: - "@metamask/base-controller": "npm:^9.0.0" - "@metamask/controller-utils": "npm:^11.14.1" - "@metamask/eth-query": "npm:^4.0.0" - "@metamask/ethjs-unit": "npm:^0.3.0" - "@metamask/polling-controller": "npm:^15.0.0" - "@metamask/utils": "npm:^11.8.1" - "@types/bn.js": "npm:^5.1.5" - "@types/uuid": "npm:^8.3.0" - bn.js: "npm:^5.2.1" - uuid: "npm:^8.3.2" - peerDependencies: - "@babel/runtime": ^7.0.0 - "@metamask/network-controller": ^25.0.0 - checksum: 10/eb4d8e3534482763f7a27aa6cfa64b91b5c505276aca4a4d983bc52e4485090d558f0d206ce41e976e14dbfcc9bb8e24d508ef9eb5c1bad1568bae67611c80d3 - languageName: node - linkType: hard - -"@metamask/gas-fee-controller@npm:^26.0.2, @metamask/gas-fee-controller@npm:^26.0.3": - version: 26.0.3 - resolution: "@metamask/gas-fee-controller@npm:26.0.3" +"@metamask/gas-fee-controller@npm:^26.0.2, @metamask/gas-fee-controller@npm:^26.0.3, @metamask/gas-fee-controller@npm:^26.1.0": + version: 26.1.0 + resolution: "@metamask/gas-fee-controller@npm:26.1.0" dependencies: "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.19.0" @@ -8580,7 +8596,7 @@ __metadata: uuid: "npm:^8.3.2" peerDependencies: "@babel/runtime": ^7.0.0 - checksum: 10/c554094e845f93d19a81b5d0273648f0a066039e3296549eb57cd53e2a48ccb503bc261feb55d8a932d2eef7b68913d25319e91bebd53fb2ba7cbfca6ddfc8cb + checksum: 10/a376b8a6349461ef1aceda258af6d766832e3e89adde5dc9d0bf95d9624c498e76270ed06fd91a52aeffeb77c4d948fd742f38c721808a77383f8b39e3246359 languageName: node linkType: hard @@ -9079,6 +9095,24 @@ __metadata: languageName: node linkType: hard +"@metamask/network-enablement-controller@npm:^5.0.0": + version: 5.0.0 + resolution: "@metamask/network-enablement-controller@npm:5.0.0" + dependencies: + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/controller-utils": "npm:^11.19.0" + "@metamask/keyring-api": "npm:^21.5.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/multichain-network-controller": "npm:^3.0.5" + "@metamask/network-controller": "npm:^30.0.0" + "@metamask/slip44": "npm:^4.3.0" + "@metamask/transaction-controller": "npm:^63.0.0" + "@metamask/utils": "npm:^11.9.0" + reselect: "npm:^5.1.1" + checksum: 10/8741db7961c7e4c5a08a46653407b0e147b194b0fc3009fa2959d47b0306c7d1715673211964e34991884f966a61a759d2a3c3d2bf7ca93323661f92d85fb187 + languageName: node + linkType: hard + "@metamask/nonce-tracker@npm:^6.0.0": version: 6.0.0 resolution: "@metamask/nonce-tracker@npm:6.0.0" @@ -9142,14 +9176,14 @@ __metadata: languageName: node linkType: hard -"@metamask/permission-controller@npm:^12.0.0, @metamask/permission-controller@npm:^12.1.0, @metamask/permission-controller@npm:^12.1.1, @metamask/permission-controller@npm:^12.2.0": - version: 12.2.0 - resolution: "@metamask/permission-controller@npm:12.2.0" +"@metamask/permission-controller@npm:^12.0.0, @metamask/permission-controller@npm:^12.1.0, @metamask/permission-controller@npm:^12.1.1, @metamask/permission-controller@npm:^12.2.0, @metamask/permission-controller@npm:^12.2.1": + version: 12.2.1 + resolution: "@metamask/permission-controller@npm:12.2.1" dependencies: - "@metamask/approval-controller": "npm:^8.0.0" + "@metamask/approval-controller": "npm:^9.0.0" "@metamask/base-controller": "npm:^9.0.0" - "@metamask/controller-utils": "npm:^11.17.0" - "@metamask/json-rpc-engine": "npm:^10.2.0" + "@metamask/controller-utils": "npm:^11.19.0" + "@metamask/json-rpc-engine": "npm:^10.2.3" "@metamask/messenger": "npm:^0.3.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/utils": "npm:^11.9.0" @@ -9157,7 +9191,7 @@ __metadata: deep-freeze-strict: "npm:^1.1.1" immer: "npm:^9.0.6" nanoid: "npm:^3.3.8" - checksum: 10/d15ce9b69b3f8dbed2409d2e789e800d06798e42e55ac05095f19db0feda7492a64e221865ec6ee3d41b6c809ba5467f2bdab443c2d99133df88ac624ae264b8 + checksum: 10/610ed3acb63ca256592319c6f775e8888102c06304e46a95faf75abe898f0bf715a6254c6784a3964c0c379082cb7f1d1acfcf7db4af9bae9797f662944c3ebc languageName: node linkType: hard @@ -9178,6 +9212,23 @@ __metadata: languageName: node linkType: hard +"@metamask/phishing-controller@npm:^17.0.0": + version: 17.0.0 + resolution: "@metamask/phishing-controller@npm:17.0.0" + dependencies: + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/controller-utils": "npm:^11.19.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/transaction-controller": "npm:^63.0.0" + "@noble/hashes": "npm:^1.8.0" + "@types/punycode": "npm:^2.1.0" + ethereum-cryptography: "npm:^2.1.2" + fastest-levenshtein: "npm:^1.0.16" + punycode: "npm:^2.1.1" + checksum: 10/a1917ad63feb5c6287b7a191f78750d6455239909b0df5d07a965279638ccccb67de73d2f3cbe5596252e14b394565978bb86aa52e0adf388059d031531d0e93 + languageName: node + linkType: hard + "@metamask/polling-controller@npm:^15.0.0": version: 15.0.0 resolution: "@metamask/polling-controller@npm:15.0.0" @@ -10001,7 +10052,7 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@npm:^62.17.0, @metamask/transaction-controller@npm:^62.19.0, @metamask/transaction-controller@npm:^62.20.0, @metamask/transaction-controller@npm:^62.21.0, @metamask/transaction-controller@npm:^62.22.0": +"@metamask/transaction-controller@npm:^62.17.0, @metamask/transaction-controller@npm:^62.20.0, @metamask/transaction-controller@npm:^62.21.0, @metamask/transaction-controller@npm:^62.22.0": version: 62.22.0 resolution: "@metamask/transaction-controller@npm:62.22.0" dependencies: @@ -10040,6 +10091,45 @@ __metadata: languageName: node linkType: hard +"@metamask/transaction-controller@npm:^63.0.0": + version: 63.0.0 + resolution: "@metamask/transaction-controller@npm:63.0.0" + dependencies: + "@ethereumjs/common": "npm:^4.4.0" + "@ethereumjs/tx": "npm:^5.4.0" + "@ethereumjs/util": "npm:^9.1.0" + "@ethersproject/abi": "npm:^5.7.0" + "@ethersproject/contracts": "npm:^5.7.0" + "@ethersproject/providers": "npm:^5.7.0" + "@ethersproject/wallet": "npm:^5.7.0" + "@metamask/accounts-controller": "npm:^37.0.0" + "@metamask/approval-controller": "npm:^9.0.0" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/controller-utils": "npm:^11.19.0" + "@metamask/core-backend": "npm:^6.1.1" + "@metamask/eth-query": "npm:^4.0.0" + "@metamask/gas-fee-controller": "npm:^26.1.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/metamask-eth-abis": "npm:^3.1.1" + "@metamask/network-controller": "npm:^30.0.0" + "@metamask/nonce-tracker": "npm:^6.0.0" + "@metamask/remote-feature-flag-controller": "npm:^4.1.0" + "@metamask/rpc-errors": "npm:^7.0.2" + "@metamask/utils": "npm:^11.9.0" + async-mutex: "npm:^0.5.0" + bignumber.js: "npm:^9.1.2" + bn.js: "npm:^5.2.1" + eth-method-registry: "npm:^4.0.0" + fast-json-patch: "npm:^3.1.1" + lodash: "npm:^4.17.21" + uuid: "npm:^8.3.2" + peerDependencies: + "@babel/runtime": ^7.0.0 + "@metamask/eth-block-tracker": ">=9" + checksum: 10/1e6c79a4b6714731880eeae3acf02d30814a54087a8ff66ddf95b1168d21434d81ce05bd0906f27a81f7c0b5de1a934bd5de879523449737b21dcbe9560d6787 + languageName: node + linkType: hard + "@metamask/transaction-controller@patch:@metamask/transaction-controller@npm%3A62.10.0#~/.yarn/patches/@metamask-transaction-controller-npm-62.10.0-e1d24e7db5.patch": version: 62.10.0 resolution: "@metamask/transaction-controller@patch:@metamask/transaction-controller@npm%3A62.10.0#~/.yarn/patches/@metamask-transaction-controller-npm-62.10.0-e1d24e7db5.patch::version=62.10.0&hash=dae606" @@ -35364,8 +35454,8 @@ __metadata: "@metamask/auto-changelog": "npm:^5.3.0" "@metamask/base-controller": "npm:^9.0.0" "@metamask/bitcoin-wallet-snap": "npm:^1.10.0" - "@metamask/bridge-controller": "npm:^68.0.0" - "@metamask/bridge-status-controller": "npm:^68.0.0" + "@metamask/bridge-controller": "npm:^69.1.1" + "@metamask/bridge-status-controller": "npm:^68.1.0" "@metamask/browser-passworder": "npm:^5.0.0" "@metamask/browser-playground": "npm:0.3.0" "@metamask/build-utils": "npm:^3.0.0" @@ -35398,7 +35488,7 @@ __metadata: "@metamask/ethjs-query": "npm:^0.7.1" "@metamask/ethjs-unit": "npm:^0.3.0" "@metamask/foundryup": "npm:1.0.0" - "@metamask/gas-fee-controller": "npm:^25.0.0" + "@metamask/gas-fee-controller": "npm:^26.1.0" "@metamask/gator-permissions-controller": "npm:^0.3.0" "@metamask/geolocation-controller": "npm:^0.1.1" "@metamask/hw-wallet-sdk": "npm:^0.4.0" From 245dd6fa4c36e7a97fbf07d57911f70385f3db93 Mon Sep 17 00:00:00 2001 From: sophieqgu <37032128+sophieqgu@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:26:36 -0400 Subject: [PATCH 093/206] feat: rwds 1069 display campaigns in mobile app (#27556) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** https://consensyssoftware.atlassian.net/browse/RWDS-1069 https://consensyssoftware.atlassian.net/browse/RWDS-1073 When campaigns feature flag is enabled, switch to CampaignsPreview view in Rewards Dashboard. Display active, upcoming and previous campaigns. Display past season as a previous campaign. ## **Changelog** CHANGELOG entry: Enable campaigns view under feature flag ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** Simulator Screenshot - E2E Test -
2026-03-12 at 16 36 01 Simulator Screenshot - E2E Test -
2026-03-12 at 16 36 22 Simulator Screenshot - E2E Test -
2026-03-12 at 16 37 01 Simulator Screenshot - E2E Test -
2026-03-17 at 15 17 30 https://github.com/user-attachments/assets/8eb0b73e-6eab-4d3e-b772-73b4f0a8a82e ## **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. ## **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 the Rewards dashboard’s primary content and navigation flow behind a feature flag and introduces new campaign-fetching UI/state paths, which could affect what opted-in users see and when data is fetched. > > **Overview** > Adds a new campaigns experience under a `selectCampaignsRewardsEnabledFlag` feature flag: the Rewards dashboard now shows a `CampaignsPreview` section (active tile + upcoming banner) and links into a new full `CampaignsView`. > > Introduces new navigation routes/screens for `CAMPAIGNS_VIEW` and `PREVIOUS_SEASON_VIEW`, plus a `PreviousSeasonTile` surfaced as part of the campaigns list. > > Implements campaign UI building blocks (`CampaignTile`, `CampaignStatus`, `CampaignsGroup`) and shared status/date utilities (`CampaignTile.utils`) with extensive new unit tests; updates Rewards screens/tests accordingly and removes the old `SeasonStatus` test coverage. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 8ccec1861078801e41b8790daa01efa3debf80ad. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Rik Van Gulck --- .../UI/Rewards/RewardsNavigator.test.tsx | 5 + .../UI/Rewards/RewardsNavigator.tsx | 16 + .../UI/Rewards/Views/CampaignsView.test.tsx | 366 +++++++++ .../UI/Rewards/Views/CampaignsView.tsx | 137 ++++ .../Rewards/Views/PreviousSeasonView.test.tsx | 100 +++ .../UI/Rewards/Views/PreviousSeasonView.tsx | 38 + .../Rewards/Views/RewardsDashboard.test.tsx | 492 +++++------- .../UI/Rewards/Views/RewardsDashboard.tsx | 192 +---- .../Views/RewardsReferralView.test.tsx | 194 ++--- .../UI/Rewards/Views/RewardsReferralView.tsx | 8 +- .../UI/Rewards/Views/RewardsView.constants.ts | 15 +- .../Campaigns/CampaignStatus.test.tsx | 181 +++++ .../components/Campaigns/CampaignStatus.tsx | 112 +++ .../Campaigns/CampaignTile.test.tsx | 245 ++++++ .../components/Campaigns/CampaignTile.tsx | 183 +++++ .../Campaigns/CampaignTile.utils.test.ts | 283 +++++++ .../Campaigns/CampaignTile.utils.ts | 156 ++++ .../Campaigns/CampaignsGroup.test.tsx | 111 +++ .../components/Campaigns/CampaignsGroup.tsx | 45 ++ .../Campaigns/CampaignsPreview.test.tsx | 251 ++++++ .../components/Campaigns/CampaignsPreview.tsx | 116 +++ .../PreviousSeasonTile.test.tsx | 94 +++ .../PreviousSeason/PreviousSeasonTile.tsx | 83 ++ .../SeasonStatus/SeasonStatus.test.tsx | 756 ------------------ .../components/SeasonStatus/SeasonStatus.tsx | 130 --- .../SnapshotTile/SnapshotTile.test.tsx | 159 ---- .../components/SnapshotTile/SnapshotTile.tsx | 105 --- .../SnapshotTile/SnapshotTile.utils.test.ts | 502 ------------ .../SnapshotTile/SnapshotTile.utils.ts | 191 ----- .../UpcomingSnapshotTileCondensed.test.tsx | 111 --- .../UpcomingSnapshotTileCondensed.tsx | 60 -- .../Rewards/components/SnapshotTile/index.ts | 10 - .../Tabs/ActivityTab/ActivityTab.tsx | 20 +- .../Tabs/OverviewTab/ActiveBoosts.tsx | 28 +- .../SnapshotsSection.test.tsx | 371 --------- .../SnapshotsSection/SnapshotsSection.tsx | 133 --- .../OverviewTab/SnapshotsSection/index.ts | 2 - .../WaysToEarn/WaysToEarn.test.tsx | 6 +- .../OverviewTab/WaysToEarn/WaysToEarn.tsx | 8 +- .../components/Tabs/RewardsOverview.test.tsx | 14 - .../components/Tabs/RewardsOverview.tsx | 5 +- .../components/Tabs/RewardsSnapshots.test.tsx | 30 - .../components/Tabs/RewardsSnapshots.tsx | 18 - .../Tabs/SnapshotsTab/SnapshotsGroup.test.tsx | 111 --- .../Tabs/SnapshotsTab/SnapshotsGroup.tsx | 36 - .../Tabs/SnapshotsTab/SnapshotsTab.test.tsx | 255 ------ .../Tabs/SnapshotsTab/SnapshotsTab.tsx | 123 --- .../components/Tabs/SnapshotsTab/index.ts | 2 - .../useGetCampaignParticipantStatus.test.ts | 73 +- .../hooks/useGetCampaignParticipantStatus.ts | 14 +- .../Rewards/hooks/useOptInToCampaign.test.ts | 2 +- .../Rewards/hooks/useRewardCampaigns.test.ts | 114 +++ .../UI/Rewards/hooks/useRewardCampaigns.ts | 44 + .../UI/Rewards/hooks/useSnapshots.test.ts | 526 ------------ .../UI/Rewards/hooks/useSnapshots.ts | 153 ---- app/constants/navigation/Routes.ts | 3 + .../RewardsController.test.ts | 579 +------------- .../rewards-controller/RewardsController.ts | 83 +- .../controllers/rewards-controller/index.ts | 2 - .../services/rewards-data-service.test.ts | 183 +---- .../services/rewards-data-service.ts | 36 - .../controllers/rewards-controller/types.ts | 188 ++--- .../rewards-controller-messenger/index.ts | 3 - app/reducers/rewards/index.test.ts | 407 ++-------- app/reducers/rewards/index.ts | 55 +- app/reducers/rewards/selectors.test.ts | 160 ++-- app/reducers/rewards/selectors.ts | 26 +- app/reducers/rewards/types.ts | 2 +- .../featureFlagController/rewards/index.ts | 3 - .../rewards/rewardsEnabled.test.ts | 91 --- .../rewards/rewardsEnabled.ts | 38 - .../logs/__snapshots__/index.test.ts.snap | 2 - app/util/test/initial-background-state.json | 1 - locales/languages/en.json | 49 +- 74 files changed, 3466 insertions(+), 5980 deletions(-) create mode 100644 app/components/UI/Rewards/Views/CampaignsView.test.tsx create mode 100644 app/components/UI/Rewards/Views/CampaignsView.tsx create mode 100644 app/components/UI/Rewards/Views/PreviousSeasonView.test.tsx create mode 100644 app/components/UI/Rewards/Views/PreviousSeasonView.tsx create mode 100644 app/components/UI/Rewards/components/Campaigns/CampaignStatus.test.tsx create mode 100644 app/components/UI/Rewards/components/Campaigns/CampaignStatus.tsx create mode 100644 app/components/UI/Rewards/components/Campaigns/CampaignTile.test.tsx create mode 100644 app/components/UI/Rewards/components/Campaigns/CampaignTile.tsx create mode 100644 app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.test.ts create mode 100644 app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.ts create mode 100644 app/components/UI/Rewards/components/Campaigns/CampaignsGroup.test.tsx create mode 100644 app/components/UI/Rewards/components/Campaigns/CampaignsGroup.tsx create mode 100644 app/components/UI/Rewards/components/Campaigns/CampaignsPreview.test.tsx create mode 100644 app/components/UI/Rewards/components/Campaigns/CampaignsPreview.tsx create mode 100644 app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonTile.test.tsx create mode 100644 app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonTile.tsx delete mode 100644 app/components/UI/Rewards/components/SeasonStatus/SeasonStatus.test.tsx delete mode 100644 app/components/UI/Rewards/components/SeasonStatus/SeasonStatus.tsx delete mode 100644 app/components/UI/Rewards/components/SnapshotTile/SnapshotTile.test.tsx delete mode 100644 app/components/UI/Rewards/components/SnapshotTile/SnapshotTile.tsx delete mode 100644 app/components/UI/Rewards/components/SnapshotTile/SnapshotTile.utils.test.ts delete mode 100644 app/components/UI/Rewards/components/SnapshotTile/SnapshotTile.utils.ts delete mode 100644 app/components/UI/Rewards/components/SnapshotTile/UpcomingSnapshotTileCondensed.test.tsx delete mode 100644 app/components/UI/Rewards/components/SnapshotTile/UpcomingSnapshotTileCondensed.tsx delete mode 100644 app/components/UI/Rewards/components/SnapshotTile/index.ts delete mode 100644 app/components/UI/Rewards/components/Tabs/OverviewTab/SnapshotsSection/SnapshotsSection.test.tsx delete mode 100644 app/components/UI/Rewards/components/Tabs/OverviewTab/SnapshotsSection/SnapshotsSection.tsx delete mode 100644 app/components/UI/Rewards/components/Tabs/OverviewTab/SnapshotsSection/index.ts delete mode 100644 app/components/UI/Rewards/components/Tabs/RewardsSnapshots.test.tsx delete mode 100644 app/components/UI/Rewards/components/Tabs/RewardsSnapshots.tsx delete mode 100644 app/components/UI/Rewards/components/Tabs/SnapshotsTab/SnapshotsGroup.test.tsx delete mode 100644 app/components/UI/Rewards/components/Tabs/SnapshotsTab/SnapshotsGroup.tsx delete mode 100644 app/components/UI/Rewards/components/Tabs/SnapshotsTab/SnapshotsTab.test.tsx delete mode 100644 app/components/UI/Rewards/components/Tabs/SnapshotsTab/SnapshotsTab.tsx delete mode 100644 app/components/UI/Rewards/components/Tabs/SnapshotsTab/index.ts delete mode 100644 app/components/UI/Rewards/hooks/useSnapshots.test.ts delete mode 100644 app/components/UI/Rewards/hooks/useSnapshots.ts diff --git a/app/components/UI/Rewards/RewardsNavigator.test.tsx b/app/components/UI/Rewards/RewardsNavigator.test.tsx index d0a237da7e2..ec5b133b891 100644 --- a/app/components/UI/Rewards/RewardsNavigator.test.tsx +++ b/app/components/UI/Rewards/RewardsNavigator.test.tsx @@ -154,6 +154,11 @@ jest.mock('./hooks/useCandidateSubscriptionId', () => ({ useCandidateSubscriptionId: jest.fn(), })); +// Mock useRewardCampaigns hook +jest.mock('./hooks/useRewardCampaigns', () => ({ + useRewardCampaigns: jest.fn(), +})); + // Mock useSeasonStatus hook jest.mock('./hooks/useSeasonStatus', () => ({ useSeasonStatus: jest.fn(), diff --git a/app/components/UI/Rewards/RewardsNavigator.tsx b/app/components/UI/Rewards/RewardsNavigator.tsx index c131e3997ab..3361637e7bc 100644 --- a/app/components/UI/Rewards/RewardsNavigator.tsx +++ b/app/components/UI/Rewards/RewardsNavigator.tsx @@ -5,12 +5,15 @@ import OnboardingNavigator from './OnboardingNavigator'; import RewardsDashboard from './Views/RewardsDashboard'; import ReferralRewardsView from './Views/RewardsReferralView'; import RewardsSettingsView from './Views/RewardsSettingsView'; +import CampaignsView from './Views/CampaignsView'; +import PreviousSeasonView from './Views/PreviousSeasonView'; import { useSelector } from 'react-redux'; import { selectRewardsSubscriptionId } from '../../../selectors/rewards'; import { useCandidateSubscriptionId } from './hooks/useCandidateSubscriptionId'; import { useNavigation } from '@react-navigation/native'; import { useSeasonStatus } from './hooks/useSeasonStatus'; import { useGeoRewardsMetadata } from './hooks/useGeoRewardsMetadata'; +import { useRewardCampaigns } from './hooks/useRewardCampaigns'; const Stack = createStackNavigator(); const RewardsNavigator: React.FC = () => { @@ -26,6 +29,9 @@ const RewardsNavigator: React.FC = () => { // Fetch geo rewards metadata so optinAllowedForGeo is available across all rewards screens useGeoRewardsMetadata({}); + // Fetch all campaigns + useRewardCampaigns(); + // Determine initial route - always start with onboarding intro step initially const getInitialRoute = () => { // If user has already opted in and has a valid subscription candidate ID, go to dashboard @@ -69,6 +75,16 @@ const RewardsNavigator: React.FC = () => { component={RewardsSettingsView} options={{ headerShown: false }} /> + + ) : null}
diff --git a/app/components/UI/Rewards/Views/CampaignsView.test.tsx b/app/components/UI/Rewards/Views/CampaignsView.test.tsx new file mode 100644 index 00000000000..e8058223565 --- /dev/null +++ b/app/components/UI/Rewards/Views/CampaignsView.test.tsx @@ -0,0 +1,366 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import CampaignsView from './CampaignsView'; +import { + type CampaignDto, + CampaignType, +} from '../../../../core/Engine/controllers/rewards-controller/types'; +import { useRewardCampaigns } from '../hooks/useRewardCampaigns'; +import { REWARDS_VIEW_SELECTORS } from './RewardsView.constants'; + +const mockGoBack = jest.fn(); +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ goBack: mockGoBack }), +})); + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + return { ...actual }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock('../hooks/useRewardCampaigns'); +const mockUseRewardCampaigns = useRewardCampaigns as jest.MockedFunction< + typeof useRewardCampaigns +>; + +jest.mock('../components/Campaigns/CampaignsGroup', () => { + const ReactActual = jest.requireActual('react'); + const { View, Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + title, + campaigns, + testID, + }: { + title: string; + campaigns: CampaignDto[]; + testID?: string; + }) => + campaigns.length > 0 + ? ReactActual.createElement( + View, + { testID }, + ReactActual.createElement(Text, null, title), + campaigns.map((c: CampaignDto) => + ReactActual.createElement(Text, { key: c.id }, c.name), + ), + ) + : null, + }; +}); + +jest.mock('../components/RewardsErrorBanner', () => { + const ReactActual = jest.requireActual('react'); + const { View, Text, Pressable } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + title, + description, + onConfirm, + confirmButtonLabel, + }: { + title: string; + description: string; + onConfirm?: () => void; + confirmButtonLabel?: string; + }) => + ReactActual.createElement( + View, + { testID: 'error-banner' }, + ReactActual.createElement(Text, null, title), + ReactActual.createElement(Text, null, description), + confirmButtonLabel && + ReactActual.createElement( + Pressable, + { onPress: onConfirm, testID: 'error-retry-button' }, + ReactActual.createElement(Text, null, confirmButtonLabel), + ), + ), + }; +}); + +jest.mock( + '../../../../component-library/components-temp/HeaderCompactStandard', + () => { + const ReactActual = jest.requireActual('react'); + const { View, Text, Pressable } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ title, onBack }: { title: string; onBack: () => void }) => + ReactActual.createElement( + View, + { testID: 'header' }, + ReactActual.createElement(Text, null, title), + ReactActual.createElement(Pressable, { + onPress: onBack, + testID: 'header-back-button', + }), + ), + }; + }, +); + +jest.mock('../../../Views/ErrorBoundary', () => { + const ReactActual = jest.requireActual('react'); + return { + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => + ReactActual.createElement(ReactActual.Fragment, null, children), + }; +}); + +jest.mock('../../../../../locales/i18n', () => ({ + strings: (key: string) => { + const translations: Record = { + 'rewards.campaigns_view.title': 'Campaigns', + 'rewards.campaigns_view.active_title': 'Active', + 'rewards.campaigns_view.upcoming_title': 'Upcoming', + 'rewards.campaigns_view.previous_title': 'Previous', + 'rewards.campaigns_view.empty_state': 'No campaigns available', + 'rewards.campaigns_view.error_title': 'Unable to load campaigns', + 'rewards.campaigns_view.error_description': + "We couldn't load the campaigns. Please try again.", + 'rewards.campaigns_view.retry_button': 'Retry', + 'rewards.campaigns_view.refreshing': 'Refreshing...', + }; + return translations[key] || key; + }, +})); + +const createTestCampaign = ( + overrides: Partial = {}, +): CampaignDto => ({ + id: 'campaign-1', + type: CampaignType.ONDO_HOLDING, + name: 'Test Campaign', + startDate: '2027-01-01T00:00:00.000Z', + endDate: '2027-12-31T23:59:59.999Z', + termsAndConditions: null, + excludedRegions: [], + statusLabel: 'Active', + details: null, + ...overrides, +}); + +const emptyCategorized = { active: [], upcoming: [], previous: [] }; +const mockFetchCampaigns = jest.fn(); + +const hookDefaults = { + campaigns: [], + categorizedCampaigns: emptyCategorized, + isLoading: false, + hasError: false, + fetchCampaigns: mockFetchCampaigns, +}; + +describe('CampaignsView', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseRewardCampaigns.mockReturnValue(hookDefaults); + }); + + it('renders the header with the correct title', () => { + const { getByText, getByTestId } = render(); + + expect( + getByTestId(REWARDS_VIEW_SELECTORS.CAMPAIGNS_VIEW), + ).toBeOnTheScreen(); + expect(getByText('Campaigns')).toBeOnTheScreen(); + }); + + it('navigates back when the back button is pressed', () => { + const { getByTestId } = render(); + + fireEvent.press(getByTestId('header-back-button')); + + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + describe('loading state', () => { + it('renders skeletons when loading with no campaigns', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + isLoading: true, + }); + + const { queryByText } = render(); + + expect(queryByText('No campaigns available')).toBeNull(); + expect(queryByText('Unable to load campaigns')).toBeNull(); + }); + + it('renders the refreshing indicator when loading with existing campaigns', () => { + const activeCampaign = createTestCampaign({ + id: 'a1', + name: 'Active One', + }); + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + isLoading: true, + categorizedCampaigns: { ...emptyCategorized, active: [activeCampaign] }, + }); + + const { getByText } = render(); + + expect(getByText('Refreshing...')).toBeOnTheScreen(); + expect(getByText('Active One')).toBeOnTheScreen(); + }); + }); + + describe('error state', () => { + it('renders the error banner when there is an error and no campaigns', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + hasError: true, + }); + + const { getByText } = render(); + + expect(getByText('Unable to load campaigns')).toBeOnTheScreen(); + expect( + getByText("We couldn't load the campaigns. Please try again."), + ).toBeOnTheScreen(); + expect(getByText('Retry')).toBeOnTheScreen(); + }); + + it('calls fetchCampaigns when retry button is pressed', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + hasError: true, + }); + + const { getByTestId } = render(); + fireEvent.press(getByTestId('error-retry-button')); + + expect(mockFetchCampaigns).toHaveBeenCalledTimes(1); + }); + }); + + describe('empty state', () => { + it('renders the empty state message when there are no campaigns and not loading', () => { + const { getByText } = render(); + + expect(getByText('No campaigns available')).toBeOnTheScreen(); + }); + }); + + describe('campaigns display', () => { + it('renders active campaigns group', () => { + const activeCampaign = createTestCampaign({ + id: 'a1', + name: 'Active One', + }); + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + categorizedCampaigns: { ...emptyCategorized, active: [activeCampaign] }, + }); + + const { getByText, getByTestId } = render(); + + expect( + getByTestId(REWARDS_VIEW_SELECTORS.CAMPAIGNS_ACTIVE_SECTION), + ).toBeOnTheScreen(); + expect(getByText('Active')).toBeOnTheScreen(); + expect(getByText('Active One')).toBeOnTheScreen(); + }); + + it('renders upcoming campaigns group', () => { + const upcomingCampaign = createTestCampaign({ + id: 'u1', + name: 'Upcoming One', + }); + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + categorizedCampaigns: { + ...emptyCategorized, + upcoming: [upcomingCampaign], + }, + }); + + const { getByText, getByTestId } = render(); + + expect( + getByTestId(REWARDS_VIEW_SELECTORS.CAMPAIGNS_UPCOMING_SECTION), + ).toBeOnTheScreen(); + expect(getByText('Upcoming')).toBeOnTheScreen(); + expect(getByText('Upcoming One')).toBeOnTheScreen(); + }); + + it('renders previous campaigns group', () => { + const previousCampaign = createTestCampaign({ + id: 'p1', + name: 'Previous One', + }); + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + categorizedCampaigns: { + ...emptyCategorized, + previous: [previousCampaign], + }, + }); + + const { getByText, getByTestId } = render(); + + expect( + getByTestId(REWARDS_VIEW_SELECTORS.CAMPAIGNS_PREVIOUS_SECTION), + ).toBeOnTheScreen(); + expect(getByText('Previous')).toBeOnTheScreen(); + expect(getByText('Previous One')).toBeOnTheScreen(); + }); + + it('renders all three groups when all categories have campaigns', () => { + const active = createTestCampaign({ id: 'a1', name: 'Active One' }); + const upcoming = createTestCampaign({ id: 'u1', name: 'Upcoming One' }); + const previous = createTestCampaign({ id: 'p1', name: 'Previous One' }); + + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + categorizedCampaigns: { + active: [active], + upcoming: [upcoming], + previous: [previous], + }, + }); + + const { getByText } = render(); + + expect(getByText('Active')).toBeOnTheScreen(); + expect(getByText('Active One')).toBeOnTheScreen(); + expect(getByText('Upcoming')).toBeOnTheScreen(); + expect(getByText('Upcoming One')).toBeOnTheScreen(); + expect(getByText('Previous')).toBeOnTheScreen(); + expect(getByText('Previous One')).toBeOnTheScreen(); + }); + + it('does not show empty state or error when campaigns exist', () => { + const active = createTestCampaign({ id: 'a1', name: 'Active One' }); + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + categorizedCampaigns: { ...emptyCategorized, active: [active] }, + }); + + const { queryByText, queryByTestId } = render(); + + expect(queryByText('No campaigns available')).toBeNull(); + expect(queryByTestId('error-banner')).toBeNull(); + }); + + it('does not show refreshing indicator when not loading', () => { + const active = createTestCampaign({ id: 'a1', name: 'Active One' }); + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + categorizedCampaigns: { ...emptyCategorized, active: [active] }, + }); + + const { queryByText } = render(); + + expect(queryByText('Refreshing...')).toBeNull(); + }); + }); +}); diff --git a/app/components/UI/Rewards/Views/CampaignsView.tsx b/app/components/UI/Rewards/Views/CampaignsView.tsx new file mode 100644 index 00000000000..9867e1414ce --- /dev/null +++ b/app/components/UI/Rewards/Views/CampaignsView.tsx @@ -0,0 +1,137 @@ +import React from 'react'; +import { ActivityIndicator } from 'react-native'; +import { ScrollView } from 'react-native-gesture-handler'; +import { useNavigation } from '@react-navigation/native'; +import { + Box, + Text, + TextVariant, + BoxFlexDirection, + BoxAlignItems, + Skeleton, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import ErrorBoundary from '../../../Views/ErrorBoundary'; +import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard'; +import { useRewardCampaigns } from '../hooks/useRewardCampaigns'; +import RewardsErrorBanner from '../components/RewardsErrorBanner'; +import { REWARDS_VIEW_SELECTORS } from './RewardsView.constants'; +import CampaignsGroup from '../components/Campaigns/CampaignsGroup'; +import { strings } from '../../../../../locales/i18n'; + +/** + * CampaignsView displays all campaigns organized by status: + * - Active + * - Upcoming + * - Previous (complete) + */ +const CampaignsView: React.FC = () => { + const tw = useTailwind(); + const navigation = useNavigation(); + const { categorizedCampaigns, isLoading, hasError, fetchCampaigns } = + useRewardCampaigns(); + + const { active, upcoming, previous } = categorizedCampaigns; + const hasCampaigns = + active.length > 0 || upcoming.length > 0 || previous.length > 0; + + const renderContent = () => { + if (isLoading && !hasCampaigns) { + return ( + + + + + + + + + + + ); + } + + if (hasError && !hasCampaigns) { + return ( + + ); + } + + if (!hasCampaigns) { + return ( + + + {strings('rewards.campaigns_view.empty_state')} + + + ); + } + + return ( + + + + + + + + ); + }; + + return ( + + + navigation.goBack()} + backButtonProps={{ testID: 'header-back-button' }} + includesTopInset + /> + + {isLoading && hasCampaigns && ( + + + + {strings('rewards.campaigns_view.refreshing')} + + + )} + + {renderContent()} + + + + ); +}; + +export default CampaignsView; diff --git a/app/components/UI/Rewards/Views/PreviousSeasonView.test.tsx b/app/components/UI/Rewards/Views/PreviousSeasonView.test.tsx new file mode 100644 index 00000000000..20ce0ec4e8b --- /dev/null +++ b/app/components/UI/Rewards/Views/PreviousSeasonView.test.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import PreviousSeasonView from './PreviousSeasonView'; + +const mockGoBack = jest.fn(); +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ goBack: mockGoBack }), +})); + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + return { ...actual }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock( + '../../../../component-library/components-temp/HeaderCompactStandard', + () => { + const ReactActual = jest.requireActual('react'); + const { View, Text, Pressable } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ title, onBack }: { title: string; onBack: () => void }) => + ReactActual.createElement( + View, + { testID: 'header' }, + ReactActual.createElement(Text, null, title), + ReactActual.createElement(Pressable, { + onPress: onBack, + testID: 'header-back-button', + }), + ), + }; + }, +); + +jest.mock('../../../Views/ErrorBoundary', () => { + const ReactActual = jest.requireActual('react'); + return { + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => + ReactActual.createElement(ReactActual.Fragment, null, children), + }; +}); + +jest.mock('../components/PreviousSeason/PreviousSeasonSummary', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + ReactActual.createElement(View, { + testID: 'previous-season-summary', + }), + }; +}); + +jest.mock('../../../../../locales/i18n', () => ({ + strings: (key: string) => { + const translations: Record = { + 'rewards.previous_season_view.title': 'Previous Season', + }; + return translations[key] || key; + }, +})); + +describe('PreviousSeasonView', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the safe area container', () => { + const { getByTestId } = render(); + + expect(getByTestId('previous-season-view-safe-area')).toBeOnTheScreen(); + }); + + it('renders the header with the correct title', () => { + const { getByText } = render(); + + expect(getByText('Previous Season')).toBeOnTheScreen(); + }); + + it('navigates back when the back button is pressed', () => { + const { getByTestId } = render(); + + fireEvent.press(getByTestId('header-back-button')); + + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('renders PreviousSeasonSummary', () => { + const { getByTestId } = render(); + + expect(getByTestId('previous-season-summary')).toBeOnTheScreen(); + }); +}); diff --git a/app/components/UI/Rewards/Views/PreviousSeasonView.tsx b/app/components/UI/Rewards/Views/PreviousSeasonView.tsx new file mode 100644 index 00000000000..e40f39c6870 --- /dev/null +++ b/app/components/UI/Rewards/Views/PreviousSeasonView.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { ScrollView } from 'react-native-gesture-handler'; +import { strings } from '../../../../../locales/i18n'; +import ErrorBoundary from '../../../Views/ErrorBoundary'; +import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard'; +import PreviousSeasonSummary from '../components/PreviousSeason/PreviousSeasonSummary'; + +const PreviousSeasonView: React.FC = () => { + const tw = useTailwind(); + const navigation = useNavigation(); + + return ( + + + navigation.goBack()} + backButtonProps={{ testID: 'header-back-button' }} + /> + + + + + + ); +}; + +export default PreviousSeasonView; diff --git a/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx b/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx index 85c3a32f6a5..10620e591a3 100644 --- a/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx +++ b/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx @@ -3,7 +3,6 @@ import { render, fireEvent, waitFor } from '@testing-library/react-native'; import { useDispatch, useSelector } from 'react-redux'; import { Alert } from 'react-native'; import RewardsDashboard from './RewardsDashboard'; -import { setActiveTab } from '../../../../actions/rewards'; import Routes from '../../../../constants/navigation/Routes'; import { REWARDS_VIEW_SELECTORS } from './RewardsView.constants'; @@ -44,7 +43,6 @@ jest.mock('@react-navigation/native', () => { jest.mock('../../../../reducers/rewards/selectors', () => ({ selectActiveTab: jest.fn(), selectSeasonId: jest.fn(), - selectSeasonEndDate: jest.fn(), selectOptinAllowedForGeo: jest.fn(), selectHideCurrentAccountNotOptedInBannerArray: jest.fn(), selectHideUnlinkedAccountsBanner: jest.fn(), @@ -55,7 +53,7 @@ jest.mock('../../../../selectors/rewards', () => ({ })); jest.mock('../../../../selectors/featureFlagController/rewards', () => ({ - selectSnapshotsRewardsEnabledFlag: jest.fn(), + selectCampaignsRewardsEnabledFlag: jest.fn(), })); jest.mock( @@ -68,14 +66,13 @@ jest.mock( import { selectActiveTab, selectSeasonId, - selectSeasonEndDate, selectOptinAllowedForGeo, selectHideUnlinkedAccountsBanner, selectHideCurrentAccountNotOptedInBannerArray, } from '../../../../reducers/rewards/selectors'; import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { selectSelectedAccountGroup } from '../../../../selectors/multichainAccounts/accountTreeController'; -import { selectSnapshotsRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards'; +import { selectCampaignsRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards'; const mockSelectActiveTab = selectActiveTab as jest.MockedFunction< typeof selectActiveTab @@ -87,9 +84,6 @@ const mockSelectRewardsSubscriptionId = const mockSelectSeasonId = selectSeasonId as jest.MockedFunction< typeof selectSeasonId >; -const mockSelectSeasonEndDate = selectSeasonEndDate as jest.MockedFunction< - typeof selectSeasonEndDate ->; const mockSelectOptinAllowedForGeo = selectOptinAllowedForGeo as jest.MockedFunction< typeof selectOptinAllowedForGeo @@ -106,9 +100,9 @@ const mockSelectSelectedAccountGroup = selectSelectedAccountGroup as jest.MockedFunction< typeof selectSelectedAccountGroup >; -const mockSelectSnapshotsRewardsEnabledFlag = - selectSnapshotsRewardsEnabledFlag as jest.MockedFunction< - typeof selectSnapshotsRewardsEnabledFlag +const mockSelectCampaignsRewardsEnabledFlag = + selectCampaignsRewardsEnabledFlag as jest.MockedFunction< + typeof selectCampaignsRewardsEnabledFlag >; // Mock theme @@ -214,15 +208,15 @@ jest.mock('../../../Views/ErrorBoundary', () => ({ })); // Mock child components -jest.mock('../components/SeasonStatus/SeasonStatus', () => ({ +jest.mock('../components/Campaigns/CampaignsPreview', () => ({ __esModule: true, - default: function MockSeasonStatus() { + default: function MockCampaignsPreview() { const ReactActual = jest.requireActual('react'); const { View, Text } = jest.requireActual('react-native'); return ReactActual.createElement( View, - { testID: 'season-status' }, - ReactActual.createElement(Text, null, 'Season Status'), + { testID: 'campaigns-preview' }, + ReactActual.createElement(Text, null, 'Campaigns Preview'), ); }, })); @@ -255,19 +249,17 @@ jest.mock('../components/Tabs/RewardsOverview', () => ({ }, })); -jest.mock('../components/Tabs/RewardsSnapshots', () => ({ - __esModule: true, - default: function MockRewardsSnapshots({ tabLabel }: { tabLabel: string }) { - const ReactActual = jest.requireActual('react'); - const { View, Text } = jest.requireActual('react-native'); - - return ReactActual.createElement( - View, - { testID: 'rewards-snapshots-tab' }, - ReactActual.createElement(Text, null, tabLabel || 'Snapshots'), +jest.mock('../Views/CampaignsView', () => { + const ReactActual = jest.requireActual('react'); + const RN = jest.requireActual('react-native'); + const MockCampaignsView = ({ tabLabel }: { tabLabel?: string }) => + ReactActual.createElement( + RN.View, + { testID: 'rewards-campaigns-tab' }, + ReactActual.createElement(RN.Text, null, tabLabel || 'Campaigns'), ); - }, -})); + return { __esModule: true, default: MockCampaignsView }; +}); jest.mock('../components/Tabs/RewardsActivity', () => ({ __esModule: true, @@ -564,20 +556,17 @@ describe('RewardsDashboard', () => { }; const currentSeasonId = '7c9fa360-8d4c-425a-8a3e-7e82e1d82179'; - const futureDate = new Date(Date.now() + 86400000).toISOString(); // Tomorrow - const pastDate = new Date(Date.now() - 86400000).toISOString(); // Yesterday const defaultSelectorValues = { - activeTab: 'overview' as const, + activeTab: 'campaigns' as const, subscriptionId: 'test-subscription-id', seasonId: currentSeasonId, - seasonEndDate: new Date(futureDate), // Season is active by default optinAllowedForGeo: false as boolean | null, hideUnlinkedAccountsBanner: false, hideCurrentAccountNotOptedInBannerArray: [], selectedAccount: mockSelectedAccount, selectedAccountGroup: mockSelectedAccountGroup, - isSnapshotsEnabled: true, // Enable snapshots by default in tests + isCampaignsEnabled: true, }; const defaultHookValues = { @@ -657,9 +646,6 @@ describe('RewardsDashboard', () => { defaultSelectorValues.subscriptionId, ); mockSelectSeasonId.mockReturnValue(defaultSelectorValues.seasonId); - mockSelectSeasonEndDate.mockReturnValue( - defaultSelectorValues.seasonEndDate, - ); mockSelectHideUnlinkedAccountsBanner.mockReturnValue( defaultSelectorValues.hideUnlinkedAccountsBanner, ); @@ -670,8 +656,8 @@ describe('RewardsDashboard', () => { mockSelectSelectedAccountGroup.mockReturnValue( defaultSelectorValues.selectedAccountGroup, ); - mockSelectSnapshotsRewardsEnabledFlag.mockReturnValue( - defaultSelectorValues.isSnapshotsEnabled, + mockSelectCampaignsRewardsEnabledFlag.mockReturnValue( + defaultSelectorValues.isCampaignsEnabled, ); mockSelectOptinAllowedForGeo.mockReturnValue( defaultSelectorValues.optinAllowedForGeo, @@ -698,8 +684,6 @@ describe('RewardsDashboard', () => { if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return defaultSelectorValues.seasonId; - if (selector === selectSeasonEndDate) - return defaultSelectorValues.seasonEndDate; if (selector === selectOptinAllowedForGeo) return defaultSelectorValues.optinAllowedForGeo; if (selector === selectHideUnlinkedAccountsBanner) @@ -708,8 +692,8 @@ describe('RewardsDashboard', () => { return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); }); @@ -727,11 +711,11 @@ describe('RewardsDashboard', () => { // Act const { getByTestId } = render(); - // Assert - season content with tabs shown by default (active season) - expect(getByTestId(REWARDS_VIEW_SELECTORS.TAB_CONTROL)).toBeTruthy(); - expect(getByTestId('season-status')).toBeTruthy(); + // Assert + expect(getByTestId(REWARDS_VIEW_SELECTORS.SAFE_AREA_VIEW)).toBeTruthy(); expect(getByTestId(REWARDS_VIEW_SELECTORS.REFERRAL_BUTTON)).toBeTruthy(); expect(getByTestId(REWARDS_VIEW_SELECTORS.SETTINGS_BUTTON)).toBeTruthy(); + expect(getByTestId('campaigns-preview')).toBeTruthy(); }); it('should call modal hooks when component is rendered', () => { @@ -742,16 +726,16 @@ describe('RewardsDashboard', () => { expect(mockUseRewardDashboardModals).toHaveBeenCalled(); }); - it('should render previous season summary when season has ended and geo not allowed', () => { - // Arrange - Season ended, geo not allowed → just PreviousSeasonSummary - const pastDateObj = new Date(pastDate); + it('should render previous season summary when campaigns disabled and geo not allowed', () => { + // Arrange - campaigns disabled, geo not allowed → just PreviousSeasonSummary + mockSelectSeasonId.mockReturnValue(currentSeasonId); + mockSelectCampaignsRewardsEnabledFlag.mockReturnValue(false); mockUseSelector.mockImplementation((selector) => { if (selector === selectActiveTab) return defaultSelectorValues.activeTab; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) return pastDateObj; if (selector === selectOptinAllowedForGeo) return false; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; @@ -759,8 +743,7 @@ describe('RewardsDashboard', () => { return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) return false; return undefined; }); @@ -775,16 +758,14 @@ describe('RewardsDashboard', () => { expect(queryByTestId(REWARDS_VIEW_SELECTORS.TAB_CONTROL)).toBeNull(); }); - it('should render mUSD and previous season tabs when season ended and geo allowed', () => { - // Arrange - Season ended + geo allowed → two-tab layout - const pastDateObj = new Date(pastDate); + it('should not render previous season summary when campaigns enabled', () => { + // isCampaignsEnabled is true (default) so showPreviousSeasonSummary is false mockUseSelector.mockImplementation((selector) => { if (selector === selectActiveTab) return defaultSelectorValues.activeTab; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) return pastDateObj; if (selector === selectOptinAllowedForGeo) return true; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; @@ -792,72 +773,40 @@ describe('RewardsDashboard', () => { return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); // Act - const { getByTestId } = render(); - - // Assert - TabsList with mUSD calculator and Previous Season Summary - expect(getByTestId(REWARDS_VIEW_SELECTORS.TAB_CONTROL)).toBeTruthy(); - expect(getByTestId('musd-calculator-tab')).toBeTruthy(); - }); - - it('should render season status and tabs when season is active', () => { - // Act - defaults have active season (future end date) - const { getByTestId, queryByTestId } = render(); + const { queryByTestId } = render(); - // Assert - SeasonStatus + overview/snapshots/activity tabs - expect(getByTestId(REWARDS_VIEW_SELECTORS.TAB_CONTROL)).toBeTruthy(); - expect(getByTestId('season-status')).toBeTruthy(); + // Assert - no previous season summary or tabs when season is active expect( queryByTestId(REWARDS_VIEW_SELECTORS.PREVIOUS_SEASON_SUMMARY), ).toBeNull(); + expect(queryByTestId(REWARDS_VIEW_SELECTORS.TAB_CONTROL)).toBeNull(); }); - it('should not render previous season summary when seasonId is null', () => { - // Arrange - mockUseSelector.mockImplementation((selector) => { - if (selector === selectActiveTab) - return defaultSelectorValues.activeTab; - if (selector === selectRewardsSubscriptionId) - return defaultSelectorValues.subscriptionId; - if (selector === selectSeasonId) return null; - if (selector === selectSeasonEndDate) return new Date(pastDate); - if (selector === selectOptinAllowedForGeo) - return defaultSelectorValues.optinAllowedForGeo; - if (selector === selectHideUnlinkedAccountsBanner) - return defaultSelectorValues.hideUnlinkedAccountsBanner; - if (selector === selectHideCurrentAccountNotOptedInBannerArray) - return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; - if (selector === selectSelectedAccountGroup) - return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; - return undefined; - }); - - // Act + it('should render campaigns preview and referral button when campaigns enabled', () => { + // Act - defaults have campaigns enabled const { getByTestId, queryByTestId } = render(); - // Assert - shows season content, not previous season summary - expect(getByTestId('season-status')).toBeTruthy(); + // Assert + expect(getByTestId(REWARDS_VIEW_SELECTORS.REFERRAL_BUTTON)).toBeTruthy(); expect( queryByTestId(REWARDS_VIEW_SELECTORS.PREVIOUS_SEASON_SUMMARY), ).toBeNull(); }); - it('should not render previous season summary when seasonEndDate is null', () => { + it('should not render previous season summary when seasonId is null', () => { // Arrange mockUseSelector.mockImplementation((selector) => { if (selector === selectActiveTab) return defaultSelectorValues.activeTab; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; - if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) return null; + if (selector === selectSeasonId) return null; if (selector === selectOptinAllowedForGeo) return defaultSelectorValues.optinAllowedForGeo; if (selector === selectHideUnlinkedAccountsBanner) @@ -866,16 +815,15 @@ describe('RewardsDashboard', () => { return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); // Act - const { getByTestId, queryByTestId } = render(); + const { queryByTestId } = render(); - // Assert - shows season content, not previous season summary - expect(getByTestId('season-status')).toBeTruthy(); + // Assert expect( queryByTestId(REWARDS_VIEW_SELECTORS.PREVIOUS_SEASON_SUMMARY), ).toBeNull(); @@ -883,16 +831,14 @@ describe('RewardsDashboard', () => { }); describe('optinAllowedForGeo-based content', () => { - it('shows mUSD calculator tab when previous season and geo allowed', () => { - // Arrange - season ended + geo allowed - const pastDateObj = new Date(pastDate); + it('shows mUSD calculator tab when campaigns disabled and geo allowed', () => { + // Arrange - campaigns disabled + geo allowed mockUseSelector.mockImplementation((selector) => { if (selector === selectActiveTab) return defaultSelectorValues.activeTab; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) return pastDateObj; if (selector === selectOptinAllowedForGeo) return true; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; @@ -900,8 +846,7 @@ describe('RewardsDashboard', () => { return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) return false; return undefined; }); @@ -913,16 +858,14 @@ describe('RewardsDashboard', () => { expect(getByTestId('musd-calculator-tab')).toBeTruthy(); }); - it('hides mUSD calculator when previous season but geo not allowed', () => { - // Arrange - season ended + geo NOT allowed - const pastDateObj = new Date(pastDate); + it('hides mUSD calculator when campaigns disabled but geo not allowed', () => { + // Arrange - campaigns disabled + geo NOT allowed mockUseSelector.mockImplementation((selector) => { if (selector === selectActiveTab) return defaultSelectorValues.activeTab; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) return pastDateObj; if (selector === selectOptinAllowedForGeo) return false; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; @@ -930,8 +873,7 @@ describe('RewardsDashboard', () => { return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) return false; return undefined; }); @@ -946,13 +888,13 @@ describe('RewardsDashboard', () => { expect(queryByTestId('musd-calculator-tab')).toBeNull(); }); - it('shows season content when season is active regardless of geo', () => { - // Act - defaults have active season - const { getByTestId, queryByTestId } = render(); + it('shows campaigns preview when season is active regardless of geo', () => { + // Act - defaults have active season with campaigns enabled + const { queryByTestId, getByTestId } = render(); - // Assert - SeasonStatus + overview tabs, no mUSD calculator - expect(getByTestId('season-status')).toBeTruthy(); - expect(getByTestId(REWARDS_VIEW_SELECTORS.TAB_CONTROL)).toBeTruthy(); + // Assert - CampaignsPreview shown, no previous season content + expect(getByTestId('campaigns-preview')).toBeTruthy(); + expect(queryByTestId(REWARDS_VIEW_SELECTORS.TAB_CONTROL)).toBeNull(); expect(queryByTestId('musd-calculator-tab')).toBeNull(); }); }); @@ -1011,77 +953,47 @@ describe('RewardsDashboard', () => { }); }); - describe('tab functionality', () => { - it('should handle tab change when user selects different tab', () => { - // Act - defaults show overview/snapshots/activity tabs - const { getByTestId } = render(); - const snapshotsTab = getByTestId('tab-1'); - fireEvent.press(snapshotsTab); - - // Assert - dispatches setActiveTab with 'snapshots' - expect(mockDispatch).toHaveBeenCalledWith(setActiveTab('snapshots')); - }); - - it('should render all tab options', () => { - // Act - const { getByTestId, queryByTestId } = render(); - - // Assert - 3 tabs: overview, snapshots, activity - expect(getByTestId('tab-headers')).toBeTruthy(); - expect(getByTestId('tab-0')).toBeTruthy(); - expect(getByTestId('tab-1')).toBeTruthy(); - expect(getByTestId('tab-2')).toBeTruthy(); - expect(queryByTestId('tab-3')).toBeNull(); - }); - - it('should show overview tab content by default', () => { - // Act - const { getByTestId } = render(); - - // Assert - overview tab is default - expect(getByTestId('rewards-overview-tab')).toBeTruthy(); - }); - - it('resets activeTab to overview when current tab becomes unavailable', () => { - // Arrange - set activeTab to a value not in tabOptions + describe('when isCampaignsEnabled is false', () => { + beforeEach(() => { + mockSelectCampaignsRewardsEnabledFlag.mockReturnValue(false); + mockSelectActiveTab.mockReturnValue('overview'); mockUseSelector.mockImplementation((selector) => { - if (selector === selectActiveTab) return 'nonexistent'; + if (selector === selectActiveTab) return 'overview'; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; - if (selector === selectSeasonId) return defaultSelectorValues.seasonId; - if (selector === selectSeasonEndDate) - return defaultSelectorValues.seasonEndDate; - if (selector === selectOptinAllowedForGeo) - return defaultSelectorValues.optinAllowedForGeo; + if (selector === selectSeasonId) return currentSeasonId; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) return false; return undefined; }); + }); + it('does not render CampaignsPreview when campaigns is disabled', () => { // Act - render(); + const { queryByTestId } = render(); // Assert - expect(mockDispatch).toHaveBeenCalledWith(setActiveTab('overview')); + expect(queryByTestId('campaigns-preview')).toBeNull(); }); }); describe('previous season summary', () => { - const setupPastSeasonMocks = (optinAllowed: boolean | null = false) => { - const pastDateObj = new Date(pastDate); + const setupCampaignsDisabledMocks = ( + optinAllowed: boolean | null = false, + ) => { + mockSelectSeasonId.mockReturnValue(currentSeasonId); + mockSelectCampaignsRewardsEnabledFlag.mockReturnValue(false); mockUseSelector.mockImplementation((selector) => { if (selector === selectActiveTab) return defaultSelectorValues.activeTab; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) return pastDateObj; if (selector === selectOptinAllowedForGeo) return optinAllowed; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; @@ -1089,14 +1001,13 @@ describe('RewardsDashboard', () => { return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) return false; return undefined; }); }; - it('should show PreviousSeasonSummary when season ended and geo not allowed', () => { - setupPastSeasonMocks(false); + it('should show PreviousSeasonSummary when campaigns disabled and geo not allowed', () => { + setupCampaignsDisabledMocks(false); const { getByTestId, queryByTestId } = render(); @@ -1106,8 +1017,8 @@ describe('RewardsDashboard', () => { expect(queryByTestId(REWARDS_VIEW_SELECTORS.TAB_CONTROL)).toBeNull(); }); - it('should show two-tab layout when season ended and geo allowed', () => { - setupPastSeasonMocks(true); + it('should show two-tab layout when campaigns disabled and geo allowed', () => { + setupCampaignsDisabledMocks(true); const { getByTestId } = render(); @@ -1115,8 +1026,27 @@ describe('RewardsDashboard', () => { expect(getByTestId('musd-calculator-tab')).toBeTruthy(); }); - it('should not show previous season summary when season is active', () => { - // Defaults have active season (future end date) + it('should not render previous season summary when campaigns enabled', () => { + // isCampaignsEnabled is true (default) so showPreviousSeasonSummary is false + mockSelectSeasonId.mockReturnValue(currentSeasonId); + mockUseSelector.mockImplementation((selector) => { + if (selector === selectActiveTab) + return defaultSelectorValues.activeTab; + if (selector === selectRewardsSubscriptionId) + return defaultSelectorValues.subscriptionId; + if (selector === selectSeasonId) return currentSeasonId; + if (selector === selectHideUnlinkedAccountsBanner) + return defaultSelectorValues.hideUnlinkedAccountsBanner; + if (selector === selectHideCurrentAccountNotOptedInBannerArray) + return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; + if (selector === selectSelectedAccountGroup) + return defaultSelectorValues.selectedAccountGroup; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; + return undefined; + }); + + // Act const { queryByTestId } = render(); expect( @@ -1125,7 +1055,24 @@ describe('RewardsDashboard', () => { }); it('should hide referral button when showing previous season summary', () => { - setupPastSeasonMocks(false); + // Arrange + mockSelectSeasonId.mockReturnValue(currentSeasonId); + mockSelectCampaignsRewardsEnabledFlag.mockReturnValue(false); + mockUseSelector.mockImplementation((selector) => { + if (selector === selectActiveTab) + return defaultSelectorValues.activeTab; + if (selector === selectRewardsSubscriptionId) + return defaultSelectorValues.subscriptionId; + if (selector === selectSeasonId) return currentSeasonId; + if (selector === selectHideUnlinkedAccountsBanner) + return defaultSelectorValues.hideUnlinkedAccountsBanner; + if (selector === selectHideCurrentAccountNotOptedInBannerArray) + return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; + if (selector === selectSelectedAccountGroup) + return defaultSelectorValues.selectedAccountGroup; + if (selector === selectCampaignsRewardsEnabledFlag) return false; + return undefined; + }); const { queryByTestId } = render(); @@ -1133,7 +1080,25 @@ describe('RewardsDashboard', () => { }); it('should show settings button when showing previous season summary', () => { - setupPastSeasonMocks(false); + setupCampaignsDisabledMocks(false); + // Arrange + mockSelectSeasonId.mockReturnValue(currentSeasonId); + mockUseSelector.mockImplementation((selector) => { + if (selector === selectActiveTab) + return defaultSelectorValues.activeTab; + if (selector === selectRewardsSubscriptionId) + return defaultSelectorValues.subscriptionId; + if (selector === selectSeasonId) return currentSeasonId; + if (selector === selectHideUnlinkedAccountsBanner) + return defaultSelectorValues.hideUnlinkedAccountsBanner; + if (selector === selectHideCurrentAccountNotOptedInBannerArray) + return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; + if (selector === selectSelectedAccountGroup) + return defaultSelectorValues.selectedAccountGroup; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; + return undefined; + }); const { getByTestId } = render(); @@ -1143,18 +1108,16 @@ describe('RewardsDashboard', () => { describe('button states when not opted in', () => { beforeEach(() => { - const futureDateObj = new Date(futureDate); mockSelectRewardsSubscriptionId.mockReturnValue(null); mockSelectSeasonId.mockReturnValue(currentSeasonId); - mockSelectSeasonEndDate.mockReturnValue(futureDateObj); mockUseSelector.mockImplementation((selector) => { if (selector === selectActiveTab) return defaultSelectorValues.activeTab; if (selector === selectRewardsSubscriptionId) return null; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) return futureDateObj; if (selector === selectOptinAllowedForGeo) return defaultSelectorValues.optinAllowedForGeo; + if (selector === selectCampaignsRewardsEnabledFlag) return true; return undefined; }); }); @@ -1215,8 +1178,6 @@ describe('RewardsDashboard', () => { if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) - return defaultSelectorValues.seasonEndDate; if (selector === selectOptinAllowedForGeo) return defaultSelectorValues.optinAllowedForGeo; return undefined; @@ -1230,25 +1191,22 @@ describe('RewardsDashboard', () => { describe('modal triggering for current account', () => { it('should show not opted in modal when account group has opted out accounts and modal has not been shown', async () => { // Arrange - Mock account group with opted out accounts - // Use future date so showPreviousSeasonSummary is false (season is active) + // isCampaignsEnabled is true so showPreviousSeasonSummary is false // Note: The modal effect only runs when showPreviousSeasonSummary is false - const futureDateObj = new Date(futureDate); - mockSelectSeasonEndDate.mockReturnValue(futureDateObj); mockUseSelector.mockImplementation((selector) => { if (selector === selectActiveTab) return defaultSelectorValues.activeTab; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) return futureDateObj; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); @@ -1292,24 +1250,21 @@ describe('RewardsDashboard', () => { }); it('should show not supported modal when account group is not fully supported and modal has not been shown', async () => { - // Arrange - Use future date so showPreviousSeasonSummary is false (season is active) - const futureDateObj = new Date(futureDate); - mockSelectSeasonEndDate.mockReturnValue(futureDateObj); + // Arrange - isCampaignsEnabled is true so showPreviousSeasonSummary is false mockUseSelector.mockImplementation((selector) => { if (selector === selectActiveTab) return defaultSelectorValues.activeTab; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) return futureDateObj; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); @@ -1337,16 +1292,14 @@ describe('RewardsDashboard', () => { if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return defaultSelectorValues.seasonId; - if (selector === selectSeasonEndDate) - return defaultSelectorValues.seasonEndDate; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); @@ -1438,24 +1391,21 @@ describe('RewardsDashboard', () => { it('should show unlinked accounts modal when there are unlinked accounts and user has subscription', async () => { // Arrange - Mock account group as fully opted in and has unlinked accounts - // Use future date so showPreviousSeasonSummary is false (season is active) - const futureDateObj = new Date(futureDate); - mockSelectSeasonEndDate.mockReturnValue(futureDateObj); + // isCampaignsEnabled is true so showPreviousSeasonSummary is false mockUseSelector.mockImplementation((selector) => { if (selector === selectActiveTab) return defaultSelectorValues.activeTab; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) return futureDateObj; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); @@ -1523,15 +1473,13 @@ describe('RewardsDashboard', () => { if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return defaultSelectorValues.seasonId; - if (selector === selectSeasonEndDate) - return defaultSelectorValues.seasonEndDate; if (selector === selectHideUnlinkedAccountsBanner) return true; if (selector === selectHideCurrentAccountNotOptedInBannerArray) return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); @@ -1544,26 +1492,23 @@ describe('RewardsDashboard', () => { it('should not show unlinked accounts modal when modal has already been shown', () => { // Arrange - setup mock to return true for unlinked accounts modal - const futureDateObj = new Date(futureDate); mockHasShownModal.mockImplementation( (modalType) => modalType === 'unlinked-accounts', ); - mockSelectSeasonEndDate.mockReturnValue(futureDateObj); mockUseSelector.mockImplementation((selector) => { if (selector === selectActiveTab) return defaultSelectorValues.activeTab; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) return futureDateObj; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); @@ -1578,11 +1523,10 @@ describe('RewardsDashboard', () => { describe('modal prioritization', () => { it('should show unlinked accounts modal when current account banner dismissed and account group is fully opted in', async () => { // Arrange - Mock account group as fully opted in and banner dismissed - // Use future date so showPreviousSeasonSummary is false (season is active) + // isCampaignsEnabled is true so showPreviousSeasonSummary is false mockSelectHideCurrentAccountNotOptedInBannerArray.mockReturnValue([ { accountGroupId: 'keyring:wallet1/1' as const, hide: true }, ]); - mockSelectSeasonEndDate.mockReturnValue(new Date(futureDate)); const mockWalletWithOptedOutAccounts = [ { @@ -1630,22 +1574,20 @@ describe('RewardsDashboard', () => { currentAccountGroupPartiallySupported: true, }); - const futureDateObj = new Date(futureDate); mockUseSelector.mockImplementation((selector) => { if (selector === selectActiveTab) return defaultSelectorValues.activeTab; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return defaultSelectorValues.seasonId; - if (selector === selectSeasonEndDate) return futureDateObj; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) return [{ accountGroupId: 'keyring:wallet1/1', hide: true }]; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); @@ -1660,24 +1602,21 @@ describe('RewardsDashboard', () => { }); it('should prioritize not supported modal over other modals', async () => { - // Arrange - Use future date so showPreviousSeasonSummary is false (season is active) - const futureDateObj = new Date(futureDate); - mockSelectSeasonEndDate.mockReturnValue(futureDateObj); + // Arrange - isCampaignsEnabled is true so showPreviousSeasonSummary is false mockUseSelector.mockImplementation((selector) => { if (selector === selectActiveTab) return defaultSelectorValues.activeTab; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) return futureDateObj; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); @@ -1742,23 +1681,20 @@ describe('RewardsDashboard', () => { describe('account group opt-in status logic', () => { it('should not show modal when account group is fully opted in', () => { // Arrange - Mock account group with all accounts opted in - const futureDateObj = new Date(futureDate); - mockSelectSeasonEndDate.mockReturnValue(futureDateObj); mockUseSelector.mockImplementation((selector) => { if (selector === selectActiveTab) return defaultSelectorValues.activeTab; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) return futureDateObj; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); @@ -1816,23 +1752,21 @@ describe('RewardsDashboard', () => { it('should show not supported modal when account group contains unsupported accounts', async () => { // Arrange - Mock account group with unsupported accounts - // Use future date so showPreviousSeasonSummary is false (season is active) - mockSelectSeasonEndDate.mockReturnValue(new Date(futureDate)); + // isCampaignsEnabled is true so showPreviousSeasonSummary is false mockUseSelector.mockImplementation((selector) => { if (selector === selectActiveTab) return defaultSelectorValues.activeTab; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) return new Date(futureDate); if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); @@ -1861,8 +1795,6 @@ describe('RewardsDashboard', () => { if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return defaultSelectorValues.seasonId; - if (selector === selectSeasonEndDate) - return defaultSelectorValues.seasonEndDate; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) @@ -1896,8 +1828,6 @@ describe('RewardsDashboard', () => { if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return defaultSelectorValues.seasonId; - if (selector === selectSeasonEndDate) - return defaultSelectorValues.seasonEndDate; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) @@ -1957,8 +1887,6 @@ describe('RewardsDashboard', () => { if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return defaultSelectorValues.seasonId; - if (selector === selectSeasonEndDate) - return defaultSelectorValues.seasonEndDate; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) @@ -1993,16 +1921,14 @@ describe('RewardsDashboard', () => { if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return null; - if (selector === selectSeasonEndDate) - return defaultSelectorValues.seasonEndDate; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); @@ -2047,25 +1973,22 @@ describe('RewardsDashboard', () => { }); it('should return early and not show modals when showPreviousSeasonSummary is true', () => { - // Arrange - Set past date so showPreviousSeasonSummary is true (season has ended) - const pastDateObj = new Date(pastDate); + // Arrange - isCampaignsEnabled must be false for showPreviousSeasonSummary to be true mockSelectSeasonId.mockReturnValue(currentSeasonId); - mockSelectSeasonEndDate.mockReturnValue(pastDateObj); + mockSelectCampaignsRewardsEnabledFlag.mockReturnValue(false); mockUseSelector.mockImplementation((selector) => { if (selector === selectActiveTab) return defaultSelectorValues.activeTab; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) return pastDateObj; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) return false; return undefined; }); @@ -2110,25 +2033,23 @@ describe('RewardsDashboard', () => { }); it('should return early and not show modals when showPreviousSeasonSummary is null', () => { - // Arrange - Set seasonId and seasonEndDate to null so showPreviousSeasonSummary is null + // Arrange - Set seasonId to null so showPreviousSeasonSummary is null // This tests the case where the useFocusEffect hasn't evaluated yet mockSelectSeasonId.mockReturnValue(null); - mockSelectSeasonEndDate.mockReturnValue(null); mockUseSelector.mockImplementation((selector) => { if (selector === selectActiveTab) return defaultSelectorValues.activeTab; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return null; - if (selector === selectSeasonEndDate) return null; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); @@ -2224,15 +2145,13 @@ describe('RewardsDashboard', () => { mockCreateEventBuilder.mockClear(); mockBuild.mockClear(); - // Act - change active tab - mockSelectActiveTab.mockReturnValue('snapshots'); + // Act - change active tab from campaigns to activity + mockSelectActiveTab.mockReturnValue('activity'); mockUseSelector.mockImplementation((selector) => { - if (selector === selectActiveTab) return 'snapshots'; + if (selector === selectActiveTab) return 'activity'; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) - return defaultSelectorValues.seasonEndDate; if (selector === selectOptinAllowedForGeo) return defaultSelectorValues.optinAllowedForGeo; if (selector === selectHideUnlinkedAccountsBanner) @@ -2241,8 +2160,8 @@ describe('RewardsDashboard', () => { return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); rerender(); @@ -2251,7 +2170,7 @@ describe('RewardsDashboard', () => { expect(mockCreateEventBuilder).toHaveBeenCalledWith( 'rewards_dashboard_tab_viewed', ); - expect(mockAddProperties).toHaveBeenCalledWith({ tab: 'snapshots' }); + expect(mockAddProperties).toHaveBeenCalledWith({ tab: 'activity' }); expect(mockBuild).toHaveBeenCalled(); expect(mockTrackEvent).toHaveBeenCalledWith({ event: 'mock-event' }); }); @@ -2264,15 +2183,13 @@ describe('RewardsDashboard', () => { mockBuild.mockClear(); mockAddProperties.mockClear(); - // Act - change to snapshots tab - mockSelectActiveTab.mockReturnValue('snapshots'); + // Act - change to activity tab + mockSelectActiveTab.mockReturnValue('activity'); mockUseSelector.mockImplementation((selector) => { - if (selector === selectActiveTab) return 'snapshots'; + if (selector === selectActiveTab) return 'activity'; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) - return defaultSelectorValues.seasonEndDate; if (selector === selectOptinAllowedForGeo) return defaultSelectorValues.optinAllowedForGeo; if (selector === selectHideUnlinkedAccountsBanner) @@ -2281,24 +2198,22 @@ describe('RewardsDashboard', () => { return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); rerender(); - // Assert - snapshots tab - expect(mockAddProperties).toHaveBeenCalledWith({ tab: 'snapshots' }); + // Assert - activity tab + expect(mockAddProperties).toHaveBeenCalledWith({ tab: 'activity' }); - // Act - change to activity tab - mockSelectActiveTab.mockReturnValue('activity'); + // Act - change back to campaigns tab + mockSelectActiveTab.mockReturnValue('campaigns'); mockUseSelector.mockImplementation((selector) => { - if (selector === selectActiveTab) return 'activity'; + if (selector === selectActiveTab) return 'campaigns'; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) - return defaultSelectorValues.seasonEndDate; if (selector === selectOptinAllowedForGeo) return defaultSelectorValues.optinAllowedForGeo; if (selector === selectHideUnlinkedAccountsBanner) @@ -2307,41 +2222,38 @@ describe('RewardsDashboard', () => { return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); rerender(); - // Assert - activity tab - expect(mockAddProperties).toHaveBeenCalledWith({ tab: 'activity' }); + // Assert - campaigns tab + expect(mockAddProperties).toHaveBeenCalledWith({ tab: 'campaigns' }); }); }); describe('TabsList ref functionality', () => { it('handles Redux state changes for activeTab without crashing', () => { // Arrange + mockSelectActiveTab.mockReturnValue('campaigns'); const { rerender } = render(); - // Act - change activeTab in Redux to snapshots - mockSelectActiveTab.mockReturnValue('snapshots'); + // Act - change activeTab in Redux to campaigns + mockSelectActiveTab.mockReturnValue('campaigns'); mockUseSelector.mockImplementation((selector) => { - if (selector === selectActiveTab) return 'snapshots'; + if (selector === selectActiveTab) return 'campaigns'; if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectSeasonId) return currentSeasonId; - if (selector === selectSeasonEndDate) - return defaultSelectorValues.seasonEndDate; - if (selector === selectOptinAllowedForGeo) - return defaultSelectorValues.optinAllowedForGeo; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; if (selector === selectSelectedAccountGroup) return defaultSelectorValues.selectedAccountGroup; - if (selector === selectSnapshotsRewardsEnabledFlag) - return defaultSelectorValues.isSnapshotsEnabled; + if (selector === selectCampaignsRewardsEnabledFlag) + return defaultSelectorValues.isCampaignsEnabled; return undefined; }); diff --git a/app/components/UI/Rewards/Views/RewardsDashboard.tsx b/app/components/UI/Rewards/Views/RewardsDashboard.tsx index e3763fc333a..4e8d039b34d 100644 --- a/app/components/UI/Rewards/Views/RewardsDashboard.tsx +++ b/app/components/UI/Rewards/Views/RewardsDashboard.tsx @@ -9,34 +9,27 @@ import { useFocusEffect, useNavigation } from '@react-navigation/native'; import { Box, IconName } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; import { strings } from '../../../../../locales/i18n'; import HeaderRoot from '../../../../component-library/components-temp/HeaderRoot'; import ErrorBoundary from '../../../Views/ErrorBoundary'; import { REWARDS_VIEW_SELECTORS } from './RewardsView.constants'; -import { setActiveTab } from '../../../../actions/rewards'; import Routes from '../../../../constants/navigation/Routes'; -import { RewardsTab } from '../../../../reducers/rewards/types'; import { selectActiveTab, selectHideUnlinkedAccountsBanner, selectHideCurrentAccountNotOptedInBannerArray, selectSeasonId, - selectSeasonEndDate, selectOptinAllowedForGeo, } from '../../../../reducers/rewards/selectors'; -import SeasonStatus from '../components/SeasonStatus/SeasonStatus'; import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; -import { selectSnapshotsRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards'; +import { selectCampaignsRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards'; import { useRewardOptinSummary } from '../hooks/useRewardOptinSummary'; import { useRewardDashboardModals, RewardsDashboardModalType, } from '../hooks/useRewardDashboardModals'; import { useBulkLinkState } from '../hooks/useBulkLinkState'; -import RewardsOverview from '../components/Tabs/RewardsOverview'; -import RewardsSnapshots from '../components/Tabs/RewardsSnapshots'; -import RewardsActivity from '../components/Tabs/RewardsActivity'; import MusdCalculatorTab from '../components/Tabs/MusdCalculatorTab/MusdCalculatorTab'; import { TabsList } from '../../../../component-library/components-temp/Tabs'; import { @@ -48,6 +41,7 @@ import { ToastRef } from '../../../../component-library/components/Toast/Toast.t import { MetaMetricsEvents, useMetrics } from '../../../hooks/useMetrics'; import { selectSelectedAccountGroup } from '../../../../selectors/multichainAccounts/accountTreeController'; import PreviousSeasonSummary from '../components/PreviousSeason/PreviousSeasonSummary'; +import CampaignsPreview from '../components/Campaigns/CampaignsPreview'; const RewardsDashboard: React.FC = () => { const tw = useTailwind(); @@ -55,16 +49,14 @@ const RewardsDashboard: React.FC = () => { const toastRef = useRef(null); const subscriptionId = useSelector(selectRewardsSubscriptionId); const activeTab = useSelector(selectActiveTab); - const dispatch = useDispatch(); const { trackEvent, createEventBuilder } = useMetrics(); const hasTrackedDashboardViewed = useRef(false); const hideUnlinkedAccountsBanner = useSelector( selectHideUnlinkedAccountsBanner, ); const seasonId = useSelector(selectSeasonId); - const seasonEndDate = useSelector(selectSeasonEndDate); const optinAllowedForGeo = useSelector(selectOptinAllowedForGeo); - const isSnapshotsEnabled = useSelector(selectSnapshotsRewardsEnabledFlag); + const isCampaignsEnabled = useSelector(selectCampaignsRewardsEnabledFlag); const hideCurrentAccountNotOptedInBannerMap = useSelector( selectHideCurrentAccountNotOptedInBannerArray, ); @@ -83,8 +75,6 @@ const RewardsDashboard: React.FC = () => { const [showPreviousSeasonSummary, setShowPreviousSeasonSummary] = useState< boolean | null >(null); - - // Ref for TabsList to control active tab programmatically const tabsListRef = useRef(null); // Use the reward dashboard modals hook @@ -125,111 +115,6 @@ const RewardsDashboard: React.FC = () => { [optInByWallet], ); - const tabOptions = useMemo(() => { - const options: { - value: 'overview' | 'snapshots' | 'activity'; - label: string; - }[] = [ - { - value: 'overview' as const, - label: strings('rewards.tab_overview_title'), - }, - ]; - - if (isSnapshotsEnabled) { - options.push({ - value: 'snapshots' as const, - label: strings('rewards.tab_snapshots_title'), - }); - } - - options.push({ - value: 'activity' as const, - label: strings('rewards.tab_activity_title'), - }); - - return options; - }, [isSnapshotsEnabled]); - - const getActiveIndex = useCallback( - () => tabOptions.findIndex((tab) => tab.value === activeTab), - [tabOptions, activeTab], - ); - - // Reset activeTab to 'overview' if current tab becomes unavailable (e.g., snapshots disabled) - // This ensures Redux state stays in sync with the visible tab and analytics events are accurate - useEffect(() => { - const isCurrentTabAvailable = tabOptions.some( - (tab) => tab.value === activeTab, - ); - if (!isCurrentTabAvailable) { - dispatch(setActiveTab('overview')); - } - }, [tabOptions, activeTab, dispatch]); - - // Sync TabsList with Redux state changes - useEffect(() => { - const activeIndex = tabOptions.findIndex((tab) => tab.value === activeTab); - if (tabsListRef.current && activeIndex !== -1) { - // Use setTimeout to avoid race conditions with TabsList internal state - if (tabsListRef.current) { - tabsListRef.current.goToTabIndex(activeIndex); - } - } - }, [activeTab, tabOptions]); - - const handleTabChange = useCallback( - ({ i }: { i: number }) => { - const newTab = tabOptions[i]?.value as RewardsTab; - // Only dispatch if the tab is actually different to prevent loops - if (newTab && newTab !== activeTab) { - dispatch(setActiveTab(newTab)); - } - }, - [dispatch, tabOptions, activeTab], - ); - - const tabsListProps = useMemo( - () => ({ - ref: tabsListRef, - initialActiveIndex: getActiveIndex(), - onChangeTab: handleTabChange, - testID: REWARDS_VIEW_SELECTORS.TAB_CONTROL, - tabsBarProps: { - twClassName: 'px-4', - }, - tabsListContentTwClassName: 'px-0', - }), - [getActiveIndex, handleTabChange], - ); - - const tabComponents = useMemo(() => { - const tabs: React.ReactElement[] = [ - , - ]; - - if (isSnapshotsEnabled) { - tabs.push( - , - ); - } - - tabs.push( - , - ); - - return tabs; - }, [isSnapshotsEnabled]); - // Auto-resume interrupted bulk link process when screen comes into focus. // This handles the case where the app was closed during a bulk opt-in process. // The saga is idempotent - it re-fetches opt-in status to skip already-linked accounts. @@ -244,13 +129,9 @@ const RewardsDashboard: React.FC = () => { // Evaluate showPreviousSeasonSummary when screen comes into focus useFocusEffect( useCallback(() => { - const shouldShow = Boolean( - seasonId && - seasonEndDate && - new Date(seasonEndDate).getTime() < Date.now(), - ); + const shouldShow = Boolean(seasonId && !isCampaignsEnabled); setShowPreviousSeasonSummary(shouldShow); - }, [seasonId, seasonEndDate]), + }, [seasonId, isCampaignsEnabled]), ); // Auto-trigger dashboard modals based on account/rewards state (session-aware) @@ -364,39 +245,36 @@ const RewardsDashboard: React.FC = () => { ]} /> - {showPreviousSeasonSummary && optinAllowedForGeo ? ( - - - - - } + {showPreviousSeasonSummary && + (optinAllowedForGeo ? ( + - - - - ) : showPreviousSeasonSummary ? ( - - ) : ( - <> - - - - {tabComponents} - - )} + + + + + + + + ) : ( + + ))} diff --git a/app/components/UI/Rewards/Views/RewardsReferralView.test.tsx b/app/components/UI/Rewards/Views/RewardsReferralView.test.tsx index f1653f20b60..8ddf1119249 100644 --- a/app/components/UI/Rewards/Views/RewardsReferralView.test.tsx +++ b/app/components/UI/Rewards/Views/RewardsReferralView.test.tsx @@ -1,43 +1,76 @@ import React from 'react'; -import { render, fireEvent } from '@testing-library/react-native'; -import RewardsReferralView, { - REWARDS_REFERRAL_SAFE_AREA_TEST_ID, -} from './RewardsReferralView'; +import { render, fireEvent, waitFor } from '@testing-library/react-native'; +import RewardsReferralView from './RewardsReferralView'; const mockGoBack = jest.fn(); - jest.mock('@react-navigation/native', () => ({ - useNavigation: () => ({ - navigate: jest.fn(), - goBack: mockGoBack, - }), + useNavigation: () => ({ goBack: mockGoBack }), })); jest.mock('@metamask/design-system-twrnc-preset', () => ({ - useTailwind: () => ({ - style: jest.fn((styles: string) => (typeof styles === 'string' ? {} : {})), + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + return { ...actual }; +}); + +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn().mockReturnValue({ + build: jest.fn().mockReturnValue({ event: 'REWARDS_REFERRALS_VIEWED' }), +}); + +jest.mock('../../../hooks/useMetrics', () => ({ + useMetrics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, }), + MetaMetricsEvents: { + REWARDS_REFERRALS_VIEWED: 'REWARDS_REFERRALS_VIEWED', + }, })); jest.mock('../../../../../locales/i18n', () => ({ - strings: jest.fn((key: string) => { + strings: (key: string) => { const translations: Record = { 'rewards.referral_title': 'Referrals', }; return translations[key] || key; - }), + }, })); +jest.mock( + '../../../../component-library/components-temp/HeaderCompactStandard', + () => { + const ReactActual = jest.requireActual('react'); + const { View, Text, Pressable } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ title, onBack }: { title: string; onBack: () => void }) => + ReactActual.createElement( + View, + { testID: 'header' }, + ReactActual.createElement(Text, null, title), + ReactActual.createElement(Pressable, { + onPress: onBack, + testID: 'header-back-button', + }), + ), + }; + }, +); + jest.mock('../../../Views/ErrorBoundary', () => ({ __esModule: true, - default: function MockErrorBoundary({ + default: ({ children, view, }: { children: React.ReactNode; navigation: unknown; view: string; - }) { + }) => { const ReactActual = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); return ReactActual.createElement( @@ -48,57 +81,33 @@ jest.mock('../../../Views/ErrorBoundary', () => ({ }, })); -jest.mock('../components/ReferralDetails/ReferralDetails', () => ({ - __esModule: true, - default: function MockReferralDetails() { - const ReactActual = jest.requireActual('react'); - const { View, Text } = jest.requireActual('react-native'); - return ReactActual.createElement( - View, - { testID: 'referral-details' }, - ReactActual.createElement(Text, null, 'Referral Details Component'), - ); - }, -})); +jest.mock('../components/ReferralDetails/ReferralDetails', () => { + const ReactActual = jest.requireActual('react'); + const { View, Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + ReactActual.createElement( + View, + { testID: 'referral-details' }, + ReactActual.createElement(Text, null, 'Referral Details Component'), + ), + }; +}); jest.mock('react-native-safe-area-context', () => { const ReactActual = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); - const actual = jest.requireActual('react-native-safe-area-context'); return { - ...actual, - useSafeAreaInsets: jest.fn(() => ({ - top: 0, - right: 0, - bottom: 0, - left: 0, - })), - SafeAreaView: ({ - children, - testID, - ...props - }: { - children: React.ReactNode; - testID?: string; - }) => ReactActual.createElement(View, { ...props, testID }, children), + SafeAreaView: ({ children, ...props }: { children: React.ReactNode }) => + ReactActual.createElement( + View, + { ...props, testID: 'safe-area-view' }, + children, + ), }; }); -const mockTrackEvent = jest.fn(); -const mockCreateEventBuilder = jest.fn(() => ({ - build: jest.fn(() => ({})), -})); - -jest.mock('../../../hooks/useMetrics', () => ({ - useMetrics: () => ({ - trackEvent: mockTrackEvent, - createEventBuilder: mockCreateEventBuilder, - }), - MetaMetricsEvents: { - REWARDS_REFERRALS_VIEWED: 'REWARDS_REFERRALS_VIEWED', - }, -})); - describe('RewardsReferralView', () => { beforeEach(() => { jest.clearAllMocks(); @@ -109,53 +118,64 @@ describe('RewardsReferralView', () => { expect(() => render()).not.toThrow(); }); - it('renders ReferralDetails component', () => { - const { getByTestId, getByText } = render(); + it('renders the header with the referral title', () => { + const { getByText } = render(); - expect(getByTestId('referral-details')).toBeTruthy(); - expect(getByText('Referral Details Component')).toBeTruthy(); + expect(getByText('Referrals')).toBeOnTheScreen(); }); - it('wraps content in ErrorBoundary', () => { - const { getByTestId } = render(); + it('renders the ReferralDetails component', () => { + const { getByTestId, getByText } = render(); - expect(getByTestId('error-boundary-referralrewardsview')).toBeTruthy(); + expect(getByTestId('referral-details')).toBeOnTheScreen(); + expect(getByText('Referral Details Component')).toBeOnTheScreen(); }); - }); - describe('header and SafeAreaView', () => { - it('renders SafeAreaView wrapper with correct testID', () => { + it('wraps content in ErrorBoundary with correct view name', () => { const { getByTestId } = render(); - expect(getByTestId(REWARDS_REFERRAL_SAFE_AREA_TEST_ID)).toBeOnTheScreen(); - }); - - it('renders HeaderCompactStandard with referral title', () => { - const { getByText } = render(); - - expect(getByText('Referrals')).toBeOnTheScreen(); + expect( + getByTestId('error-boundary-referralrewardsview'), + ).toBeOnTheScreen(); }); + }); - it('calls navigation.goBack when back button is pressed', () => { + describe('navigation', () => { + it('navigates back when the back button is pressed', () => { const { getByTestId } = render(); - const backButton = getByTestId('header-back-button'); - fireEvent.press(backButton); + fireEvent.press(getByTestId('header-back-button')); expect(mockGoBack).toHaveBeenCalledTimes(1); }); }); - describe('error boundary integration', () => { - it('passes correct view prop to ErrorBoundary', () => { - const { getByTestId } = render(); + describe('analytics', () => { + it('tracks REWARDS_REFERRALS_VIEWED event on mount', async () => { + render(); - expect(getByTestId('error-boundary-referralrewardsview')).toBeTruthy(); + await waitFor(() => { + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + 'REWARDS_REFERRALS_VIEWED', + ); + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + }); + }); + + it('tracks the event only once across re-renders', async () => { + const { rerender } = render(); + + rerender(); + rerender(); + + await waitFor(() => { + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + }); }); }); describe('component lifecycle', () => { - it('cleanups properly when unmounted', () => { + it('cleans up properly when unmounted', () => { const { unmount } = render(); expect(() => unmount()).not.toThrow(); @@ -170,12 +190,4 @@ describe('RewardsReferralView', () => { }).not.toThrow(); }); }); - - describe('integration with child components', () => { - it('renders ReferralDetails without any props', () => { - const { getByTestId } = render(); - - expect(getByTestId('referral-details')).toBeTruthy(); - }); - }); }); diff --git a/app/components/UI/Rewards/Views/RewardsReferralView.tsx b/app/components/UI/Rewards/Views/RewardsReferralView.tsx index 1dafe67d9fb..79fdce9c9ba 100644 --- a/app/components/UI/Rewards/Views/RewardsReferralView.tsx +++ b/app/components/UI/Rewards/Views/RewardsReferralView.tsx @@ -5,11 +5,9 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { ScrollView } from 'react-native'; import { strings } from '../../../../../locales/i18n'; import ErrorBoundary from '../../../Views/ErrorBoundary'; +import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard'; import ReferralDetails from '../components/ReferralDetails/ReferralDetails'; import { MetaMetricsEvents, useMetrics } from '../../../hooks/useMetrics'; -import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard'; - -export const REWARDS_REFERRAL_SAFE_AREA_TEST_ID = 'rewards-referral-safe-area'; const ReferralRewardsView: React.FC = () => { const tw = useTailwind(); @@ -31,7 +29,6 @@ const ReferralRewardsView: React.FC = () => { { includesTopInset /> diff --git a/app/components/UI/Rewards/Views/RewardsView.constants.ts b/app/components/UI/Rewards/Views/RewardsView.constants.ts index f02fa232e30..898b92460c2 100644 --- a/app/components/UI/Rewards/Views/RewardsView.constants.ts +++ b/app/components/UI/Rewards/Views/RewardsView.constants.ts @@ -62,10 +62,13 @@ export const REWARDS_VIEW_SELECTORS = { ACTIVITY_EVENT_ROW_DETAILS: 'activity-event-row-details', ACTIVITY_EVENT_ROW_DATE: 'activity-event-row-date', ACTIVITY_EVENT_ROW_BONUS: 'activity-event-row-bonus', - // Snapshots - TAB_CONTENT_SNAPSHOTS: 'rewards-view-tab-content-snapshots', - SNAPSHOTS_SECTION: 'rewards-view-snapshots-section', - SNAPSHOTS_ACTIVE_SECTION: 'rewards-view-snapshots-active-section', - SNAPSHOTS_UPCOMING_SECTION: 'rewards-view-snapshots-upcoming-section', - SNAPSHOTS_PREVIOUS_SECTION: 'rewards-view-snapshots-previous-section', + // Campaigns + CAMPAIGNS_PREVIEW: 'rewards-view-campaigns-preview', + CAMPAIGNS_PREVIEW_ACTIVE_TILE: 'rewards-view-campaigns-preview-active-tile', + CAMPAIGNS_PREVIEW_UPCOMING_BANNER: + 'rewards-view-campaigns-preview-upcoming-banner', + CAMPAIGNS_VIEW: 'rewards-view-campaigns-view', + CAMPAIGNS_ACTIVE_SECTION: 'rewards-view-campaigns-active-section', + CAMPAIGNS_UPCOMING_SECTION: 'rewards-view-campaigns-upcoming-section', + CAMPAIGNS_PREVIOUS_SECTION: 'rewards-view-campaigns-previous-section', } as const; diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignStatus.test.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignStatus.test.tsx new file mode 100644 index 00000000000..898759d8971 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignStatus.test.tsx @@ -0,0 +1,181 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import CampaignStatus, { CAMPAIGN_STATUS_TEST_IDS } from './CampaignStatus'; +import { + type CampaignDto, + CampaignType, +} from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { getCampaignStatusInfo } from './CampaignTile.utils'; + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + return { ...actual }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock('./CampaignTile.utils', () => ({ + getCampaignStatusInfo: jest.fn().mockReturnValue({ + status: 'active', + statusLabel: 'Live', + dateLabel: 'Ends March 15', + dateLabelIcon: 'Clock', + }), +})); + +const createTestCampaign = (overrides = {}): CampaignDto => ({ + id: 'campaign-1', + type: CampaignType.ONDO_HOLDING, + name: 'Test Campaign', + startDate: '2027-01-01T00:00:00.000Z', + endDate: '2027-12-31T23:59:59.999Z', + termsAndConditions: null, + excludedRegions: [], + statusLabel: 'Active', + details: null, + ...overrides, +}); + +describe('CampaignStatus', () => { + beforeEach(() => { + jest.clearAllMocks(); + (getCampaignStatusInfo as jest.Mock).mockReturnValue({ + status: 'active', + statusLabel: 'Live', + dateLabel: 'Ends March 15', + dateLabelIcon: 'Clock', + }); + }); + + it('renders the container', () => { + const campaign = createTestCampaign(); + const { getByTestId } = render(); + expect(getByTestId(CAMPAIGN_STATUS_TEST_IDS.CONTAINER)).toBeDefined(); + }); + + it('renders campaign image', () => { + const campaign = createTestCampaign({ + details: { + image: { + lightModeUrl: 'https://example.com/light.png', + darkModeUrl: 'https://example.com/dark.png', + }, + howItWorks: { title: '', description: '', phases: [] }, + }, + }); + const { getByTestId } = render(); + expect(getByTestId(CAMPAIGN_STATUS_TEST_IDS.IMAGE)).toBeDefined(); + }); + + it('renders status label', () => { + const campaign = createTestCampaign(); + const { getByTestId } = render(); + expect( + getByTestId(CAMPAIGN_STATUS_TEST_IDS.STATUS_LABEL), + ).toHaveTextContent('Live'); + }); + + it('renders date label', () => { + const campaign = createTestCampaign(); + const { getByTestId } = render(); + expect(getByTestId(CAMPAIGN_STATUS_TEST_IDS.DATE_LABEL)).toHaveTextContent( + /Ends March 15/, + ); + }); + + it('renders howItWorks title when available', () => { + const campaign = createTestCampaign({ + details: { + image: { + lightModeUrl: 'https://example.com/light.png', + darkModeUrl: 'https://example.com/dark.png', + }, + howItWorks: { + title: 'How it works', + description: 'Description', + phases: [], + }, + }, + }); + const { getByTestId } = render(); + expect( + getByTestId(CAMPAIGN_STATUS_TEST_IDS.HOW_IT_WORKS_TITLE), + ).toHaveTextContent('How it works'); + }); + + it('does not render howItWorks title when details is null', () => { + const campaign = createTestCampaign({ details: null }); + const { queryByTestId } = render(); + expect( + queryByTestId(CAMPAIGN_STATUS_TEST_IDS.HOW_IT_WORKS_TITLE), + ).toBeNull(); + }); + + it('does not render howItWorks title when title is empty', () => { + const campaign = createTestCampaign({ + details: { + image: { + lightModeUrl: 'https://example.com/light.png', + darkModeUrl: 'https://example.com/dark.png', + }, + howItWorks: { title: '', description: '', phases: [] }, + }, + }); + const { queryByTestId } = render(); + expect( + queryByTestId(CAMPAIGN_STATUS_TEST_IDS.HOW_IT_WORKS_TITLE), + ).toBeNull(); + }); + + it('renders howItWorks description when available', () => { + const campaign = createTestCampaign({ + details: { + image: { + lightModeUrl: 'https://example.com/light.png', + darkModeUrl: 'https://example.com/dark.png', + }, + howItWorks: { + title: 'How it works', + description: 'Hold ONDO tokens to earn rewards', + phases: [], + }, + }, + }); + const { getByTestId } = render(); + expect( + getByTestId(CAMPAIGN_STATUS_TEST_IDS.HOW_IT_WORKS_DESCRIPTION), + ).toHaveTextContent('Hold ONDO tokens to earn rewards'); + }); + + it('does not render howItWorks description when details is null', () => { + const campaign = createTestCampaign({ details: null }); + const { queryByTestId } = render(); + expect( + queryByTestId(CAMPAIGN_STATUS_TEST_IDS.HOW_IT_WORKS_DESCRIPTION), + ).toBeNull(); + }); + + it('does not render howItWorks description when description is empty', () => { + const campaign = createTestCampaign({ + details: { + image: { + lightModeUrl: 'https://example.com/light.png', + darkModeUrl: 'https://example.com/dark.png', + }, + howItWorks: { title: 'Title', description: '', phases: [] }, + }, + }); + const { queryByTestId } = render(); + expect( + queryByTestId(CAMPAIGN_STATUS_TEST_IDS.HOW_IT_WORKS_DESCRIPTION), + ).toBeNull(); + }); + + it('calls getCampaignStatusInfo with campaign', () => { + const campaign = createTestCampaign(); + render(); + expect(getCampaignStatusInfo).toHaveBeenCalledWith(campaign); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignStatus.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignStatus.tsx new file mode 100644 index 00000000000..2ca2e5ac7c9 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignStatus.tsx @@ -0,0 +1,112 @@ +import React, { useMemo } from 'react'; +import { ImageBackground, useColorScheme } from 'react-native'; +import { + Box, + BoxFlexDirection, + BoxAlignItems, + Text, + TextVariant, + FontWeight, + TextColor, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import type { CampaignDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { getCampaignStatusInfo } from './CampaignTile.utils'; + +export const CAMPAIGN_STATUS_TEST_IDS = { + CONTAINER: 'campaign-status-container', + IMAGE: 'campaign-status-image', + STATUS_LABEL: 'campaign-status-label', + DATE_LABEL: 'campaign-status-date-label', + HOW_IT_WORKS_TITLE: 'campaign-status-how-it-works-title', + HOW_IT_WORKS_DESCRIPTION: 'campaign-status-how-it-works-description', +} as const; + +interface CampaignStatusProps { + campaign: CampaignDto; +} + +const CampaignStatus: React.FC = ({ campaign }) => { + const tw = useTailwind(); + const colorScheme = useColorScheme(); + + const { statusLabel, dateLabel } = useMemo( + () => getCampaignStatusInfo(campaign), + [campaign], + ); + + const backgroundImageUrl = + colorScheme === 'dark' + ? campaign.details?.image?.darkModeUrl + : campaign.details?.image?.lightModeUrl; + + const howItWorksTitle = campaign.details?.howItWorks?.title; + const howItWorksDescription = campaign.details?.howItWorks?.description; + + return ( + + + + + + + + + + {statusLabel} + + + + + + • + + + {dateLabel} + + + + + {howItWorksTitle ? ( + + {howItWorksTitle} + + ) : null} + + {howItWorksDescription ? ( + + {howItWorksDescription} + + ) : null} + + + ); +}; + +export default CampaignStatus; diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignTile.test.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignTile.test.tsx new file mode 100644 index 00000000000..39ecb98ccc5 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignTile.test.tsx @@ -0,0 +1,245 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; +import CampaignTile from './CampaignTile'; +import { + type CampaignDto, + CampaignType, +} from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { getCampaignStatusInfo } from './CampaignTile.utils'; +import { selectCampaignParticipantCount } from '../../../../../reducers/rewards/selectors'; +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + return { ...actual }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock('../../hooks/useGetCampaignParticipantStatus', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('./CampaignTile.utils', () => ({ + getCampaignStatusInfo: jest.fn().mockReturnValue({ + status: 'active', + statusLabel: 'Active', + dateLabel: 'Ends Mar 15, 2:30 PM', + dateLabelIcon: 'Clock', + }), +})); + +jest.mock('../../../../../reducers/rewards/selectors', () => ({ + selectCampaignParticipantCount: jest.fn(), +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string, params?: Record) => { + const translations: Record = { + 'rewards.campaign.enter_now': 'Enter now', + 'rewards.campaign.participant_count': `#${params?.count ?? ''}`, + }; + return translations[key] || key; + }, +})); + +const mockUseSelector = useSelector as jest.MockedFunction; +const mockSelectCampaignParticipantCount = + selectCampaignParticipantCount as jest.MockedFunction< + typeof selectCampaignParticipantCount + >; + +const createTestCampaign = (overrides = {}): CampaignDto => ({ + id: 'campaign-1', + type: CampaignType.ONDO_HOLDING, + name: 'Test Campaign', + startDate: '2027-01-01T00:00:00.000Z', + endDate: '2027-12-31T23:59:59.999Z', + termsAndConditions: null, + excludedRegions: [], + statusLabel: 'Active', + details: null, + ...overrides, +}); + +function setupParticipantCount(count: number | null) { + const mockSelector = jest.fn().mockReturnValue(count); + mockSelectCampaignParticipantCount.mockReturnValue(mockSelector); + mockUseSelector.mockImplementation((selector) => { + if (selector === mockSelector) return count; + return undefined; + }); +} + +describe('CampaignTile', () => { + beforeEach(() => { + jest.clearAllMocks(); + (getCampaignStatusInfo as jest.Mock).mockReturnValue({ + status: 'active', + statusLabel: 'Active', + dateLabel: 'Ends Mar 15, 2:30 PM', + dateLabelIcon: 'Clock', + }); + setupParticipantCount(null); + }); + + it('renders campaign name via campaign-tile-name testID', () => { + const campaign = createTestCampaign({ name: 'My Campaign' }); + + const { getByTestId } = render(); + + expect(getByTestId('campaign-tile-name')).toHaveTextContent('My Campaign'); + }); + + it('renders date label via campaign-tile-date-label testID', () => { + const campaign = createTestCampaign(); + + const { getByTestId } = render(); + + expect(getByTestId('campaign-tile-date-label')).toHaveTextContent( + 'Ends Mar 15, 2:30 PM', + ); + }); + + it('renders status label via campaign-tile-status-label testID', () => { + const campaign = createTestCampaign(); + + const { getByTestId } = render(); + + expect(getByTestId('campaign-tile-status-label')).toHaveTextContent( + /Active/, + ); + }); + + it('renders background image via campaign-tile-background testID', () => { + const campaign = createTestCampaign({ + details: { + image: { + lightModeUrl: 'https://example.com/light.png', + darkModeUrl: 'https://example.com/dark.png', + }, + howItWorks: { + title: '', + description: '', + phases: [], + }, + }, + }); + + const { getByTestId } = render(); + + expect(getByTestId('campaign-tile-background')).toBeDefined(); + }); + + it('calls getCampaignStatusInfo with campaign', () => { + const campaign = createTestCampaign(); + + render(); + + expect(getCampaignStatusInfo).toHaveBeenCalledWith(campaign); + }); + + describe('enter now label', () => { + it('renders enter-now when status is active and participantCount is null', () => { + setupParticipantCount(null); + const campaign = createTestCampaign(); + + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId('campaign-tile-enter-now')).toHaveTextContent( + '•Enter now', + ); + expect(queryByTestId('campaign-tile-participant-count')).toBeNull(); + }); + + it('does not render enter-now when status is upcoming', () => { + (getCampaignStatusInfo as jest.Mock).mockReturnValue({ + status: 'upcoming', + statusLabel: 'Up next', + dateLabel: 'Starts June 1', + dateLabelIcon: 'Speed', + }); + setupParticipantCount(null); + const campaign = createTestCampaign(); + + const { queryByTestId } = render(); + + expect(queryByTestId('campaign-tile-enter-now')).toBeNull(); + expect(queryByTestId('campaign-tile-participant-count')).toBeNull(); + }); + + it('does not render enter-now when status is complete', () => { + (getCampaignStatusInfo as jest.Mock).mockReturnValue({ + status: 'complete', + statusLabel: 'Complete', + dateLabel: 'December 31', + dateLabelIcon: 'Confirmation', + }); + setupParticipantCount(null); + const campaign = createTestCampaign(); + + const { queryByTestId } = render(); + + expect(queryByTestId('campaign-tile-enter-now')).toBeNull(); + expect(queryByTestId('campaign-tile-participant-count')).toBeNull(); + }); + }); + + describe('participant count', () => { + it('renders participant count when count is available', () => { + setupParticipantCount(1234); + const campaign = createTestCampaign(); + + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId('campaign-tile-participant-count')).toHaveTextContent( + '#1,234', + ); + expect(queryByTestId('campaign-tile-enter-now')).toBeNull(); + }); + + it('renders participant count of zero', () => { + setupParticipantCount(0); + const campaign = createTestCampaign(); + + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId('campaign-tile-participant-count')).toHaveTextContent( + '#0', + ); + expect(queryByTestId('campaign-tile-enter-now')).toBeNull(); + }); + + it('renders participant count even when status is not active', () => { + (getCampaignStatusInfo as jest.Mock).mockReturnValue({ + status: 'complete', + statusLabel: 'Complete', + dateLabel: 'December 31', + dateLabelIcon: 'Confirmation', + }); + setupParticipantCount(5000); + const campaign = createTestCampaign(); + + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId('campaign-tile-participant-count')).toHaveTextContent( + '#5,000', + ); + expect(queryByTestId('campaign-tile-enter-now')).toBeNull(); + }); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignTile.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignTile.tsx new file mode 100644 index 00000000000..378045d3f4a --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignTile.tsx @@ -0,0 +1,183 @@ +import React, { useMemo } from 'react'; +import { ImageBackground, Pressable, useColorScheme } from 'react-native'; +import { useSelector } from 'react-redux'; +import { + Box, + BoxFlexDirection, + BoxAlignItems, + BoxJustifyContent, + Text, + TextColor, + TextVariant, + Icon, + IconColor, + IconName, + IconSize, + FontWeight, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import type { CampaignDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { getCampaignStatusInfo } from './CampaignTile.utils'; +import { selectCampaignParticipantCount } from '../../../../../reducers/rewards/selectors'; +import { strings } from '../../../../../../locales/i18n'; +import useGetCampaignParticipantStatus from '../../hooks/useGetCampaignParticipantStatus'; + +interface CampaignTileProps { + campaign: CampaignDto; +} + +/** + * CampaignTile displays campaign information with status. + * Tapping navigates to the campaign details screen. + */ +const CampaignTile: React.FC = ({ campaign }) => { + const tw = useTailwind(); + const colorScheme = useColorScheme(); + + useGetCampaignParticipantStatus(campaign.id); + + const participantCount = useSelector( + selectCampaignParticipantCount(campaign.id), + ); + + const { status, statusLabel, dateLabel, dateLabelIcon } = useMemo( + () => getCampaignStatusInfo(campaign), + [campaign], + ); + + const backgroundImageUrl = + colorScheme === 'dark' + ? campaign.details?.image?.darkModeUrl + : campaign.details?.image?.lightModeUrl; + + const handlePress = () => { + // TODO: Implement campaign details screen + }; + + return ( + + tw.style( + 'rounded-xl overflow-hidden h-50 bg-muted', + pressed && 'opacity-70', + ) + } + testID={`campaign-tile-${campaign.id}`} + > + + + {/* Date label */} + + + + {dateLabel} + + + + + + + {statusLabel} + + {participantCount != null ? ( + + + + {strings('rewards.campaign.participant_count', { + count: participantCount.toLocaleString(), + })} + + + ) : status === 'active' ? ( + + + • + + + {strings('rewards.campaign.enter_now')} + + + ) : ( + <> + )} + + + + + {campaign.name} + + + + + + ); +}; + +export default CampaignTile; diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.test.ts b/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.test.ts new file mode 100644 index 00000000000..92f83565905 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.test.ts @@ -0,0 +1,283 @@ +/** + * Unit tests for CampaignTile utility functions + */ + +import { + getCampaignStatus, + formatCampaignStatusLabel, + getCampaignPillLabel, + getCampaignStatusInfo, +} from './CampaignTile.utils'; +import { + type CampaignDto, + CampaignType, +} from '../../../../../core/Engine/controllers/rewards-controller/types'; + +jest.mock('@metamask/design-system-react-native', () => ({ + IconName: { + Clock: 'Clock', + Confirmation: 'Confirmation', + Speed: 'Speed', + }, +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string, params?: Record) => + params ? `${key}:${JSON.stringify(params)}` : key, + ), +})); + +import { strings } from '../../../../../../locales/i18n'; + +/** + * Helper to build test CampaignDto objects. + */ +function buildCampaignDto(overrides: Partial = {}): CampaignDto { + return { + id: 'campaign-1', + type: CampaignType.ONDO_HOLDING, + name: 'Test Campaign', + startDate: '2025-06-01T00:00:00.000Z', + endDate: '2025-12-31T23:59:59.999Z', + termsAndConditions: null, + excludedRegions: [], + statusLabel: 'Active', + details: null, + ...overrides, + }; +} + +describe('CampaignTile.utils', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('getCampaignStatus', () => { + it('returns upcoming when now is before startDate', () => { + const fixedNow = new Date('2025-01-15T12:00:00.000Z'); + jest.useFakeTimers(); + jest.setSystemTime(fixedNow); + + const campaign = buildCampaignDto({ + startDate: '2025-06-01T00:00:00.000Z', + endDate: '2025-12-31T23:59:59.999Z', + }); + + const result = getCampaignStatus(campaign); + + expect(result).toBe('upcoming'); + }); + + it('returns active when now is within startDate and endDate', () => { + const fixedNow = new Date('2025-08-15T12:00:00.000Z'); + jest.useFakeTimers(); + jest.setSystemTime(fixedNow); + + const campaign = buildCampaignDto({ + startDate: '2025-06-01T00:00:00.000Z', + endDate: '2025-12-31T23:59:59.999Z', + }); + + const result = getCampaignStatus(campaign); + + expect(result).toBe('active'); + }); + + it('returns active when now equals startDate', () => { + const fixedNow = new Date('2025-06-01T00:00:00.000Z'); + jest.useFakeTimers(); + jest.setSystemTime(fixedNow); + + const campaign = buildCampaignDto({ + startDate: '2025-06-01T00:00:00.000Z', + endDate: '2025-12-31T23:59:59.999Z', + }); + + const result = getCampaignStatus(campaign); + + expect(result).toBe('active'); + }); + + it('returns complete when now is after endDate', () => { + const fixedNow = new Date('2026-01-15T12:00:00.000Z'); + jest.useFakeTimers(); + jest.setSystemTime(fixedNow); + + const campaign = buildCampaignDto({ + startDate: '2025-06-01T00:00:00.000Z', + endDate: '2025-12-31T23:59:59.999Z', + }); + + const result = getCampaignStatus(campaign); + + expect(result).toBe('complete'); + }); + + it('returns complete when now equals endDate', () => { + const fixedNow = new Date('2025-12-31T23:59:59.999Z'); + jest.useFakeTimers(); + jest.setSystemTime(fixedNow); + + const campaign = buildCampaignDto({ + startDate: '2025-06-01T00:00:00.000Z', + endDate: '2025-12-31T23:59:59.999Z', + }); + + const result = getCampaignStatus(campaign); + + expect(result).toBe('complete'); + }); + }); + + describe('formatCampaignStatusLabel', () => { + it('returns localized starts_date for upcoming status with formatted startDate', () => { + const campaign = buildCampaignDto({ + startDate: '2025-03-15T14:30:00.000Z', + endDate: '2025-12-31T23:59:59.999Z', + }); + + const result = formatCampaignStatusLabel('upcoming', campaign); + + expect(strings).toHaveBeenCalledWith('rewards.campaign.starts_date', { + date: 'March 15', + }); + expect(result).toContain('rewards.campaign.starts_date:'); + expect(result).toContain('"date"'); + }); + + it('returns localized ends_date for active status with formatted endDate', () => { + const campaign = buildCampaignDto({ + startDate: '2025-01-01T00:00:00.000Z', + endDate: '2025-12-31T23:59:00.000Z', + }); + + const result = formatCampaignStatusLabel('active', campaign); + + expect(strings).toHaveBeenCalledWith('rewards.campaign.ends_date', { + date: 'December 31', + }); + expect(result).toContain('rewards.campaign.ends_date:'); + expect(result).toContain('"date"'); + }); + + it('returns formatted endDate for complete status without localization', () => { + const campaign = buildCampaignDto({ + startDate: '2025-01-01T00:00:00.000Z', + endDate: '2025-07-04T18:00:00.000Z', + }); + + const result = formatCampaignStatusLabel('complete', campaign); + + expect(strings).not.toHaveBeenCalled(); + expect(result).toBe('July 4'); + }); + + it('returns empty string for unknown status', () => { + const campaign = buildCampaignDto(); + + const result = formatCampaignStatusLabel( + 'unknown' as 'upcoming' | 'active' | 'complete', + campaign, + ); + + expect(result).toBe(''); + }); + }); + + describe('getCampaignPillLabel', () => { + it('returns pill_up_next for upcoming status', () => { + const result = getCampaignPillLabel('upcoming'); + + expect(strings).toHaveBeenCalledWith('rewards.campaign.pill_up_next'); + expect(result).toBe('rewards.campaign.pill_up_next'); + }); + + it('returns pill_active for active status', () => { + const result = getCampaignPillLabel('active'); + + expect(strings).toHaveBeenCalledWith('rewards.campaign.pill_active'); + expect(result).toBe('rewards.campaign.pill_active'); + }); + + it('returns pill_complete for complete status', () => { + const result = getCampaignPillLabel('complete'); + + expect(strings).toHaveBeenCalledWith('rewards.campaign.pill_complete'); + expect(result).toBe('rewards.campaign.pill_complete'); + }); + + it('returns empty string for unknown status', () => { + const result = getCampaignPillLabel( + 'unknown' as 'upcoming' | 'active' | 'complete', + ); + + expect(result).toBe(''); + }); + }); + + describe('getCampaignStatusInfo', () => { + it('combines status, pill label, description, and icon for upcoming campaign', () => { + const fixedNow = new Date('2025-01-15T12:00:00.000Z'); + jest.useFakeTimers(); + jest.setSystemTime(fixedNow); + + const campaign = buildCampaignDto({ + startDate: '2025-06-01T00:00:00.000Z', + endDate: '2025-12-31T23:59:59.999Z', + }); + + const result = getCampaignStatusInfo(campaign); + + expect(result).toEqual({ + status: 'upcoming', + statusLabel: 'rewards.campaign.pill_up_next', + dateLabel: expect.stringContaining('rewards.campaign.starts_date'), + dateLabelIcon: 'Speed', + }); + }); + + it('combines status, pill label, description, and icon for active campaign', () => { + const fixedNow = new Date('2025-08-15T12:00:00.000Z'); + jest.useFakeTimers(); + jest.setSystemTime(fixedNow); + + const campaign = buildCampaignDto({ + startDate: '2025-06-01T00:00:00.000Z', + endDate: '2025-12-31T23:59:59.999Z', + }); + + const result = getCampaignStatusInfo(campaign); + + expect(result).toEqual({ + status: 'active', + statusLabel: 'rewards.campaign.pill_active', + dateLabel: expect.stringContaining('rewards.campaign.ends_date'), + dateLabelIcon: 'Clock', + }); + }); + + it('combines status, pill label, description, and icon for complete campaign', () => { + const fixedNow = new Date('2026-01-15T12:00:00.000Z'); + jest.useFakeTimers(); + jest.setSystemTime(fixedNow); + + const campaign = buildCampaignDto({ + startDate: '2025-06-01T00:00:00.000Z', + endDate: '2025-12-31T23:59:59.999Z', + }); + + const result = getCampaignStatusInfo(campaign); + + expect(result).toEqual({ + status: 'complete', + statusLabel: 'rewards.campaign.pill_complete', + dateLabel: 'December 31', + dateLabelIcon: 'Confirmation', + }); + }); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.ts b/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.ts new file mode 100644 index 00000000000..e57f8d4ce2f --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.ts @@ -0,0 +1,156 @@ +import { IconName } from '@metamask/design-system-react-native'; +import type { + CampaignDto, + CampaignStatus, +} from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { strings } from '../../../../../../locales/i18n'; + +/** + * Derives the status of a campaign based on its date fields. + * + * Status logic: + * - upcoming: now < startDate + * - active: startDate <= now < endDate + * - complete: now >= endDate + * + * @param campaign - The campaign data + * @returns The derived status + */ +export function getCampaignStatus(campaign: CampaignDto): CampaignStatus { + const now = new Date(); + const startDate = new Date(campaign.startDate); + const endDate = new Date(campaign.endDate); + + if (now < startDate) { + return 'upcoming'; + } + + if (now >= startDate && now < endDate) { + return 'active'; + } + + return 'complete'; +} + +const MONTHS = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +]; + +/** + * Formats a date for display in campaign tiles. + * + * @param date - The date to format + * @returns Formatted date string (e.g., "March 15") + */ +function formatCampaignDate(date: Date): string { + const month = MONTHS[date.getMonth()]; + const day = date.getDate(); + + return `${month} ${day}`; +} + +/** + * Formats the status label for display in the campaign tile. + * + * @param status - The campaign status + * @param campaign - The campaign data (used for date formatting) + * @returns The formatted status label + */ +export function formatCampaignStatusLabel( + status: CampaignStatus, + campaign: CampaignDto, +): string { + switch (status) { + case 'upcoming': { + const startDate = new Date(campaign.startDate); + return strings('rewards.campaign.starts_date', { + date: formatCampaignDate(startDate), + }); + } + case 'active': { + const endDate = new Date(campaign.endDate); + return strings('rewards.campaign.ends_date', { + date: formatCampaignDate(endDate), + }); + } + case 'complete': { + const endDate = new Date(campaign.endDate); + return formatCampaignDate(endDate); + } + default: + return ''; + } +} + +/** + * Gets the pill label text based on the campaign status. + * + * @param status - The campaign status + * @returns The pill label text + */ +export function getCampaignPillLabel(status: CampaignStatus): string { + switch (status) { + case 'upcoming': + return strings('rewards.campaign.pill_up_next'); + case 'active': + return strings('rewards.campaign.pill_active'); + case 'complete': + return strings('rewards.campaign.pill_complete'); + default: + return ''; + } +} + +/** + * Gets the appropriate icon for the campaign status. + * + * @param status - The campaign status + * @returns The icon name for the status + */ +function getStatusIcon(status: CampaignStatus): IconName { + switch (status) { + case 'active': + return IconName.Clock; + case 'complete': + return IconName.Confirmation; + case 'upcoming': + default: + return IconName.Speed; + } +} + +export interface CampaignStatusInfo { + status: CampaignStatus; + statusLabel: string; + dateLabel: string; + dateLabelIcon: IconName; +} + +/** + * Gets all status-related information for a campaign. + * + * @param campaign - The campaign data + * @returns Object containing status, statusLabel, statusDescription, and statusDescriptionIcon + */ +export function getCampaignStatusInfo( + campaign: CampaignDto, +): CampaignStatusInfo { + const status = getCampaignStatus(campaign); + return { + status, + statusLabel: getCampaignPillLabel(status), + dateLabel: formatCampaignStatusLabel(status, campaign), + dateLabelIcon: getStatusIcon(status), + }; +} diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignsGroup.test.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignsGroup.test.tsx new file mode 100644 index 00000000000..70155caee21 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignsGroup.test.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import CampaignsGroup from './CampaignsGroup'; +import type { + CampaignDto, + CampaignType, +} from '../../../../../core/Engine/controllers/rewards-controller/types'; + +const createTestCampaign = ( + overrides: Partial = {}, +): CampaignDto => ({ + id: 'campaign-1', + type: 'ONDO_HOLDING' as CampaignType, + name: 'Test Campaign', + startDate: '2025-01-01T00:00:00.000Z', + endDate: '2027-12-31T23:59:59.999Z', + termsAndConditions: null, + excludedRegions: [], + statusLabel: 'Active', + details: null, + ...overrides, +}); + +jest.mock('./CampaignTile', () => { + const ReactActual = jest.requireActual('react'); + const { Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ campaign }: { campaign: { name: string } }) => + ReactActual.createElement(Text, null, campaign.name), + }; +}); + +jest.mock('../PreviousSeason/PreviousSeasonTile', () => { + const ReactActual = jest.requireActual('react'); + const { Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => ReactActual.createElement(Text, null, 'PreviousSeasonTile'), + }; +}); + +const mockUseSelector = jest.fn(); +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: (selector: unknown) => mockUseSelector(selector), +})); + +describe('CampaignsGroup', () => { + beforeEach(() => { + mockUseSelector.mockReturnValue(null); + }); + + it('renders title and campaign tiles', () => { + const campaigns = [ + createTestCampaign({ id: '1', name: 'Campaign One' }), + createTestCampaign({ id: '2', name: 'Campaign Two' }), + ]; + + const { getByText } = render( + , + ); + + expect(getByText('Active Campaigns')).toBeOnTheScreen(); + expect(getByText('Campaign One')).toBeOnTheScreen(); + expect(getByText('Campaign Two')).toBeOnTheScreen(); + }); + + it('returns null when campaigns array is empty', () => { + const { queryByText } = render( + , + ); + + expect(queryByText('Active Campaigns')).toBeNull(); + expect(queryByText('Test Campaign')).toBeNull(); + }); + + it('renders PreviousSeasonTile when displayPreviousSeason is true and seasonName exists', () => { + mockUseSelector.mockReturnValue('Season 1'); + + const { getByText } = render( + , + ); + + expect(getByText('Previous')).toBeOnTheScreen(); + expect(getByText('PreviousSeasonTile')).toBeOnTheScreen(); + }); + + it('returns null when displayPreviousSeason is true but seasonName is empty', () => { + mockUseSelector.mockReturnValue(null); + + const { queryByText } = render( + , + ); + + expect(queryByText('Previous')).toBeNull(); + expect(queryByText('PreviousSeasonTile')).toBeNull(); + }); + + it('does not render PreviousSeasonTile when displayPreviousSeason is false', () => { + mockUseSelector.mockReturnValue('Season 1'); + + const campaigns = [createTestCampaign({ id: '1', name: 'Campaign One' })]; + + const { queryByText } = render( + , + ); + + expect(queryByText('PreviousSeasonTile')).toBeNull(); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignsGroup.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignsGroup.tsx new file mode 100644 index 00000000000..bd1fa64d9ec --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignsGroup.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { Box, Text, TextVariant } from '@metamask/design-system-react-native'; +import CampaignTile from './CampaignTile'; +import type { CampaignDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; +import PreviousSeasonTile from '../PreviousSeason/PreviousSeasonTile'; +import { selectSeasonName } from '../../../../../reducers/rewards/selectors'; +import { useSelector } from 'react-redux'; + +interface CampaignsGroupProps { + title: string; + campaigns: CampaignDto[]; + testID?: string; + displayPreviousSeason?: boolean; +} + +/** + * Section component for displaying a group of campaigns with a title. + */ +const CampaignsGroup: React.FC = ({ + title, + campaigns, + testID, + displayPreviousSeason = false, +}) => { + const seasonName = useSelector(selectSeasonName); + const showPreviousSeason = displayPreviousSeason && !!seasonName; + + if (campaigns.length === 0 && !showPreviousSeason) { + return null; + } + + return ( + + + {title} + + {campaigns.map((campaign) => ( + + ))} + {showPreviousSeason && } + + ); +}; + +export default CampaignsGroup; diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignsPreview.test.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignsPreview.test.tsx new file mode 100644 index 00000000000..033c2428b5f --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignsPreview.test.tsx @@ -0,0 +1,251 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import CampaignsPreview from './CampaignsPreview'; +import { + type CampaignDto, + CampaignType, +} from '../../../../../core/Engine/controllers/rewards-controller/types'; +import Routes from '../../../../../constants/navigation/Routes'; +import { useRewardCampaigns } from '../../hooks/useRewardCampaigns'; +import { REWARDS_VIEW_SELECTORS } from '../../Views/RewardsView.constants'; + +const mockNavigate = jest.fn(); +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ navigate: mockNavigate }), +})); + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + return { ...actual }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock('../../hooks/useRewardCampaigns'); +const mockUseRewardCampaigns = useRewardCampaigns as jest.MockedFunction< + typeof useRewardCampaigns +>; + +jest.mock('./CampaignTile', () => { + const ReactActual = jest.requireActual('react'); + const { Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ campaign }: { campaign: CampaignDto }) => + ReactActual.createElement( + Text, + { testID: `campaign-tile-${campaign.id}` }, + campaign.name, + ), + }; +}); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string) => { + const translations: Record = { + 'rewards.campaigns_preview.title': 'Campaigns', + 'rewards.campaigns_preview.coming_soon': 'Coming soon', + 'rewards.campaigns_preview.notify_me': 'Notify me', + }; + return translations[key] || key; + }, +})); + +const createTestCampaign = ( + overrides: Partial = {}, +): CampaignDto => ({ + id: 'campaign-1', + type: CampaignType.ONDO_HOLDING, + name: 'Test Campaign', + startDate: '2027-01-01T00:00:00.000Z', + endDate: '2027-12-31T23:59:59.999Z', + termsAndConditions: null, + excludedRegions: [], + statusLabel: 'Active', + details: null, + ...overrides, +}); + +const emptyCategorized = { active: [], upcoming: [], previous: [] }; + +const mockHookDefaults = { + campaigns: [], + categorizedCampaigns: emptyCategorized, + isLoading: false, + hasError: false, + fetchCampaigns: jest.fn(), +}; + +describe('CampaignsPreview', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseRewardCampaigns.mockReturnValue(mockHookDefaults); + }); + + it('returns null when there are no active or upcoming campaigns', () => { + const { queryByTestId } = render(); + + expect(queryByTestId(REWARDS_VIEW_SELECTORS.CAMPAIGNS_PREVIEW)).toBeNull(); + }); + + it('renders the section title when an active campaign exists', () => { + const activeCampaign = createTestCampaign({ + id: 'active-1', + name: 'Active Campaign', + }); + mockUseRewardCampaigns.mockReturnValue({ + ...mockHookDefaults, + categorizedCampaigns: { ...emptyCategorized, active: [activeCampaign] }, + }); + + const { getByText, getByTestId } = render(); + + expect( + getByTestId(REWARDS_VIEW_SELECTORS.CAMPAIGNS_PREVIEW), + ).toBeOnTheScreen(); + expect(getByText('Campaigns')).toBeOnTheScreen(); + }); + + it('renders a CampaignTile for the first active campaign', () => { + const activeCampaign = createTestCampaign({ + id: 'active-1', + name: 'Active Campaign', + }); + mockUseRewardCampaigns.mockReturnValue({ + ...mockHookDefaults, + categorizedCampaigns: { ...emptyCategorized, active: [activeCampaign] }, + }); + + const { getByText } = render(); + + expect(getByText('Active Campaign')).toBeOnTheScreen(); + }); + + it('renders the upcoming banner when an upcoming campaign exists', () => { + const upcomingCampaign = createTestCampaign({ + id: 'up-1', + name: 'Upcoming Campaign', + }); + mockUseRewardCampaigns.mockReturnValue({ + ...mockHookDefaults, + categorizedCampaigns: { + ...emptyCategorized, + upcoming: [upcomingCampaign], + }, + }); + + const { getByText, getByTestId } = render(); + + expect( + getByTestId(REWARDS_VIEW_SELECTORS.CAMPAIGNS_PREVIEW_UPCOMING_BANNER), + ).toBeOnTheScreen(); + expect(getByText('Coming soon')).toBeOnTheScreen(); + expect(getByText('Upcoming Campaign')).toBeOnTheScreen(); + expect(getByText('Notify me')).toBeOnTheScreen(); + }); + + it('renders both active tile and upcoming banner when both exist', () => { + const activeCampaign = createTestCampaign({ + id: 'active-1', + name: 'Active Campaign', + }); + const upcomingCampaign = createTestCampaign({ + id: 'up-1', + name: 'Upcoming Campaign', + }); + mockUseRewardCampaigns.mockReturnValue({ + ...mockHookDefaults, + categorizedCampaigns: { + ...emptyCategorized, + active: [activeCampaign], + upcoming: [upcomingCampaign], + }, + }); + + const { getByText } = render(); + + expect(getByText('Active Campaign')).toBeOnTheScreen(); + expect(getByText('Upcoming Campaign')).toBeOnTheScreen(); + }); + + it('does not render a CampaignTile when there are no active campaigns', () => { + const upcomingCampaign = createTestCampaign({ + id: 'up-1', + name: 'Upcoming Campaign', + }); + mockUseRewardCampaigns.mockReturnValue({ + ...mockHookDefaults, + categorizedCampaigns: { + ...emptyCategorized, + upcoming: [upcomingCampaign], + }, + }); + + const { queryByTestId } = render(); + + expect(queryByTestId('campaign-tile-up-1')).toBeNull(); + }); + + it('does not render the upcoming banner when there are no upcoming campaigns', () => { + const activeCampaign = createTestCampaign({ + id: 'active-1', + name: 'Active Campaign', + }); + mockUseRewardCampaigns.mockReturnValue({ + ...mockHookDefaults, + categorizedCampaigns: { ...emptyCategorized, active: [activeCampaign] }, + }); + + const { queryByTestId } = render(); + + expect( + queryByTestId(REWARDS_VIEW_SELECTORS.CAMPAIGNS_PREVIEW_UPCOMING_BANNER), + ).toBeNull(); + }); + + it('navigates to campaigns view when the title header is pressed', () => { + const activeCampaign = createTestCampaign({ + id: 'active-1', + name: 'Active Campaign', + }); + mockUseRewardCampaigns.mockReturnValue({ + ...mockHookDefaults, + categorizedCampaigns: { ...emptyCategorized, active: [activeCampaign] }, + }); + + const { getByText } = render(); + fireEvent.press(getByText('Campaigns')); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.CAMPAIGNS_VIEW); + }); + + it('only shows the first active campaign even when multiple exist', () => { + const first = createTestCampaign({ id: 'a1', name: 'First Active' }); + const second = createTestCampaign({ id: 'a2', name: 'Second Active' }); + mockUseRewardCampaigns.mockReturnValue({ + ...mockHookDefaults, + categorizedCampaigns: { ...emptyCategorized, active: [first, second] }, + }); + + const { getByText, queryByText } = render(); + + expect(getByText('First Active')).toBeOnTheScreen(); + expect(queryByText('Second Active')).toBeNull(); + }); + + it('only shows the first upcoming campaign even when multiple exist', () => { + const first = createTestCampaign({ id: 'u1', name: 'First Upcoming' }); + const second = createTestCampaign({ id: 'u2', name: 'Second Upcoming' }); + mockUseRewardCampaigns.mockReturnValue({ + ...mockHookDefaults, + categorizedCampaigns: { ...emptyCategorized, upcoming: [first, second] }, + }); + + const { getByText, queryByText } = render(); + + expect(getByText('First Upcoming')).toBeOnTheScreen(); + expect(queryByText('Second Upcoming')).toBeNull(); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignsPreview.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignsPreview.tsx new file mode 100644 index 00000000000..9c38fe165a7 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignsPreview.tsx @@ -0,0 +1,116 @@ +import React, { useCallback, useMemo } from 'react'; +import { Pressable } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { + Box, + BoxFlexDirection, + BoxAlignItems, + BoxJustifyContent, + Text, + TextVariant, + Icon, + IconName, + IconSize, + FontWeight, + Button, + ButtonVariant, + ButtonSize, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import Routes from '../../../../../constants/navigation/Routes'; +import { REWARDS_VIEW_SELECTORS } from '../../Views/RewardsView.constants'; +import { strings } from '../../../../../../locales/i18n'; +import { useRewardCampaigns } from '../../hooks/useRewardCampaigns'; +import CampaignTile from './CampaignTile'; + +/** + * CampaignsPreview shows a snapshot of campaigns on the dashboard: + * the first active campaign as a tile and the first upcoming campaign + * as a compact banner with "Coming soon". + */ +const CampaignsPreview: React.FC = () => { + const tw = useTailwind(); + const navigation = useNavigation(); + const { categorizedCampaigns } = useRewardCampaigns(); + + const activeCampaign = useMemo( + () => categorizedCampaigns.active[0] ?? null, + [categorizedCampaigns.active], + ); + + const upcomingCampaign = useMemo( + () => categorizedCampaigns.upcoming[0] ?? null, + [categorizedCampaigns.upcoming], + ); + + const handleNavigateToCampaigns = useCallback(() => { + navigation.navigate(Routes.CAMPAIGNS_VIEW); + }, [navigation]); + + if (!activeCampaign && !upcomingCampaign) { + return null; + } + + return ( + + + + + {strings('rewards.campaigns_preview.title')} + + + + + + {activeCampaign && } + + {upcomingCampaign && ( + + tw.style('rounded-xl bg-muted px-4 py-3', pressed && 'opacity-70') + } + testID={REWARDS_VIEW_SELECTORS.CAMPAIGNS_PREVIEW_UPCOMING_BANNER} + > + + + + {strings('rewards.campaigns_preview.coming_soon')} + + + {upcomingCampaign.name} + + + + + + )} + + ); +}; + +export default CampaignsPreview; diff --git a/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonTile.test.tsx b/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonTile.test.tsx new file mode 100644 index 00000000000..3c0e565cc1a --- /dev/null +++ b/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonTile.test.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; +import PreviousSeasonTile from './PreviousSeasonTile'; +import Routes from '../../../../../constants/navigation/Routes'; + +const mockNavigate = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ navigate: mockNavigate }), +})); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + return { ...actual }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string) => { + const translations: Record = { + 'rewards.campaign.pill_complete': 'Complete', + }; + return translations[key] || key; + }, +})); + +const mockUseSelector = useSelector as jest.MockedFunction; + +describe('PreviousSeasonTile', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns null when seasonName is not available', () => { + mockUseSelector.mockReturnValue(null); + + const { toJSON } = render(); + + expect(toJSON()).toBeNull(); + }); + + it('renders season name when seasonName is available', () => { + mockUseSelector.mockReturnValue('Season 1'); + + const { getByTestId } = render(); + + expect(getByTestId('previous-season-tile-name')).toHaveTextContent( + 'Season 1', + ); + }); + + it('renders the complete label', () => { + mockUseSelector.mockReturnValue('Season 1'); + + const { getByText } = render(); + + expect(getByText('Complete')).toBeOnTheScreen(); + }); + + it('renders background image', () => { + mockUseSelector.mockReturnValue('Season 1'); + + const { getByTestId } = render(); + + expect(getByTestId('previous-season-tile-background')).toBeOnTheScreen(); + }); + + it('renders foreground image', () => { + mockUseSelector.mockReturnValue('Season 1'); + + const { getByTestId } = render(); + + expect(getByTestId('previous-season-tile-image')).toBeOnTheScreen(); + }); + + it('navigates to PreviousSeasonView on press', () => { + mockUseSelector.mockReturnValue('Season 1'); + + const { getByTestId } = render(); + + fireEvent.press(getByTestId('previous-season-tile')); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREVIOUS_SEASON_VIEW); + }); +}); diff --git a/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonTile.tsx b/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonTile.tsx new file mode 100644 index 00000000000..acbc940f752 --- /dev/null +++ b/app/components/UI/Rewards/components/PreviousSeason/PreviousSeasonTile.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { Image, ImageBackground, Pressable } from 'react-native'; +import { useSelector } from 'react-redux'; +import { useNavigation } from '@react-navigation/native'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + BoxJustifyContent, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { selectSeasonName } from '../../../../../reducers/rewards/selectors'; +import Routes from '../../../../../constants/navigation/Routes'; +import introBg from '../../../../../images/rewards/rewards-onboarding-intro-bg.png'; +import intro from '../../../../../images/rewards/rewards-onboarding-intro.png'; +import { strings } from '../../../../../../locales/i18n'; + +const PreviousSeasonTile: React.FC = () => { + const tw = useTailwind(); + const navigation = useNavigation(); + const seasonName = useSelector(selectSeasonName); + + if (!seasonName) { + return null; + } + + return ( + navigation.navigate(Routes.PREVIOUS_SEASON_VIEW)} + style={({ pressed }) => + tw.style( + 'rounded-xl overflow-hidden h-50 bg-muted', + pressed && 'opacity-70', + ) + } + testID="previous-season-tile" + > + + + + + {strings('rewards.campaign.pill_complete')} + + + {seasonName} + + + + + + + ); +}; + +export default PreviousSeasonTile; diff --git a/app/components/UI/Rewards/components/SeasonStatus/SeasonStatus.test.tsx b/app/components/UI/Rewards/components/SeasonStatus/SeasonStatus.test.tsx deleted file mode 100644 index 7fb518b10b6..00000000000 --- a/app/components/UI/Rewards/components/SeasonStatus/SeasonStatus.test.tsx +++ /dev/null @@ -1,756 +0,0 @@ -import React from 'react'; -import { render, fireEvent } from '@testing-library/react-native'; -import { useSelector } from 'react-redux'; -import SeasonStatusSummary from './SeasonStatus'; -import { - selectSeasonStatusLoading, - selectBalanceTotal, - selectSeasonEndDate, - selectSeasonName, - selectSeasonStatusError, - selectSeasonStartDate, -} from '../../../../../reducers/rewards/selectors'; -import { formatNumber, formatTimeRemaining } from '../../utils/formatUtils'; - -// Mock react-redux -jest.mock('react-redux', () => ({ - useSelector: jest.fn(), -})); - -const mockUseSelector = useSelector as jest.MockedFunction; - -// Mock selectors -jest.mock('../../../../../reducers/rewards/selectors', () => ({ - selectSeasonStatusLoading: jest.fn(), - selectBalanceTotal: jest.fn(), - selectSeasonEndDate: jest.fn(), - selectSeasonName: jest.fn(), - selectSeasonStatusError: jest.fn(), - selectSeasonStartDate: jest.fn(), -})); - -// Mock formatUtils -jest.mock('../../utils/formatUtils', () => ({ - formatNumber: jest.fn((value: number | null) => - value === null || value === undefined ? '0' : value.toLocaleString(), - ), - formatTimeRemaining: jest.fn((endDate: Date) => { - const now = new Date('2024-06-15T12:00:00.000Z'); - const diff = endDate.getTime() - now.getTime(); - if (diff <= 0) return null; - const days = Math.floor(diff / (1000 * 60 * 60 * 24)); - const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); - const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); - return `${days}d ${hours}h ${minutes}m`; - }), -})); - -// Mock i18n -jest.mock('../../../../../../locales/i18n', () => ({ - strings: jest.fn((key: string) => { - const translations: Record = { - 'rewards.season_status.points_earned': 'Points earned', - 'rewards.season_status_error.error_fetching_title': - "Season balance couldn't be loaded", - 'rewards.season_status_error.error_fetching_description': - 'Check your connection and try again.', - 'rewards.season_status_error.retry_button': 'Retry', - }; - return translations[key] || key; - }), -})); - -// Mock useSeasonStatus hook -const mockFetchSeasonStatus = jest.fn(); -jest.mock('../../hooks/useSeasonStatus', () => ({ - useSeasonStatus: () => ({ - fetchSeasonStatus: mockFetchSeasonStatus, - }), -})); - -// Mock useTheme -jest.mock('../../../../../util/theme', () => { - const { mockTheme } = jest.requireActual('../../../../../util/theme'); - return { - useTheme: jest.fn(() => mockTheme), - }; -}); - -// Mock Tailwind -jest.mock('@metamask/design-system-twrnc-preset', () => ({ - useTailwind: () => { - const mockTw = jest.fn(() => ({})); - Object.assign(mockTw, { - style: jest.fn((styles) => { - if (typeof styles === 'object') { - return styles; - } - if (Array.isArray(styles)) { - return styles.reduce( - (acc, style) => ({ ...acc, ...style }), - {} as Record, - ); - } - return {}; - }), - }); - return mockTw; - }, -})); - -// Mock design system components -jest.mock('@metamask/design-system-react-native', () => { - const ReactActual = jest.requireActual('react'); - const { View, Text: RNText } = jest.requireActual('react-native'); - - const Box = ({ - children, - testID, - style, - ...props - }: { - children?: React.ReactNode; - testID?: string; - style?: Record; - [key: string]: unknown; - }) => ReactActual.createElement(View, { testID, style, ...props }, children); - - const TextComponent = ({ - children, - testID, - ...props - }: { - children?: React.ReactNode; - testID?: string; - [key: string]: unknown; - }) => ReactActual.createElement(RNText, { testID, ...props }, children); - - return { - Box, - Text: TextComponent, - TextVariant: { - HeadingMd: 'HeadingMd', - BodyMd: 'BodyMd', - BodySm: 'BodySm', - }, - FontWeight: { - Bold: 'bold', - Medium: 'medium', - }, - BoxFlexDirection: { - Row: 'row', - Column: 'column', - }, - BoxAlignItems: { - Center: 'center', - FlexEnd: 'flex-end', - }, - BoxJustifyContent: { - SpaceBetween: 'space-between', - }, - }; -}); - -// Mock SVG image -jest.mock('../../../../../images/rewards/metamask-rewards-points.svg', () => { - const ReactActual = jest.requireActual('react'); - const { View } = jest.requireActual('react-native'); - return function MockSvg(props: Record) { - return ReactActual.createElement(View, { - testID: 'metamask-rewards-points-image', - ...props, - }); - }; -}); - -// Mock Skeleton component -jest.mock('../../../../../component-library/components-temp/Skeleton', () => { - const ReactActual = jest.requireActual('react'); - const { View } = jest.requireActual('react-native'); - return { - Skeleton: ({ height, width }: { height: number; width: string }) => - ReactActual.createElement(View, { - testID: 'skeleton-loader', - style: { height, width }, - }), - }; -}); - -// Mock RewardsErrorBanner -jest.mock('../RewardsErrorBanner', () => { - const ReactActual = jest.requireActual('react'); - const { View, Text: RNText, Pressable } = jest.requireActual('react-native'); - const RewardsErrorBanner = ({ - title, - description, - onConfirm, - confirmButtonLabel, - }: { - title: string; - description: string; - onConfirm?: () => void; - confirmButtonLabel?: string; - }) => - ReactActual.createElement( - View, - { testID: 'rewards-error-banner' }, - ReactActual.createElement(RNText, { testID: 'error-title' }, title), - ReactActual.createElement( - RNText, - { testID: 'error-description' }, - description, - ), - onConfirm && - ReactActual.createElement( - Pressable, - { onPress: onConfirm, testID: 'error-retry-button' }, - ReactActual.createElement( - RNText, - null, - confirmButtonLabel || 'Confirm', - ), - ), - ); - return RewardsErrorBanner; -}); - -describe('SeasonStatusSummary', () => { - const mockFormatNumber = formatNumber as jest.MockedFunction< - typeof formatNumber - >; - const mockFormatTimeRemaining = formatTimeRemaining as jest.MockedFunction< - typeof formatTimeRemaining - >; - - beforeEach(() => { - jest.clearAllMocks(); - mockFetchSeasonStatus.mockClear(); - jest.useFakeTimers(); - jest.setSystemTime(new Date('2024-06-15T12:00:00.000Z')); - - // Default mock implementation - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return false; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonEndDate) return '2024-12-31'; - if (selector === selectSeasonName) return 'Season 1'; - if (selector === selectBalanceTotal) return 1500; - return undefined; - }); - - // Reset format mocks - mockFormatNumber.mockImplementation((value: number | null) => - value === null || value === undefined ? '0' : value.toLocaleString(), - ); - mockFormatTimeRemaining.mockImplementation(() => '10d 5h 30m'); - }); - - afterEach(() => { - jest.useRealTimers(); - jest.resetAllMocks(); - }); - - describe('useSelector calls', () => { - it('calls selectSeasonStatusLoading selector', () => { - render(); - - expect(mockUseSelector).toHaveBeenCalledWith(selectSeasonStatusLoading); - }); - - it('calls selectSeasonStatusError selector', () => { - render(); - - expect(mockUseSelector).toHaveBeenCalledWith(selectSeasonStatusError); - }); - - it('calls selectSeasonStartDate selector', () => { - render(); - - expect(mockUseSelector).toHaveBeenCalledWith(selectSeasonStartDate); - }); - - it('calls selectSeasonEndDate selector', () => { - render(); - - expect(mockUseSelector).toHaveBeenCalledWith(selectSeasonEndDate); - }); - - it('calls selectSeasonName selector', () => { - render(); - - expect(mockUseSelector).toHaveBeenCalledWith(selectSeasonName); - }); - - it('calls selectBalanceTotal selector', () => { - render(); - - expect(mockUseSelector).toHaveBeenCalledWith(selectBalanceTotal); - }); - }); - - describe('loading state', () => { - it('renders skeleton loader when loading', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonStatusLoading) return true; - return undefined; - }); - - const { getByTestId } = render(); - - expect(getByTestId('skeleton-loader')).toBeOnTheScreen(); - }); - - it('does not render points image when loading', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonStatusLoading) return true; - return undefined; - }); - - const { queryByTestId } = render(); - - expect(queryByTestId('metamask-rewards-points-image')).toBeNull(); - }); - - it('does not render season name when loading', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonStatusLoading) return true; - if (selector === selectSeasonName) return 'Season 1'; - return undefined; - }); - - const { queryByText } = render(); - - expect(queryByText('Season 1')).toBeNull(); - }); - }); - - describe('error state', () => { - it('renders error banner when error exists and no season start date', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return true; - if (selector === selectSeasonStartDate) return null; - return undefined; - }); - - const { getByTestId } = render(); - - expect(getByTestId('rewards-error-banner')).toBeOnTheScreen(); - }); - - it('renders error title in error banner', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return true; - if (selector === selectSeasonStartDate) return null; - return undefined; - }); - - const { getByTestId } = render(); - - expect(getByTestId('error-title')).toBeOnTheScreen(); - }); - - it('renders error description in error banner', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return true; - if (selector === selectSeasonStartDate) return null; - return undefined; - }); - - const { getByTestId } = render(); - - expect(getByTestId('error-description')).toBeOnTheScreen(); - }); - - it('renders retry button in error banner', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return true; - if (selector === selectSeasonStartDate) return null; - return undefined; - }); - - const { getByTestId } = render(); - - expect(getByTestId('error-retry-button')).toBeOnTheScreen(); - }); - - it('calls fetchSeasonStatus when retry button is pressed', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return true; - if (selector === selectSeasonStartDate) return null; - return undefined; - }); - - const { getByTestId } = render(); - - const retryButton = getByTestId('error-retry-button'); - fireEvent.press(retryButton); - - expect(mockFetchSeasonStatus).toHaveBeenCalledTimes(1); - }); - - it('does not render error banner when error exists but season start date is present', () => { - // When there's an error but we have cached data (seasonStartDate exists), - // component should render the normal state - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return true; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonEndDate) return '2024-12-31'; - if (selector === selectSeasonName) return 'Season 1'; - if (selector === selectBalanceTotal) return 1500; - return undefined; - }); - - const { getByTestId, queryByTestId } = render(); - - // Normal state renders the points image instead of error banner - expect(getByTestId('metamask-rewards-points-image')).toBeOnTheScreen(); - expect(queryByTestId('rewards-error-banner')).toBeNull(); - }); - - it('renders normal content when there is no error', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return false; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonEndDate) return '2024-12-31'; - if (selector === selectSeasonName) return 'Season 1'; - if (selector === selectBalanceTotal) return 1500; - return undefined; - }); - - const { getByTestId } = render(); - - expect(getByTestId('metamask-rewards-points-image')).toBeOnTheScreen(); - }); - - it('does not render points image when error state shows', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return true; - if (selector === selectSeasonStartDate) return null; - return undefined; - }); - - const { queryByTestId } = render(); - - expect(queryByTestId('metamask-rewards-points-image')).toBeNull(); - }); - }); - - describe('normal state - points display', () => { - it('renders points image', () => { - const { getByTestId } = render(); - - expect(getByTestId('metamask-rewards-points-image')).toBeOnTheScreen(); - }); - - it('displays formatted balance total', () => { - mockFormatNumber.mockReturnValue('1,500'); - mockUseSelector.mockImplementation((selector) => { - if (selector === selectBalanceTotal) return 1500; - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return false; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonEndDate) return '2024-12-31'; - if (selector === selectSeasonName) return 'Season 1'; - return undefined; - }); - - const { getByText } = render(); - - expect(getByText('1,500')).toBeOnTheScreen(); - }); - - it('calls formatNumber with balance total', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectBalanceTotal) return 2500; - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return false; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonEndDate) return '2024-12-31'; - if (selector === selectSeasonName) return 'Season 1'; - return undefined; - }); - - render(); - - expect(mockFormatNumber).toHaveBeenCalledWith(2500); - }); - - it('renders points section with balance total and season info', () => { - // The points section contains balance total and points label - // We verify the structure renders correctly - const { getByText } = render(); - - // Balance total is visible - expect(getByText('1,500')).toBeOnTheScreen(); - - // Season info is visible - expect(getByText('Season 1')).toBeOnTheScreen(); - - // Time remaining is shown - expect(getByText('10d 5h 30m')).toBeOnTheScreen(); - }); - }); - - describe('normal state - season info display', () => { - it('displays season name when provided', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonName) return 'Summer Season'; - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return false; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonEndDate) return '2024-12-31'; - if (selector === selectBalanceTotal) return 1500; - return undefined; - }); - - const { getByText } = render(); - - expect(getByText('Summer Season')).toBeOnTheScreen(); - }); - - it('does not display season name when null', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonName) return null; - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return false; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonEndDate) return '2024-12-31'; - if (selector === selectBalanceTotal) return 1500; - return undefined; - }); - - const { queryByText } = render(); - - expect(queryByText('Summer Season')).toBeNull(); - }); - - it('does not display season name text when value is empty string', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonName) return ''; - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return false; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonEndDate) return '2024-12-31'; - if (selector === selectBalanceTotal) return 1500; - return undefined; - }); - - const { queryByText } = render(); - - // Season 1 should not appear since it's set to empty string - expect(queryByText('Season 1')).toBeNull(); - }); - - it('displays time remaining when season end date is provided', () => { - mockFormatTimeRemaining.mockReturnValue('15d 8h 22m'); - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonEndDate) return '2024-07-01'; - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return false; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonName) return 'Season 1'; - if (selector === selectBalanceTotal) return 1500; - return undefined; - }); - - const { getByText } = render(); - - expect(getByText('15d 8h 22m')).toBeOnTheScreen(); - }); - - it('does not display time remaining when season end date is null', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonEndDate) return null; - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return false; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonName) return 'Season 1'; - if (selector === selectBalanceTotal) return 1500; - return undefined; - }); - - const { queryByText } = render(); - - expect(queryByText(/d.*h.*m/)).toBeNull(); - }); - - it('does not display time remaining when formatTimeRemaining returns null', () => { - mockFormatTimeRemaining.mockReturnValue(null); - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonEndDate) return '2024-01-01'; - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return false; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonName) return 'Season 1'; - if (selector === selectBalanceTotal) return 1500; - return undefined; - }); - - const { queryByText } = render(); - - expect(queryByText(/d.*h.*m/)).toBeNull(); - }); - }); - - describe('edge cases', () => { - it('renders with zero balance', () => { - mockFormatNumber.mockReturnValue('0'); - mockUseSelector.mockImplementation((selector) => { - if (selector === selectBalanceTotal) return 0; - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return false; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonEndDate) return '2024-12-31'; - if (selector === selectSeasonName) return 'Season 1'; - return undefined; - }); - - const { getByText } = render(); - - expect(getByText('0')).toBeOnTheScreen(); - }); - - it('renders with null balance', () => { - mockFormatNumber.mockReturnValue('0'); - mockUseSelector.mockImplementation((selector) => { - if (selector === selectBalanceTotal) return null; - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return false; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonEndDate) return '2024-12-31'; - if (selector === selectSeasonName) return 'Season 1'; - return undefined; - }); - - const { getByText } = render(); - - expect(mockFormatNumber).toHaveBeenCalledWith(null); - expect(getByText('0')).toBeOnTheScreen(); - }); - - it('renders with large balance', () => { - mockFormatNumber.mockReturnValue('1,000,000'); - mockUseSelector.mockImplementation((selector) => { - if (selector === selectBalanceTotal) return 1000000; - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return false; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonEndDate) return '2024-12-31'; - if (selector === selectSeasonName) return 'Season 1'; - return undefined; - }); - - const { getByText } = render(); - - expect(getByText('1,000,000')).toBeOnTheScreen(); - }); - - it('renders correctly when only loading state changes', () => { - // First render with loading - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonStatusLoading) return true; - return undefined; - }); - - const { rerender, getByTestId, queryByTestId } = render( - , - ); - - expect(getByTestId('skeleton-loader')).toBeOnTheScreen(); - - // Then update to not loading - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return false; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonEndDate) return '2024-12-31'; - if (selector === selectSeasonName) return 'Season 1'; - if (selector === selectBalanceTotal) return 1500; - return undefined; - }); - mockFormatNumber.mockReturnValue('1,500'); - mockFormatTimeRemaining.mockReturnValue('10d 5h 30m'); - - rerender(); - - expect(queryByTestId('skeleton-loader')).toBeNull(); - expect(getByTestId('metamask-rewards-points-image')).toBeOnTheScreen(); - }); - }); - - describe('timeRemaining calculation', () => { - it('calls formatTimeRemaining with Date object from season end date string', () => { - const endDateString = '2024-12-31T23:59:59.000Z'; - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonEndDate) return endDateString; - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return false; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonName) return 'Season 1'; - if (selector === selectBalanceTotal) return 1500; - return undefined; - }); - - render(); - - expect(mockFormatTimeRemaining).toHaveBeenCalledWith( - new Date(endDateString), - ); - }); - - it('does not call formatTimeRemaining when season end date is null', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonEndDate) return null; - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return false; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonName) return 'Season 1'; - if (selector === selectBalanceTotal) return 1500; - return undefined; - }); - - render(); - - expect(mockFormatTimeRemaining).not.toHaveBeenCalled(); - }); - }); - - describe('component rendering without crashing', () => { - it('renders without crashing with minimal props', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return false; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonEndDate) return null; - if (selector === selectSeasonName) return null; - if (selector === selectBalanceTotal) return null; - return undefined; - }); - - expect(() => render()).not.toThrow(); - }); - - it('renders without crashing with all values present', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonStatusLoading) return false; - if (selector === selectSeasonStatusError) return false; - if (selector === selectSeasonStartDate) return '2024-01-01'; - if (selector === selectSeasonEndDate) return '2024-12-31'; - if (selector === selectSeasonName) return 'Season 1'; - if (selector === selectBalanceTotal) return 5000; - return undefined; - }); - - expect(() => render()).not.toThrow(); - }); - }); -}); diff --git a/app/components/UI/Rewards/components/SeasonStatus/SeasonStatus.tsx b/app/components/UI/Rewards/components/SeasonStatus/SeasonStatus.tsx deleted file mode 100644 index 238c3b94d32..00000000000 --- a/app/components/UI/Rewards/components/SeasonStatus/SeasonStatus.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import React from 'react'; -import { - Box, - BoxFlexDirection, - BoxAlignItems, - BoxJustifyContent, - TextVariant, - Text, - FontWeight, -} from '@metamask/design-system-react-native'; -import { strings } from '../../../../../../locales/i18n'; -import { useTheme } from '../../../../../util/theme'; -import MetamaskRewardsPointsImage from '../../../../../images/rewards/metamask-rewards-points.svg'; -import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; -import { useSelector } from 'react-redux'; -import { - selectSeasonStatusLoading, - selectBalanceTotal, - selectSeasonEndDate, - selectSeasonName, - selectSeasonStatusError, - selectSeasonStartDate, -} from '../../../../../reducers/rewards/selectors'; -import { formatNumber, formatTimeRemaining } from '../../utils/formatUtils'; -import RewardsErrorBanner from '../RewardsErrorBanner'; -import { useSeasonStatus } from '../../hooks/useSeasonStatus'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; - -const SeasonStatus: React.FC = () => { - const theme = useTheme(); - const { fetchSeasonStatus } = useSeasonStatus({ - onlyForExplicitFetch: true, - }); - const tw = useTailwind(); - const balanceTotal = useSelector(selectBalanceTotal); - const seasonStatusLoading = useSelector(selectSeasonStatusLoading); - const seasonStatusError = useSelector(selectSeasonStatusError); - const seasonStartDate = useSelector(selectSeasonStartDate); - const seasonEndDate = useSelector(selectSeasonEndDate); - const seasonName = useSelector(selectSeasonName); - - const timeRemaining = React.useMemo(() => { - if (!seasonEndDate) { - return null; - } - return formatTimeRemaining(new Date(seasonEndDate)); - }, [seasonEndDate]); - - if (seasonStatusLoading) { - return ; - } - - if (seasonStatusError && !seasonStartDate) { - return ( - { - fetchSeasonStatus(); - }} - confirmButtonLabel={strings('rewards.season_status_error.retry_button')} - /> - ); - } - - return ( - - {/* Left side - Points */} - - - - - {formatNumber(balanceTotal)} - - - {strings('rewards.season_status.points_earned')} - - - - - {/* Right side - Season info */} - - {!!seasonName && ( - - {seasonName} - - )} - {!!timeRemaining && ( - - {timeRemaining} - - )} - - - ); -}; - -export default SeasonStatus; diff --git a/app/components/UI/Rewards/components/SnapshotTile/SnapshotTile.test.tsx b/app/components/UI/Rewards/components/SnapshotTile/SnapshotTile.test.tsx deleted file mode 100644 index e91ea372f07..00000000000 --- a/app/components/UI/Rewards/components/SnapshotTile/SnapshotTile.test.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react-native'; -import SnapshotTile from './SnapshotTile'; -import type { SnapshotDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; -import { getSnapshotStatusInfo } from './SnapshotTile.utils'; - -// Mock design system -jest.mock('@metamask/design-system-react-native', () => ({ - Box: 'Box', - BoxFlexDirection: { Column: 'Column', Row: 'Row' }, - BoxAlignItems: { Center: 'Center' }, - BoxJustifyContent: { Between: 'Between' }, - Text: 'Text', - TextVariant: { BodySm: 'BodySm', BodyMd: 'BodyMd', HeadingLg: 'HeadingLg' }, - Icon: 'Icon', - IconSize: { Sm: 'Sm' }, - IconName: { Clock: 'Clock', Speed: 'Speed' }, -})); - -// Mock Tailwind -jest.mock('@metamask/design-system-twrnc-preset', () => ({ - useTailwind: () => ({ style: jest.fn(() => ({})) }), -})); - -// Mock the utils to control status output -jest.mock('./SnapshotTile.utils', () => ({ - getSnapshotStatusInfo: jest.fn(), -})); - -// Mock RewardsThemeImageComponent -jest.mock('../ThemeImageComponent', () => ({ - __esModule: true, - default: 'RewardsThemeImageComponent', -})); - -const mockGetSnapshotStatusInfo = getSnapshotStatusInfo as jest.MockedFunction< - typeof getSnapshotStatusInfo ->; - -/** - * Creates a test snapshot with default values that can be overridden - */ -function createTestSnapshot(overrides: Partial = {}): SnapshotDto { - return { - id: 'snapshot-1', - seasonId: 'season-1', - name: 'Test Snapshot Prize', - description: 'Test description', - tokenSymbol: 'TEST', - tokenAmount: '1000', - tokenChainId: '1', - receivingBlockchain: 'ethereum', - opensAt: '2024-01-01T00:00:00Z', - closesAt: '2024-01-31T23:59:59Z', - calculatedAt: undefined, - distributedAt: undefined, - backgroundImage: { - lightModeUrl: 'https://example.com/light.png', - darkModeUrl: 'https://example.com/dark.png', - }, - ...overrides, - }; -} - -describe('SnapshotTile', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockGetSnapshotStatusInfo.mockReturnValue({ - status: 'live', - statusLabel: 'Live Now', - statusDescription: 'Ends Mar 15, 2:30 PM', - statusDescriptionIcon: 'Clock' as never, - }); - }); - - it('renders snapshot name as prize display', () => { - const snapshot = createTestSnapshot({ name: 'Monad Airdrop' }); - - const { getByText } = render(); - - expect(getByText('Monad Airdrop')).toBeOnTheScreen(); - }); - - it('renders status description text', () => { - const snapshot = createTestSnapshot(); - mockGetSnapshotStatusInfo.mockReturnValue({ - status: 'live', - statusLabel: 'Live Now', - statusDescription: 'Ends Mar 15, 2:30 PM', - statusDescriptionIcon: 'Clock' as never, - }); - - const { getByText } = render(); - - expect(getByText('Ends Mar 15, 2:30 PM')).toBeOnTheScreen(); - }); - - it('renders status label text', () => { - const snapshot = createTestSnapshot(); - mockGetSnapshotStatusInfo.mockReturnValue({ - status: 'live', - statusLabel: 'Live Now', - statusDescription: 'Ends Mar 15, 2:30 PM', - statusDescriptionIcon: 'Clock' as never, - }); - - const { getByText } = render(); - - expect(getByText('Live Now')).toBeOnTheScreen(); - }); - - it('displays ActivityIndicator when status is calculating', () => { - const snapshot = createTestSnapshot(); - mockGetSnapshotStatusInfo.mockReturnValue({ - status: 'calculating', - statusLabel: 'Calculating', - statusDescription: 'Results coming soon', - statusDescriptionIcon: 'Loading' as never, - }); - - const { UNSAFE_getByType } = render(); - const { ActivityIndicator } = jest.requireActual('react-native'); - - expect(UNSAFE_getByType(ActivityIndicator)).toBeDefined(); - }); - - it('displays Icon when status is not calculating', () => { - const snapshot = createTestSnapshot(); - mockGetSnapshotStatusInfo.mockReturnValue({ - status: 'live', - statusLabel: 'Live Now', - statusDescription: 'Ends Mar 15, 2:30 PM', - statusDescriptionIcon: 'Clock' as never, - }); - - const { UNSAFE_queryByType, UNSAFE_getByType } = render( - , - ); - const { ActivityIndicator } = jest.requireActual('react-native'); - - expect(UNSAFE_queryByType(ActivityIndicator)).toBeNull(); - expect(UNSAFE_getByType('Icon' as never)).toBeDefined(); - }); - - it('renders background image component', () => { - const snapshot = createTestSnapshot({ - backgroundImage: { - lightModeUrl: 'https://example.com/custom-light.png', - darkModeUrl: 'https://example.com/custom-dark.png', - }, - }); - - const { UNSAFE_getByType } = render(); - - expect( - UNSAFE_getByType('RewardsThemeImageComponent' as never), - ).toBeDefined(); - }); -}); diff --git a/app/components/UI/Rewards/components/SnapshotTile/SnapshotTile.tsx b/app/components/UI/Rewards/components/SnapshotTile/SnapshotTile.tsx deleted file mode 100644 index 074683205dd..00000000000 --- a/app/components/UI/Rewards/components/SnapshotTile/SnapshotTile.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import React, { useMemo } from 'react'; -import { View, ActivityIndicator } from 'react-native'; -import { - Box, - BoxFlexDirection, - BoxAlignItems, - BoxJustifyContent, - Text, - TextVariant, - Icon, - IconSize, -} from '@metamask/design-system-react-native'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import type { SnapshotDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; -import RewardsThemeImageComponent from '../ThemeImageComponent'; -import { getSnapshotStatusInfo } from './SnapshotTile.utils'; - -interface SnapshotTileProps { - /** - * The snapshot data to display - */ - snapshot: SnapshotDto; -} - -/** - * SnapshotTile component displays snapshot/airdrop information with status. - * - * Shows: - * - Background image - * - Status pill (Live now, Up next, Calculating, Results Ready, Complete) - * - Status label with date - * - Prize information - */ -const SnapshotTile: React.FC = ({ snapshot }) => { - const tw = useTailwind(); - - const { status, statusLabel, statusDescription, statusDescriptionIcon } = - useMemo(() => getSnapshotStatusInfo(snapshot), [snapshot]); - - // Format prize display (e.g., "$50,000 Monad") - const prizeDisplay = useMemo( - () => - // For now, just show the token symbol and name - // The actual formatting would depend on how we want to display the amount - `${snapshot.name}`, - [snapshot], - ); - - return ( - - {/* Background Image */} - - - - - {/* Content */} - - {/* Status Description Icon and Text */} - - {status === 'calculating' ? ( - - ) : ( - - )} - - {statusDescription} - - - - {/* Bottom Content */} - - - {statusLabel} - - - {/* Prize Info */} - - {prizeDisplay} - - - - - ); -}; - -export default SnapshotTile; diff --git a/app/components/UI/Rewards/components/SnapshotTile/SnapshotTile.utils.test.ts b/app/components/UI/Rewards/components/SnapshotTile/SnapshotTile.utils.test.ts deleted file mode 100644 index 5aa5c7dde93..00000000000 --- a/app/components/UI/Rewards/components/SnapshotTile/SnapshotTile.utils.test.ts +++ /dev/null @@ -1,502 +0,0 @@ -import { IconName } from '@metamask/design-system-react-native'; -import type { - SnapshotDto, - SnapshotStatus, -} from '../../../../../core/Engine/controllers/rewards-controller/types'; -import { - getSnapshotStatus, - formatSnapshotStatusLabel, - getSnapshotPillLabel, - getSnapshotStatusInfo, -} from './SnapshotTile.utils'; - -// Mock the strings function - must return the key-based string for assertions -jest.mock('../../../../../../locales/i18n', () => ({ - strings: (key: string, params?: { date?: string }) => { - const keyPart = key.split('.').pop() || key; - if (params?.date) { - return `${keyPart}: ${params.date}`; - } - return keyPart; - }, -})); - -/** - * Creates a test snapshot with sensible defaults. - * @param overrides - Partial SnapshotDto to override defaults - * @returns Complete SnapshotDto for testing - */ -const createTestSnapshot = ( - overrides: Partial = {}, -): SnapshotDto => ({ - id: 'test-snapshot-id', - seasonId: 'test-season-id', - name: 'Test Snapshot', - description: 'Test description', - tokenSymbol: 'TEST', - tokenAmount: '1000000000000000000', - tokenChainId: '1', - tokenAddress: '0x1234567890abcdef1234567890abcdef12345678', - receivingBlockchain: 'Ethereum', - opensAt: '2025-03-01T00:00:00.000Z', - closesAt: '2025-03-15T00:00:00.000Z', - backgroundImage: { - lightModeUrl: 'https://example.com/light.png', - darkModeUrl: 'https://example.com/dark.png', - }, - ...overrides, -}); - -/** - * Helper to format a date for comparison in the America/Toronto timezone - * The production code uses new Date().getHours() which returns local time - */ -const getExpectedFormattedDate = (isoString: string): string => { - const date = new Date(isoString); - const months = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', - ]; - const month = months[date.getMonth()]; - const day = date.getDate(); - const hours = date.getHours(); - const minutes = date.getMinutes(); - const hour12 = hours % 12 || 12; - const ampm = hours >= 12 ? 'PM' : 'AM'; - const paddedMinutes = minutes.toString().padStart(2, '0'); - - return `${month} ${day}, ${hour12}:${paddedMinutes} ${ampm}`; -}; - -describe('SnapshotTile.utils', () => { - beforeEach(() => { - jest.useFakeTimers(); - jest.clearAllMocks(); - }); - - afterEach(() => { - jest.useRealTimers(); - jest.resetAllMocks(); - }); - - describe('getSnapshotStatus', () => { - it('returns "complete" when distributedAt is set', () => { - jest.setSystemTime(new Date('2025-03-20T00:00:00.000Z')); - const snapshot = createTestSnapshot({ - calculatedAt: '2025-03-16T00:00:00.000Z', - distributedAt: '2025-03-20T00:00:00.000Z', - }); - - const result = getSnapshotStatus(snapshot); - - expect(result).toBe('complete'); - }); - - it('returns "distributing" when calculatedAt is set but distributedAt is not', () => { - jest.setSystemTime(new Date('2025-03-18T00:00:00.000Z')); - const snapshot = createTestSnapshot({ - calculatedAt: '2025-03-16T00:00:00.000Z', - distributedAt: undefined, - }); - - const result = getSnapshotStatus(snapshot); - - expect(result).toBe('distributing'); - }); - - it('returns "upcoming" when current time is before opensAt', () => { - jest.setSystemTime(new Date('2025-02-28T00:00:00.000Z')); - const snapshot = createTestSnapshot(); - - const result = getSnapshotStatus(snapshot); - - expect(result).toBe('upcoming'); - }); - - it('returns "live" when current time is between opensAt and closesAt', () => { - jest.setSystemTime(new Date('2025-03-10T00:00:00.000Z')); - const snapshot = createTestSnapshot(); - - const result = getSnapshotStatus(snapshot); - - expect(result).toBe('live'); - }); - - it('returns "live" when current time equals opensAt exactly', () => { - jest.setSystemTime(new Date('2025-03-01T00:00:00.000Z')); - const snapshot = createTestSnapshot(); - - const result = getSnapshotStatus(snapshot); - - expect(result).toBe('live'); - }); - - it('returns "calculating" when current time is past closesAt and calculatedAt is not set', () => { - jest.setSystemTime(new Date('2025-03-16T00:00:00.000Z')); - const snapshot = createTestSnapshot({ - calculatedAt: undefined, - distributedAt: undefined, - }); - - const result = getSnapshotStatus(snapshot); - - expect(result).toBe('calculating'); - }); - - it('returns "calculating" when current time equals closesAt exactly', () => { - jest.setSystemTime(new Date('2025-03-15T00:00:00.000Z')); - const snapshot = createTestSnapshot({ - calculatedAt: undefined, - distributedAt: undefined, - }); - - const result = getSnapshotStatus(snapshot); - - expect(result).toBe('calculating'); - }); - - it('prioritizes "complete" over other statuses when distributedAt is set', () => { - jest.setSystemTime(new Date('2025-02-01T00:00:00.000Z')); - const snapshot = createTestSnapshot({ - calculatedAt: '2025-03-16T00:00:00.000Z', - distributedAt: '2025-03-20T00:00:00.000Z', - }); - - const result = getSnapshotStatus(snapshot); - - expect(result).toBe('complete'); - }); - - it('prioritizes "distributing" over date-based statuses when calculatedAt is set', () => { - jest.setSystemTime(new Date('2025-02-01T00:00:00.000Z')); - const snapshot = createTestSnapshot({ - calculatedAt: '2025-03-16T00:00:00.000Z', - distributedAt: undefined, - }); - - const result = getSnapshotStatus(snapshot); - - expect(result).toBe('distributing'); - }); - }); - - describe('formatSnapshotStatusLabel', () => { - it('returns starts_date label with formatted opensAt for upcoming status', () => { - const opensAtDate = '2025-03-01T14:30:00.000Z'; - const snapshot = createTestSnapshot({ - opensAt: opensAtDate, - }); - const expectedDate = getExpectedFormattedDate(opensAtDate); - - const result = formatSnapshotStatusLabel('upcoming', snapshot); - - expect(result).toBe(`starts_date: ${expectedDate}`); - }); - - it('returns ends_date label with formatted closesAt for live status', () => { - const closesAtDate = '2025-03-15T09:00:00.000Z'; - const snapshot = createTestSnapshot({ - closesAt: closesAtDate, - }); - const expectedDate = getExpectedFormattedDate(closesAtDate); - - const result = formatSnapshotStatusLabel('live', snapshot); - - expect(result).toBe(`ends_date: ${expectedDate}`); - }); - - it('returns results_coming_soon label for calculating status', () => { - const snapshot = createTestSnapshot(); - - const result = formatSnapshotStatusLabel('calculating', snapshot); - - expect(result).toBe('results_coming_soon'); - }); - - it('returns tokens_on_the_way label for distributing status', () => { - const snapshot = createTestSnapshot(); - - const result = formatSnapshotStatusLabel('distributing', snapshot); - - expect(result).toBe('tokens_on_the_way'); - }); - - it('returns formatted distributedAt date for complete status', () => { - const distributedAtDate = '2025-03-20T16:45:00.000Z'; - const snapshot = createTestSnapshot({ - distributedAt: distributedAtDate, - }); - const expectedDate = getExpectedFormattedDate(distributedAtDate); - - const result = formatSnapshotStatusLabel('complete', snapshot); - - expect(result).toBe(expectedDate); - }); - - it('returns formatted current date for complete status when distributedAt is undefined', () => { - const currentDate = '2025-04-01T10:00:00.000Z'; - jest.setSystemTime(new Date(currentDate)); - const snapshot = createTestSnapshot({ - distributedAt: undefined, - }); - const expectedDate = getExpectedFormattedDate(currentDate); - - const result = formatSnapshotStatusLabel('complete', snapshot); - - expect(result).toBe(expectedDate); - }); - - it('returns empty string for unknown status', () => { - const snapshot = createTestSnapshot(); - - const result = formatSnapshotStatusLabel( - 'unknown' as SnapshotStatus, - snapshot, - ); - - expect(result).toBe(''); - }); - - it('formats midnight correctly (12:00 AM)', () => { - const opensAtDate = '2025-03-01T05:00:00.000Z'; // Midnight in America/Toronto (UTC-5) - const snapshot = createTestSnapshot({ - opensAt: opensAtDate, - }); - const expectedDate = getExpectedFormattedDate(opensAtDate); - - const result = formatSnapshotStatusLabel('upcoming', snapshot); - - expect(result).toBe(`starts_date: ${expectedDate}`); - }); - - it('formats noon correctly (12:00 PM)', () => { - const opensAtDate = '2025-03-01T17:00:00.000Z'; // Noon in America/Toronto (UTC-5) - const snapshot = createTestSnapshot({ - opensAt: opensAtDate, - }); - const expectedDate = getExpectedFormattedDate(opensAtDate); - - const result = formatSnapshotStatusLabel('upcoming', snapshot); - - expect(result).toBe(`starts_date: ${expectedDate}`); - }); - - it('pads single-digit minutes with leading zero', () => { - const opensAtDate = '2025-03-01T19:05:00.000Z'; - const snapshot = createTestSnapshot({ - opensAt: opensAtDate, - }); - const expectedDate = getExpectedFormattedDate(opensAtDate); - - const result = formatSnapshotStatusLabel('upcoming', snapshot); - - expect(result).toBe(`starts_date: ${expectedDate}`); - expect(result).toContain(':05'); - }); - }); - - describe('getSnapshotPillLabel', () => { - it.each([ - ['upcoming', 'pill_up_next'], - ['live', 'pill_live_now'], - ['calculating', 'pill_calculating'], - ['distributing', 'pill_results_ready'], - ['complete', 'pill_complete'], - ] as const)('returns %s for %s status', (status, expectedLabel) => { - const result = getSnapshotPillLabel(status); - - expect(result).toBe(expectedLabel); - }); - - it('returns empty string for unknown status', () => { - const result = getSnapshotPillLabel('unknown' as SnapshotStatus); - - expect(result).toBe(''); - }); - }); - - describe('getSnapshotStatusInfo', () => { - it('returns complete status info object for upcoming snapshot', () => { - jest.setSystemTime(new Date('2025-02-28T00:00:00.000Z')); - const opensAtDate = '2025-03-01T14:30:00.000Z'; - const snapshot = createTestSnapshot({ - opensAt: opensAtDate, - }); - const expectedDate = getExpectedFormattedDate(opensAtDate); - - const result = getSnapshotStatusInfo(snapshot); - - expect(result).toEqual({ - status: 'upcoming', - statusLabel: 'pill_up_next', - statusDescription: `starts_date: ${expectedDate}`, - statusDescriptionIcon: IconName.Speed, - }); - }); - - it('returns complete status info object for live snapshot', () => { - jest.setSystemTime(new Date('2025-03-10T00:00:00.000Z')); - const closesAtDate = '2025-03-15T09:00:00.000Z'; - const snapshot = createTestSnapshot({ - closesAt: closesAtDate, - }); - const expectedDate = getExpectedFormattedDate(closesAtDate); - - const result = getSnapshotStatusInfo(snapshot); - - expect(result).toEqual({ - status: 'live', - statusLabel: 'pill_live_now', - statusDescription: `ends_date: ${expectedDate}`, - statusDescriptionIcon: IconName.Clock, - }); - }); - - it('returns complete status info object for calculating snapshot', () => { - jest.setSystemTime(new Date('2025-03-16T00:00:00.000Z')); - const snapshot = createTestSnapshot({ - calculatedAt: undefined, - distributedAt: undefined, - }); - - const result = getSnapshotStatusInfo(snapshot); - - expect(result).toEqual({ - status: 'calculating', - statusLabel: 'pill_calculating', - statusDescription: 'results_coming_soon', - statusDescriptionIcon: IconName.Loading, - }); - }); - - it('returns complete status info object for distributing snapshot', () => { - jest.setSystemTime(new Date('2025-03-18T00:00:00.000Z')); - const snapshot = createTestSnapshot({ - calculatedAt: '2025-03-16T00:00:00.000Z', - distributedAt: undefined, - }); - - const result = getSnapshotStatusInfo(snapshot); - - expect(result).toEqual({ - status: 'distributing', - statusLabel: 'pill_results_ready', - statusDescription: 'tokens_on_the_way', - statusDescriptionIcon: IconName.Send, - }); - }); - - it('returns complete status info object for complete snapshot', () => { - jest.setSystemTime(new Date('2025-03-25T00:00:00.000Z')); - const distributedAtDate = '2025-03-20T16:45:00.000Z'; - const snapshot = createTestSnapshot({ - calculatedAt: '2025-03-16T00:00:00.000Z', - distributedAt: distributedAtDate, - }); - const expectedDate = getExpectedFormattedDate(distributedAtDate); - - const result = getSnapshotStatusInfo(snapshot); - - expect(result).toEqual({ - status: 'complete', - statusLabel: 'pill_complete', - statusDescription: expectedDate, - statusDescriptionIcon: IconName.Confirmation, - }); - }); - - it('integrates all utility functions correctly', () => { - jest.setSystemTime(new Date('2025-03-10T12:00:00.000Z')); - const closesAtDate = '2025-03-15T18:30:00.000Z'; - const snapshot = createTestSnapshot({ - opensAt: '2025-03-01T00:00:00.000Z', - closesAt: closesAtDate, - }); - const expectedDate = getExpectedFormattedDate(closesAtDate); - - const result = getSnapshotStatusInfo(snapshot); - - expect(result.status).toBe('live'); - expect(result.statusLabel).toBe('pill_live_now'); - expect(result.statusDescription).toBe(`ends_date: ${expectedDate}`); - expect(result.statusDescriptionIcon).toBe(IconName.Clock); - }); - }); - - describe('date formatting edge cases', () => { - it('formats all months correctly', () => { - const months = [ - { date: '2025-01-15T12:00:00.000Z', expected: 'Jan' }, - { date: '2025-02-15T12:00:00.000Z', expected: 'Feb' }, - { date: '2025-03-15T12:00:00.000Z', expected: 'Mar' }, - { date: '2025-04-15T12:00:00.000Z', expected: 'Apr' }, - { date: '2025-05-15T12:00:00.000Z', expected: 'May' }, - { date: '2025-06-15T12:00:00.000Z', expected: 'Jun' }, - { date: '2025-07-15T12:00:00.000Z', expected: 'Jul' }, - { date: '2025-08-15T12:00:00.000Z', expected: 'Aug' }, - { date: '2025-09-15T12:00:00.000Z', expected: 'Sep' }, - { date: '2025-10-15T12:00:00.000Z', expected: 'Oct' }, - { date: '2025-11-15T12:00:00.000Z', expected: 'Nov' }, - { date: '2025-12-15T12:00:00.000Z', expected: 'Dec' }, - ]; - - months.forEach(({ date, expected }) => { - const snapshot = createTestSnapshot({ - distributedAt: date, - }); - - const result = formatSnapshotStatusLabel('complete', snapshot); - - expect(result).toContain(expected); - }); - }); - - it('handles AM hours correctly (before noon)', () => { - const opensAtDate = '2025-03-01T15:59:00.000Z'; // 10:59 AM in America/Toronto (UTC-5) - const snapshot = createTestSnapshot({ - opensAt: opensAtDate, - }); - const expectedDate = getExpectedFormattedDate(opensAtDate); - - const result = formatSnapshotStatusLabel('upcoming', snapshot); - - expect(result).toBe(`starts_date: ${expectedDate}`); - expect(result).toContain('AM'); - }); - - it('handles PM hours correctly (after noon)', () => { - const opensAtDate = '2025-03-01T18:01:00.000Z'; // 1:01 PM in America/Toronto (UTC-5) - const snapshot = createTestSnapshot({ - opensAt: opensAtDate, - }); - const expectedDate = getExpectedFormattedDate(opensAtDate); - - const result = formatSnapshotStatusLabel('upcoming', snapshot); - - expect(result).toBe(`starts_date: ${expectedDate}`); - expect(result).toContain('PM'); - }); - - it('handles late night hours correctly', () => { - const opensAtDate = '2025-03-02T04:59:00.000Z'; // 11:59 PM in America/Toronto (UTC-5) - const snapshot = createTestSnapshot({ - opensAt: opensAtDate, - }); - const expectedDate = getExpectedFormattedDate(opensAtDate); - - const result = formatSnapshotStatusLabel('upcoming', snapshot); - - expect(result).toBe(`starts_date: ${expectedDate}`); - }); - }); -}); diff --git a/app/components/UI/Rewards/components/SnapshotTile/SnapshotTile.utils.ts b/app/components/UI/Rewards/components/SnapshotTile/SnapshotTile.utils.ts deleted file mode 100644 index abe56857fd9..00000000000 --- a/app/components/UI/Rewards/components/SnapshotTile/SnapshotTile.utils.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { IconName } from '@metamask/design-system-react-native'; -import type { - SnapshotDto, - SnapshotStatus, -} from '../../../../../core/Engine/controllers/rewards-controller/types'; -import { strings } from '../../../../../../locales/i18n'; - -/** - * Derives the status of a snapshot based on its date fields. - * - * Status logic: - * - upcoming: now < opensAt - * - live: opensAt <= now < closesAt - * - calculating: closesAt <= now && !calculatedAt - * - distributing: calculatedAt && !distributedAt - * - complete: distributedAt is set - * - * @param snapshot - The snapshot data - * @returns The derived status - */ -export function getSnapshotStatus(snapshot: SnapshotDto): SnapshotStatus { - const now = new Date(); - const opensAt = new Date(snapshot.opensAt); - const closesAt = new Date(snapshot.closesAt); - - // Check if distribution is complete - if (snapshot.distributedAt) { - return 'complete'; - } - - // Check if results are calculated but not distributed yet - if (snapshot.calculatedAt) { - return 'distributing'; - } - - // Check if snapshot is still upcoming - if (now < opensAt) { - return 'upcoming'; - } - - // Check if snapshot is currently live - if (now >= opensAt && now < closesAt) { - return 'live'; - } - - // Snapshot has closed but results not calculated yet - return 'calculating'; -} - -const MONTHS = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', -]; - -/** - * Formats the date for display in the snapshot tile. - * - * @param date - The date to format - * @returns Formatted date string (e.g., "Mar 15, 2:30 PM") - */ -function formatSnapshotDate(date: Date): string { - const month = MONTHS[date.getMonth()]; - const day = date.getDate(); - const hours = date.getHours(); - const minutes = date.getMinutes(); - - const hour12 = hours % 12 || 12; - const ampm = hours >= 12 ? 'PM' : 'AM'; - const paddedMinutes = minutes.toString().padStart(2, '0'); - - return `${month} ${day}, ${hour12}:${paddedMinutes} ${ampm}`; -} - -/** - * Formats the status label for display in the snapshot tile. - * - * @param status - The snapshot status - * @param snapshot - The snapshot data (used for date formatting) - * @returns The formatted status label - */ -export function formatSnapshotStatusLabel( - status: SnapshotStatus, - snapshot: SnapshotDto, -): string { - switch (status) { - case 'upcoming': { - const opensAt = new Date(snapshot.opensAt); - return strings('rewards.snapshot.starts_date', { - date: formatSnapshotDate(opensAt), - }); - } - case 'live': { - const closesAt = new Date(snapshot.closesAt); - return strings('rewards.snapshot.ends_date', { - date: formatSnapshotDate(closesAt), - }); - } - case 'calculating': - return strings('rewards.snapshot.results_coming_soon'); - case 'distributing': - return strings('rewards.snapshot.tokens_on_the_way'); - case 'complete': { - const distributedAt = snapshot.distributedAt - ? new Date(snapshot.distributedAt) - : new Date(); - return formatSnapshotDate(distributedAt); - } - default: - return ''; - } -} - -/** - * Gets the pill label text based on the snapshot status. - * - * @param status - The snapshot status - * @returns The pill label text - */ -export function getSnapshotPillLabel(status: SnapshotStatus): string { - switch (status) { - case 'upcoming': - return strings('rewards.snapshot.pill_up_next'); - case 'live': - return strings('rewards.snapshot.pill_live_now'); - case 'calculating': - return strings('rewards.snapshot.pill_calculating'); - case 'distributing': - return strings('rewards.snapshot.pill_results_ready'); - case 'complete': - return strings('rewards.snapshot.pill_complete'); - default: - return ''; - } -} - -/** - * Gets the appropriate icon for the status - * - * @param status - The snapshot status - * @returns The icon name for the status - */ -function getStatusIcon(status: SnapshotStatus): IconName { - switch (status) { - case 'live': - return IconName.Clock; - case 'complete': - return IconName.Confirmation; - case 'calculating': - return IconName.Loading; - case 'distributing': - return IconName.Send; - case 'upcoming': - default: - return IconName.Speed; - } -} - -export interface SnapshotStatusInfo { - status: SnapshotStatus; - statusLabel: string; - statusDescription: string; - statusDescriptionIcon: IconName; -} - -/** - * Gets all status-related information for a snapshot. - * - * @param snapshot - The snapshot data - * @returns Object containing status, statusLabel, statusDescription, and statusDescriptionIcon - */ -export function getSnapshotStatusInfo( - snapshot: SnapshotDto, -): SnapshotStatusInfo { - const status = getSnapshotStatus(snapshot); - return { - status, - statusLabel: getSnapshotPillLabel(status), - statusDescription: formatSnapshotStatusLabel(status, snapshot), - statusDescriptionIcon: getStatusIcon(status), - }; -} diff --git a/app/components/UI/Rewards/components/SnapshotTile/UpcomingSnapshotTileCondensed.test.tsx b/app/components/UI/Rewards/components/SnapshotTile/UpcomingSnapshotTileCondensed.test.tsx deleted file mode 100644 index d443d88cd92..00000000000 --- a/app/components/UI/Rewards/components/SnapshotTile/UpcomingSnapshotTileCondensed.test.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react-native'; -import UpcomingSnapshotTileCondensed from './UpcomingSnapshotTileCondensed'; -import type { SnapshotDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; -import { getSnapshotStatusInfo } from './SnapshotTile.utils'; - -// Mock design system -jest.mock('@metamask/design-system-react-native', () => ({ - Box: 'Box', - BoxFlexDirection: { Column: 'Column', Row: 'Row' }, - BoxAlignItems: { Center: 'Center' }, - BoxJustifyContent: { Between: 'Between' }, - Text: 'Text', - TextVariant: { BodySm: 'BodySm', BodyMd: 'BodyMd', HeadingLg: 'HeadingLg' }, - Icon: 'Icon', - IconSize: { Sm: 'Sm' }, - IconName: { Clock: 'Clock', Speed: 'Speed' }, -})); - -// Mock Tailwind -jest.mock('@metamask/design-system-twrnc-preset', () => ({ - useTailwind: () => ({ style: jest.fn(() => ({})) }), -})); - -// Mock the utils to control status output -jest.mock('./SnapshotTile.utils', () => ({ - getSnapshotStatusInfo: jest.fn(), -})); - -const mockGetSnapshotStatusInfo = getSnapshotStatusInfo as jest.MockedFunction< - typeof getSnapshotStatusInfo ->; - -/** - * Creates a test snapshot with default values that can be overridden - */ -function createTestSnapshot(overrides: Partial = {}): SnapshotDto { - return { - id: 'snapshot-1', - seasonId: 'season-1', - name: 'Test Snapshot Prize', - description: 'Test description', - tokenSymbol: 'TEST', - tokenAmount: '1000', - tokenChainId: '1', - receivingBlockchain: 'ethereum', - opensAt: '2024-01-01T00:00:00Z', - closesAt: '2024-01-31T23:59:59Z', - calculatedAt: undefined, - distributedAt: undefined, - backgroundImage: { - lightModeUrl: 'https://example.com/light.png', - darkModeUrl: 'https://example.com/dark.png', - }, - ...overrides, - }; -} - -describe('UpcomingSnapshotTileCondensed', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('renders snapshot name when status is upcoming', () => { - const snapshot = createTestSnapshot({ name: 'Upcoming Airdrop' }); - mockGetSnapshotStatusInfo.mockReturnValue({ - status: 'upcoming', - statusLabel: 'Up Next', - statusDescription: 'Starts Mar 1, 12:00 PM', - statusDescriptionIcon: 'Speed' as never, - }); - - const { getByText } = render( - , - ); - - expect(getByText('Upcoming Airdrop')).toBeOnTheScreen(); - }); - - it('renders status description when status is upcoming', () => { - const snapshot = createTestSnapshot(); - mockGetSnapshotStatusInfo.mockReturnValue({ - status: 'upcoming', - statusLabel: 'Up Next', - statusDescription: 'Starts Mar 1, 12:00 PM', - statusDescriptionIcon: 'Speed' as never, - }); - - const { getByText } = render( - , - ); - - expect(getByText('Starts Mar 1, 12:00 PM')).toBeOnTheScreen(); - }); - - it('returns null when status is not upcoming', () => { - const snapshot = createTestSnapshot(); - mockGetSnapshotStatusInfo.mockReturnValue({ - status: 'live', - statusLabel: 'Live Now', - statusDescription: 'Ends Mar 15, 2:30 PM', - statusDescriptionIcon: 'Clock' as never, - }); - - const { toJSON } = render( - , - ); - - expect(toJSON()).toBeNull(); - }); -}); diff --git a/app/components/UI/Rewards/components/SnapshotTile/UpcomingSnapshotTileCondensed.tsx b/app/components/UI/Rewards/components/SnapshotTile/UpcomingSnapshotTileCondensed.tsx deleted file mode 100644 index 79eb0375a1a..00000000000 --- a/app/components/UI/Rewards/components/SnapshotTile/UpcomingSnapshotTileCondensed.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React, { useMemo } from 'react'; -import { - Box, - BoxFlexDirection, - Text, - TextVariant, -} from '@metamask/design-system-react-native'; -import type { SnapshotDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; -import { getSnapshotStatusInfo } from './SnapshotTile.utils'; - -interface UpcomingSnapshotTileCondensedProps { - /** - * The snapshot data to display - */ - snapshot: SnapshotDto; -} - -/** - * UpcomingSnapshotTileCondensed component displays a condensed view of upcoming snapshots. - * - * Returns null if the snapshot is not in "upcoming" status. - * - * Shows: - * - Status pill with icon - * - Prize name - * - Status label with date - */ -const UpcomingSnapshotTileCondensed: React.FC< - UpcomingSnapshotTileCondensedProps -> = ({ snapshot }) => { - const { status, statusDescription } = useMemo( - () => getSnapshotStatusInfo(snapshot), - [snapshot], - ); - - // Return null if not upcoming - if (status !== 'upcoming') { - return null; - } - - // Format prize display - const prizeDisplay = snapshot.name; - - return ( - - {/* Content */} - - - {prizeDisplay} - - - - {statusDescription} - - - - ); -}; - -export default UpcomingSnapshotTileCondensed; diff --git a/app/components/UI/Rewards/components/SnapshotTile/index.ts b/app/components/UI/Rewards/components/SnapshotTile/index.ts deleted file mode 100644 index 9f420893576..00000000000 --- a/app/components/UI/Rewards/components/SnapshotTile/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { default } from './SnapshotTile'; -export { default as SnapshotTile } from './SnapshotTile'; -export { default as UpcomingSnapshotTileCondensed } from './UpcomingSnapshotTileCondensed'; -export { - getSnapshotStatus, - formatSnapshotStatusLabel, - getSnapshotPillLabel, - getSnapshotStatusInfo, -} from './SnapshotTile.utils'; -export type { SnapshotStatusInfo } from './SnapshotTile.utils'; diff --git a/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.tsx b/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.tsx index 7f29a5345a7..d0f9d023238 100644 --- a/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.tsx +++ b/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityTab.tsx @@ -1,13 +1,7 @@ import React, { useMemo, useState, useCallback } from 'react'; -import { useSelector, useDispatch } from 'react-redux'; +import { useSelector } from 'react-redux'; import { FlatList, ListRenderItem, ActivityIndicator } from 'react-native'; -import { - Box, - Text, - TextVariant, - Button, - ButtonVariant, -} from '@metamask/design-system-react-native'; +import { Box, Text, TextVariant } from '@metamask/design-system-react-native'; import { usePointsEvents } from '../../../hooks/usePointsEvents'; import { PointsEventDto, @@ -26,7 +20,6 @@ import { import { Skeleton } from '../../../../../../component-library/components-temp/Skeleton'; import MetamaskRewardsActivityEmptyImage from '../../../../../../images/rewards/metamask-rewards-activity-empty.svg'; import RewardsErrorBanner from '../../RewardsErrorBanner'; -import { setActiveTab } from '../../../../../../actions/rewards'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { useAccountNames } from '../../../../../hooks/DisplayName/useAccountNames'; import { NameType } from '../../../../Name/Name.types'; @@ -98,13 +91,8 @@ const LoadingFooter: React.FC = () => ( const ItemSeparator: React.FC = () => ; const EmptyState: React.FC = () => { - const dispatch = useDispatch(); const tw = useTailwind(); - const handleSeeWaysToEarn = () => { - dispatch(setActiveTab('overview')); - }; - return ( { > {strings('rewards.activity_empty_description')} - - ); diff --git a/app/components/UI/Rewards/components/Tabs/OverviewTab/ActiveBoosts.tsx b/app/components/UI/Rewards/components/Tabs/OverviewTab/ActiveBoosts.tsx index ebe5358febf..641f3dd4664 100644 --- a/app/components/UI/Rewards/components/Tabs/OverviewTab/ActiveBoosts.tsx +++ b/app/components/UI/Rewards/components/Tabs/OverviewTab/ActiveBoosts.tsx @@ -183,20 +183,18 @@ const SectionHeader: React.FC<{ count: number | null; isLoading: boolean }> = ({ count, isLoading, }) => ( - - - - {strings('rewards.active_boosts_title')} - - {isLoading && } - {count !== null && !isLoading && ( - - - {count} - - - )} - + + + {strings('rewards.active_boosts_title')} + + {isLoading && } + {count !== null && !isLoading && ( + + + {count} + + + )} ); @@ -278,7 +276,7 @@ const ActiveBoosts: React.FC<{ } return ( - + {/* Always show section header */} ({ - useSelector: jest.fn(), -})); - -jest.mock( - '../../../../../../../selectors/featureFlagController/rewards', - () => ({ - selectSnapshotsRewardsEnabledFlag: jest.fn(), - }), -); - -jest.mock('../../../../hooks/useSnapshots', () => ({ - useSnapshots: jest.fn(), -})); - -jest.mock('../../../../components/SnapshotTile', () => { - const ReactNative = jest.requireActual('react-native'); - const ReactActual = jest.requireActual('react'); - return { - SnapshotTile: jest.fn(({ snapshot }) => - ReactActual.createElement( - ReactNative.View, - { testID: `snapshot-tile-${snapshot.id}` }, - ReactActual.createElement(ReactNative.Text, null, snapshot.name), - ), - ), - UpcomingSnapshotTileCondensed: jest.fn(({ snapshot }) => - ReactActual.createElement( - ReactNative.View, - { testID: `upcoming-tile-${snapshot.id}` }, - ReactActual.createElement(ReactNative.Text, null, snapshot.name), - ), - ), - }; -}); - -jest.mock( - '../../../../../../../component-library/components-temp/Skeleton', - () => { - const ReactNative = jest.requireActual('react-native'); - const ReactActual = jest.requireActual('react'); - return { - Skeleton: jest.fn(() => - ReactActual.createElement(ReactNative.View, { - testID: 'skeleton-loader', - }), - ), - }; - }, -); - -jest.mock('../../../../components/RewardsErrorBanner', () => { - const ReactNative = jest.requireActual('react-native'); - const ReactActual = jest.requireActual('react'); - return jest.fn(({ title, description }) => - ReactActual.createElement( - ReactNative.View, - { testID: 'error-banner' }, - ReactActual.createElement( - ReactNative.Text, - { testID: 'error-title' }, - title, - ), - ReactActual.createElement( - ReactNative.Text, - { testID: 'error-description' }, - description, - ), - ), - ); -}); - -jest.mock('../../../../../../../../locales/i18n', () => ({ - strings: jest.fn((key: string) => key), -})); - -jest.mock('@metamask/design-system-react-native', () => { - const ReactNative = jest.requireActual('react-native'); - const ReactActual = jest.requireActual('react'); - return { - Box: jest.fn(({ children, testID }) => - ReactActual.createElement(ReactNative.View, { testID }, children), - ), - Text: jest.fn(({ children }) => - ReactActual.createElement(ReactNative.Text, null, children), - ), - TextVariant: { - HeadingMd: 'HeadingMd', - }, - BoxFlexDirection: { - Row: 'row', - }, - BoxAlignItems: { - Center: 'center', - }, - BoxJustifyContent: { - Between: 'space-between', - }, - }; -}); - -jest.mock('@metamask/design-system-twrnc-preset', () => ({ - useTailwind: jest.fn(() => ({ - style: jest.fn((className) => ({ className })), - })), -})); - -jest.mock('../../../../Views/RewardsView.constants', () => ({ - REWARDS_VIEW_SELECTORS: { - SNAPSHOTS_SECTION: 'rewards-view-snapshots-section', - }, -})); - -/** - * Creates a test snapshot with customizable overrides - */ -const createTestSnapshot = ( - overrides: Partial = {}, -): SnapshotDto => ({ - id: `snapshot-${Math.random().toString(36).substr(2, 9)}`, - seasonId: 'season-1', - name: 'Test Snapshot', - description: 'Test description', - tokenSymbol: 'TEST', - tokenAmount: '1000000000000000000', - tokenChainId: '1', - tokenAddress: '0x1234567890abcdef1234567890abcdef12345678', - receivingBlockchain: 'Ethereum', - opensAt: '2025-03-01T00:00:00.000Z', - closesAt: '2025-03-15T00:00:00.000Z', - backgroundImage: { - lightModeUrl: 'https://example.com/light.png', - darkModeUrl: 'https://example.com/dark.png', - }, - ...overrides, -}); - -describe('SnapshotsSection', () => { - const mockUseSnapshots = useSnapshots as jest.MockedFunction< - typeof useSnapshots - >; - const mockUseSelector = useSelector as jest.MockedFunction< - typeof useSelector - >; - const mockFetchSnapshots = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - mockFetchSnapshots.mockClear(); - - // Enable snapshots feature flag by default - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSnapshotsRewardsEnabledFlag) return true; - return undefined; - }); - }); - - const setupMock = ( - options: { - active?: SnapshotDto[]; - upcoming?: SnapshotDto[]; - previous?: SnapshotDto[]; - isLoading?: boolean; - hasError?: boolean; - } = {}, - ) => { - const { - active = [], - upcoming = [], - previous = [], - isLoading = false, - hasError = false, - } = options; - - mockUseSnapshots.mockReturnValue({ - snapshots: [...active, ...upcoming, ...previous], - categorizedSnapshots: { active, upcoming, previous }, - isLoading, - hasError, - fetchSnapshots: mockFetchSnapshots, - }); - }; - - describe('feature flag disabled', () => { - it('returns null when snapshots feature flag is disabled', () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSnapshotsRewardsEnabledFlag) return false; - return undefined; - }); - setupMock({ active: [], upcoming: [] }); - - const { toJSON } = render(); - - expect(toJSON()).toBeNull(); - }); - }); - - describe('empty state', () => { - it('returns null when no snapshots and not loading/error', () => { - setupMock({ - active: [], - upcoming: [], - isLoading: false, - hasError: false, - }); - - const { toJSON } = render(); - - expect(toJSON()).toBeNull(); - }); - }); - - describe('loading state', () => { - it('renders loading skeleton when loading with no snapshots', () => { - setupMock({ - active: [], - upcoming: [], - isLoading: true, - hasError: false, - }); - - const { getByTestId } = render(); - - expect(getByTestId('skeleton-loader')).toBeTruthy(); - }); - }); - - describe('error state', () => { - it('renders error banner when error with no snapshots', () => { - setupMock({ - active: [], - upcoming: [], - isLoading: false, - hasError: true, - }); - - const { getByTestId } = render(); - - expect(getByTestId('error-banner')).toBeTruthy(); - expect(strings).toHaveBeenCalledWith( - 'rewards.snapshots_section.error_title', - ); - expect(strings).toHaveBeenCalledWith( - 'rewards.snapshots_section.error_description', - ); - }); - }); - - describe('with snapshots', () => { - it('renders section with title', () => { - const activeSnapshot = createTestSnapshot({ - id: 'active-1', - name: 'Active Snapshot', - }); - setupMock({ active: [activeSnapshot] }); - - const { getByTestId } = render(); - - expect(getByTestId('rewards-view-snapshots-section')).toBeTruthy(); - expect(strings).toHaveBeenCalledWith('rewards.snapshots_section.title'); - }); - - it('renders SnapshotTile for active snapshots', () => { - const activeSnapshot1 = createTestSnapshot({ - id: 'active-1', - name: 'Active Snapshot 1', - }); - const activeSnapshot2 = createTestSnapshot({ - id: 'active-2', - name: 'Active Snapshot 2', - }); - setupMock({ active: [activeSnapshot1, activeSnapshot2] }); - - const { getByTestId } = render(); - - expect(getByTestId('snapshot-tile-active-1')).toBeTruthy(); - expect(getByTestId('snapshot-tile-active-2')).toBeTruthy(); - }); - - it('renders UpcomingSnapshotTileCondensed for upcoming snapshots', () => { - const upcomingSnapshot1 = createTestSnapshot({ - id: 'upcoming-1', - name: 'Upcoming Snapshot 1', - }); - const upcomingSnapshot2 = createTestSnapshot({ - id: 'upcoming-2', - name: 'Upcoming Snapshot 2', - }); - setupMock({ upcoming: [upcomingSnapshot1, upcomingSnapshot2] }); - - const { getByTestId } = render(); - - expect(getByTestId('upcoming-tile-upcoming-1')).toBeTruthy(); - expect(getByTestId('upcoming-tile-upcoming-2')).toBeTruthy(); - }); - - it('renders mixed active and upcoming snapshots correctly', () => { - const activeSnapshot = createTestSnapshot({ - id: 'active-1', - name: 'Active Snapshot', - }); - const upcomingSnapshot = createTestSnapshot({ - id: 'upcoming-1', - name: 'Upcoming Snapshot', - }); - setupMock({ active: [activeSnapshot], upcoming: [upcomingSnapshot] }); - - const { getByTestId } = render(); - - expect(getByTestId('snapshot-tile-active-1')).toBeTruthy(); - expect(getByTestId('upcoming-tile-upcoming-1')).toBeTruthy(); - }); - }); - - describe('refresh indicator', () => { - it('shows loading indicator when refreshing existing data', () => { - const activeSnapshot = createTestSnapshot({ - id: 'active-1', - name: 'Active Snapshot', - }); - setupMock({ - active: [activeSnapshot], - isLoading: true, - hasError: false, - }); - - const { getByTestId, queryByTestId } = render(); - - // Section renders with snapshots (not skeleton) - expect(getByTestId('snapshot-tile-active-1')).toBeTruthy(); - // Skeleton is not shown when we have data - expect(queryByTestId('skeleton-loader')).toBeNull(); - }); - }); - - describe('sorting behavior', () => { - it('displays active snapshots before upcoming snapshots', () => { - const activeSnapshot = createTestSnapshot({ - id: 'active-1', - name: 'Active', - opensAt: '2025-03-15T00:00:00.000Z', - }); - const upcomingSnapshot = createTestSnapshot({ - id: 'upcoming-1', - name: 'Upcoming', - opensAt: '2025-03-01T00:00:00.000Z', - }); - - setupMock({ active: [activeSnapshot], upcoming: [upcomingSnapshot] }); - - const { toJSON } = render(); - const json = JSON.stringify(toJSON()); - - // Active snapshot should appear before upcoming in the rendered output - const activeIndex = json.indexOf('snapshot-tile-active-1'); - const upcomingIndex = json.indexOf('upcoming-tile-upcoming-1'); - - expect(activeIndex).toBeLessThan(upcomingIndex); - }); - }); -}); diff --git a/app/components/UI/Rewards/components/Tabs/OverviewTab/SnapshotsSection/SnapshotsSection.tsx b/app/components/UI/Rewards/components/Tabs/OverviewTab/SnapshotsSection/SnapshotsSection.tsx deleted file mode 100644 index 1c5d8e5fa84..00000000000 --- a/app/components/UI/Rewards/components/Tabs/OverviewTab/SnapshotsSection/SnapshotsSection.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import React, { useMemo } from 'react'; -import { ActivityIndicator, Dimensions } from 'react-native'; -import { useSelector } from 'react-redux'; -import { - Box, - Text, - TextVariant, - BoxFlexDirection, - BoxAlignItems, - BoxJustifyContent, -} from '@metamask/design-system-react-native'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { strings } from '../../../../../../../../locales/i18n'; -import { selectSnapshotsRewardsEnabledFlag } from '../../../../../../../selectors/featureFlagController/rewards'; -import { useSnapshots } from '../../../../hooks/useSnapshots'; -import { - SnapshotTile, - UpcomingSnapshotTileCondensed, -} from '../../../../components/SnapshotTile'; -import { Skeleton } from '../../../../../../../component-library/components-temp/Skeleton'; -import RewardsErrorBanner from '../../../../components/RewardsErrorBanner'; -import { REWARDS_VIEW_SELECTORS } from '../../../../Views/RewardsView.constants'; - -const SCREEN_WIDTH = Dimensions.get('window').width; -const CARD_WIDTH = SCREEN_WIDTH - 32; // Full width minus padding - -/** - * SnapshotsSection displays active and upcoming snapshots in the Overview tab. - * Shows all active snapshots first (as large tiles), then all upcoming snapshots - * (as condensed tiles). Both groups are sorted by opensAt ascending (earliest first). - */ -const SnapshotsSection: React.FC = () => { - const isSnapshotsEnabled = useSelector(selectSnapshotsRewardsEnabledFlag); - const tw = useTailwind(); - const { categorizedSnapshots, isLoading, hasError, fetchSnapshots } = - useSnapshots(); - - const { active, upcoming } = categorizedSnapshots; - - // Sort active and upcoming by opensAt ascending (earliest first) - const sortedSnapshots = useMemo(() => { - const sortByOpensAt = (a: (typeof active)[0], b: (typeof active)[0]) => - new Date(a.opensAt).getTime() - new Date(b.opensAt).getTime(); - - const sortedActive = [...active].sort(sortByOpensAt); - const sortedUpcoming = [...upcoming].sort(sortByOpensAt); - - // Active snapshots first, then upcoming - return [...sortedActive, ...sortedUpcoming]; - }, [active, upcoming]); - - const hasSnapshots = sortedSnapshots.length > 0; - - // Return null if snapshots feature is disabled - if (!isSnapshotsEnabled) { - return null; - } - - // Don't render if no snapshots and not loading/error - if (!isLoading && !hasError && !hasSnapshots) { - return null; - } - - const renderContent = () => { - // Show loading state - if (isLoading && !hasSnapshots) { - return ( - - ); - } - - // Show error state - if (hasError && !hasSnapshots) { - return ( - - ); - } - - return ( - - {sortedSnapshots.map((snapshot) => { - const isActive = active.some((s) => s.id === snapshot.id); - return isActive ? ( - - ) : ( - - ); - })} - - ); - }; - - return ( - - {/* Section Header */} - - - - {strings('rewards.snapshots_section.title')} - - {isLoading && hasSnapshots && } - - - - {/* Content */} - {renderContent()} - - ); -}; - -export default SnapshotsSection; diff --git a/app/components/UI/Rewards/components/Tabs/OverviewTab/SnapshotsSection/index.ts b/app/components/UI/Rewards/components/Tabs/OverviewTab/SnapshotsSection/index.ts deleted file mode 100644 index 2b5ce67425c..00000000000 --- a/app/components/UI/Rewards/components/Tabs/OverviewTab/SnapshotsSection/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from './SnapshotsSection'; -export { default as SnapshotsSection } from './SnapshotsSection'; diff --git a/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.test.tsx b/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.test.tsx index a857f8fb1e2..deb72f54678 100644 --- a/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.test.tsx +++ b/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.test.tsx @@ -287,17 +287,17 @@ describe('WaysToEarn', () => { expect(getByText('10 points per $100')).toBeOnTheScreen(); }); - it('renders an empty list when no ways to earn exist', () => { + it('renders nothing when no ways to earn exist', () => { // Arrange const mockUseSelector = jest.requireMock('react-redux') .useSelector as jest.Mock; mockUseSelector.mockReturnValue([]); // Act - const { getByText, queryByText } = render(); + const { queryByText } = render(); // Assert - expect(getByText('Ways to earn')).toBeOnTheScreen(); + expect(queryByText('Ways to earn')).toBeNull(); expect(queryByText('Swap')).toBeNull(); }); }); diff --git a/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.tsx b/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.tsx index 08474742742..5aaaf712cb4 100644 --- a/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.tsx +++ b/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.tsx @@ -154,9 +154,13 @@ export const WaysToEarn = () => { }); }; + if (!seasonWaysToEarn.length) { + return null; + } + return ( - - + + {strings('rewards.ways_to_earn.title')} diff --git a/app/components/UI/Rewards/components/Tabs/RewardsOverview.test.tsx b/app/components/UI/Rewards/components/Tabs/RewardsOverview.test.tsx index 7fc5aa68498..c0abda296c0 100644 --- a/app/components/UI/Rewards/components/Tabs/RewardsOverview.test.tsx +++ b/app/components/UI/Rewards/components/Tabs/RewardsOverview.test.tsx @@ -68,20 +68,6 @@ jest.mock('./OverviewTab/WaysToEarn/WaysToEarn', () => ({ }, })); -// Mock SnapshotsSection component to avoid Redux provider requirements -jest.mock('./OverviewTab/SnapshotsSection', () => ({ - __esModule: true, - default: () => { - const ReactActual = jest.requireActual('react'); - const { View, Text } = jest.requireActual('react-native'); - return ReactActual.createElement( - View, - { testID: 'snapshots-section-mock' }, - ReactActual.createElement(Text, null, 'SnapshotsSection Mock'), - ); - }, -})); - // Mock useTailwind jest.mock('@metamask/design-system-twrnc-preset', () => ({ useTailwind: () => ({ diff --git a/app/components/UI/Rewards/components/Tabs/RewardsOverview.tsx b/app/components/UI/Rewards/components/Tabs/RewardsOverview.tsx index 458ccc89cae..1df6e3ae9c4 100644 --- a/app/components/UI/Rewards/components/Tabs/RewardsOverview.tsx +++ b/app/components/UI/Rewards/components/Tabs/RewardsOverview.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { REWARDS_VIEW_SELECTORS } from '../../Views/RewardsView.constants'; -import SnapshotsSection from './OverviewTab/SnapshotsSection'; import ActiveBoosts from './OverviewTab/ActiveBoosts'; import { useActivePointsBoosts } from '../../hooks/useActivePointsBoosts'; import { WaysToEarn } from './OverviewTab/WaysToEarn/WaysToEarn'; @@ -17,12 +16,10 @@ const RewardsOverview: React.FC = () => { return ( - - diff --git a/app/components/UI/Rewards/components/Tabs/RewardsSnapshots.test.tsx b/app/components/UI/Rewards/components/Tabs/RewardsSnapshots.test.tsx deleted file mode 100644 index 3af21d481dc..00000000000 --- a/app/components/UI/Rewards/components/Tabs/RewardsSnapshots.test.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react-native'; -import RewardsSnapshots from './RewardsSnapshots'; - -// Mock the SnapshotsTab component -jest.mock('./SnapshotsTab', () => ({ - SnapshotsTab: function MockSnapshotsTab() { - const ReactActual = jest.requireActual('react'); - const { View } = jest.requireActual('react-native'); - return ReactActual.createElement(View, { testID: 'snapshots-tab-mock' }); - }, -})); - -describe('RewardsSnapshots', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('renders SnapshotsTab component', () => { - const { getByTestId } = render(); - - expect(getByTestId('snapshots-tab-mock')).toBeOnTheScreen(); - }); - - it('accepts optional tabLabel prop without errors', () => { - const { getByTestId } = render(); - - expect(getByTestId('snapshots-tab-mock')).toBeOnTheScreen(); - }); -}); diff --git a/app/components/UI/Rewards/components/Tabs/RewardsSnapshots.tsx b/app/components/UI/Rewards/components/Tabs/RewardsSnapshots.tsx deleted file mode 100644 index bfd7ca7a91b..00000000000 --- a/app/components/UI/Rewards/components/Tabs/RewardsSnapshots.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react'; -import { SnapshotsTab } from './SnapshotsTab'; - -interface RewardsSnapshotsProps { - tabLabel?: string; -} - -/** - * RewardsSnapshots tab displays all snapshots organized by status: - * - Active (live) - * - Upcoming - * - Previous (calculating, distributing, complete) - */ -const RewardsSnapshots: React.FC = () => ( - -); - -export default RewardsSnapshots; diff --git a/app/components/UI/Rewards/components/Tabs/SnapshotsTab/SnapshotsGroup.test.tsx b/app/components/UI/Rewards/components/Tabs/SnapshotsTab/SnapshotsGroup.test.tsx deleted file mode 100644 index 5776d267960..00000000000 --- a/app/components/UI/Rewards/components/Tabs/SnapshotsTab/SnapshotsGroup.test.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react-native'; -import SnapshotsGroup from './SnapshotsGroup'; -import { SnapshotTile } from '../../SnapshotTile'; -import type { SnapshotDto } from '../../../../../../core/Engine/controllers/rewards-controller/types'; - -jest.mock('@metamask/design-system-react-native', () => ({ - Box: 'Box', - Text: 'Text', - TextVariant: { HeadingMd: 'HeadingMd' }, -})); - -jest.mock('../../SnapshotTile', () => ({ - SnapshotTile: jest.fn(() => null), -})); - -const createTestSnapshot = ( - overrides: Partial = {}, -): SnapshotDto => ({ - id: 'snapshot-1', - seasonId: 'season-1', - name: 'Test Airdrop', - description: 'Test description', - tokenSymbol: 'TEST', - tokenAmount: '1000000000000000000000', - tokenChainId: '1', - tokenAddress: '0x1234567890abcdef1234567890abcdef12345678', - receivingBlockchain: 'Ethereum', - opensAt: '2025-03-01T00:00:00.000Z', - closesAt: '2025-03-15T00:00:00.000Z', - backgroundImage: { - lightModeUrl: 'https://example.com/light.png', - darkModeUrl: 'https://example.com/dark.png', - }, - ...overrides, -}); - -describe('SnapshotsGroup', () => { - const mockSnapshotTile = SnapshotTile as jest.Mock; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('returns null when snapshots array is empty', () => { - const { toJSON } = render(); - - expect(toJSON()).toBeNull(); - }); - - it('renders title text when snapshots exist', () => { - const snapshots = [createTestSnapshot()]; - - const { getByText } = render( - , - ); - - expect(getByText('Active Snapshots')).toBeOnTheScreen(); - }); - - it('renders SnapshotTile for each snapshot', () => { - const snapshots = [ - createTestSnapshot({ id: 'snapshot-1', name: 'Snapshot One' }), - createTestSnapshot({ id: 'snapshot-2', name: 'Snapshot Two' }), - createTestSnapshot({ id: 'snapshot-3', name: 'Snapshot Three' }), - ]; - - render(); - - expect(mockSnapshotTile).toHaveBeenCalledTimes(3); - expect(mockSnapshotTile).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ snapshot: snapshots[0] }), - expect.anything(), - ); - expect(mockSnapshotTile).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ snapshot: snapshots[1] }), - expect.anything(), - ); - expect(mockSnapshotTile).toHaveBeenNthCalledWith( - 3, - expect.objectContaining({ snapshot: snapshots[2] }), - expect.anything(), - ); - }); - - it('applies testID prop to container', () => { - const snapshots = [createTestSnapshot()]; - - const { getByTestId } = render( - , - ); - - expect(getByTestId('test-snapshots-group')).toBeOnTheScreen(); - }); - - it('does not apply testID when not provided', () => { - const snapshots = [createTestSnapshot()]; - - const { queryByTestId } = render( - , - ); - - expect(queryByTestId('test-snapshots-group')).toBeNull(); - }); -}); diff --git a/app/components/UI/Rewards/components/Tabs/SnapshotsTab/SnapshotsGroup.tsx b/app/components/UI/Rewards/components/Tabs/SnapshotsTab/SnapshotsGroup.tsx deleted file mode 100644 index 5a3ca96555a..00000000000 --- a/app/components/UI/Rewards/components/Tabs/SnapshotsTab/SnapshotsGroup.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from 'react'; -import { Box, Text, TextVariant } from '@metamask/design-system-react-native'; -import { SnapshotTile } from '../../SnapshotTile'; -import type { SnapshotDto } from '../../../../../../core/Engine/controllers/rewards-controller/types'; - -interface SnapshotsGroupProps { - title: string; - snapshots: SnapshotDto[]; - testID?: string; -} - -/** - * Section component for displaying a group of snapshots with a title - */ -const SnapshotsGroup: React.FC = ({ - title, - snapshots, - testID, -}) => { - if (snapshots.length === 0) { - return null; - } - - return ( - - - {title} - - {snapshots.map((snapshot) => ( - - ))} - - ); -}; - -export default SnapshotsGroup; diff --git a/app/components/UI/Rewards/components/Tabs/SnapshotsTab/SnapshotsTab.test.tsx b/app/components/UI/Rewards/components/Tabs/SnapshotsTab/SnapshotsTab.test.tsx deleted file mode 100644 index 52b404a4526..00000000000 --- a/app/components/UI/Rewards/components/Tabs/SnapshotsTab/SnapshotsTab.test.tsx +++ /dev/null @@ -1,255 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react-native'; -import { SnapshotsTab } from './SnapshotsTab'; -import { useSnapshots } from '../../../hooks/useSnapshots'; -import { SnapshotTile } from '../../SnapshotTile'; -import { REWARDS_VIEW_SELECTORS } from '../../../Views/RewardsView.constants'; -import type { SnapshotDto } from '../../../../../../core/Engine/controllers/rewards-controller/types'; - -jest.mock('../../../hooks/useSnapshots', () => ({ - useSnapshots: jest.fn(), -})); - -jest.mock('../../SnapshotTile', () => ({ - SnapshotTile: jest.fn(() => null), -})); - -jest.mock('@metamask/design-system-react-native', () => ({ - Box: 'Box', - Text: 'Text', - TextVariant: { - BodyMd: 'BodyMd', - BodySm: 'BodySm', - HeadingMd: 'HeadingMd', - }, - BoxFlexDirection: { Row: 'row' }, - BoxAlignItems: { Center: 'center' }, -})); - -jest.mock('@metamask/design-system-twrnc-preset', () => ({ - useTailwind: () => ({ - style: jest.fn(() => ({ flexGrow: 1 })), - }), -})); - -jest.mock( - '../../../../../../component-library/components-temp/Skeleton', - () => ({ - Skeleton: 'Skeleton', - }), -); - -jest.mock('../../RewardsErrorBanner', () => ({ - __esModule: true, - default: function MockRewardsErrorBanner() { - const ReactActual = jest.requireActual('react'); - const { View } = jest.requireActual('react-native'); - return ReactActual.createElement(View, { testID: 'rewards-error-banner' }); - }, -})); - -jest.mock('../../../../../../../locales/i18n', () => ({ - strings: jest.fn((key: string) => key), -})); - -const createTestSnapshot = ( - overrides: Partial = {}, -): SnapshotDto => ({ - id: 'snapshot-1', - seasonId: 'season-1', - name: 'Test Airdrop', - description: 'Test description', - tokenSymbol: 'TEST', - tokenAmount: '1000000000000000000000', - tokenChainId: '1', - tokenAddress: '0x1234567890abcdef1234567890abcdef12345678', - receivingBlockchain: 'Ethereum', - opensAt: '2025-03-01T00:00:00.000Z', - closesAt: '2025-03-15T00:00:00.000Z', - backgroundImage: { - lightModeUrl: 'https://example.com/light.png', - darkModeUrl: 'https://example.com/dark.png', - }, - ...overrides, -}); - -describe('SnapshotsTab', () => { - const mockUseSnapshots = useSnapshots as jest.Mock; - const mockSnapshotTile = SnapshotTile as jest.Mock; - const mockFetchSnapshots = jest.fn(); - - const defaultHookReturn = { - categorizedSnapshots: { - active: [], - upcoming: [], - previous: [], - }, - isLoading: false, - hasError: false, - fetchSnapshots: mockFetchSnapshots, - }; - - beforeEach(() => { - jest.clearAllMocks(); - mockUseSnapshots.mockReturnValue(defaultHookReturn); - }); - - it('renders loading skeleton when loading with no data', () => { - mockUseSnapshots.mockReturnValue({ - ...defaultHookReturn, - isLoading: true, - }); - - const { toJSON } = render(); - const jsonString = JSON.stringify(toJSON()); - - expect(jsonString).toContain('Skeleton'); - }); - - it('renders error banner when error with no data', () => { - mockUseSnapshots.mockReturnValue({ - ...defaultHookReturn, - hasError: true, - }); - - const { getByTestId } = render(); - - expect(getByTestId('rewards-error-banner')).toBeOnTheScreen(); - }); - - it('renders empty state when no snapshots exist', () => { - mockUseSnapshots.mockReturnValue(defaultHookReturn); - - const { getByText } = render(); - - expect(getByText('rewards.snapshots_tab.empty_state')).toBeOnTheScreen(); - }); - - it('renders active section when active snapshots exist', () => { - const activeSnapshot = createTestSnapshot({ id: 'active-1' }); - - mockUseSnapshots.mockReturnValue({ - ...defaultHookReturn, - categorizedSnapshots: { - active: [activeSnapshot], - upcoming: [], - previous: [], - }, - }); - - const { getByTestId, getByText } = render(); - - expect( - getByTestId(REWARDS_VIEW_SELECTORS.SNAPSHOTS_ACTIVE_SECTION), - ).toBeOnTheScreen(); - expect(getByText('rewards.snapshots_tab.active_title')).toBeOnTheScreen(); - expect(mockSnapshotTile).toHaveBeenCalledWith( - expect.objectContaining({ snapshot: activeSnapshot }), - expect.anything(), - ); - }); - - it('renders upcoming section when upcoming snapshots exist', () => { - const upcomingSnapshot = createTestSnapshot({ id: 'upcoming-1' }); - - mockUseSnapshots.mockReturnValue({ - ...defaultHookReturn, - categorizedSnapshots: { - active: [], - upcoming: [upcomingSnapshot], - previous: [], - }, - }); - - const { getByTestId, getByText } = render(); - - expect( - getByTestId(REWARDS_VIEW_SELECTORS.SNAPSHOTS_UPCOMING_SECTION), - ).toBeOnTheScreen(); - expect(getByText('rewards.snapshots_tab.upcoming_title')).toBeOnTheScreen(); - expect(mockSnapshotTile).toHaveBeenCalledWith( - expect.objectContaining({ snapshot: upcomingSnapshot }), - expect.anything(), - ); - }); - - it('renders previous section when previous snapshots exist', () => { - const previousSnapshot = createTestSnapshot({ id: 'previous-1' }); - - mockUseSnapshots.mockReturnValue({ - ...defaultHookReturn, - categorizedSnapshots: { - active: [], - upcoming: [], - previous: [previousSnapshot], - }, - }); - - const { getByTestId, getByText } = render(); - - expect( - getByTestId(REWARDS_VIEW_SELECTORS.SNAPSHOTS_PREVIOUS_SECTION), - ).toBeOnTheScreen(); - expect(getByText('rewards.snapshots_tab.previous_title')).toBeOnTheScreen(); - expect(mockSnapshotTile).toHaveBeenCalledWith( - expect.objectContaining({ snapshot: previousSnapshot }), - expect.anything(), - ); - }); - - it('renders refreshing indicator when loading with existing data', () => { - const activeSnapshot = createTestSnapshot({ id: 'active-1' }); - - mockUseSnapshots.mockReturnValue({ - ...defaultHookReturn, - isLoading: true, - categorizedSnapshots: { - active: [activeSnapshot], - upcoming: [], - previous: [], - }, - }); - - const { getByText } = render(); - - expect(getByText('rewards.snapshots_tab.refreshing')).toBeOnTheScreen(); - }); - - it('renders snapshots tab content container with correct testID', () => { - mockUseSnapshots.mockReturnValue(defaultHookReturn); - - const { getByTestId } = render(); - - expect( - getByTestId(REWARDS_VIEW_SELECTORS.TAB_CONTENT_SNAPSHOTS), - ).toBeOnTheScreen(); - }); - - it('renders all sections when all snapshot categories have data', () => { - const activeSnapshot = createTestSnapshot({ id: 'active-1' }); - const upcomingSnapshot = createTestSnapshot({ id: 'upcoming-1' }); - const previousSnapshot = createTestSnapshot({ id: 'previous-1' }); - - mockUseSnapshots.mockReturnValue({ - ...defaultHookReturn, - categorizedSnapshots: { - active: [activeSnapshot], - upcoming: [upcomingSnapshot], - previous: [previousSnapshot], - }, - }); - - const { getByTestId } = render(); - - expect( - getByTestId(REWARDS_VIEW_SELECTORS.SNAPSHOTS_ACTIVE_SECTION), - ).toBeOnTheScreen(); - expect( - getByTestId(REWARDS_VIEW_SELECTORS.SNAPSHOTS_UPCOMING_SECTION), - ).toBeOnTheScreen(); - expect( - getByTestId(REWARDS_VIEW_SELECTORS.SNAPSHOTS_PREVIOUS_SECTION), - ).toBeOnTheScreen(); - expect(mockSnapshotTile).toHaveBeenCalledTimes(3); - }); -}); diff --git a/app/components/UI/Rewards/components/Tabs/SnapshotsTab/SnapshotsTab.tsx b/app/components/UI/Rewards/components/Tabs/SnapshotsTab/SnapshotsTab.tsx deleted file mode 100644 index d87b1685f17..00000000000 --- a/app/components/UI/Rewards/components/Tabs/SnapshotsTab/SnapshotsTab.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import React from 'react'; -import { ActivityIndicator } from 'react-native'; -import { ScrollView } from 'react-native-gesture-handler'; -import { - Box, - Text, - TextVariant, - BoxFlexDirection, - BoxAlignItems, -} from '@metamask/design-system-react-native'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { strings } from '../../../../../../../locales/i18n'; -import { useSnapshots } from '../../../hooks/useSnapshots'; -import { Skeleton } from '../../../../../../component-library/components-temp/Skeleton'; -import RewardsErrorBanner from '../../RewardsErrorBanner'; -import { REWARDS_VIEW_SELECTORS } from '../../../Views/RewardsView.constants'; -import SnapshotsGroup from './SnapshotsGroup'; - -/** - * SnapshotsTab displays all snapshots organized by status: - * - Active (live) - * - Upcoming - * - Previous (calculating, distributing, complete) - */ -export const SnapshotsTab: React.FC = () => { - const tw = useTailwind(); - const { categorizedSnapshots, isLoading, hasError, fetchSnapshots } = - useSnapshots(); - - const { active, upcoming, previous } = categorizedSnapshots; - const hasSnapshots = - active.length > 0 || upcoming.length > 0 || previous.length > 0; - - const renderContent = () => { - // Show loading state - if (isLoading && !hasSnapshots) { - return ( - - - - - - - - - - - ); - } - - // Show error state - if (hasError && !hasSnapshots) { - return ( - - ); - } - - // Show empty state - if (!hasSnapshots) { - return ( - - - {strings('rewards.snapshots_tab.empty_state')} - - - ); - } - - return ( - - {/* Active Snapshots */} - - - {/* Upcoming Snapshots */} - - - {/* Previous Snapshots */} - - - ); - }; - - return ( - - {/* Loading indicator when refreshing */} - {isLoading && hasSnapshots && ( - - - - {strings('rewards.snapshots_tab.refreshing')} - - - )} - - {renderContent()} - - ); -}; diff --git a/app/components/UI/Rewards/components/Tabs/SnapshotsTab/index.ts b/app/components/UI/Rewards/components/Tabs/SnapshotsTab/index.ts deleted file mode 100644 index abce82d10a7..00000000000 --- a/app/components/UI/Rewards/components/Tabs/SnapshotsTab/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { SnapshotsTab } from './SnapshotsTab'; -export { default as SnapshotsGroup } from './SnapshotsGroup'; diff --git a/app/components/UI/Rewards/hooks/useGetCampaignParticipantStatus.test.ts b/app/components/UI/Rewards/hooks/useGetCampaignParticipantStatus.test.ts index 74664d6c9d1..139179bee20 100644 --- a/app/components/UI/Rewards/hooks/useGetCampaignParticipantStatus.test.ts +++ b/app/components/UI/Rewards/hooks/useGetCampaignParticipantStatus.test.ts @@ -1,13 +1,17 @@ import { renderHook, act } from '@testing-library/react-hooks'; -import { useSelector } from 'react-redux'; +import { useSelector, useDispatch } from 'react-redux'; import { useGetCampaignParticipantStatus } from './useGetCampaignParticipantStatus'; import Engine from '../../../../core/Engine'; import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { selectCampaignsRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards'; +import { selectCampaignParticipantStatusById } from '../../../../reducers/rewards/selectors'; +import { setCampaignParticipantStatus } from '../../../../reducers/rewards'; import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; +import type { CampaignParticipantStatusDto } from '../../../../core/Engine/controllers/rewards-controller/types'; jest.mock('react-redux', () => ({ useSelector: jest.fn(), + useDispatch: jest.fn(), })); jest.mock('../../../../core/Engine', () => ({ @@ -26,6 +30,14 @@ jest.mock('../../../../selectors/featureFlagController/rewards', () => ({ selectCampaignsRewardsEnabledFlag: jest.fn(), })); +jest.mock('../../../../reducers/rewards/selectors', () => ({ + selectCampaignParticipantStatusById: jest.fn(), +})); + +jest.mock('../../../../reducers/rewards', () => ({ + setCampaignParticipantStatus: jest.fn(), +})); + const mockCall = Engine.controllerMessenger.call as jest.MockedFunction< typeof Engine.controllerMessenger.call >; @@ -34,18 +46,52 @@ const mockUseInvalidateByRewardEvents = typeof useInvalidateByRewardEvents >; const mockUseSelector = useSelector as jest.MockedFunction; +const mockUseDispatch = useDispatch as jest.MockedFunction; +const mockSetCampaignParticipantStatus = + setCampaignParticipantStatus as unknown as jest.MockedFunction< + (payload: { campaignId: string; status: CampaignParticipantStatusDto }) => { + type: string; + payload: { campaignId: string; status: CampaignParticipantStatusDto }; + } + >; +const mockSelectCampaignParticipantStatusById = + selectCampaignParticipantStatusById as jest.MockedFunction< + typeof selectCampaignParticipantStatusById + >; const SUB_ID = 'sub-123'; const CAMPAIGN_ID = 'camp-456'; -const STATUS = { optedIn: true }; +const STATUS = { optedIn: true, participantCount: 42 }; + +const mockDispatch = jest.fn(); +const mockParticipantStatusSelector = jest.fn(); function setupSelectors( subscriptionId: string | null, campaignsEnabled: boolean, + participantStatus: CampaignParticipantStatusDto | null = null, ) { + mockParticipantStatusSelector.mockReturnValue(participantStatus); + mockSelectCampaignParticipantStatusById.mockReturnValue( + mockParticipantStatusSelector, + ); + + let currentStatus = participantStatus; + + mockDispatch.mockImplementation((action) => { + if ( + action?.type === 'rewards/setCampaignParticipantStatus' && + action.payload?.status + ) { + currentStatus = action.payload.status; + mockParticipantStatusSelector.mockReturnValue(currentStatus); + } + }); + mockUseSelector.mockImplementation((selector) => { if (selector === selectRewardsSubscriptionId) return subscriptionId; if (selector === selectCampaignsRewardsEnabledFlag) return campaignsEnabled; + if (selector === mockParticipantStatusSelector) return currentStatus; return undefined; }); } @@ -53,6 +99,11 @@ function setupSelectors( describe('useGetCampaignParticipantStatus', () => { beforeEach(() => { jest.clearAllMocks(); + mockUseDispatch.mockReturnValue(mockDispatch); + mockSetCampaignParticipantStatus.mockImplementation((payload) => ({ + type: 'rewards/setCampaignParticipantStatus', + payload, + })); }); it('skips fetch and returns null status when feature flag is disabled', async () => { @@ -67,7 +118,7 @@ describe('useGetCampaignParticipantStatus', () => { expect(result.current.status).toBeNull(); }); - it('fetches and returns status on mount', async () => { + it('fetches and dispatches status on mount', async () => { setupSelectors(SUB_ID, true); mockCall.mockResolvedValueOnce(STATUS as never); @@ -83,6 +134,12 @@ describe('useGetCampaignParticipantStatus', () => { CAMPAIGN_ID, SUB_ID, ); + expect(mockDispatch).toHaveBeenCalledWith( + mockSetCampaignParticipantStatus({ + campaignId: CAMPAIGN_ID, + status: STATUS, + }), + ); expect(result.current.status).toEqual(STATUS); expect(result.current.isLoading).toBe(false); expect(result.current.hasError).toBe(false); @@ -105,7 +162,10 @@ describe('useGetCampaignParticipantStatus', () => { it('subscribes to RewardsController:campaignOptedIn to auto-refetch', () => { setupSelectors(SUB_ID, true); - mockCall.mockResolvedValue({ optedIn: false } as never); + mockCall.mockResolvedValue({ + optedIn: false, + participantCount: 0, + } as never); renderHook(() => useGetCampaignParticipantStatus(CAMPAIGN_ID)); @@ -116,9 +176,10 @@ describe('useGetCampaignParticipantStatus', () => { }); it('allows manual refetch', async () => { + const INITIAL_STATUS = { optedIn: false, participantCount: 0 }; setupSelectors(SUB_ID, true); mockCall - .mockResolvedValueOnce({ optedIn: false } as never) + .mockResolvedValueOnce(INITIAL_STATUS as never) .mockResolvedValueOnce(STATUS as never); const { result, waitForNextUpdate } = renderHook(() => @@ -127,7 +188,7 @@ describe('useGetCampaignParticipantStatus', () => { await act(async () => { await waitForNextUpdate(); }); - expect(result.current.status).toEqual({ optedIn: false }); + expect(result.current.status).toEqual(INITIAL_STATUS); await act(async () => { result.current.refetch(); diff --git a/app/components/UI/Rewards/hooks/useGetCampaignParticipantStatus.ts b/app/components/UI/Rewards/hooks/useGetCampaignParticipantStatus.ts index d4b2166f77d..a8383061879 100644 --- a/app/components/UI/Rewards/hooks/useGetCampaignParticipantStatus.ts +++ b/app/components/UI/Rewards/hooks/useGetCampaignParticipantStatus.ts @@ -1,8 +1,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { selectCampaignsRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards'; +import { selectCampaignParticipantStatusById } from '../../../../reducers/rewards/selectors'; +import { setCampaignParticipantStatus } from '../../../../reducers/rewards'; import type { CampaignParticipantStatusDto } from '../../../../core/Engine/controllers/rewards-controller/types'; import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; @@ -27,15 +29,13 @@ export const useGetCampaignParticipantStatus = ( ): UseGetCampaignParticipantStatusResult => { const subscriptionId = useSelector(selectRewardsSubscriptionId); const isCampaignsEnabled = useSelector(selectCampaignsRewardsEnabledFlag); - const [status, setStatus] = useState( - null, - ); + const status = useSelector(selectCampaignParticipantStatusById(campaignId)); + const dispatch = useDispatch(); const [isLoading, setIsLoading] = useState(false); const [hasError, setHasError] = useState(false); const fetchStatus = useCallback(async (): Promise => { if (!isCampaignsEnabled || !subscriptionId || !campaignId) { - setStatus(null); return; } @@ -47,13 +47,13 @@ export const useGetCampaignParticipantStatus = ( campaignId, subscriptionId, ); - setStatus(result); + dispatch(setCampaignParticipantStatus({ campaignId, status: result })); } catch { setHasError(true); } finally { setIsLoading(false); } - }, [subscriptionId, isCampaignsEnabled, campaignId]); + }, [dispatch, subscriptionId, isCampaignsEnabled, campaignId]); useEffect(() => { fetchStatus(); diff --git a/app/components/UI/Rewards/hooks/useOptInToCampaign.test.ts b/app/components/UI/Rewards/hooks/useOptInToCampaign.test.ts index fa2f47b3254..6c8140c664b 100644 --- a/app/components/UI/Rewards/hooks/useOptInToCampaign.test.ts +++ b/app/components/UI/Rewards/hooks/useOptInToCampaign.test.ts @@ -28,7 +28,7 @@ const mockUseSelector = useSelector as jest.MockedFunction; const SUB_ID = 'sub-123'; const CAMPAIGN_ID = 'camp-456'; -const STATUS = { optedIn: true }; +const STATUS = { optedIn: true, participantCount: 42 }; function setupSelectors( subscriptionId: string | null, diff --git a/app/components/UI/Rewards/hooks/useRewardCampaigns.test.ts b/app/components/UI/Rewards/hooks/useRewardCampaigns.test.ts index 9c6c58b4662..050039e053b 100644 --- a/app/components/UI/Rewards/hooks/useRewardCampaigns.test.ts +++ b/app/components/UI/Rewards/hooks/useRewardCampaigns.test.ts @@ -61,6 +61,19 @@ jest.mock('./useInvalidateByRewardEvents', () => ({ useInvalidateByRewardEvents: jest.fn(), })); +jest.mock('../components/Campaigns/CampaignTile.utils', () => ({ + getCampaignStatus: jest.fn( + (campaign: { startDate: string; endDate: string }) => { + const now = new Date(); + const startDate = new Date(campaign.startDate); + const endDate = new Date(campaign.endDate); + if (now < startDate) return 'upcoming'; + if (now >= startDate && now < endDate) return 'active'; + return 'complete'; + }, + ), +})); + const createTestCampaign = ( overrides: Partial = {}, ): CampaignDto => ({ @@ -72,6 +85,7 @@ const createTestCampaign = ( termsAndConditions: null, excludedRegions: [], statusLabel: 'Active', + details: null, ...overrides, }); @@ -336,4 +350,104 @@ describe('useRewardCampaigns', () => { ); }); }); + + describe('categorizedCampaigns', () => { + it('categorizes campaigns into active, upcoming, and previous', () => { + const activeCampaign = createTestCampaign({ + id: 'active-1', + startDate: '2020-01-01T00:00:00.000Z', + endDate: '2099-12-31T23:59:59.999Z', + }); + const upcomingCampaign = createTestCampaign({ + id: 'upcoming-1', + startDate: '2099-06-01T00:00:00.000Z', + endDate: '2099-12-31T23:59:59.999Z', + }); + const completeCampaign = createTestCampaign({ + id: 'complete-1', + startDate: '2020-01-01T00:00:00.000Z', + endDate: '2020-12-31T23:59:59.999Z', + }); + + setupSelectorMocks({ + campaigns: [activeCampaign, upcomingCampaign, completeCampaign], + }); + + const { result } = renderHook(() => useRewardCampaigns()); + + expect(result.current.categorizedCampaigns.active).toEqual([ + activeCampaign, + ]); + expect(result.current.categorizedCampaigns.upcoming).toEqual([ + upcomingCampaign, + ]); + expect(result.current.categorizedCampaigns.previous).toEqual([ + completeCampaign, + ]); + }); + + it('returns empty categories when no campaigns', () => { + setupSelectorMocks({ campaigns: [] }); + + const { result } = renderHook(() => useRewardCampaigns()); + + expect(result.current.categorizedCampaigns).toEqual({ + active: [], + upcoming: [], + previous: [], + }); + }); + + it('sorts upcoming by startDate ascending', () => { + const upcomingLater = createTestCampaign({ + id: 'upcoming-2', + startDate: '2099-09-01T00:00:00.000Z', + endDate: '2099-12-31T23:59:59.999Z', + }); + const upcomingEarlier = createTestCampaign({ + id: 'upcoming-1', + startDate: '2099-06-01T00:00:00.000Z', + endDate: '2099-12-31T23:59:59.999Z', + }); + + setupSelectorMocks({ + campaigns: [upcomingLater, upcomingEarlier], + }); + + const { result } = renderHook(() => useRewardCampaigns()); + + expect(result.current.categorizedCampaigns.upcoming[0].id).toBe( + 'upcoming-1', + ); + expect(result.current.categorizedCampaigns.upcoming[1].id).toBe( + 'upcoming-2', + ); + }); + + it('sorts previous by endDate descending', () => { + const completeOlder = createTestCampaign({ + id: 'complete-1', + startDate: '2020-01-01T00:00:00.000Z', + endDate: '2020-06-30T23:59:59.999Z', + }); + const completeNewer = createTestCampaign({ + id: 'complete-2', + startDate: '2020-07-01T00:00:00.000Z', + endDate: '2020-12-31T23:59:59.999Z', + }); + + setupSelectorMocks({ + campaigns: [completeOlder, completeNewer], + }); + + const { result } = renderHook(() => useRewardCampaigns()); + + expect(result.current.categorizedCampaigns.previous[0].id).toBe( + 'complete-2', + ); + expect(result.current.categorizedCampaigns.previous[1].id).toBe( + 'complete-1', + ); + }); + }); }); diff --git a/app/components/UI/Rewards/hooks/useRewardCampaigns.ts b/app/components/UI/Rewards/hooks/useRewardCampaigns.ts index 8309f87fb99..47ec5398881 100644 --- a/app/components/UI/Rewards/hooks/useRewardCampaigns.ts +++ b/app/components/UI/Rewards/hooks/useRewardCampaigns.ts @@ -16,10 +16,19 @@ import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { selectCampaignsRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards'; import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; import type { CampaignDto } from '../../../../core/Engine/controllers/rewards-controller/types'; +import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils'; + +interface CategorizedCampaigns { + active: CampaignDto[]; + upcoming: CampaignDto[]; + previous: CampaignDto[]; +} interface UseRewardCampaignsReturn { /** Campaigns fetched from the API, or empty array when flag is disabled */ campaigns: CampaignDto[]; + /** Campaigns categorized by status */ + categorizedCampaigns: CategorizedCampaigns; /** Whether campaigns are loading */ isLoading: boolean; /** Whether there was an error fetching campaigns */ @@ -30,6 +39,7 @@ interface UseRewardCampaignsReturn { /** * Custom hook to fetch and manage campaigns data from the rewards API. + * Categorizes campaigns into active, upcoming, and previous (complete). * Returns an empty list when the rewards-campaigns-enabled feature flag is off. */ export const useRewardCampaigns = (): UseRewardCampaignsReturn => { @@ -72,6 +82,39 @@ export const useRewardCampaigns = (): UseRewardCampaignsReturn => { } }, [dispatch, subscriptionId, isCampaignsEnabled]); + const categorizedCampaigns = useMemo((): CategorizedCampaigns => { + const campaignsList = campaigns ?? []; + const active: CampaignDto[] = []; + const upcoming: CampaignDto[] = []; + const previous: CampaignDto[] = []; + + campaignsList.forEach((campaign) => { + const status = getCampaignStatus(campaign); + switch (status) { + case 'active': + active.push(campaign); + break; + case 'upcoming': + upcoming.push(campaign); + break; + case 'complete': + previous.push(campaign); + break; + } + }); + + upcoming.sort( + (a, b) => + new Date(a.startDate).getTime() - new Date(b.startDate).getTime(), + ); + + previous.sort( + (a, b) => new Date(b.endDate).getTime() - new Date(a.endDate).getTime(), + ); + + return { active, upcoming, previous }; + }, [campaigns]); + useFocusEffect( useCallback(() => { fetchCampaigns(); @@ -91,6 +134,7 @@ export const useRewardCampaigns = (): UseRewardCampaignsReturn => { return { campaigns: campaigns ?? [], + categorizedCampaigns, isLoading, hasError, fetchCampaigns, diff --git a/app/components/UI/Rewards/hooks/useSnapshots.test.ts b/app/components/UI/Rewards/hooks/useSnapshots.test.ts deleted file mode 100644 index 7868e11991b..00000000000 --- a/app/components/UI/Rewards/hooks/useSnapshots.test.ts +++ /dev/null @@ -1,526 +0,0 @@ -import { renderHook, act } from '@testing-library/react-hooks'; -import { useDispatch, useSelector } from 'react-redux'; -import { useFocusEffect } from '@react-navigation/native'; -import { useSnapshots } from './useSnapshots'; -import Engine from '../../../../core/Engine'; -import { - setSnapshots, - setSnapshotsLoading, - setSnapshotsError, -} from '../../../../reducers/rewards'; -import { - selectSeasonId, - selectSnapshots, - selectSnapshotsLoading, - selectSnapshotsError, -} from '../../../../reducers/rewards/selectors'; -import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; -import { selectSnapshotsRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards'; -import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; -import { getSnapshotStatus } from '../components/SnapshotTile/SnapshotTile.utils'; -import type { SnapshotDto } from '../../../../core/Engine/controllers/rewards-controller/types'; - -// Mock dependencies -jest.mock('react-redux', () => ({ - useDispatch: jest.fn(), - useSelector: jest.fn(), -})); - -jest.mock('../../../../core/Engine', () => ({ - controllerMessenger: { - call: jest.fn(), - }, -})); - -jest.mock('../../../../reducers/rewards', () => ({ - setSnapshots: jest.fn(), - setSnapshotsLoading: jest.fn(), - setSnapshotsError: jest.fn(), -})); - -jest.mock('../../../../reducers/rewards/selectors', () => ({ - selectSeasonId: jest.fn(), - selectSnapshots: jest.fn(), - selectSnapshotsLoading: jest.fn(), - selectSnapshotsError: jest.fn(), -})); - -jest.mock('../../../../selectors/rewards', () => ({ - selectRewardsSubscriptionId: jest.fn(), -})); - -jest.mock('../../../../selectors/featureFlagController/rewards', () => ({ - selectSnapshotsRewardsEnabledFlag: jest.fn(), -})); - -jest.mock('@react-navigation/native', () => ({ - useFocusEffect: jest.fn(), -})); - -jest.mock('./useInvalidateByRewardEvents', () => ({ - useInvalidateByRewardEvents: jest.fn(), -})); - -jest.mock('../components/SnapshotTile/SnapshotTile.utils', () => ({ - getSnapshotStatus: jest.fn(), -})); - -/** - * Creates a test snapshot with customizable overrides - */ -const createTestSnapshot = ( - overrides: Partial = {}, -): SnapshotDto => ({ - id: `snapshot-${Math.random().toString(36).substr(2, 9)}`, - seasonId: 'season-1', - name: 'Test Snapshot', - description: 'Test description', - tokenSymbol: 'TEST', - tokenAmount: '1000000000000000000', - tokenChainId: '1', - tokenAddress: '0x1234567890abcdef1234567890abcdef12345678', - receivingBlockchain: 'Ethereum', - opensAt: '2025-03-01T00:00:00.000Z', - closesAt: '2025-03-15T00:00:00.000Z', - backgroundImage: { - lightModeUrl: 'https://example.com/light.png', - darkModeUrl: 'https://example.com/dark.png', - }, - ...overrides, -}); - -describe('useSnapshots', () => { - const mockDispatch = jest.fn(); - const mockUseFocusEffect = useFocusEffect as jest.MockedFunction< - typeof useFocusEffect - >; - const mockEngineCall = Engine.controllerMessenger.call as jest.MockedFunction< - typeof Engine.controllerMessenger.call - >; - const mockUseDispatch = useDispatch as jest.MockedFunction< - typeof useDispatch - >; - const mockUseSelector = useSelector as jest.MockedFunction< - typeof useSelector - >; - const mockSetSnapshots = setSnapshots as jest.MockedFunction< - typeof setSnapshots - >; - const mockSetSnapshotsLoading = setSnapshotsLoading as jest.MockedFunction< - typeof setSnapshotsLoading - >; - const mockSetSnapshotsError = setSnapshotsError as jest.MockedFunction< - typeof setSnapshotsError - >; - const mockUseInvalidateByRewardEvents = - useInvalidateByRewardEvents as jest.MockedFunction< - typeof useInvalidateByRewardEvents - >; - const mockGetSnapshotStatus = getSnapshotStatus as jest.MockedFunction< - typeof getSnapshotStatus - >; - - beforeEach(() => { - jest.clearAllMocks(); - mockUseDispatch.mockReturnValue(mockDispatch); - mockSetSnapshots.mockReturnValue({ - type: 'rewards/setSnapshots', - payload: null, - }); - mockSetSnapshotsLoading.mockReturnValue({ - type: 'rewards/setSnapshotsLoading', - payload: false, - }); - mockSetSnapshotsError.mockReturnValue({ - type: 'rewards/setSnapshotsError', - payload: false, - }); - mockUseFocusEffect.mockClear(); - mockUseInvalidateByRewardEvents.mockClear(); - }); - - const setupSelectorMocks = ( - options: { - seasonId?: string | null; - subscriptionId?: string | null; - snapshots?: SnapshotDto[] | null; - isLoading?: boolean; - hasError?: boolean; - isSnapshotsEnabled?: boolean; - } = {}, - ) => { - const { - seasonId = 'season-1', - subscriptionId = 'subscription-1', - snapshots = null, - isLoading = false, - hasError = false, - isSnapshotsEnabled = true, - } = options; - - mockUseSelector.mockImplementation((selector) => { - if (selector === selectSeasonId) return seasonId; - if (selector === selectRewardsSubscriptionId) return subscriptionId; - if (selector === selectSnapshots) return snapshots; - if (selector === selectSnapshotsLoading) return isLoading; - if (selector === selectSnapshotsError) return hasError; - if (selector === selectSnapshotsRewardsEnabledFlag) - return isSnapshotsEnabled; - return undefined; - }); - }; - - describe('initial state', () => { - it('returns initial state from selectors', () => { - const testSnapshots = [createTestSnapshot()]; - setupSelectorMocks({ - snapshots: testSnapshots, - isLoading: false, - hasError: false, - }); - mockGetSnapshotStatus.mockReturnValue('live'); - - const { result } = renderHook(() => useSnapshots()); - - expect(result.current.snapshots).toEqual(testSnapshots); - expect(result.current.isLoading).toBe(false); - expect(result.current.hasError).toBe(false); - expect(typeof result.current.fetchSnapshots).toBe('function'); - }); - }); - - describe('fetchSnapshots', () => { - it('calls Engine controller when fetching snapshots', async () => { - setupSelectorMocks(); - const mockSnapshotsData = [createTestSnapshot()]; - mockEngineCall.mockResolvedValueOnce(mockSnapshotsData); - mockGetSnapshotStatus.mockReturnValue('live'); - - const { result } = renderHook(() => useSnapshots()); - - await act(async () => { - await result.current.fetchSnapshots(); - }); - - expect(mockEngineCall).toHaveBeenCalledWith( - 'RewardsController:getSnapshots', - 'season-1', - 'subscription-1', - ); - }); - - it('dispatches loading state before fetch', async () => { - setupSelectorMocks(); - mockEngineCall.mockResolvedValueOnce([]); - mockGetSnapshotStatus.mockReturnValue('live'); - - const { result } = renderHook(() => useSnapshots()); - - await act(async () => { - await result.current.fetchSnapshots(); - }); - - expect(mockDispatch).toHaveBeenCalledWith(mockSetSnapshotsLoading(true)); - expect(mockDispatch).toHaveBeenCalledWith(mockSetSnapshotsError(false)); - }); - - it('dispatches snapshots on successful fetch', async () => { - setupSelectorMocks(); - const mockSnapshotsData = [createTestSnapshot()]; - mockEngineCall.mockResolvedValueOnce(mockSnapshotsData); - mockGetSnapshotStatus.mockReturnValue('live'); - - const { result } = renderHook(() => useSnapshots()); - - await act(async () => { - await result.current.fetchSnapshots(); - }); - - expect(mockDispatch).toHaveBeenCalledWith( - mockSetSnapshots(mockSnapshotsData), - ); - expect(mockDispatch).toHaveBeenCalledWith(mockSetSnapshotsLoading(false)); - }); - - it('dispatches error state on fetch failure', async () => { - setupSelectorMocks(); - const mockError = new Error('Network failed'); - mockEngineCall.mockRejectedValueOnce(mockError); - mockGetSnapshotStatus.mockReturnValue('live'); - - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); - - const { result } = renderHook(() => useSnapshots()); - - await act(async () => { - await result.current.fetchSnapshots(); - }); - - expect(mockDispatch).toHaveBeenCalledWith(mockSetSnapshotsError(true)); - expect(mockDispatch).toHaveBeenCalledWith(mockSetSnapshotsLoading(false)); - expect(consoleErrorSpy).toHaveBeenCalledWith('Error fetching snapshots'); - - consoleErrorSpy.mockRestore(); - }); - - it('does not fetch when subscriptionId is null', async () => { - setupSelectorMocks({ subscriptionId: null }); - mockGetSnapshotStatus.mockReturnValue('live'); - - const { result } = renderHook(() => useSnapshots()); - - await act(async () => { - await result.current.fetchSnapshots(); - }); - - expect(mockEngineCall).not.toHaveBeenCalled(); - expect(mockDispatch).toHaveBeenCalledWith(mockSetSnapshots(null)); - expect(mockDispatch).toHaveBeenCalledWith(mockSetSnapshotsLoading(false)); - expect(mockDispatch).toHaveBeenCalledWith(mockSetSnapshotsError(false)); - }); - - it('does not fetch when seasonId is null', async () => { - setupSelectorMocks({ seasonId: null }); - mockGetSnapshotStatus.mockReturnValue('live'); - - const { result } = renderHook(() => useSnapshots()); - - await act(async () => { - await result.current.fetchSnapshots(); - }); - - expect(mockEngineCall).not.toHaveBeenCalled(); - expect(mockDispatch).toHaveBeenCalledWith(mockSetSnapshots(null)); - expect(mockDispatch).toHaveBeenCalledWith(mockSetSnapshotsLoading(false)); - expect(mockDispatch).toHaveBeenCalledWith(mockSetSnapshotsError(false)); - }); - - it('does not fetch when isSnapshotsEnabled is false', async () => { - setupSelectorMocks({ isSnapshotsEnabled: false }); - mockGetSnapshotStatus.mockReturnValue('live'); - - const { result } = renderHook(() => useSnapshots()); - - await act(async () => { - await result.current.fetchSnapshots(); - }); - - expect(mockEngineCall).not.toHaveBeenCalled(); - expect(mockDispatch).toHaveBeenCalledWith(mockSetSnapshots(null)); - expect(mockDispatch).toHaveBeenCalledWith(mockSetSnapshotsLoading(false)); - expect(mockDispatch).toHaveBeenCalledWith(mockSetSnapshotsError(false)); - }); - }); - - describe('categorizedSnapshots', () => { - it('categorizes snapshots by status (live to active, upcoming to upcoming, others to previous)', () => { - const liveSnapshot = createTestSnapshot({ id: 'live-1' }); - const upcomingSnapshot1 = createTestSnapshot({ id: 'upcoming-1' }); - const upcomingSnapshot2 = createTestSnapshot({ id: 'upcoming-2' }); - const calculatingSnapshot = createTestSnapshot({ id: 'calculating-1' }); - const distributingSnapshot = createTestSnapshot({ id: 'distributing-1' }); - const completeSnapshot = createTestSnapshot({ id: 'complete-1' }); - - const allSnapshots = [ - liveSnapshot, - upcomingSnapshot1, - upcomingSnapshot2, - calculatingSnapshot, - distributingSnapshot, - completeSnapshot, - ]; - - setupSelectorMocks({ snapshots: allSnapshots }); - - mockGetSnapshotStatus.mockImplementation((snapshot) => { - switch (snapshot.id) { - case 'live-1': - return 'live'; - case 'upcoming-1': - case 'upcoming-2': - return 'upcoming'; - case 'calculating-1': - return 'calculating'; - case 'distributing-1': - return 'distributing'; - case 'complete-1': - return 'complete'; - default: - return 'upcoming'; - } - }); - - const { result } = renderHook(() => useSnapshots()); - - expect(result.current.categorizedSnapshots.active).toHaveLength(1); - expect(result.current.categorizedSnapshots.active[0].id).toBe('live-1'); - - expect(result.current.categorizedSnapshots.upcoming).toHaveLength(2); - expect( - result.current.categorizedSnapshots.upcoming.map((s) => s.id), - ).toContain('upcoming-1'); - expect( - result.current.categorizedSnapshots.upcoming.map((s) => s.id), - ).toContain('upcoming-2'); - - expect(result.current.categorizedSnapshots.previous).toHaveLength(3); - expect( - result.current.categorizedSnapshots.previous.map((s) => s.id), - ).toContain('calculating-1'); - expect( - result.current.categorizedSnapshots.previous.map((s) => s.id), - ).toContain('distributing-1'); - expect( - result.current.categorizedSnapshots.previous.map((s) => s.id), - ).toContain('complete-1'); - }); - - it('sorts upcoming by opensAt ascending', () => { - const upcomingEarlier = createTestSnapshot({ - id: 'upcoming-earlier', - opensAt: '2025-03-01T00:00:00.000Z', - }); - const upcomingLater = createTestSnapshot({ - id: 'upcoming-later', - opensAt: '2025-03-15T00:00:00.000Z', - }); - const upcomingMiddle = createTestSnapshot({ - id: 'upcoming-middle', - opensAt: '2025-03-08T00:00:00.000Z', - }); - - setupSelectorMocks({ - snapshots: [upcomingLater, upcomingEarlier, upcomingMiddle], - }); - - mockGetSnapshotStatus.mockReturnValue('upcoming'); - - const { result } = renderHook(() => useSnapshots()); - - const upcomingIds = result.current.categorizedSnapshots.upcoming.map( - (s) => s.id, - ); - - expect(upcomingIds).toEqual([ - 'upcoming-earlier', - 'upcoming-middle', - 'upcoming-later', - ]); - }); - - it('sorts previous by closesAt descending', () => { - const previousEarlier = createTestSnapshot({ - id: 'previous-earlier', - closesAt: '2025-01-01T00:00:00.000Z', - }); - const previousLater = createTestSnapshot({ - id: 'previous-later', - closesAt: '2025-03-15T00:00:00.000Z', - }); - const previousMiddle = createTestSnapshot({ - id: 'previous-middle', - closesAt: '2025-02-08T00:00:00.000Z', - }); - - setupSelectorMocks({ - snapshots: [previousEarlier, previousLater, previousMiddle], - }); - - mockGetSnapshotStatus.mockReturnValue('complete'); - - const { result } = renderHook(() => useSnapshots()); - - const previousIds = result.current.categorizedSnapshots.previous.map( - (s) => s.id, - ); - - expect(previousIds).toEqual([ - 'previous-later', - 'previous-middle', - 'previous-earlier', - ]); - }); - - it('returns empty categories when snapshots is null', () => { - setupSelectorMocks({ snapshots: null }); - - const { result } = renderHook(() => useSnapshots()); - - expect(result.current.categorizedSnapshots).toEqual({ - active: [], - upcoming: [], - previous: [], - }); - }); - }); - - describe('useFocusEffect integration', () => { - it('registers focus effect callback', () => { - setupSelectorMocks(); - mockGetSnapshotStatus.mockReturnValue('live'); - - renderHook(() => useSnapshots()); - - expect(mockUseFocusEffect).toHaveBeenCalledWith(expect.any(Function)); - }); - - it('fetches snapshots when focus effect is triggered', async () => { - setupSelectorMocks(); - const mockSnapshotsData = [createTestSnapshot()]; - mockEngineCall.mockResolvedValueOnce(mockSnapshotsData); - mockGetSnapshotStatus.mockReturnValue('live'); - - renderHook(() => useSnapshots()); - - expect(mockUseFocusEffect).toHaveBeenCalledWith(expect.any(Function)); - - const focusCallback = mockUseFocusEffect.mock.calls[0][0]; - - await act(async () => { - focusCallback(); - }); - - expect(mockEngineCall).toHaveBeenCalledWith( - 'RewardsController:getSnapshots', - 'season-1', - 'subscription-1', - ); - }); - }); - - describe('useInvalidateByRewardEvents integration', () => { - it('registers invalidation events', () => { - setupSelectorMocks(); - mockGetSnapshotStatus.mockReturnValue('live'); - - renderHook(() => useSnapshots()); - - expect(mockUseInvalidateByRewardEvents).toHaveBeenCalledWith( - ['RewardsController:accountLinked', 'RewardsController:balanceUpdated'], - expect.any(Function), - ); - }); - - it('passes fetchSnapshots as callback to invalidation hook', async () => { - setupSelectorMocks(); - const mockSnapshotsData = [createTestSnapshot()]; - mockEngineCall.mockResolvedValueOnce(mockSnapshotsData); - mockGetSnapshotStatus.mockReturnValue('live'); - - renderHook(() => useSnapshots()); - - const invalidationCallback = - mockUseInvalidateByRewardEvents.mock.calls[0][1]; - - await act(async () => { - await invalidationCallback(); - }); - - expect(mockEngineCall).toHaveBeenCalledWith( - 'RewardsController:getSnapshots', - 'season-1', - 'subscription-1', - ); - }); - }); -}); diff --git a/app/components/UI/Rewards/hooks/useSnapshots.ts b/app/components/UI/Rewards/hooks/useSnapshots.ts deleted file mode 100644 index 83822261434..00000000000 --- a/app/components/UI/Rewards/hooks/useSnapshots.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { useCallback, useRef, useMemo } from 'react'; -import Engine from '../../../../core/Engine'; -import { useDispatch, useSelector } from 'react-redux'; -import { - setSnapshots, - setSnapshotsLoading, - setSnapshotsError, -} from '../../../../reducers/rewards'; -import { - selectSeasonId, - selectSnapshots, - selectSnapshotsLoading, - selectSnapshotsError, -} from '../../../../reducers/rewards/selectors'; -import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; -import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; -import { useFocusEffect } from '@react-navigation/native'; -import type { SnapshotDto } from '../../../../core/Engine/controllers/rewards-controller/types'; -import { getSnapshotStatus } from '../components/SnapshotTile/SnapshotTile.utils'; -import { selectSnapshotsRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards'; - -interface CategorizedSnapshots { - active: SnapshotDto[]; - upcoming: SnapshotDto[]; - previous: SnapshotDto[]; -} - -interface UseSnapshotsReturn { - /** All snapshots */ - snapshots: SnapshotDto[] | null; - /** Categorized snapshots by status */ - categorizedSnapshots: CategorizedSnapshots; - /** Whether snapshots are loading */ - isLoading: boolean; - /** Whether there was an error fetching snapshots */ - hasError: boolean; - /** Fetch snapshots from the API */ - fetchSnapshots: () => Promise; -} - -/** - * Custom hook to fetch and manage snapshots data from the rewards API. - * Categorizes snapshots into active (live), upcoming, and previous (calculating/distributing/complete). - */ -export const useSnapshots = (): UseSnapshotsReturn => { - const seasonId = useSelector(selectSeasonId); - const subscriptionId = useSelector(selectRewardsSubscriptionId); - const snapshots = useSelector(selectSnapshots); - const isLoading = useSelector(selectSnapshotsLoading); - const hasError = useSelector(selectSnapshotsError); - const dispatch = useDispatch(); - const isLoadingRef = useRef(false); - const isSnapshotsEnabled = useSelector(selectSnapshotsRewardsEnabledFlag); - - const fetchSnapshots = useCallback(async (): Promise => { - if (!subscriptionId || !seasonId || !isSnapshotsEnabled) { - dispatch(setSnapshots(null)); - dispatch(setSnapshotsLoading(false)); - dispatch(setSnapshotsError(false)); - return; - } - - if (isLoadingRef.current) { - return; - } - - try { - isLoadingRef.current = true; - dispatch(setSnapshotsLoading(true)); - dispatch(setSnapshotsError(false)); - - const snapshotsData = await Engine.controllerMessenger.call( - 'RewardsController:getSnapshots', - seasonId, - subscriptionId, - ); - - dispatch(setSnapshots(snapshotsData)); - } catch { - dispatch(setSnapshotsError(true)); - console.error('Error fetching snapshots'); - } finally { - isLoadingRef.current = false; - dispatch(setSnapshotsLoading(false)); - } - }, [dispatch, seasonId, subscriptionId, isSnapshotsEnabled]); - - // Categorize snapshots by status - const categorizedSnapshots = useMemo((): CategorizedSnapshots => { - if (!snapshots) { - return { active: [], upcoming: [], previous: [] }; - } - - const active: SnapshotDto[] = []; - const upcoming: SnapshotDto[] = []; - const previous: SnapshotDto[] = []; - - snapshots.forEach((snapshot) => { - const status = getSnapshotStatus(snapshot); - switch (status) { - case 'live': - active.push(snapshot); - break; - case 'upcoming': - upcoming.push(snapshot); - break; - case 'calculating': - case 'distributing': - case 'complete': - previous.push(snapshot); - break; - } - }); - - // Sort upcoming by opensAt date (earliest first) - upcoming.sort( - (a, b) => new Date(a.opensAt).getTime() - new Date(b.opensAt).getTime(), - ); - - // Sort previous by closesAt date (most recent first) - previous.sort( - (a, b) => new Date(b.closesAt).getTime() - new Date(a.closesAt).getTime(), - ); - - return { active, upcoming, previous }; - }, [snapshots]); - - useFocusEffect( - useCallback(() => { - fetchSnapshots(); - }, [fetchSnapshots]), - ); - - const invalidateEvents = useMemo( - () => - [ - 'RewardsController:accountLinked', - 'RewardsController:balanceUpdated', - ] as const, - [], - ); - - // Listen for reward events to trigger refetch - useInvalidateByRewardEvents(invalidateEvents, fetchSnapshots); - - return { - snapshots, - categorizedSnapshots, - isLoading, - hasError, - fetchSnapshots, - }; -}; diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index 59ff53524b5..7054ad59592 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -99,6 +99,9 @@ const Routes = { REFERRAL_REWARDS_VIEW: 'ReferralRewardsView', REWARDS_SETTINGS_VIEW: 'RewardsSettingsView', REWARDS_DASHBOARD: 'RewardsDashboard', + CAMPAIGNS_VIEW: 'CampaignsView', + PREVIOUS_SEASON_VIEW: 'PreviousSeasonView', + CAMPAIGN_DETAILS: 'CampaignDetails', TRENDING_VIEW: 'TrendingView', TRENDING_FEED: 'TrendingFeed', SITES_FULL_VIEW: 'SitesFullView', diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts index a2802bcccb4..e6a56e5da07 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts @@ -15835,7 +15835,6 @@ describe('RewardsController', () => { "pointsEvents": {}, "seasonStatuses": {}, "seasons": {}, - "snapshots": {}, "subscriptionReferralDetails": {}, "subscriptions": {}, "unlockedRewards": {}, @@ -15859,7 +15858,6 @@ describe('RewardsController', () => { "rewardsEnvUrl": null, "seasonStatuses": {}, "seasons": {}, - "snapshots": {}, "subscriptionReferralDetails": {}, "subscriptions": {}, "unlockedRewards": {}, @@ -15886,7 +15884,6 @@ describe('RewardsController', () => { "rewardsEnvUrl": null, "seasonStatuses": {}, "seasons": {}, - "snapshots": {}, "subscriptionReferralDetails": {}, "subscriptions": {}, "unlockedRewards": {}, @@ -18205,540 +18202,6 @@ describe('RewardsController', () => { }); }); - describe('getSnapshots', () => { - let controller: RewardsController; - let mockMessenger: jest.Mocked; - const mockSeasonId = 'season123'; - const mockSubscriptionId = 'sub123'; - - // Helper function to create test snapshot data - const createTestSnapshot = ( - overrides: Partial<{ - id: string; - seasonId: string; - name: string; - description: string; - tokenSymbol: string; - tokenAmount: string; - tokenChainId: string; - tokenAddress: string; - receivingBlockchain: string; - opensAt: string; - closesAt: string; - calculatedAt: string; - backgroundImage: { lightModeUrl: string; darkModeUrl: string }; - }> = {}, - ) => ({ - id: 'snapshot-1', - seasonId: mockSeasonId, - name: 'Monad 50000', - description: 'Earn MONAD tokens by participating in the airdrop', - tokenSymbol: 'MONAD', - tokenAmount: '50000000000000000000000', - tokenChainId: '1', - tokenAddress: '0x1234567890abcdef1234567890abcdef12345678', - receivingBlockchain: 'Monad', - opensAt: '2025-01-01T00:00:00.000Z', - closesAt: '2025-01-15T00:00:00.000Z', - calculatedAt: '2025-01-16T00:00:00.000Z', - backgroundImage: { - lightModeUrl: 'https://example.com/snapshot-light.png', - darkModeUrl: 'https://example.com/snapshot-dark.png', - }, - ...overrides, - }); - - beforeEach(() => { - mockMessenger = { - subscribe: jest.fn(), - call: jest.fn(), - registerActionHandler: jest.fn(), - unregisterActionHandler: jest.fn(), - publish: jest.fn(), - clearEventSubscriptions: jest.fn(), - registerInitialEventPayload: jest.fn(), - unsubscribe: jest.fn(), - } as unknown as jest.Mocked; - }); - - it('returns empty array when rewards feature flag is disabled', async () => { - const disabledController = new RewardsController({ - messenger: mockMessenger, - state: getRewardsControllerDefaultState(), - isDisabled: () => true, - isSnapshotsEnabled: () => true, - }); - - const result = await disabledController.getSnapshots( - mockSeasonId, - mockSubscriptionId, - ); - - expect(result).toEqual([]); - expect(mockMessenger.call).not.toHaveBeenCalledWith( - 'RewardsDataService:getSnapshots', - expect.anything(), - expect.anything(), - ); - }); - - it('throws error when snapshots feature is not enabled', async () => { - controller = new RewardsController({ - messenger: mockMessenger, - state: getRewardsControllerDefaultState(), - isDisabled: () => false, - isSnapshotsEnabled: () => false, - }); - - await expect( - controller.getSnapshots(mockSeasonId, mockSubscriptionId), - ).rejects.toThrow('Snapshots feature is not enabled'); - - expect(mockMessenger.call).not.toHaveBeenCalledWith( - 'RewardsDataService:getSnapshots', - expect.anything(), - expect.anything(), - ); - }); - - it('returns cached snapshots when cache is fresh', async () => { - const recentTime = Date.now() - 60000; // 1 minute ago (within 5 minute threshold) - - const mockCachedSnapshots = [ - createTestSnapshot({ id: 'snapshot-1', name: 'Monad 50000' }), - createTestSnapshot({ id: 'snapshot-2', name: 'Linea 25000' }), - ]; - - controller = new RewardsController({ - messenger: mockMessenger, - state: { - activeAccount: null, - accounts: {}, - subscriptions: {}, - seasons: {}, - subscriptionReferralDetails: {}, - seasonStatuses: {}, - activeBoosts: {}, - pointsEvents: {}, - unlockedRewards: {}, - snapshots: { - [mockSeasonId]: { - snapshots: mockCachedSnapshots, - lastFetched: recentTime, - }, - }, - pointsEstimateHistory: [], - }, - isSnapshotsEnabled: () => true, - }); - - const result = await controller.getSnapshots( - mockSeasonId, - mockSubscriptionId, - ); - - expect(result).toEqual(mockCachedSnapshots); - expect(result).toHaveLength(2); - expect(result[0].id).toBe('snapshot-1'); - expect(result[0].name).toBe('Monad 50000'); - expect(result[1].id).toBe('snapshot-2'); - expect(result[1].name).toBe('Linea 25000'); - expect(mockMessenger.call).not.toHaveBeenCalledWith( - 'RewardsDataService:getSnapshots', - expect.anything(), - expect.anything(), - ); - }); - - it('fetches fresh snapshots when cache is stale', async () => { - const staleTime = Date.now() - 360000; // 6 minutes ago (beyond 5 minute threshold) - - const mockStaleSnapshots = [ - createTestSnapshot({ id: 'stale-snapshot', name: 'Stale 10000' }), - ]; - - const mockFreshSnapshots = [ - createTestSnapshot({ id: 'fresh-snapshot-1', name: 'Arbitrum 75000' }), - createTestSnapshot({ id: 'fresh-snapshot-2', name: 'Optimism 60000' }), - createTestSnapshot({ id: 'fresh-snapshot-3', name: 'Base 45000' }), - ]; - - controller = new RewardsController({ - messenger: mockMessenger, - state: { - activeAccount: null, - accounts: {}, - subscriptions: {}, - seasons: {}, - subscriptionReferralDetails: {}, - seasonStatuses: {}, - activeBoosts: {}, - pointsEvents: {}, - unlockedRewards: {}, - snapshots: { - [mockSeasonId]: { - snapshots: mockStaleSnapshots, - lastFetched: staleTime, - }, - }, - pointsEstimateHistory: [], - }, - isSnapshotsEnabled: () => true, - }); - - mockMessenger.call.mockResolvedValue(mockFreshSnapshots); - - const result = await controller.getSnapshots( - mockSeasonId, - mockSubscriptionId, - ); - - expect(mockMessenger.call).toHaveBeenCalledWith( - 'RewardsDataService:getSnapshots', - mockSeasonId, - mockSubscriptionId, - ); - expect(result).toEqual(mockFreshSnapshots); - expect(result).toHaveLength(3); - expect(result[0].id).toBe('fresh-snapshot-1'); - expect(result[1].name).toBe('Optimism 60000'); - expect(result[2].id).toBe('fresh-snapshot-3'); - - // Verify state was updated with fresh data - const updatedCache = controller.state.snapshots[mockSeasonId]; - expect(updatedCache).toBeDefined(); - expect(updatedCache.snapshots).toEqual(mockFreshSnapshots); - expect(updatedCache.lastFetched).toBeGreaterThan(Date.now() - 1000); - }); - - it('handles cache miss and fetches fresh data', async () => { - const mockApiSnapshots = [ - createTestSnapshot({ - id: 'api-snapshot-1', - name: 'Polygon 30000', - tokenSymbol: 'MATIC', - }), - createTestSnapshot({ - id: 'api-snapshot-2', - name: 'zkSync 55000', - tokenSymbol: 'ZK', - }), - ]; - - controller = new RewardsController({ - messenger: mockMessenger, - state: getRewardsControllerDefaultState(), - isSnapshotsEnabled: () => true, - }); - - mockMessenger.call.mockResolvedValue(mockApiSnapshots); - - const result = await controller.getSnapshots( - mockSeasonId, - mockSubscriptionId, - ); - - expect(mockMessenger.call).toHaveBeenCalledWith( - 'RewardsDataService:getSnapshots', - mockSeasonId, - mockSubscriptionId, - ); - expect(result).toEqual(mockApiSnapshots); - expect(result).toHaveLength(2); - expect(result[0].tokenSymbol).toBe('MATIC'); - expect(result[1].tokenSymbol).toBe('ZK'); - - // Verify state was updated with cached data - const cachedData = controller.state.snapshots[mockSeasonId]; - expect(cachedData).toBeDefined(); - expect(cachedData.snapshots).toEqual(mockApiSnapshots); - expect(cachedData.lastFetched).toBeGreaterThan(Date.now() - 1000); - }); - - it('throws error when API fails', async () => { - controller = new RewardsController({ - messenger: mockMessenger, - state: getRewardsControllerDefaultState(), - isSnapshotsEnabled: () => true, - }); - - mockMessenger.call.mockRejectedValue(new Error('API error')); - - await expect( - controller.getSnapshots(mockSeasonId, mockSubscriptionId), - ).rejects.toThrow('API error'); - - expect(mockMessenger.call).toHaveBeenCalledWith( - 'RewardsDataService:getSnapshots', - mockSeasonId, - mockSubscriptionId, - ); - }); - - it('handles null API response by returning empty array', async () => { - controller = new RewardsController({ - messenger: mockMessenger, - state: getRewardsControllerDefaultState(), - isSnapshotsEnabled: () => true, - }); - - mockMessenger.call.mockResolvedValue(null); - - const result = await controller.getSnapshots( - mockSeasonId, - mockSubscriptionId, - ); - - expect(result).toEqual([]); - - // Verify state was updated with empty array - const cachedData = controller.state.snapshots[mockSeasonId]; - expect(cachedData).toBeDefined(); - expect(cachedData.snapshots).toEqual([]); - }); - - it('handles empty snapshots array from API', async () => { - controller = new RewardsController({ - messenger: mockMessenger, - state: getRewardsControllerDefaultState(), - isSnapshotsEnabled: () => true, - }); - - mockMessenger.call.mockResolvedValue([]); - - const result = await controller.getSnapshots( - mockSeasonId, - mockSubscriptionId, - ); - - expect(result).toEqual([]); - expect(result).toHaveLength(0); - - // Verify state was updated - const cachedData = controller.state.snapshots[mockSeasonId]; - expect(cachedData).toBeDefined(); - expect(cachedData.snapshots).toEqual([]); - expect(cachedData.lastFetched).toBeGreaterThan(Date.now() - 1000); - }); - - it('handles multiple calls with different season IDs using separate caches', async () => { - const seasonId1 = 'season-A'; - const seasonId2 = 'season-B'; - - const mockSnapshots1 = [ - createTestSnapshot({ - id: 'snapshot-A', - seasonId: seasonId1, - name: 'Monad 50000', - }), - ]; - - const mockSnapshots2 = [ - createTestSnapshot({ - id: 'snapshot-B-1', - seasonId: seasonId2, - name: 'Linea 25000', - }), - createTestSnapshot({ - id: 'snapshot-B-2', - seasonId: seasonId2, - name: 'Arbitrum 75000', - }), - ]; - - controller = new RewardsController({ - messenger: mockMessenger, - state: { - activeAccount: null, - accounts: {}, - subscriptions: {}, - seasons: {}, - subscriptionReferralDetails: {}, - seasonStatuses: {}, - activeBoosts: {}, - pointsEvents: {}, - unlockedRewards: {}, - snapshots: { - [seasonId1]: { - snapshots: mockSnapshots1, - lastFetched: Date.now() - 30000, // Fresh cache - }, - }, - pointsEstimateHistory: [], - }, - isSnapshotsEnabled: () => true, - }); - - // Clear any calls made during controller initialization - mockMessenger.call.mockClear(); - mockMessenger.call.mockResolvedValue(mockSnapshots2); - - // First call uses cache - const result1 = await controller.getSnapshots( - seasonId1, - mockSubscriptionId, - ); - - // Second call fetches fresh data - const result2 = await controller.getSnapshots( - seasonId2, - mockSubscriptionId, - ); - - // Assert - expect(result1).toEqual(mockSnapshots1); - expect(result1[0].id).toBe('snapshot-A'); - expect(result2).toEqual(mockSnapshots2); - expect(result2).toHaveLength(2); - expect(result2[0].name).toBe('Linea 25000'); - expect(result2[1].name).toBe('Arbitrum 75000'); - - // Verify API was called only once (for the second request) - expect(mockMessenger.call).toHaveBeenCalledTimes(1); - expect(mockMessenger.call).toHaveBeenCalledWith( - 'RewardsDataService:getSnapshots', - seasonId2, - mockSubscriptionId, - ); - - // Verify both caches exist - expect(controller.state.snapshots[seasonId1]).toBeDefined(); - expect(controller.state.snapshots[seasonId2]).toBeDefined(); - expect(controller.state.snapshots[seasonId2].snapshots).toEqual( - mockSnapshots2, - ); - }); - - it('uses seasonId as cache key (not composite with subscriptionId)', async () => { - const mockSnapshots = [ - createTestSnapshot({ id: 'cached-snapshot', name: 'Scroll 40000' }), - ]; - - // Pre-populate cache with seasonId as key - controller = new RewardsController({ - messenger: mockMessenger, - state: { - activeAccount: null, - accounts: {}, - subscriptions: {}, - seasons: {}, - subscriptionReferralDetails: {}, - seasonStatuses: {}, - activeBoosts: {}, - pointsEvents: {}, - unlockedRewards: {}, - snapshots: { - [mockSeasonId]: { - snapshots: mockSnapshots, - lastFetched: Date.now() - 30000, // Fresh cache - }, - }, - pointsEstimateHistory: [], - }, - isSnapshotsEnabled: () => true, - }); - - mockMessenger.call.mockClear(); - - // Call with different subscriptionId but same seasonId - const result = await controller.getSnapshots( - mockSeasonId, - 'different-subscription-id', - ); - - // Verify cached data was returned (same cache key since it uses seasonId only) - expect(result).toEqual(mockSnapshots); - expect(mockMessenger.call).not.toHaveBeenCalled(); - }); - - it('stores snapshot data with all properties correctly', async () => { - const mockSnapshotWithAllProps = createTestSnapshot({ - id: 'full-snapshot', - seasonId: mockSeasonId, - name: 'Starknet 80000', - description: 'Earn STRK tokens by participating in the airdrop', - tokenSymbol: 'STRK', - tokenAmount: '80000000000000000000000', - tokenChainId: '137', - tokenAddress: '0xabcdef1234567890abcdef1234567890abcdef12', - receivingBlockchain: 'Polygon', - opensAt: '2025-02-01T00:00:00.000Z', - closesAt: '2025-02-28T00:00:00.000Z', - calculatedAt: '2025-03-01T00:00:00.000Z', - }); - - controller = new RewardsController({ - messenger: mockMessenger, - state: getRewardsControllerDefaultState(), - isSnapshotsEnabled: () => true, - }); - - mockMessenger.call.mockResolvedValue([mockSnapshotWithAllProps]); - - const result = await controller.getSnapshots( - mockSeasonId, - mockSubscriptionId, - ); - - expect(result).toHaveLength(1); - const snapshot = result[0]; - expect(snapshot.id).toBe('full-snapshot'); - expect(snapshot.seasonId).toBe(mockSeasonId); - expect(snapshot.name).toBe('Starknet 80000'); - expect(snapshot.description).toBe( - 'Earn STRK tokens by participating in the airdrop', - ); - expect(snapshot.tokenSymbol).toBe('STRK'); - expect(snapshot.tokenAmount).toBe('80000000000000000000000'); - expect(snapshot.tokenChainId).toBe('137'); - expect(snapshot.tokenAddress).toBe( - '0xabcdef1234567890abcdef1234567890abcdef12', - ); - expect(snapshot.receivingBlockchain).toBe('Polygon'); - expect(snapshot.opensAt).toBe('2025-02-01T00:00:00.000Z'); - expect(snapshot.closesAt).toBe('2025-02-28T00:00:00.000Z'); - expect(snapshot.calculatedAt).toBe('2025-03-01T00:00:00.000Z'); - - // Verify stored in state correctly - const cachedData = controller.state.snapshots[mockSeasonId]; - expect(cachedData.snapshots[0]).toEqual(mockSnapshotWithAllProps); - }); - - it('logs error message when API call fails', async () => { - controller = new RewardsController({ - messenger: mockMessenger, - state: getRewardsControllerDefaultState(), - isSnapshotsEnabled: () => true, - }); - - const apiError = new Error('Network timeout'); - mockMessenger.call.mockRejectedValue(apiError); - mockLogger.log.mockClear(); - - await expect( - controller.getSnapshots(mockSeasonId, mockSubscriptionId), - ).rejects.toThrow('Network timeout'); - }); - - it('logs when fetching fresh snapshots data', async () => { - controller = new RewardsController({ - messenger: mockMessenger, - state: getRewardsControllerDefaultState(), - isSnapshotsEnabled: () => true, - }); - - mockMessenger.call.mockResolvedValue([]); - mockLogger.log.mockClear(); - - await controller.getSnapshots(mockSeasonId, mockSubscriptionId); - - expect(mockLogger.log).toHaveBeenCalledWith( - 'RewardsController: Fetching fresh snapshots data via API call for seasonId', - mockSeasonId, - ); - }); - }); - describe('getOffDeviceSubscriptionAccounts', () => { let localController: RewardsController; let localMockMessenger: jest.Mocked; @@ -19148,6 +18611,7 @@ describe('RewardsController', () => { termsAndConditions: Json | null; excludedRegions: string[]; statusLabel: string; + details: null; }> = {}, ) => ({ id: 'campaign-1', @@ -19158,6 +18622,7 @@ describe('RewardsController', () => { termsAndConditions: null, excludedRegions: [], statusLabel: 'Active', + details: null, ...overrides, }); @@ -19331,7 +18796,7 @@ describe('RewardsController', () => { let mockMessenger: jest.Mocked; const mockSubscriptionId = 'sub123'; const mockCampaignId = 'campaign-456'; - const mockStatus = { optedIn: true }; + const mockStatus = { optedIn: true, participantCount: 42 }; beforeEach(() => { mockMessenger = { @@ -19346,7 +18811,7 @@ describe('RewardsController', () => { } as unknown as jest.Mocked; }); - it('returns { optedIn: false } when rewards feature flag is disabled', async () => { + it('returns { optedIn: false, participantCount: 0 } when rewards feature flag is disabled', async () => { const disabledController = new RewardsController({ messenger: mockMessenger, state: getRewardsControllerDefaultState(), @@ -19358,7 +18823,7 @@ describe('RewardsController', () => { mockSubscriptionId, ); - expect(result).toEqual({ optedIn: false }); + expect(result).toEqual({ optedIn: false, participantCount: 0 }); expect(mockMessenger.call).not.toHaveBeenCalledWith( 'RewardsDataService:optInToCampaign', expect.anything(), @@ -19366,7 +18831,7 @@ describe('RewardsController', () => { ); }); - it('returns { optedIn: false } when campaigns feature flag is disabled', async () => { + it('returns { optedIn: false, participantCount: 0 } when campaigns feature flag is disabled', async () => { const disabledController = new RewardsController({ messenger: mockMessenger, state: getRewardsControllerDefaultState(), @@ -19378,7 +18843,7 @@ describe('RewardsController', () => { mockSubscriptionId, ); - expect(result).toEqual({ optedIn: false }); + expect(result).toEqual({ optedIn: false, participantCount: 0 }); expect(mockMessenger.call).not.toHaveBeenCalled(); }); @@ -19429,7 +18894,11 @@ describe('RewardsController', () => { state: { ...getRewardsControllerDefaultState(), campaignParticipantStatus: { - [cacheKey]: { optedIn: false, lastFetched: Date.now() }, + [cacheKey]: { + optedIn: false, + participantCount: 0, + lastFetched: Date.now(), + }, }, }, isCampaignsEnabled: () => true, @@ -19447,7 +18916,7 @@ describe('RewardsController', () => { let mockMessenger: jest.Mocked; const mockSubscriptionId = 'sub123'; const mockCampaignId = 'campaign-456'; - const mockStatus = { optedIn: true }; + const mockStatus = { optedIn: true, participantCount: 42 }; beforeEach(() => { mockMessenger = { @@ -19462,7 +18931,7 @@ describe('RewardsController', () => { } as unknown as jest.Mocked; }); - it('returns { optedIn: false } when rewards feature flag is disabled', async () => { + it('returns { optedIn: false, participantCount: 0 } when rewards feature flag is disabled', async () => { const disabledController = new RewardsController({ messenger: mockMessenger, state: getRewardsControllerDefaultState(), @@ -19474,11 +18943,11 @@ describe('RewardsController', () => { mockSubscriptionId, ); - expect(result).toEqual({ optedIn: false }); + expect(result).toEqual({ optedIn: false, participantCount: 0 }); expect(mockMessenger.call).not.toHaveBeenCalled(); }); - it('returns { optedIn: false } when campaigns feature flag is disabled', async () => { + it('returns { optedIn: false, participantCount: 0 } when campaigns feature flag is disabled', async () => { const disabledController = new RewardsController({ messenger: mockMessenger, state: getRewardsControllerDefaultState(), @@ -19490,7 +18959,7 @@ describe('RewardsController', () => { mockSubscriptionId, ); - expect(result).toEqual({ optedIn: false }); + expect(result).toEqual({ optedIn: false, participantCount: 0 }); expect(mockMessenger.call).not.toHaveBeenCalled(); }); @@ -19531,7 +19000,11 @@ describe('RewardsController', () => { state: { ...getRewardsControllerDefaultState(), campaignParticipantStatus: { - [cacheKey]: { optedIn: false, lastFetched: recentTime }, + [cacheKey]: { + optedIn: false, + participantCount: 10, + lastFetched: recentTime, + }, }, }, isCampaignsEnabled: () => true, @@ -19542,7 +19015,7 @@ describe('RewardsController', () => { mockSubscriptionId, ); - expect(result).toEqual({ optedIn: false }); + expect(result).toEqual({ optedIn: false, participantCount: 10 }); expect(mockMessenger.call).not.toHaveBeenCalled(); }); @@ -19555,7 +19028,11 @@ describe('RewardsController', () => { state: { ...getRewardsControllerDefaultState(), campaignParticipantStatus: { - [cacheKey]: { optedIn: false, lastFetched: staleTime }, + [cacheKey]: { + optedIn: false, + participantCount: 0, + lastFetched: staleTime, + }, }, }, isCampaignsEnabled: () => true, diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.ts index c95ae939a9d..76a014c9224 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.ts @@ -21,7 +21,6 @@ import { type PointsBoostDto, type PointsEventDto, type RewardDto, - type SnapshotDto, type CampaignDto, type CampaignParticipantStatusDto, type PointsEstimateHistoryEntry, @@ -94,9 +93,6 @@ const ACTIVE_BOOSTS_CACHE_THRESHOLD_MS = 1000 * 60 * 1; // 1 minute // Unlocked rewards cache threshold const UNLOCKED_REWARDS_CACHE_THRESHOLD_MS = 1000 * 60 * 1; // 1 minute -// Snapshots cache threshold -const SNAPSHOTS_CACHE_THRESHOLD_MS = 1000 * 60 * 5; // 5 minutes - // Off-device subscription accounts cache threshold const OFF_DEVICE_SUBSCRIPTION_ACCOUNTS_CACHE_THRESHOLD_MS = 1000 * 60 * 5; // 5 minutes @@ -173,12 +169,6 @@ const metadata: StateMetadata = { includeInDebugSnapshot: false, usedInUi: true, }, - snapshots: { - includeInStateLogs: true, - persist: true, - includeInDebugSnapshot: false, - usedInUi: true, - }, offDeviceSubscriptionAccounts: { includeInStateLogs: true, persist: true, @@ -224,7 +214,6 @@ export const getRewardsControllerDefaultState = (): RewardsControllerState => ({ activeBoosts: {}, unlockedRewards: {}, pointsEvents: {}, - snapshots: {}, offDeviceSubscriptionAccounts: {}, campaigns: {}, campaignParticipantStatus: {}, @@ -324,7 +313,6 @@ export class RewardsController extends BaseController< #isDisabled: () => boolean; #isBitcoinOptinEnabled: () => boolean; #isTronOptinEnabled: () => boolean; - #isSnapshotsEnabled: () => boolean; #isCampaignsEnabled: () => boolean; #reauthPromises: Map> = new Map(); @@ -478,7 +466,6 @@ export class RewardsController extends BaseController< isDisabled, isBitcoinOptinEnabled, isTronOptinEnabled, - isSnapshotsEnabled, isCampaignsEnabled, }: { messenger: RewardsControllerMessenger; @@ -486,7 +473,6 @@ export class RewardsController extends BaseController< isDisabled?: () => boolean; isBitcoinOptinEnabled?: () => boolean; isTronOptinEnabled?: () => boolean; - isSnapshotsEnabled?: () => boolean; isCampaignsEnabled?: () => boolean; }) { super({ @@ -502,7 +488,6 @@ export class RewardsController extends BaseController< this.#isDisabled = isDisabled ?? (() => false); this.#isBitcoinOptinEnabled = isBitcoinOptinEnabled ?? (() => false); this.#isTronOptinEnabled = isTronOptinEnabled ?? (() => false); - this.#isSnapshotsEnabled = isSnapshotsEnabled ?? (() => false); this.#isCampaignsEnabled = isCampaignsEnabled ?? (() => false); this.#registerActionHandlers(); @@ -597,10 +582,6 @@ export class RewardsController extends BaseController< 'RewardsController:getUnlockedRewards', this.getUnlockedRewards.bind(this), ); - this.messenger.registerActionHandler( - 'RewardsController:getSnapshots', - this.getSnapshots.bind(this), - ); this.messenger.registerActionHandler( 'RewardsController:getOffDeviceSubscriptionAccounts', this.getOffDeviceSubscriptionAccounts.bind(this), @@ -3301,60 +3282,6 @@ export class RewardsController extends BaseController< return result; } - /** - * Get snapshots for a season with caching - * @param seasonId - The season ID - * @param subscriptionId - The subscription ID for authentication - * @returns Promise - The snapshots data - */ - async getSnapshots( - seasonId: string, - subscriptionId: string, - ): Promise { - const rewardsEnabled = this.isRewardsFeatureEnabled(); - if (!rewardsEnabled) { - return []; - } - if (!this.#isSnapshotsEnabled()) { - throw new Error('Snapshots feature is not enabled'); - } - const result = await wrapWithCache({ - key: seasonId, - ttl: SNAPSHOTS_CACHE_THRESHOLD_MS, - readCache: (key) => { - const cachedSnapshots = this.state.snapshots[key] || undefined; - if (!cachedSnapshots) return; - return { - payload: cachedSnapshots.snapshots, - lastFetched: cachedSnapshots.lastFetched, - }; - }, - fetchFresh: async () => - this.#withAuthRetry(async () => { - Logger.log( - 'RewardsController: Fetching fresh snapshots data via API call for seasonId', - seasonId, - ); - const response = (await this.messenger.call( - 'RewardsDataService:getSnapshots', - seasonId, - subscriptionId, - )) as SnapshotDto[]; - return response || []; - }, subscriptionId), - writeCache: (key, payload) => { - this.update((state: RewardsControllerState) => { - state.snapshots[key] = { - snapshots: payload, - lastFetched: Date.now(), - }; - }); - }, - }); - - return result; - } - /** * Get CAIP-10 encoded accounts linked to a subscription with caching * @param subscriptionId - The subscription ID for authentication @@ -3506,7 +3433,7 @@ export class RewardsController extends BaseController< subscriptionId: string, ): Promise { if (!this.isRewardsFeatureEnabled() || !this.#isCampaignsEnabled()) { - return { optedIn: false }; + return { optedIn: false, participantCount: 0 }; } const result = await this.#withAuthRetry(async () => { Logger.log('RewardsController: Opting into campaign', campaignId); @@ -3539,7 +3466,7 @@ export class RewardsController extends BaseController< subscriptionId: string, ): Promise { if (!this.isRewardsFeatureEnabled() || !this.#isCampaignsEnabled()) { - return { optedIn: false }; + return { optedIn: false, participantCount: 0 }; } const key = `${subscriptionId}:${campaignId}`; const result = await wrapWithCache({ @@ -3549,7 +3476,10 @@ export class RewardsController extends BaseController< const cached = this.state.campaignParticipantStatus[k]; if (!cached) return undefined; return { - payload: { optedIn: cached.optedIn }, + payload: { + optedIn: cached.optedIn, + participantCount: cached.participantCount, + }, lastFetched: cached.lastFetched, }; }, @@ -3568,6 +3498,7 @@ export class RewardsController extends BaseController< this.update((state: RewardsControllerState) => { state.campaignParticipantStatus[k] = { optedIn: payload.optedIn, + participantCount: payload.participantCount, lastFetched: Date.now(), }; }); diff --git a/app/core/Engine/controllers/rewards-controller/index.ts b/app/core/Engine/controllers/rewards-controller/index.ts index d2977d0d2f1..52c0be1a1a6 100644 --- a/app/core/Engine/controllers/rewards-controller/index.ts +++ b/app/core/Engine/controllers/rewards-controller/index.ts @@ -2,7 +2,6 @@ import { selectBasicFunctionalityEnabled } from '../../../../selectors/settings' import { selectBitcoinRewardsEnabledFlag, selectTronRewardsEnabledFlag, - selectSnapshotsRewardsEnabledFlag, selectCampaignsRewardsEnabledFlag, } from '../../../../selectors/featureFlagController/rewards/rewardsEnabled'; import type { ControllerInitFunction } from '../../types'; @@ -35,7 +34,6 @@ export const rewardsControllerInit: ControllerInitFunction< }, isBitcoinOptinEnabled: () => selectBitcoinRewardsEnabledFlag(getState()), isTronOptinEnabled: () => selectTronRewardsEnabledFlag(getState()), - isSnapshotsEnabled: () => selectSnapshotsRewardsEnabledFlag(getState()), isCampaignsEnabled: () => selectCampaignsRewardsEnabledFlag(getState()), }); diff --git a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts index d2dd985b486..20aaebdda00 100644 --- a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts +++ b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts @@ -20,7 +20,6 @@ import type { DiscoverSeasonsDto, SeasonMetadataDto, LineaTokenRewardDto, - SnapshotDto, CampaignDto, } from '../types'; import { getSubscriptionToken } from '../utils/multi-subscription-token-vault'; @@ -1177,11 +1176,6 @@ describe('RewardsDataService', () => { service.getUnlockedRewards('season-1', 'sub-1'), ).rejects.toBeInstanceOf(AuthorizationFailedError); - mockFetch.mockResolvedValue(mockResponse); - await expect( - service.getSnapshots('season-1', 'sub-1'), - ).rejects.toBeInstanceOf(AuthorizationFailedError); - mockFetch.mockResolvedValue(mockResponse); await expect(service.optOut('sub-1')).rejects.toBeInstanceOf( AuthorizationFailedError, @@ -4220,178 +4214,6 @@ describe('RewardsDataService', () => { }); }); - describe('getSnapshots', () => { - const mockSeasonId = 'season-123'; - const mockSubscriptionId = 'sub-456'; - const mockToken = 'test-bearer-token'; - - const mockSnapshotsResponse: SnapshotDto[] = [ - { - id: '01974010-377f-7553-a365-0c33c8130980', - seasonId: mockSeasonId, - name: 'Monad Airdrop', - description: 'Earn Monad tokens by participating in the airdrop', - tokenSymbol: 'MONAD', - tokenAmount: '50000000000000000000000', - tokenChainId: '1', - tokenAddress: '0x1234567890abcdef1234567890abcdef12345678', - receivingBlockchain: 'Ethereum', - opensAt: '2025-03-01T00:00:00.000Z', - closesAt: '2025-03-15T00:00:00.000Z', - calculatedAt: '2025-03-16T00:00:00.000Z', - distributedAt: '2025-03-20T00:00:00.000Z', - backgroundImage: { - lightModeUrl: 'https://example.com/light.png', - darkModeUrl: 'https://example.com/dark.png', - }, - }, - { - id: '02985121-488g-8664-b476-1d44d9241091', - seasonId: mockSeasonId, - name: 'ETH Rewards', - tokenSymbol: 'ETH', - tokenAmount: '1000000000000000000', - tokenChainId: '1', - backgroundImage: { - lightModeUrl: 'https://example.com/light.png', - darkModeUrl: 'https://example.com/dark.png', - }, - receivingBlockchain: 'Ethereum', - opensAt: '2025-04-01T00:00:00.000Z', - closesAt: '2025-04-15T00:00:00.000Z', - }, - ]; - - beforeEach(() => { - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(mockSnapshotsResponse), - } as unknown as Response; - mockGetSubscriptionToken.mockResolvedValue({ - success: true, - token: mockToken, - }); - mockFetch.mockResolvedValue(mockResponse); - }); - - it('should successfully get snapshots', async () => { - // Act - const result = await service.getSnapshots( - mockSeasonId, - mockSubscriptionId, - ); - - // Assert - expect(mockGetSubscriptionToken).toHaveBeenCalledWith(mockSubscriptionId); - expect(mockFetch).toHaveBeenCalledWith( - `https://uat.rewards.test/v1/seasons/${mockSeasonId}/snapshots`, - expect.objectContaining({ - method: 'GET', - headers: expect.objectContaining({ - 'Accept-Language': 'en-US', - 'Content-Type': 'application/json', - 'rewards-client-id': 'mobile-7.50.1', - }), - credentials: 'omit', - }), - ); - expect(result).toEqual(mockSnapshotsResponse); - expect(result).toHaveLength(2); - expect(result[0].id).toBe('01974010-377f-7553-a365-0c33c8130980'); - expect(result[0].name).toBe('Monad Airdrop'); - expect(result[1].name).toBe('ETH Rewards'); - }); - - it('should handle empty snapshots array', async () => { - // Arrange - const emptyResponse: SnapshotDto[] = []; - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue(emptyResponse), - } as unknown as Response; - mockFetch.mockResolvedValue(mockResponse); - - // Act - const result = await service.getSnapshots( - mockSeasonId, - mockSubscriptionId, - ); - - // Assert - expect(result).toEqual([]); - expect(result).toHaveLength(0); - }); - - it('should throw error when response is not ok', async () => { - // Arrange - const mockResponse = { - ok: false, - status: 404, - } as Response; - mockFetch.mockResolvedValue(mockResponse); - - // Act & Assert - await expect( - service.getSnapshots(mockSeasonId, mockSubscriptionId), - ).rejects.toThrow('Get snapshots failed: 404'); - }); - - it('should throw error when response is 500', async () => { - // Arrange - const mockResponse = { - ok: false, - status: 500, - } as Response; - mockFetch.mockResolvedValue(mockResponse); - - // Act & Assert - await expect( - service.getSnapshots(mockSeasonId, mockSubscriptionId), - ).rejects.toThrow('Get snapshots failed: 500'); - }); - - it('should throw error when fetch fails', async () => { - // Arrange - const fetchError = new Error('Network error'); - mockFetch.mockRejectedValue(fetchError); - - // Act & Assert - await expect( - service.getSnapshots(mockSeasonId, mockSubscriptionId), - ).rejects.toThrow('Network error'); - }); - - it('should handle different season IDs correctly', async () => { - // Arrange - const differentSeasonId = 'current-season'; - - // Act - await service.getSnapshots(differentSeasonId, mockSubscriptionId); - - // Assert - expect(mockFetch).toHaveBeenCalledWith( - `https://uat.rewards.test/v1/seasons/${differentSeasonId}/snapshots`, - expect.any(Object), - ); - }); - - it('should include subscription token in authentication', async () => { - // Act - await service.getSnapshots(mockSeasonId, mockSubscriptionId); - - // Assert - expect(mockGetSubscriptionToken).toHaveBeenCalledWith(mockSubscriptionId); - expect(mockFetch).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - headers: expect.objectContaining({ - 'rewards-client-id': 'mobile-7.50.1', - }), - }), - ); - }); - }); - describe('getSubscriptionAccounts', () => { const mockSubscriptionId = 'sub-456'; const mockToken = 'test-bearer-token'; @@ -4500,6 +4322,7 @@ describe('RewardsDataService', () => { termsAndConditions: null, excludedRegions: [], statusLabel: 'Active', + details: null, }, ]; @@ -4564,7 +4387,7 @@ describe('RewardsDataService', () => { const mockSubscriptionId = 'sub-456'; const mockCampaignId = 'campaign-789'; const mockToken = 'test-bearer-token'; - const mockStatusResponse = { optedIn: true }; + const mockStatusResponse = { optedIn: true, participantCount: 42 }; beforeEach(() => { mockGetSubscriptionToken.mockResolvedValue({ @@ -4608,7 +4431,7 @@ describe('RewardsDataService', () => { const mockSubscriptionId = 'sub-456'; const mockCampaignId = 'campaign-789'; const mockToken = 'test-bearer-token'; - const mockStatusResponse = { optedIn: false }; + const mockStatusResponse = { optedIn: false, participantCount: 0 }; beforeEach(() => { mockGetSubscriptionToken.mockResolvedValue({ diff --git a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts index 8745cb6f7a1..d51c76ebf94 100644 --- a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts +++ b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts @@ -25,7 +25,6 @@ import type { LineaTokenRewardDto, ApplyReferralDto, ApplyBonusCodeDto, - SnapshotDto, CampaignDto, CampaignParticipantStatusDto, } from '../types'; @@ -195,11 +194,6 @@ export interface RewardsDataServiceApplyBonusCodeAction { handler: RewardsDataService['applyBonusCode']; } -export interface RewardsDataServiceGetSnapshotsAction { - type: `${typeof SERVICE_NAME}:getSnapshots`; - handler: RewardsDataService['getSnapshots']; -} - export interface RewardsDataServiceGetSubscriptionAccountsAction { type: `${typeof SERVICE_NAME}:getSubscriptionAccounts`; handler: RewardsDataService['getSubscriptionAccounts']; @@ -262,7 +256,6 @@ export type RewardsDataServiceActions = | RewardsDataServiceGetSeasonMetadataAction | RewardsDataServiceGetSeasonOneLineaRewardTokensAction | RewardsDataServiceApplyReferralCodeAction - | RewardsDataServiceGetSnapshotsAction | RewardsDataServiceGetRewardsEnvUrlAction | RewardsDataServiceCanChangeRewardsEnvUrlAction | RewardsDataServiceSetRewardsEnvUrlAction @@ -403,10 +396,6 @@ export class RewardsDataService { `${SERVICE_NAME}:applyBonusCode`, this.applyBonusCode.bind(this), ); - this.#messenger.registerActionHandler( - `${SERVICE_NAME}:getSnapshots`, - this.getSnapshots.bind(this), - ); this.#messenger.registerActionHandler( `${SERVICE_NAME}:getSubscriptionAccounts`, this.getSubscriptionAccounts.bind(this), @@ -1272,31 +1261,6 @@ export class RewardsDataService { } } - /** - * Get snapshots for a specific season. - * @param seasonId - The ID of the season to get snapshots for. - * @param subscriptionId - The subscription ID for authentication. - * @returns The list of snapshots for the season. - */ - async getSnapshots( - seasonId: string, - subscriptionId: string, - ): Promise { - const response = await this.makeRequest( - `/v1/seasons/${seasonId}/snapshots`, - { - method: 'GET', - }, - subscriptionId, - ); - - if (!response.ok) { - throw new Error(`Get snapshots failed: ${response.status}`); - } - - return (await response.json()) as SnapshotDto[]; - } - /** * Get CAIP-10 encoded account addresses linked to the current subscription. * @param subscriptionId - The subscription ID for authentication. diff --git a/app/core/Engine/controllers/rewards-controller/types.ts b/app/core/Engine/controllers/rewards-controller/types.ts index 374e1176b14..26d83d51553 100644 --- a/app/core/Engine/controllers/rewards-controller/types.ts +++ b/app/core/Engine/controllers/rewards-controller/types.ts @@ -144,6 +144,12 @@ export interface CampaignDto { * @example 'Active' */ statusLabel: string; + + /** + * The details of the campaign + * @example { image: { lightModeUrl: 'https://example.com/image.png', darkModeUrl: 'https://example.com/image-dark.png' }, howItWorks: { title: 'How it works', description: 'How it works', phases: [{ name: 'Phase 1', daysLabel: 'Days', sortOrder: 1, steps: [{ title: 'Step 1', description: 'Step 1', iconName: 'icon-name' }] }] } } + */ + details: CampaignDetails | null; } // eslint-disable-next-line @typescript-eslint/consistent-type-definitions @@ -157,6 +163,27 @@ export type CampaignsState = { termsAndConditions: Json | null; excludedRegions: string[]; statusLabel: string; + details: { + image: { + lightModeUrl: string; + darkModeUrl: string; + }; + howItWorks: { + title: string; + description: string; + phases: { + name: string; + daysLabel: string; + sortOrder: number; + steps: { + title: string; + description: string; + iconName: string; + }[]; + }[]; + notes?: Json | null; + }; + } | null; }[]; lastFetched: number; }; @@ -167,116 +194,55 @@ export type CampaignsState = { export interface CampaignParticipantStatusDto { /** Whether the subscription has opted into the campaign */ optedIn: boolean; + + /** + * The number of participants in the campaign + * @example 100 + */ + participantCount: number; } // eslint-disable-next-line @typescript-eslint/consistent-type-definitions export type CampaignParticipantStatusState = { optedIn: boolean; + participantCount: number; lastFetched: number; }; -/** - * DTO for snapshot data from the backend - */ -export interface SnapshotDto { - /** - * The unique identifier of the snapshot - * @example '01974010-377f-7553-a365-0c33c8130980' - */ - id: string; - - /** - * The season ID this snapshot belongs to - * @example '7444682d-9050-43b8-9038-28a6a62d6264' - */ - seasonId: string; +export interface OndoCampaignStep { + title: string; + description: string; + iconName: string; +} - /** - * The name of the snapshot/airdrop - * @example 'Monad Airdrop' - */ +export interface OndoCampaignPhase { name: string; + daysLabel: string; + sortOrder: number; + steps: OndoCampaignStep[]; +} - /** - * Optional description of the snapshot - * @example 'Earn Monad tokens by participating in the airdrop' - */ - description?: string; - - /** - * The token symbol being distributed - * @example 'MONAD' - */ - tokenSymbol: string; - - /** - * The token amount as a serialized bigint string - * @example '50000000000000000000000' - */ - tokenAmount: string; - - /** - * The chain ID as a serialized bigint string - * @example '1' - */ - tokenChainId: string; - - /** - * Optional token contract address - * @example '0x1234567890abcdef1234567890abcdef12345678' - */ - tokenAddress?: string; - - /** - * The blockchain where tokens will be distributed - * @example 'Ethereum' - */ - receivingBlockchain: string; - - /** - * When the snapshot opens (ISO date string) - * @example '2025-03-01T00:00:00.000Z' - */ - opensAt: string; - - /** - * When the snapshot closes (ISO date string) - * @example '2025-03-15T00:00:00.000Z' - */ - closesAt: string; - - /** - * When results were calculated (ISO date string) - * @example '2025-03-16T00:00:00.000Z' - */ - calculatedAt?: string; - - /** - * When tokens were distributed (ISO date string) - * @example '2025-03-20T00:00:00.000Z' - */ - distributedAt?: string; +export interface OndoCampaignHowItWorks { + title: string; + description: string; + phases: OndoCampaignPhase[]; + notes?: Json | null; +} - /** - * Background image for the snapshot tile - */ - backgroundImage: ThemeImage; +export interface OndoHoldingDetails { + image: ThemeImage; + howItWorks: OndoCampaignHowItWorks; } +export type CampaignDetails = OndoHoldingDetails; + /** - * Snapshot status derived from dates - * - upcoming: now < opensAt - * - live: opensAt <= now < closesAt - * - calculating: closesAt <= now && !calculatedAt - * - distributing: calculatedAt && !distributedAt - * - complete: distributedAt is set + * Campaign status derived from dates + * - upcoming: now < startDate + * - active: startDate <= now < endDate + * - complete: now >= endDate */ -export type SnapshotStatus = - | 'upcoming' - | 'live' - | 'calculating' - | 'distributing' - | 'complete'; +export type CampaignStatus = 'upcoming' | 'active' | 'complete'; export interface EstimateAssetDto { /** @@ -930,30 +896,6 @@ export type OffDeviceSubscriptionAccountsState = { lastFetched: number; }; -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export type SnapshotsState = { - snapshots: { - id: string; - seasonId: string; - name: string; - description?: string; - tokenSymbol: string; - tokenAmount: string; - tokenChainId: string; - tokenAddress?: string; - receivingBlockchain: string; - opensAt: string; - closesAt: string; - calculatedAt?: string; - distributedAt?: string; - backgroundImage: { - lightModeUrl: string; - darkModeUrl: string; - }; - }[]; - lastFetched: number; -}; - // eslint-disable-next-line @typescript-eslint/consistent-type-definitions export type PointsEventsDtoState = { results: { @@ -1286,7 +1228,6 @@ export type RewardsControllerState = { activeBoosts: { [compositeId: string]: ActiveBoostsState }; unlockedRewards: { [compositeId: string]: UnlockedRewardsState }; pointsEvents: { [compositeId: string]: PointsEventsDtoState }; - snapshots: { [seasonId: string]: SnapshotsState }; offDeviceSubscriptionAccounts: { [subscriptionId: string]: OffDeviceSubscriptionAccountsState; }; @@ -1670,14 +1611,6 @@ export interface RewardsControllerGetCampaignParticipantStatusAction { ) => Promise; } -/** - * Action for getting snapshots for a season - */ -export interface RewardsControllerGetSnapshotsAction { - type: 'RewardsController:getSnapshots'; - handler: (seasonId: string, subscriptionId: string) => Promise; -} - /** * Action for getting CAIP-10 accounts linked to a subscription that are not on this device */ @@ -1782,7 +1715,6 @@ export type RewardsControllerActions = | RewardsControllerGetCampaignsAction | RewardsControllerOptInToCampaignAction | RewardsControllerGetCampaignParticipantStatusAction - | RewardsControllerGetSnapshotsAction | RewardsControllerGetOffDeviceSubscriptionAccountsAction | RewardsControllerClaimRewardAction | RewardsControllerGetSeasonOneLineaRewardTokensAction diff --git a/app/core/Engine/messengers/rewards-controller-messenger/index.ts b/app/core/Engine/messengers/rewards-controller-messenger/index.ts index 661b65acfd4..7cd64254b90 100644 --- a/app/core/Engine/messengers/rewards-controller-messenger/index.ts +++ b/app/core/Engine/messengers/rewards-controller-messenger/index.ts @@ -48,7 +48,6 @@ import { RewardsDataServiceGetSeasonOneLineaRewardTokensAction, RewardsDataServiceApplyReferralCodeAction, RewardsDataServiceApplyBonusCodeAction, - RewardsDataServiceGetSnapshotsAction, RewardsDataServiceGetRewardsEnvUrlAction, RewardsDataServiceCanChangeRewardsEnvUrlAction, RewardsDataServiceSetRewardsEnvUrlAction, @@ -91,7 +90,6 @@ type AllowedActions = | RewardsDataServiceGetSeasonMetadataAction | RewardsDataServiceGetSeasonOneLineaRewardTokensAction | RewardsDataServiceApplyReferralCodeAction - | RewardsDataServiceGetSnapshotsAction | RewardsDataServiceGetRewardsEnvUrlAction | RewardsDataServiceCanChangeRewardsEnvUrlAction | RewardsDataServiceSetRewardsEnvUrlAction @@ -157,7 +155,6 @@ export function getRewardsControllerMessenger( 'RewardsDataService:getSeasonOneLineaRewardTokens', 'RewardsDataService:applyReferralCode', 'RewardsDataService:applyBonusCode', - 'RewardsDataService:getSnapshots', 'RewardsDataService:getSubscriptionAccounts', 'RewardsDataService:getCampaigns', 'RewardsDataService:optInToCampaign', diff --git a/app/reducers/rewards/index.test.ts b/app/reducers/rewards/index.test.ts index 06af21f7415..ca0a5a27f94 100644 --- a/app/reducers/rewards/index.test.ts +++ b/app/reducers/rewards/index.test.ts @@ -24,12 +24,10 @@ import rewardsReducer, { setUnlockedRewardLoading, setUnlockedRewardError, setPointsEvents, - setSnapshots, - setSnapshotsLoading, - setSnapshotsError, setCampaigns, setCampaignsLoading, setCampaignsError, + setCampaignParticipantStatus, bulkLinkStarted, bulkLinkAccountResult, bulkLinkCompleted, @@ -45,7 +43,6 @@ import { SeasonStatusState, RewardClaimStatus, PointsEventDto, - SnapshotDto, CampaignDto, CampaignType, } from '../../core/Engine/controllers/rewards-controller/types'; @@ -2133,12 +2130,10 @@ describe('rewardsReducer', () => { wasInterrupted: false, initialSubscriptionId: null, }, - snapshots: null, - snapshotsLoading: false, - snapshotsError: false, campaigns: [], campaignsLoading: false, campaignsError: false, + campaignParticipantStatuses: {}, }; const action = resetRewardsState(); @@ -2239,12 +2234,10 @@ describe('rewardsReducer', () => { wasInterrupted: false, initialSubscriptionId: null, }, - snapshots: null, - snapshotsLoading: false, - snapshotsError: false, campaigns: [], campaignsLoading: false, campaignsError: false, + campaignParticipantStatuses: {}, }; const rehydrateAction = { type: 'persist/REHYDRATE', @@ -4458,333 +4451,6 @@ describe('persist/REHYDRATE with bulk link state', () => { }); }); -describe('setSnapshots', () => { - const mockSnapshot: SnapshotDto = { - id: '01974010-377f-7553-a365-0c33c8130980', - seasonId: '7444682d-9050-43b8-9038-28a6a62d6264', - name: 'Monad Airdrop', - description: 'Earn Monad tokens by participating in the airdrop', - tokenSymbol: 'MONAD', - tokenAmount: '50000000000000000000000', - tokenChainId: '1', - tokenAddress: '0x1234567890abcdef1234567890abcdef12345678', - receivingBlockchain: 'Ethereum', - opensAt: '2025-03-01T00:00:00.000Z', - closesAt: '2025-03-15T00:00:00.000Z', - calculatedAt: '2025-03-16T00:00:00.000Z', - distributedAt: '2025-03-20T00:00:00.000Z', - backgroundImage: { - lightModeUrl: 'https://example.com/light.png', - darkModeUrl: 'https://example.com/dark.png', - }, - }; - - it('should set snapshots array', () => { - // Arrange - const mockSnapshots: SnapshotDto[] = [mockSnapshot]; - const action = setSnapshots(mockSnapshots); - - // Act - const state = rewardsReducer(initialState, action); - - // Assert - expect(state.snapshots).toEqual(mockSnapshots); - expect(state.snapshotsError).toBe(false); - }); - - it('should replace existing snapshots with new ones', () => { - // Arrange - const stateWithSnapshots: RewardsState = { - ...initialState, - snapshots: [mockSnapshot], - }; - const newSnapshot: SnapshotDto = { - ...mockSnapshot, - id: 'new-snapshot-id', - name: 'New Airdrop', - }; - const action = setSnapshots([newSnapshot]); - - // Act - const state = rewardsReducer(stateWithSnapshots, action); - - // Assert - expect(state.snapshots).toHaveLength(1); - expect(state.snapshots?.[0].id).toBe('new-snapshot-id'); - expect(state.snapshots?.[0].name).toBe('New Airdrop'); - }); - - it('should set snapshots to empty array', () => { - // Arrange - const stateWithSnapshots: RewardsState = { - ...initialState, - snapshots: [mockSnapshot], - }; - const action = setSnapshots([]); - - // Act - const state = rewardsReducer(stateWithSnapshots, action); - - // Assert - expect(state.snapshots).toEqual([]); - expect(state.snapshotsError).toBe(false); - }); - - it('should set snapshots to null', () => { - // Arrange - const stateWithSnapshots: RewardsState = { - ...initialState, - snapshots: [mockSnapshot], - }; - const action = setSnapshots(null); - - // Act - const state = rewardsReducer(stateWithSnapshots, action); - - // Assert - expect(state.snapshots).toBeNull(); - expect(state.snapshotsError).toBe(false); - }); - - it('should reset snapshotsError when setting snapshots', () => { - // Arrange - const stateWithError: RewardsState = { - ...initialState, - snapshotsError: true, - }; - const action = setSnapshots([mockSnapshot]); - - // Act - const state = rewardsReducer(stateWithError, action); - - // Assert - expect(state.snapshots).toEqual([mockSnapshot]); - expect(state.snapshotsError).toBe(false); - }); -}); - -describe('setSnapshotsLoading', () => { - it('should set snapshotsLoading to true when no snapshots exist', () => { - // Arrange - const action = setSnapshotsLoading(true); - - // Act - const state = rewardsReducer(initialState, action); - - // Assert - expect(state.snapshotsLoading).toBe(true); - }); - - it('should not set loading to true when snapshots already exist', () => { - // Arrange - const mockSnapshot: SnapshotDto = { - id: '01974010-377f-7553-a365-0c33c8130980', - seasonId: '7444682d-9050-43b8-9038-28a6a62d6264', - name: 'Monad Airdrop', - tokenSymbol: 'MONAD', - tokenAmount: '50000000000000000000000', - tokenChainId: '1', - receivingBlockchain: 'Ethereum', - opensAt: '2025-03-01T00:00:00.000Z', - closesAt: '2025-03-15T00:00:00.000Z', - backgroundImage: { - lightModeUrl: 'https://example.com/light.png', - darkModeUrl: 'https://example.com/dark.png', - }, - }; - const stateWithSnapshots: RewardsState = { - ...initialState, - snapshots: [mockSnapshot], - snapshotsLoading: false, - }; - const action = setSnapshotsLoading(true); - - // Act - const state = rewardsReducer(stateWithSnapshots, action); - - // Assert - loading should remain false when snapshots already loaded - expect(state.snapshotsLoading).toBe(false); - }); - - it('should set snapshotsLoading to false when loading is true', () => { - // Arrange - const stateWithLoading: RewardsState = { - ...initialState, - snapshotsLoading: true, - }; - const action = setSnapshotsLoading(false); - - // Act - const state = rewardsReducer(stateWithLoading, action); - - // Assert - expect(state.snapshotsLoading).toBe(false); - }); - - it('should set snapshotsLoading to false even when snapshots exist', () => { - // Arrange - const mockSnapshot: SnapshotDto = { - id: '01974010-377f-7553-a365-0c33c8130980', - seasonId: '7444682d-9050-43b8-9038-28a6a62d6264', - name: 'Monad Airdrop', - tokenSymbol: 'MONAD', - tokenAmount: '50000000000000000000000', - tokenChainId: '1', - receivingBlockchain: 'Ethereum', - opensAt: '2025-03-01T00:00:00.000Z', - closesAt: '2025-03-15T00:00:00.000Z', - backgroundImage: { - lightModeUrl: 'https://example.com/light.png', - darkModeUrl: 'https://example.com/dark.png', - }, - }; - const stateWithSnapshotsAndLoading: RewardsState = { - ...initialState, - snapshots: [mockSnapshot], - snapshotsLoading: true, - }; - const action = setSnapshotsLoading(false); - - // Act - const state = rewardsReducer(stateWithSnapshotsAndLoading, action); - - // Assert - expect(state.snapshotsLoading).toBe(false); - expect(state.snapshots).toHaveLength(1); - }); - - it('should not affect other state properties', () => { - // Arrange - const stateWithData: RewardsState = { - ...initialState, - activeTab: 'activity' as const, - referralCode: 'TEST123', - }; - const action = setSnapshotsLoading(true); - - // Act - const state = rewardsReducer(stateWithData, action); - - // Assert - expect(state.snapshotsLoading).toBe(true); - expect(state.activeTab).toBe('activity'); - expect(state.referralCode).toBe('TEST123'); - }); - - it('should allow setting loading true when snapshots is empty array', () => { - // Arrange - const stateWithEmptySnapshots: RewardsState = { - ...initialState, - snapshots: [], - snapshotsLoading: false, - }; - const action = setSnapshotsLoading(true); - - // Act - const state = rewardsReducer(stateWithEmptySnapshots, action); - - // Assert - loading should be set to true when snapshots array is empty - expect(state.snapshotsLoading).toBe(true); - }); - - it('should allow setting loading true when snapshots is null', () => { - // Arrange - const stateWithNullSnapshots: RewardsState = { - ...initialState, - snapshots: null, - snapshotsLoading: false, - }; - const action = setSnapshotsLoading(true); - - // Act - const state = rewardsReducer(stateWithNullSnapshots, action); - - // Assert - loading should be set to true when snapshots is null - expect(state.snapshotsLoading).toBe(true); - }); -}); - -describe('setSnapshotsError', () => { - it('should set snapshotsError to true', () => { - // Arrange - const action = setSnapshotsError(true); - - // Act - const state = rewardsReducer(initialState, action); - - // Assert - expect(state.snapshotsError).toBe(true); - }); - - it('should set snapshotsError to false', () => { - // Arrange - const stateWithError: RewardsState = { - ...initialState, - snapshotsError: true, - }; - const action = setSnapshotsError(false); - - // Act - const state = rewardsReducer(stateWithError, action); - - // Assert - expect(state.snapshotsError).toBe(false); - }); - - it('should not affect other state properties', () => { - // Arrange - const mockSnapshot: SnapshotDto = { - id: '01974010-377f-7553-a365-0c33c8130980', - seasonId: '7444682d-9050-43b8-9038-28a6a62d6264', - name: 'Monad Airdrop', - tokenSymbol: 'MONAD', - tokenAmount: '50000000000000000000000', - tokenChainId: '1', - receivingBlockchain: 'Ethereum', - opensAt: '2025-03-01T00:00:00.000Z', - closesAt: '2025-03-15T00:00:00.000Z', - backgroundImage: { - lightModeUrl: 'https://example.com/light.png', - darkModeUrl: 'https://example.com/dark.png', - }, - }; - const stateWithData: RewardsState = { - ...initialState, - snapshots: [mockSnapshot], - snapshotsLoading: true, - }; - const action = setSnapshotsError(true); - - // Act - const state = rewardsReducer(stateWithData, action); - - // Assert - expect(state.snapshotsError).toBe(true); - expect(state.snapshots).toHaveLength(1); - expect(state.snapshotsLoading).toBe(true); - }); - - it('should toggle error state correctly', () => { - // Arrange - let currentState = initialState; - - // Act & Assert - Set error to true - let action = setSnapshotsError(true); - currentState = rewardsReducer(currentState, action); - expect(currentState.snapshotsError).toBe(true); - - // Act & Assert - Set error back to false - action = setSnapshotsError(false); - currentState = rewardsReducer(currentState, action); - expect(currentState.snapshotsError).toBe(false); - - // Act & Assert - Set error to true again - action = setSnapshotsError(true); - currentState = rewardsReducer(currentState, action); - expect(currentState.snapshotsError).toBe(true); - }); -}); - const mockCampaign: CampaignDto = { id: 'campaign-1', type: 'ONDO_HOLDING' as CampaignType, @@ -4794,6 +4460,7 @@ const mockCampaign: CampaignDto = { termsAndConditions: null, excludedRegions: [], statusLabel: 'Active', + details: null, }; describe('setCampaigns', () => { @@ -4936,3 +4603,69 @@ describe('setCampaignsError', () => { expect(currentState.campaignsError).toBe(true); }); }); + +describe('setCampaignParticipantStatus', () => { + it('should set participant status for a campaign', () => { + const action = setCampaignParticipantStatus({ + campaignId: 'campaign-1', + status: { optedIn: true, participantCount: 42 }, + }); + + const state = rewardsReducer(initialState, action); + + expect(state.campaignParticipantStatuses['campaign-1']).toEqual({ + optedIn: true, + participantCount: 42, + }); + }); + + it('should update existing participant status for a campaign', () => { + const stateWithStatus: RewardsState = { + ...initialState, + campaignParticipantStatuses: { + 'campaign-1': { optedIn: false, participantCount: 10 }, + }, + }; + + const action = setCampaignParticipantStatus({ + campaignId: 'campaign-1', + status: { optedIn: true, participantCount: 50 }, + }); + + const state = rewardsReducer(stateWithStatus, action); + + expect(state.campaignParticipantStatuses['campaign-1']).toEqual({ + optedIn: true, + participantCount: 50, + }); + }); + + it('should store statuses for multiple campaigns independently', () => { + let currentState = initialState; + + currentState = rewardsReducer( + currentState, + setCampaignParticipantStatus({ + campaignId: 'campaign-1', + status: { optedIn: true, participantCount: 42 }, + }), + ); + + currentState = rewardsReducer( + currentState, + setCampaignParticipantStatus({ + campaignId: 'campaign-2', + status: { optedIn: false, participantCount: 0 }, + }), + ); + + expect(currentState.campaignParticipantStatuses['campaign-1']).toEqual({ + optedIn: true, + participantCount: 42, + }); + expect(currentState.campaignParticipantStatuses['campaign-2']).toEqual({ + optedIn: false, + participantCount: 0, + }); + }); +}); diff --git a/app/reducers/rewards/index.ts b/app/reducers/rewards/index.ts index 2b35b7df6bb..477bccdd160 100644 --- a/app/reducers/rewards/index.ts +++ b/app/reducers/rewards/index.ts @@ -7,9 +7,9 @@ import { RewardDto, PointsEventDto, SeasonActivityTypeDto, - SnapshotDto, SeasonWayToEarnDto, CampaignDto, + CampaignParticipantStatusDto, } from '../../core/Engine/controllers/rewards-controller/types'; import { OnboardingStep } from './types'; import { AccountGroupId } from '@metamask/account-api'; @@ -52,7 +52,7 @@ export interface BulkLinkState { } export interface RewardsState { - activeTab: 'overview' | 'snapshots' | 'activity'; + activeTab: 'overview' | 'campaigns' | 'activity'; seasonStatusLoading: boolean; seasonStatusError: string | null; @@ -116,15 +116,13 @@ export interface RewardsState { // Bulk link state (for linking all account groups across all wallets) bulkLink: BulkLinkState; - // Snapshots state - snapshots: SnapshotDto[] | null; - snapshotsLoading: boolean; - snapshotsError: boolean; - // Campaigns state campaigns: CampaignDto[]; campaignsLoading: boolean; campaignsError: boolean; + + // Campaign participant status (keyed by campaignId) + campaignParticipantStatuses: Record; } export const initialState: RewardsState = { @@ -187,15 +185,13 @@ export const initialState: RewardsState = { initialSubscriptionId: null, }, - // Snapshots initial state - snapshots: null, - snapshotsLoading: false, - snapshotsError: false, - // Campaigns initial state campaigns: [], campaignsLoading: false, campaignsError: false, + + // Campaign participant statuses initial state + campaignParticipantStatuses: {}, }; interface RehydrateAction extends Action<'persist/REHYDRATE'> { @@ -210,7 +206,7 @@ const rewardsSlice = createSlice({ reducers: { setActiveTab: ( state, - action: PayloadAction<'overview' | 'snapshots' | 'activity'>, + action: PayloadAction<'overview' | 'campaigns' | 'activity'>, ) => { state.activeTab = action.payload; }, @@ -359,7 +355,6 @@ const rewardsSlice = createSlice({ state.activeBoosts = initialState.activeBoosts; state.pointsEvents = initialState.pointsEvents; state.unlockedRewards = initialState.unlockedRewards; - state.snapshots = initialState.snapshots; } state.candidateSubscriptionId = action.payload; @@ -449,21 +444,6 @@ const rewardsSlice = createSlice({ state.pointsEvents = action.payload; }, - // Snapshots reducers - setSnapshots: (state, action: PayloadAction) => { - state.snapshots = action.payload; - state.snapshotsError = false; - }, - setSnapshotsLoading: (state, action: PayloadAction) => { - if (action.payload && state.snapshots?.length) { - return; - } - state.snapshotsLoading = action.payload; - }, - setSnapshotsError: (state, action: PayloadAction) => { - state.snapshotsError = action.payload; - }, - // Campaigns reducers setCampaigns: (state, action: PayloadAction) => { state.campaigns = action.payload; @@ -479,6 +459,17 @@ const rewardsSlice = createSlice({ state.campaignsError = action.payload; }, + setCampaignParticipantStatus: ( + state, + action: PayloadAction<{ + campaignId: string; + status: CampaignParticipantStatusDto; + }>, + ) => { + state.campaignParticipantStatuses[action.payload.campaignId] = + action.payload.status; + }, + // Bulk link reducers bulkLinkStarted: ( state, @@ -578,7 +569,6 @@ const rewardsSlice = createSlice({ activeBoosts: action.payload.rewards.activeBoosts, pointsEvents: action.payload.rewards.pointsEvents, unlockedRewards: action.payload.rewards.unlockedRewards, - snapshots: action.payload.rewards.snapshots, hideUnlinkedAccountsBanner: action.payload.rewards.hideUnlinkedAccountsBanner, hideCurrentAccountNotOptedInBanner: @@ -632,14 +622,11 @@ export const { setUnlockedRewardLoading, setUnlockedRewardError, setPointsEvents, - // Snapshots actions - setSnapshots, - setSnapshotsLoading, - setSnapshotsError, // Campaigns actions setCampaigns, setCampaignsLoading, setCampaignsError, + setCampaignParticipantStatus, // Bulk link actions bulkLinkStarted, bulkLinkAccountResult, diff --git a/app/reducers/rewards/selectors.test.ts b/app/reducers/rewards/selectors.test.ts index 9fa37d07069..7e917ee4a7b 100644 --- a/app/reducers/rewards/selectors.test.ts +++ b/app/reducers/rewards/selectors.test.ts @@ -47,11 +47,12 @@ import { selectBulkLinkFailedAccounts, selectBulkLinkWasInterrupted, selectBulkLinkAccountProgress, - selectSnapshotsLoading, - selectSnapshotsError, selectCampaigns, selectCampaignsLoading, selectCampaignsError, + selectCampaignParticipantStatuses, + selectCampaignParticipantStatusById, + selectCampaignParticipantCount, } from './selectors'; import { OnboardingStep } from './types'; import { @@ -102,12 +103,12 @@ describe('Rewards selectors', () => { expect(result.current).toBe('overview'); }); - it('returns snapshots tab when set', () => { - const mockState = { rewards: { activeTab: 'snapshots' as const } }; + it('returns campaigns tab when set', () => { + const mockState = { rewards: { activeTab: 'campaigns' as const } }; mockedUseSelector.mockImplementation((selector) => selector(mockState)); const { result } = renderHook(() => useSelector(selectActiveTab)); - expect(result.current).toBe('snapshots'); + expect(result.current).toBe('campaigns'); }); it('returns activity tab when set', () => { @@ -3120,66 +3121,6 @@ describe('Rewards selectors', () => { }); }); - describe('selectSnapshotsLoading', () => { - it('returns false when snapshots are not loading', () => { - const mockState = { rewards: { snapshotsLoading: false } }; - mockedUseSelector.mockImplementation((selector) => selector(mockState)); - - const { result } = renderHook(() => useSelector(selectSnapshotsLoading)); - expect(result.current).toBe(false); - }); - - it('returns true when snapshots are loading', () => { - const mockState = { rewards: { snapshotsLoading: true } }; - mockedUseSelector.mockImplementation((selector) => selector(mockState)); - - const { result } = renderHook(() => useSelector(selectSnapshotsLoading)); - expect(result.current).toBe(true); - }); - - describe('Direct selector calls', () => { - it('returns false when snapshotsLoading is false', () => { - const state = createMockRootState({ snapshotsLoading: false }); - expect(selectSnapshotsLoading(state)).toBe(false); - }); - - it('returns true when snapshotsLoading is true', () => { - const state = createMockRootState({ snapshotsLoading: true }); - expect(selectSnapshotsLoading(state)).toBe(true); - }); - }); - }); - - describe('selectSnapshotsError', () => { - it('returns false when there is no snapshots error', () => { - const mockState = { rewards: { snapshotsError: false } }; - mockedUseSelector.mockImplementation((selector) => selector(mockState)); - - const { result } = renderHook(() => useSelector(selectSnapshotsError)); - expect(result.current).toBe(false); - }); - - it('returns true when there is a snapshots error', () => { - const mockState = { rewards: { snapshotsError: true } }; - mockedUseSelector.mockImplementation((selector) => selector(mockState)); - - const { result } = renderHook(() => useSelector(selectSnapshotsError)); - expect(result.current).toBe(true); - }); - - describe('Direct selector calls', () => { - it('returns false when snapshotsError is false', () => { - const state = createMockRootState({ snapshotsError: false }); - expect(selectSnapshotsError(state)).toBe(false); - }); - - it('returns true when snapshotsError is true', () => { - const state = createMockRootState({ snapshotsError: true }); - expect(selectSnapshotsError(state)).toBe(true); - }); - }); - }); - const mockCampaign: CampaignDto = { id: 'campaign-1', type: 'ONDO_HOLDING' as CampaignType, @@ -3189,6 +3130,7 @@ describe('Rewards selectors', () => { termsAndConditions: null, excludedRegions: [], statusLabel: 'Active', + details: null, }; describe('selectCampaigns', () => { @@ -3280,4 +3222,92 @@ describe('Rewards selectors', () => { }); }); }); + + describe('selectCampaignParticipantStatuses', () => { + it('returns empty object when no statuses exist', () => { + const state = createMockRootState({ + campaignParticipantStatuses: {}, + }); + expect(selectCampaignParticipantStatuses(state)).toEqual({}); + }); + + it('returns all participant statuses', () => { + const statuses = { + 'campaign-1': { optedIn: true, participantCount: 42 }, + 'campaign-2': { optedIn: false, participantCount: 0 }, + }; + const state = createMockRootState({ + campaignParticipantStatuses: statuses, + }); + expect(selectCampaignParticipantStatuses(state)).toEqual(statuses); + }); + }); + + describe('selectCampaignParticipantStatusById', () => { + it('returns null when campaignId is undefined', () => { + const state = createMockRootState({ + campaignParticipantStatuses: { + 'campaign-1': { optedIn: true, participantCount: 42 }, + }, + }); + expect(selectCampaignParticipantStatusById(undefined)(state)).toBeNull(); + }); + + it('returns null when campaign has no status', () => { + const state = createMockRootState({ + campaignParticipantStatuses: {}, + }); + expect( + selectCampaignParticipantStatusById('campaign-1')(state), + ).toBeNull(); + }); + + it('returns status for a specific campaign', () => { + const status = { optedIn: true, participantCount: 42 }; + const state = createMockRootState({ + campaignParticipantStatuses: { + 'campaign-1': status, + }, + }); + expect(selectCampaignParticipantStatusById('campaign-1')(state)).toEqual( + status, + ); + }); + }); + + describe('selectCampaignParticipantCount', () => { + it('returns null when campaignId is undefined', () => { + const state = createMockRootState({ + campaignParticipantStatuses: { + 'campaign-1': { optedIn: true, participantCount: 42 }, + }, + }); + expect(selectCampaignParticipantCount(undefined)(state)).toBeNull(); + }); + + it('returns null when campaign has no status', () => { + const state = createMockRootState({ + campaignParticipantStatuses: {}, + }); + expect(selectCampaignParticipantCount('campaign-1')(state)).toBeNull(); + }); + + it('returns participantCount for a specific campaign', () => { + const state = createMockRootState({ + campaignParticipantStatuses: { + 'campaign-1': { optedIn: true, participantCount: 42 }, + }, + }); + expect(selectCampaignParticipantCount('campaign-1')(state)).toBe(42); + }); + + it('returns 0 when participantCount is zero', () => { + const state = createMockRootState({ + campaignParticipantStatuses: { + 'campaign-1': { optedIn: false, participantCount: 0 }, + }, + }); + expect(selectCampaignParticipantCount('campaign-1')(state)).toBe(0); + }); + }); }); diff --git a/app/reducers/rewards/selectors.ts b/app/reducers/rewards/selectors.ts index 3f2da35613b..6e3af865521 100644 --- a/app/reducers/rewards/selectors.ts +++ b/app/reducers/rewards/selectors.ts @@ -151,15 +151,6 @@ export const selectBulkLinkAccountProgress = (state: RootState) => { return (linkedAccounts + failedAccounts) / totalAccounts; }; -// Snapshots selectors -export const selectSnapshots = (state: RootState) => state.rewards.snapshots; - -export const selectSnapshotsLoading = (state: RootState) => - state.rewards.snapshotsLoading; - -export const selectSnapshotsError = (state: RootState) => - state.rewards.snapshotsError; - // Campaigns selectors export const selectCampaigns = (state: RootState) => state.rewards.campaigns; @@ -168,3 +159,20 @@ export const selectCampaignsLoading = (state: RootState) => export const selectCampaignsError = (state: RootState) => state.rewards.campaignsError; + +// Campaign participant status selectors +export const selectCampaignParticipantStatuses = (state: RootState) => + state.rewards.campaignParticipantStatuses; + +export const selectCampaignParticipantStatusById = + (campaignId: string | undefined) => (state: RootState) => + campaignId + ? (state.rewards.campaignParticipantStatuses[campaignId] ?? null) + : null; + +export const selectCampaignParticipantCount = + (campaignId: string | undefined) => (state: RootState) => + campaignId + ? (state.rewards.campaignParticipantStatuses[campaignId] + ?.participantCount ?? null) + : null; diff --git a/app/reducers/rewards/types.ts b/app/reducers/rewards/types.ts index fe5fced74b5..7c183703662 100644 --- a/app/reducers/rewards/types.ts +++ b/app/reducers/rewards/types.ts @@ -1,4 +1,4 @@ -export type RewardsTab = 'overview' | 'snapshots' | 'activity'; +export type RewardsTab = 'overview' | 'campaigns' | 'activity'; /** * Rewards onboarding step enumeration diff --git a/app/selectors/featureFlagController/rewards/index.ts b/app/selectors/featureFlagController/rewards/index.ts index a574ce0817d..2f11c1b2e76 100644 --- a/app/selectors/featureFlagController/rewards/index.ts +++ b/app/selectors/featureFlagController/rewards/index.ts @@ -4,15 +4,12 @@ export { selectBitcoinRewardsEnabledRawFlag, selectTronRewardsEnabledFlag, selectTronRewardsEnabledRawFlag, - selectSnapshotsRewardsEnabledFlag, - selectSnapshotsRewardsEnabledRawFlag, selectMissingEnrolledAccountsRewardsEnabledFlag, selectMissingEnrolledAccountsRewardsEnabledRawFlag, selectCampaignsRewardsEnabledFlag, selectCampaignsRewardsEnabledRawFlag, BITCOIN_REWARDS_FLAG_NAME, TRON_REWARDS_FLAG_NAME, - SNAPSHOTS_REWARDS_FLAG_NAME, MISSING_ENROLLED_ACCOUNTS_FLAG_NAME, CAMPAIGNS_REWARDS_FLAG_NAME, } from './rewardsEnabled'; diff --git a/app/selectors/featureFlagController/rewards/rewardsEnabled.test.ts b/app/selectors/featureFlagController/rewards/rewardsEnabled.test.ts index ca937a38e7a..48ce26bbecb 100644 --- a/app/selectors/featureFlagController/rewards/rewardsEnabled.test.ts +++ b/app/selectors/featureFlagController/rewards/rewardsEnabled.test.ts @@ -3,15 +3,12 @@ import { selectBitcoinRewardsEnabledFlag, selectTronRewardsEnabledRawFlag, selectTronRewardsEnabledFlag, - selectSnapshotsRewardsEnabledRawFlag, - selectSnapshotsRewardsEnabledFlag, selectMissingEnrolledAccountsRewardsEnabledRawFlag, selectMissingEnrolledAccountsRewardsEnabledFlag, selectCampaignsRewardsEnabledRawFlag, selectCampaignsRewardsEnabledFlag, BITCOIN_REWARDS_FLAG_NAME, TRON_REWARDS_FLAG_NAME, - SNAPSHOTS_REWARDS_FLAG_NAME, MISSING_ENROLLED_ACCOUNTS_FLAG_NAME, CAMPAIGNS_REWARDS_FLAG_NAME, } from './rewardsEnabled'; @@ -214,94 +211,6 @@ describe('Rewards Enabled Feature Flag Selectors', () => { }); }); - describe('selectSnapshotsRewardsEnabledRawFlag', () => { - it('returns true when remote flag is valid and enabled', () => { - const result = selectSnapshotsRewardsEnabledRawFlag.resultFunc({ - [SNAPSHOTS_REWARDS_FLAG_NAME]: { - enabled: true, - minimumVersion: '1.0.0', - }, - }); - - expect(result).toBe(true); - }); - - it('returns false when remote flag is valid but disabled', () => { - const result = selectSnapshotsRewardsEnabledRawFlag.resultFunc({ - [SNAPSHOTS_REWARDS_FLAG_NAME]: { - enabled: false, - minimumVersion: '1.0.0', - }, - }); - - expect(result).toBe(false); - }); - - it('returns false when version check fails', () => { - mockHasMinimumRequiredVersion.mockReturnValue(false); - - const result = selectSnapshotsRewardsEnabledRawFlag.resultFunc({ - [SNAPSHOTS_REWARDS_FLAG_NAME]: { - enabled: true, - minimumVersion: '99.0.0', - }, - }); - - expect(result).toBe(false); - }); - - it('returns false when remote flag is invalid', () => { - const result = selectSnapshotsRewardsEnabledRawFlag.resultFunc({ - [SNAPSHOTS_REWARDS_FLAG_NAME]: { - enabled: 'invalid', - minimumVersion: 123, - }, - }); - - expect(result).toBe(false); - }); - - it('returns false when remote feature flags are empty', () => { - const result = selectSnapshotsRewardsEnabledRawFlag.resultFunc({}); - - expect(result).toBe(false); - }); - - it('returns false when flag property is missing', () => { - const result = selectSnapshotsRewardsEnabledRawFlag.resultFunc({ - someOtherFlag: true, - }); - - expect(result).toBe(false); - }); - }); - - describe('selectSnapshotsRewardsEnabledFlag', () => { - it('returns true when basic functionality is enabled and raw flag is true', () => { - const result = selectSnapshotsRewardsEnabledFlag.resultFunc(true, true); - - expect(result).toBe(true); - }); - - it('returns false when basic functionality is enabled and raw flag is false', () => { - const result = selectSnapshotsRewardsEnabledFlag.resultFunc(true, false); - - expect(result).toBe(false); - }); - - it('returns false when basic functionality is disabled even if raw flag is true', () => { - const result = selectSnapshotsRewardsEnabledFlag.resultFunc(false, true); - - expect(result).toBe(false); - }); - - it('returns false when basic functionality is disabled and raw flag is false', () => { - const result = selectSnapshotsRewardsEnabledFlag.resultFunc(false, false); - - expect(result).toBe(false); - }); - }); - describe('selectMissingEnrolledAccountsRewardsEnabledRawFlag', () => { it('returns true when remote flag is valid and enabled', () => { const result = diff --git a/app/selectors/featureFlagController/rewards/rewardsEnabled.ts b/app/selectors/featureFlagController/rewards/rewardsEnabled.ts index 14f9a544d4c..34ff35dd685 100644 --- a/app/selectors/featureFlagController/rewards/rewardsEnabled.ts +++ b/app/selectors/featureFlagController/rewards/rewardsEnabled.ts @@ -9,14 +9,12 @@ import { selectBasicFunctionalityEnabled } from '../../settings'; export const BITCOIN_REWARDS_FLAG_NAME = 'rewardsBitcoinEnabled'; export const TRON_REWARDS_FLAG_NAME = 'rewardsTronEnabled'; -export const SNAPSHOTS_REWARDS_FLAG_NAME = 'rewardsSnapshotsEnabled'; export const MISSING_ENROLLED_ACCOUNTS_FLAG_NAME = 'rewards-missing-enrolled-accounts'; export const CAMPAIGNS_REWARDS_FLAG_NAME = 'rewardsCampaignsEnabled'; const DEFAULT_BITCOIN_REWARDS_ENABLED = false; const DEFAULT_TRON_REWARDS_ENABLED = false; -const DEFAULT_SNAPSHOTS_REWARDS_ENABLED = false; const DEFAULT_MISSING_ENROLLED_ACCOUNTS_ENABLED = false; const DEFAULT_CAMPAIGNS_REWARDS_ENABLED = false; @@ -92,42 +90,6 @@ export const selectTronRewardsEnabledFlag = createSelector( }, ); -/** - * Selector for the raw Snapshots rewards enabled remote flag value. - * Returns the flag value without considering basic functionality. - */ -export const selectSnapshotsRewardsEnabledRawFlag = createSelector( - selectRemoteFeatureFlags, - (remoteFeatureFlags) => { - if (!hasProperty(remoteFeatureFlags, SNAPSHOTS_REWARDS_FLAG_NAME)) { - return DEFAULT_SNAPSHOTS_REWARDS_ENABLED; - } - const remoteFlag = remoteFeatureFlags[ - SNAPSHOTS_REWARDS_FLAG_NAME - ] as unknown as VersionGatedFeatureFlag; - - return ( - validatedVersionGatedFeatureFlag(remoteFlag) ?? - DEFAULT_SNAPSHOTS_REWARDS_ENABLED - ); - }, -); - -/** - * Selector for the Snapshots rewards enabled flag. - * Returns false if basic functionality is disabled, otherwise returns the remote flag value. - */ -export const selectSnapshotsRewardsEnabledFlag = createSelector( - selectBasicFunctionalityEnabled, - selectSnapshotsRewardsEnabledRawFlag, - (isBasicFunctionalityEnabled, snapshotsRewardsEnabledRawFlag) => { - if (!isBasicFunctionalityEnabled) { - return false; - } - return snapshotsRewardsEnabledRawFlag; - }, -); - /** * Selector for the raw missing enrolled accounts remote flag value. * Returns the flag value without considering basic functionality. diff --git a/app/util/logs/__snapshots__/index.test.ts.snap b/app/util/logs/__snapshots__/index.test.ts.snap index 7254f9cc576..a6eedf7d26a 100644 --- a/app/util/logs/__snapshots__/index.test.ts.snap +++ b/app/util/logs/__snapshots__/index.test.ts.snap @@ -682,7 +682,6 @@ exports[`logs :: generateStateLogs Sanitized SeedlessOnboardingController State "rewardsEnvUrl": null, "seasonStatuses": {}, "seasons": {}, - "snapshots": {}, "subscriptionReferralDetails": {}, "subscriptions": {}, "unlockedRewards": {}, @@ -1548,7 +1547,6 @@ exports[`logs :: generateStateLogs generates a valid json export 1`] = ` "rewardsEnvUrl": null, "seasonStatuses": {}, "seasons": {}, - "snapshots": {}, "subscriptionReferralDetails": {}, "subscriptions": {}, "unlockedRewards": {}, diff --git a/app/util/test/initial-background-state.json b/app/util/test/initial-background-state.json index 698d5cf349c..cc85b0ce369 100644 --- a/app/util/test/initial-background-state.json +++ b/app/util/test/initial-background-state.json @@ -758,7 +758,6 @@ "offDeviceSubscriptionAccounts": {}, "pointsEstimateHistory": [], "pointsEvents": {}, - "snapshots": {}, "campaigns": {}, "campaignParticipantStatus": {}, "unlockedRewards": {}, diff --git a/locales/languages/en.json b/locales/languages/en.json index 056f0976028..85548001c3a 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -7575,7 +7575,6 @@ "main_title": "Rewards", "referral_title": "Referrals", "tab_overview_title": "Overview", - "tab_snapshots_title": "Snapshots", "tab_activity_title": "Activity", "referral_stats_earned_from_referrals": "Earned from referrals", "referral_stats_referrals": "Referrals", @@ -7630,6 +7629,9 @@ "no_end_of_season_rewards": "You didn't earn rewards this season, but there's always next time.", "verifying_rewards": "We're making sure everything's correct before you claim your rewards." }, + "previous_season_view": { + "title": "Previous Season" + }, "season_status": { "points_earned": "Points earned" }, @@ -7836,30 +7838,39 @@ "animation": { "could_not_load": "Couldn't load" }, - "snapshot": { + "campaign": { "starts_date": "Starts {{date}}", "ends_date": "Ends {{date}}", - "results_coming_soon": "Results coming soon", - "tokens_on_the_way": "Tokens on the way", "pill_up_next": "Up next", - "pill_live_now": "Live now", - "pill_calculating": "Calculating", - "pill_results_ready": "Results Ready", - "pill_complete": "Complete" - }, - "snapshots_section": { - "title": "Snapshots", - "error_title": "Unable to load snapshots", - "error_description": "We couldn't load the snapshots. Please try again.", - "retry_button": "Retry" - }, - "snapshots_tab": { + "pill_active": "Live", + "pill_complete": "Complete", + "enter_now": "Enter now", + "participant_count": "#{{count}}" + }, + "campaign_details": { + "start_date": "Starts: {{date}}", + "end_date": "Ends: {{date}}", + "opt_in": "Opt in", + "opting_in": "Opting in...", + "opted_in": "You're opted in to this campaign", + "opt_in_error": "Failed to opt in. Please try again.", + "join_campaign": "Join the campaign", + "swap": "Swap", + "how_it_works": "How it works" + }, + "campaigns_preview": { + "title": "Campaigns", + "coming_soon": "Coming soon", + "notify_me": "Notify me" + }, + "campaigns_view": { + "title": "Campaigns", "active_title": "Active", "upcoming_title": "Upcoming", "previous_title": "Previous", - "empty_state": "No snapshots available", - "error_title": "Unable to load snapshots", - "error_description": "We couldn't load the snapshots. Please try again.", + "empty_state": "No campaigns available", + "error_title": "Unable to load campaigns", + "error_description": "We couldn't load the campaigns. Please try again.", "retry_button": "Retry", "refreshing": "Refreshing..." } From d350b8de419e599dd190bbd9e31133d8c1b29330 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Wed, 18 Mar 2026 15:32:35 +0000 Subject: [PATCH 094/206] feat(tron): add claim button for unstaked TRX withdrawal (#27076) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Add a "Withdraw" button to `TronUnstakedBanner` so users can claim TRX that has completed the 14-day unstaking lock period. Also migrates Tron staking components (`TronStakingButtons`, `TronStakingCta`) from `StyleSheet`/`View` to `@metamask/design-system-react-native` and restructures related i18n keys. ### Changes - **`TronUnstakedBanner`** — Upgraded from info-only to actionable: now accepts `chainId`, renders a "Withdraw" `Button`, and wires it to the new `useTronClaimUnstakedTrx` hook. - **`useTronClaimUnstakedTrx` hook** (new) — Selects the Tron account via `selectSelectedInternalAccountByScope`, calls the `claimUnstakedTrx` snap RPC, and exposes `handleClaimUnstakedTrx`, `isSubmitting`, and `errors`. - **`claimUnstakedTrx` snap RPC wrapper** (new) — Sends `claimUnstakedTrx` request to the Tron Wallet Snap via `handleSnapRequest`. - **`TronClaimUnstakedTrxParams` / `TronClaimUnstakedTrxResult` types** (new). - **`TronStakingButtons` / `TronStakingCta`** — Migrated from `StyleSheet.create`/`View`/component-library `Button`/`Text` to design-system `Box`/`Button`/`Text`. Deleted corresponding `.styles.ts` files and removed `useStyles` mocks from tests. - **`TronUnstakingBanner`** — Simplified to use Banner's built-in `title`/`description` string props instead of a custom `Text` wrapper. - **`AssetOverviewContent`** — Passes `chainId` to `TronUnstakedBanner`; replaced `View` wrappers with `Box`. - **i18n** — Restructured `stake.tron` keys from flat (`trx_unstaking_in_progress`, `has_claimable_trx`) to nested (`unstaking_banner.{title,description}`, `unstaked_banner.{title,description,button}`). - **Dependencies** — Added preview resolutions for `@metamask-previews/tron-wallet-snap` and `@metamask-previews/assets-controllers`. - **Tests** — Rewrote `TronUnstakedBanner` tests (5 cases) and added `useTronClaimUnstakedTrx` tests (5 cases). https://github.com/user-attachments/assets/4d4ac698-6228-457b-8bb3-24f6fbc0647b ## **Changelog** CHANGELOG entry: Added a "Withdraw" button to the unstaked TRX banner so users can claim TRX that has completed the lock period. ## **Related issues** Refs: NEB-576 ## **Manual testing steps** ```gherkin Feature: Withdraw unstaked TRX Scenario: User sees withdraw banner when unstaking is complete Given the user has TRX that has completed the 14-day unstaking lock period When the user navigates to the TRX token details view Then a green success banner is displayed with title "Unstaking TRX complete" And the banner description reads "Your unstaked TRX can now be withdrawn" And a "Withdraw" button is displayed inside the banner Scenario: User taps Withdraw Given the user sees the withdraw banner with a "Withdraw" button When the user taps "Withdraw" Then the button is disabled to prevent double-submission And the withdrawal transaction is submitted And the button re-enables after the transaction completes Scenario: User sees unstaking-in-progress banner Given the user has TRX currently in the 14-day unstaking lock period When the user navigates to the TRX token details view Then an info banner is displayed with title "Unstaking TRX in progress" And the banner description reads "It will take 14 days for unstaking" ``` ## **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. ## **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** > Adds a new Tron withdrawal flow that triggers a Snap RPC from the token details screen and introduces new error-toast behavior; failures could affect users’ ability to reclaim funds after unstaking. UI refactors to the design system are lower risk but touch several Tron Earn surfaces and i18n keys. > > **Overview** > Adds an actionable *unstaked TRX* banner: `TronUnstakedBanner` now takes a `chainId`, renders a **Withdraw** button, calls new hook `useTronClaimUnstakedTrx`, disables while submitting, and surfaces failures via a new `useEarnToasts` `tronWithdrawal.failed(errors)` toast. > > Introduces Snap plumbing for withdrawals (`claimUnstakedTrx` request + new `TronClaimUnstakedTrx*` types) and updates `AssetOverviewContent` to pass the Tron `chainId` into the banner. > > Refactors Tron staking CTA/buttons (and related token-detail wrappers) from `View`/`StyleSheet` + component-library controls to `@metamask/design-system-react-native`, simplifies `TronUnstakingBanner` to use `Banner` `title`/`description`, and restructures related `stake.tron` i18n keys; tests are updated/added accordingly. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 630084e6d91432abe1266709a8a9c00ebe870113. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../TronStakingButtons.styles.ts | 22 --- .../TronStakingButtons.test.tsx | 10 -- .../TronStakingButtons/TronStakingButtons.tsx | 58 ++++---- .../TronStakingCta/TronStakingCta.styles.ts | 28 ---- .../TronStakingCta/TronStakingCta.test.tsx | 12 -- .../Tron/TronStakingCta/TronStakingCta.tsx | 51 ++++--- .../TronUnstakedBanner.test.tsx | 130 +++++++++++++++-- .../TronUnstakedBanner.testIds.ts | 3 +- .../TronUnstakedBanner/TronUnstakedBanner.tsx | 61 ++++++-- .../TronUnstakingBanner.test.tsx | 28 ++-- .../TronUnstakingBanner.testIds.ts | 3 - .../TronUnstakingBanner.tsx | 9 +- .../UI/Earn/hooks/useEarnToasts.tsx | 23 +++ .../UI/Earn/hooks/useMerklClaimStatus.test.ts | 3 + .../hooks/useMusdConversionStatus.test.ts | 11 ++ .../hooks/useTronClaimUnstakedTrx.test.ts | 135 ++++++++++++++++++ .../UI/Earn/hooks/useTronClaimUnstakedTrx.ts | 61 ++++++++ .../UI/Earn/types/tron-staking.types.ts | 10 ++ .../UI/Earn/utils/tron-staking-snap.ts | 17 +++ .../components/AssetOverviewContent.tsx | 27 ++-- locales/languages/en.json | 12 +- package.json | 2 +- yarn.lock | 10 +- 23 files changed, 532 insertions(+), 194 deletions(-) delete mode 100644 app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.styles.ts delete mode 100644 app/components/UI/Earn/components/Tron/TronStakingCta/TronStakingCta.styles.ts delete mode 100644 app/components/UI/Earn/components/Tron/TronUnstakingBanner/TronUnstakingBanner.testIds.ts create mode 100644 app/components/UI/Earn/hooks/useTronClaimUnstakedTrx.test.ts create mode 100644 app/components/UI/Earn/hooks/useTronClaimUnstakedTrx.ts diff --git a/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.styles.ts b/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.styles.ts deleted file mode 100644 index 52d1b4225cf..00000000000 --- a/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.styles.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { StyleSheet } from 'react-native'; -import { Theme } from '../../../../../../util/theme/models'; - -const styleSheet = (params: { theme: Theme }) => - StyleSheet.create({ - balanceButtonsContainer: { - marginTop: 16, - padding: 16, - borderRadius: 12, - backgroundColor: params.theme.colors.background.section, - }, - buttonsRow: { - flexDirection: 'row', - justifyContent: 'space-between', - gap: 16, - }, - balanceActionButton: { - flex: 1, - }, - }); - -export default styleSheet; diff --git a/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.test.tsx b/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.test.tsx index 6c3255a4063..9ae6c33edcf 100644 --- a/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.test.tsx +++ b/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.test.tsx @@ -27,16 +27,6 @@ jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({ navigate: mockNavigate }), })); -jest.mock('../../../../../../component-library/hooks', () => ({ - useStyles: () => ({ - styles: { - balanceButtonsContainer: {}, - balanceActionButton: {}, - buttonsRow: {}, - }, - }), -})); - const mockTrackEvent = jest.fn(); const mockBuilderAddProps = jest.fn().mockReturnThis(); const mockBuilderBuild = jest.fn().mockReturnValue({}); diff --git a/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.tsx b/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.tsx index e2aaaddd41c..ba8e90e84fe 100644 --- a/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.tsx +++ b/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.tsx @@ -1,15 +1,15 @@ import React from 'react'; import { useSelector } from 'react-redux'; -import { View } from 'react-native'; import { useNavigation } from '@react-navigation/native'; -import Button, { - ButtonVariants, -} from '../../../../../../component-library/components/Buttons/Button'; -import { useStyles } from '../../../../../../component-library/hooks'; -import { useTheme } from '../../../../../../util/theme'; +import { + Box, + BoxFlexDirection, + BoxJustifyContent, + Button, + ButtonVariant, +} from '@metamask/design-system-react-native'; import Routes from '../../../../../../constants/navigation/Routes'; import { TokenI } from '../../../../Tokens/types'; -import styleSheet from './TronStakingButtons.styles'; import { TronStakingButtonsTestIds } from './TronStakingButtons.testIds'; import { strings } from '../../../../../../../locales/i18n'; import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics'; @@ -25,8 +25,6 @@ interface TronStakingButtonsProps { } const TronStakingButtons = ({ asset }: TronStakingButtonsProps) => { - const theme = useTheme(); - const { styles } = useStyles(styleSheet, { theme }); const navigation = useNavigation(); const { trackEvent, createEventBuilder } = useAnalytics(); const { isEligible } = useStakingEligibility(); @@ -76,26 +74,30 @@ const TronStakingButtons = ({ asset }: TronStakingButtonsProps) => { }; return ( - - + + + {isEligible && ( + )} + ); }; diff --git a/app/components/UI/Earn/components/Tron/TronStakingCta/TronStakingCta.styles.ts b/app/components/UI/Earn/components/Tron/TronStakingCta/TronStakingCta.styles.ts deleted file mode 100644 index 22503998c09..00000000000 --- a/app/components/UI/Earn/components/Tron/TronStakingCta/TronStakingCta.styles.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { StyleSheet } from 'react-native'; -import { Theme } from '../../../../../../util/theme/models'; - -const styleSheet = (params: { theme: Theme }) => - StyleSheet.create({ - container: { - marginTop: 16, - padding: 16, - borderRadius: 12, - backgroundColor: params.theme.colors.background.section, - }, - ctaContent: { - alignItems: 'center', - marginBottom: 16, - gap: 4, - }, - ctaTitle: { - textAlign: 'center', - }, - ctaText: { - textAlign: 'center', - }, - earnButton: { - width: '100%', - }, - }); - -export default styleSheet; diff --git a/app/components/UI/Earn/components/Tron/TronStakingCta/TronStakingCta.test.tsx b/app/components/UI/Earn/components/Tron/TronStakingCta/TronStakingCta.test.tsx index aedc9fec711..b5d2024e456 100644 --- a/app/components/UI/Earn/components/Tron/TronStakingCta/TronStakingCta.test.tsx +++ b/app/components/UI/Earn/components/Tron/TronStakingCta/TronStakingCta.test.tsx @@ -13,18 +13,6 @@ jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({ navigate: mockNavigate }), })); -jest.mock('../../../../../../component-library/hooks', () => ({ - useStyles: () => ({ - styles: { - container: {}, - ctaContent: {}, - ctaTitle: {}, - ctaText: {}, - earnButton: {}, - }, - }), -})); - const mockTrackEvent = jest.fn(); jest.mock('../../../../../hooks/useAnalytics/useAnalytics', () => ({ diff --git a/app/components/UI/Earn/components/Tron/TronStakingCta/TronStakingCta.tsx b/app/components/UI/Earn/components/Tron/TronStakingCta/TronStakingCta.tsx index ee1c5e9191d..4f4812ad574 100644 --- a/app/components/UI/Earn/components/Tron/TronStakingCta/TronStakingCta.tsx +++ b/app/components/UI/Earn/components/Tron/TronStakingCta/TronStakingCta.tsx @@ -1,18 +1,17 @@ import React from 'react'; -import { View } from 'react-native'; import { useNavigation } from '@react-navigation/native'; -import Button, { - ButtonVariants, -} from '../../../../../../component-library/components/Buttons/Button'; -import Text, { - TextColor, +import { + Box, + BoxAlignItems, + BoxBackgroundColor, + Text, TextVariant, -} from '../../../../../../component-library/components/Texts/Text'; -import { useStyles } from '../../../../../../component-library/hooks'; -import { useTheme } from '../../../../../../util/theme'; + TextColor, + Button, + ButtonVariant, +} from '@metamask/design-system-react-native'; import Routes from '../../../../../../constants/navigation/Routes'; import { TokenI } from '../../../../Tokens/types'; -import styleSheet from './TronStakingCta.styles'; import { TronStakingCtaTestIds } from './TronStakingCta.testIds'; import { strings } from '../../../../../../../locales/i18n'; import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics'; @@ -27,8 +26,6 @@ interface TronStakingCtaProps { } const TronStakingCta = ({ asset, aprText }: TronStakingCtaProps) => { - const theme = useTheme(); - const { styles } = useStyles(styleSheet, { theme }); const navigation = useNavigation(); const { trackEvent, createEventBuilder } = useAnalytics(); const { isEligible } = useStakingEligibility(); @@ -55,25 +52,33 @@ const TronStakingCta = ({ asset, aprText }: TronStakingCtaProps) => { }; return ( - - - + + + {strings('stake.stake_your_trx_cta.title')} - + {strings('stake.stake_your_trx_cta.description_start')} - {aprText ? {aprText} : null} + {aprText ? ( + {aprText} + ) : null} {strings('stake.stake_your_trx_cta.description_end')} - + + ); }; diff --git a/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.test.tsx b/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.test.tsx index 02f082ad394..c50b458e01a 100644 --- a/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.test.tsx +++ b/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.test.tsx @@ -1,29 +1,131 @@ import React from 'react'; -import { render } from '@testing-library/react-native'; +import { render, fireEvent } from '@testing-library/react-native'; import TronUnstakedBanner from './TronUnstakedBanner'; -import { TronUnstakedBannerTestIds } from './TronUnstakedBanner.testIds'; import { strings } from '../../../../../../../locales/i18n'; +import useTronClaimUnstakedTrx from '../../../hooks/useTronClaimUnstakedTrx'; +import useEarnToasts from '../../../hooks/useEarnToasts'; +import { TronUnstakedBannerTestIds } from './TronUnstakedBanner.testIds'; + +jest.mock('../../../hooks/useTronClaimUnstakedTrx'); +const mockUseTronClaimUnstakedTrx = + useTronClaimUnstakedTrx as jest.MockedFunction< + typeof useTronClaimUnstakedTrx + >; + +const mockShowToast = jest.fn(); +const mockFailedToastResult = { variant: 'Icon', labelOptions: [] }; +const mockFailedToastFn = jest.fn().mockReturnValue(mockFailedToastResult); +jest.mock('../../../hooks/useEarnToasts'); +(useEarnToasts as jest.Mock).mockReturnValue({ + showToast: mockShowToast, + EarnToastOptions: { + tronWithdrawal: { failed: mockFailedToastFn }, + }, +}); describe('TronUnstakedBanner', () => { - it('renders the claim text with the given amount', () => { - const { getByTestId } = render(); + const mockHandleClaimUnstakedTrx = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockUseTronClaimUnstakedTrx.mockReturnValue({ + handleClaimUnstakedTrx: mockHandleClaimUnstakedTrx, + isSubmitting: false, + errors: undefined, + }); + (useEarnToasts as jest.Mock).mockReturnValue({ + showToast: mockShowToast, + EarnToastOptions: { + tronWithdrawal: { failed: mockFailedToastFn }, + }, + }); + }); - const expected = strings('stake.tron.has_claimable_trx', { + it('renders the title with the given amount', () => { + const { getByText } = render( + , + ); + + const expectedTitle = strings('stake.tron.unstaked_banner.title', { amount: '100', }); + expect(getByText(expectedTitle)).toBeOnTheScreen(); + }); + + it('renders the description', () => { + const { getByText } = render( + , + ); + + const expectedDescription = strings( + 'stake.tron.unstaked_banner.description', + ); + expect(getByText(expectedDescription)).toBeOnTheScreen(); + }); + + it('renders the Withdraw button', () => { + const { getByTestId } = render( + , + ); + expect( - getByTestId(TronUnstakedBannerTestIds.BANNER_TEXT), - ).toHaveTextContent(expected); + getByTestId(TronUnstakedBannerTestIds.CLAIM_BUTTON), + ).toBeOnTheScreen(); + }); + + it('calls handleClaimUnstakedTrx when button is pressed', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(TronUnstakedBannerTestIds.CLAIM_BUTTON)); + expect(mockHandleClaimUnstakedTrx).toHaveBeenCalledTimes(1); }); - it('renders with a different amount', () => { - const { getByTestId } = render(); + it('disables the button when isSubmitting is true', () => { + mockUseTronClaimUnstakedTrx.mockReturnValue({ + handleClaimUnstakedTrx: mockHandleClaimUnstakedTrx, + isSubmitting: true, + errors: undefined, + }); - const expected = strings('stake.tron.has_claimable_trx', { - amount: '2,500', + const { getByTestId } = render( + , + ); + + const button = getByTestId(TronUnstakedBannerTestIds.CLAIM_BUTTON); + expect(button.props.accessibilityState?.disabled).toBe(true); + }); + + it('shows error toast when errors are returned', () => { + mockUseTronClaimUnstakedTrx.mockReturnValue({ + handleClaimUnstakedTrx: mockHandleClaimUnstakedTrx, + isSubmitting: false, + errors: ['InsufficientBalance'], }); - expect( - getByTestId(TronUnstakedBannerTestIds.BANNER_TEXT), - ).toHaveTextContent(expected); + + render(); + + expect(mockFailedToastFn).toHaveBeenCalledWith(['InsufficientBalance']); + expect(mockShowToast).toHaveBeenCalledWith(mockFailedToastResult); + }); + + it('shows title-only error toast when valid is false without explicit errors', () => { + mockUseTronClaimUnstakedTrx.mockReturnValue({ + handleClaimUnstakedTrx: mockHandleClaimUnstakedTrx, + isSubmitting: false, + errors: [], + }); + + render(); + + expect(mockFailedToastFn).toHaveBeenCalledWith([]); + expect(mockShowToast).toHaveBeenCalledWith(mockFailedToastResult); + }); + + it('does not show error toast when there are no errors', () => { + render(); + + expect(mockShowToast).not.toHaveBeenCalled(); }); }); diff --git a/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.testIds.ts b/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.testIds.ts index e63594cf588..8118ba57fd9 100644 --- a/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.testIds.ts +++ b/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.testIds.ts @@ -1,3 +1,4 @@ export enum TronUnstakedBannerTestIds { - BANNER_TEXT = 'tron-unstaked-banner', + BANNER_DESCRIPTION = 'tron-unstaked-banner-description', + CLAIM_BUTTON = 'tron-unstaked-banner-button', } diff --git a/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.tsx b/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.tsx index de5efe97e61..5e245729048 100644 --- a/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.tsx +++ b/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.tsx @@ -1,26 +1,63 @@ -import React from 'react'; +import React, { useEffect } from 'react'; +import type { CaipChainId } from '@metamask/utils'; import { strings } from '../../../../../../../locales/i18n'; import Banner, { BannerAlertSeverity, BannerVariant, } from '../../../../../../component-library/components/Banners/Banner'; -import { Text } from '@metamask/design-system-react-native'; +import { + Text, + Button, + ButtonVariant, + ButtonSize, +} from '@metamask/design-system-react-native'; +import useTronClaimUnstakedTrx from '../../../hooks/useTronClaimUnstakedTrx'; +import useEarnToasts from '../../../hooks/useEarnToasts'; import { TronUnstakedBannerTestIds } from './TronUnstakedBanner.testIds'; interface TronUnstakedBannerProps { amount: string; + chainId: CaipChainId; } -const TronUnstakedBanner = ({ amount }: TronUnstakedBannerProps) => ( - - {strings('stake.tron.has_claimable_trx', { amount })} - +const TronUnstakedBanner = ({ amount, chainId }: TronUnstakedBannerProps) => { + const { handleClaimUnstakedTrx, isSubmitting, errors } = + useTronClaimUnstakedTrx({ chainId }); + const { showToast, EarnToastOptions } = useEarnToasts(); + + useEffect(() => { + if (errors) { + showToast(EarnToastOptions.tronWithdrawal.failed(errors)); } - /> -); + // eslint-disable-next-line react-hooks/exhaustive-deps -- Only re-fire when a new error occurs; showToast/EarnToastOptions refs change on theme switch and would cause repeat toasts. + }, [errors]); + + return ( + + + {strings('stake.tron.unstaked_banner.description')} + + + + } + /> + ); +}; export default TronUnstakedBanner; diff --git a/app/components/UI/Earn/components/Tron/TronUnstakingBanner/TronUnstakingBanner.test.tsx b/app/components/UI/Earn/components/Tron/TronUnstakingBanner/TronUnstakingBanner.test.tsx index 7690dc03d6e..29715638e52 100644 --- a/app/components/UI/Earn/components/Tron/TronUnstakingBanner/TronUnstakingBanner.test.tsx +++ b/app/components/UI/Earn/components/Tron/TronUnstakingBanner/TronUnstakingBanner.test.tsx @@ -1,29 +1,33 @@ import React from 'react'; import { render } from '@testing-library/react-native'; import TronUnstakingBanner from './TronUnstakingBanner'; -import { TronUnstakingBannerTestIds } from './TronUnstakingBanner.testIds'; import { strings } from '../../../../../../../locales/i18n'; describe('TronUnstakingBanner', () => { - it('renders the unstaking text with the given amount', () => { - const { getByTestId } = render(); + it('renders the title with the given amount', () => { + const { getByText } = render(); - const expected = strings('stake.tron.trx_unstaking_in_progress', { + const expectedTitle = strings('stake.tron.unstaking_banner.title', { amount: '500', }); - expect( - getByTestId(TronUnstakingBannerTestIds.BANNER_TEXT), - ).toHaveTextContent(expected); + expect(getByText(expectedTitle)).toBeOnTheScreen(); + }); + + it('renders the description', () => { + const { getByText } = render(); + + const expectedDescription = strings( + 'stake.tron.unstaking_banner.description', + ); + expect(getByText(expectedDescription)).toBeOnTheScreen(); }); it('renders with a different amount', () => { - const { getByTestId } = render(); + const { getByText } = render(); - const expected = strings('stake.tron.trx_unstaking_in_progress', { + const expectedTitle = strings('stake.tron.unstaking_banner.title', { amount: '1,234.5', }); - expect( - getByTestId(TronUnstakingBannerTestIds.BANNER_TEXT), - ).toHaveTextContent(expected); + expect(getByText(expectedTitle)).toBeOnTheScreen(); }); }); diff --git a/app/components/UI/Earn/components/Tron/TronUnstakingBanner/TronUnstakingBanner.testIds.ts b/app/components/UI/Earn/components/Tron/TronUnstakingBanner/TronUnstakingBanner.testIds.ts deleted file mode 100644 index 72fc0ac4dfa..00000000000 --- a/app/components/UI/Earn/components/Tron/TronUnstakingBanner/TronUnstakingBanner.testIds.ts +++ /dev/null @@ -1,3 +0,0 @@ -export enum TronUnstakingBannerTestIds { - BANNER_TEXT = 'tron-unstaking-banner', -} diff --git a/app/components/UI/Earn/components/Tron/TronUnstakingBanner/TronUnstakingBanner.tsx b/app/components/UI/Earn/components/Tron/TronUnstakingBanner/TronUnstakingBanner.tsx index 58c75e54c68..83dc7d73a02 100644 --- a/app/components/UI/Earn/components/Tron/TronUnstakingBanner/TronUnstakingBanner.tsx +++ b/app/components/UI/Earn/components/Tron/TronUnstakingBanner/TronUnstakingBanner.tsx @@ -4,8 +4,6 @@ import Banner, { BannerAlertSeverity, BannerVariant, } from '../../../../../../component-library/components/Banners/Banner'; -import { Text } from '@metamask/design-system-react-native'; -import { TronUnstakingBannerTestIds } from './TronUnstakingBanner.testIds'; interface TronUnstakingBannerProps { amount: string; @@ -15,11 +13,8 @@ const TronUnstakingBanner = ({ amount }: TronUnstakingBannerProps) => ( - {strings('stake.tron.trx_unstaking_in_progress', { amount })} - - } + title={strings('stake.tron.unstaking_banner.title', { amount })} + description={strings('stake.tron.unstaking_banner.description')} /> ); diff --git a/app/components/UI/Earn/hooks/useEarnToasts.tsx b/app/components/UI/Earn/hooks/useEarnToasts.tsx index 978311ff51f..b5ea01e41c5 100644 --- a/app/components/UI/Earn/hooks/useEarnToasts.tsx +++ b/app/components/UI/Earn/hooks/useEarnToasts.tsx @@ -48,6 +48,9 @@ export interface EarnToastOptionsConfig { success: EarnToastOptions; failed: EarnToastOptions; }; + tronWithdrawal: { + failed: (errors: string[]) => EarnToastOptions; + }; } interface EarnToastLabelOptions { @@ -233,6 +236,26 @@ const useEarnToasts = (): { closeButtonOptions, }, }, + tronWithdrawal: { + failed: (errors: string[]) => ({ + ...earnBaseToastOptions.error, + labelOptions: getEarnToastLabels({ + primary: strings('stake.tron.unstaked_banner.error'), + primaryIsBold: true, + ...(errors.length > 0 && { + secondary: ( + + {errors.map((err) => `\u2022 ${err}`).join('\n')} + + ), + }), + }), + closeButtonOptions, + }), + }, }), [ closeButtonOptions, diff --git a/app/components/UI/Earn/hooks/useMerklClaimStatus.test.ts b/app/components/UI/Earn/hooks/useMerklClaimStatus.test.ts index 0d696491db7..ff3aa66d5e0 100644 --- a/app/components/UI/Earn/hooks/useMerklClaimStatus.test.ts +++ b/app/components/UI/Earn/hooks/useMerklClaimStatus.test.ts @@ -129,6 +129,9 @@ describe('useMerklClaimStatus', () => { success: mockSuccessToast, failed: mockFailedToast, }, + tronWithdrawal: { + failed: jest.fn().mockReturnValue(mockFailedToast), + }, }; const createMockEventBuilder = () => { diff --git a/app/components/UI/Earn/hooks/useMusdConversionStatus.test.ts b/app/components/UI/Earn/hooks/useMusdConversionStatus.test.ts index 2795dd14830..3b3f0fb0780 100644 --- a/app/components/UI/Earn/hooks/useMusdConversionStatus.test.ts +++ b/app/components/UI/Earn/hooks/useMusdConversionStatus.test.ts @@ -180,6 +180,17 @@ describe('useMusdConversionStatus', () => { labelOptions: [{ label: 'Bonus claim failed', isBold: true }], }, }, + tronWithdrawal: { + failed: jest.fn().mockReturnValue({ + variant: ToastVariants.Icon as const, + iconName: IconName.Danger, + hasNoTimeout: false, + iconColor: '#000000', + backgroundColor: '#FFFFFF', + hapticsType: NotificationFeedbackType.Error, + labelOptions: [{ label: 'Withdrawal failed', isBold: true }], + }), + }, }; // Default mock data diff --git a/app/components/UI/Earn/hooks/useTronClaimUnstakedTrx.test.ts b/app/components/UI/Earn/hooks/useTronClaimUnstakedTrx.test.ts new file mode 100644 index 00000000000..2148bcf5fd5 --- /dev/null +++ b/app/components/UI/Earn/hooks/useTronClaimUnstakedTrx.test.ts @@ -0,0 +1,135 @@ +import { act, renderHook } from '@testing-library/react-native'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import useTronClaimUnstakedTrx from './useTronClaimUnstakedTrx'; +import { claimUnstakedTrx } from '../utils/tron-staking-snap'; + +const mockSelectSelectedInternalAccountByScope = jest.fn(); + +jest.mock('react-redux', () => ({ + useSelector: jest.fn((selector) => selector()), +})); + +jest.mock('../../../../selectors/multichainAccounts/accounts', () => ({ + selectSelectedInternalAccountByScope: () => + mockSelectSelectedInternalAccountByScope, +})); + +jest.mock('../utils/tron-staking-snap', () => ({ + claimUnstakedTrx: jest.fn(), +})); + +describe('useTronClaimUnstakedTrx', () => { + const mockClaimUnstakedTrx = claimUnstakedTrx as jest.MockedFunction< + typeof claimUnstakedTrx + >; + + const mockAccount: Partial = { + id: 'tron-account-1', + metadata: { + name: 'Tron Account', + snap: { + id: 'npm:@metamask/tron-wallet-snap', + name: 'Tron Wallet Snap', + enabled: true, + }, + importTime: 0, + keyring: { type: 'snap' }, + lastSelected: 0, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockSelectSelectedInternalAccountByScope.mockReturnValue(mockAccount); + }); + + it('returns initial state', () => { + const { result } = renderHook(() => + useTronClaimUnstakedTrx({ chainId: 'tron:728126428' }), + ); + + expect(result.current.isSubmitting).toBe(false); + expect(result.current.errors).toBeUndefined(); + expect(typeof result.current.handleClaimUnstakedTrx).toBe('function'); + }); + + it('calls claimUnstakedTrx with correct params on handleClaimUnstakedTrx', async () => { + mockClaimUnstakedTrx.mockResolvedValue({ valid: true }); + + const { result } = renderHook(() => + useTronClaimUnstakedTrx({ chainId: 'tron:728126428' }), + ); + + await act(async () => { + await result.current.handleClaimUnstakedTrx(); + }); + + expect(mockClaimUnstakedTrx).toHaveBeenCalledWith(mockAccount, { + fromAccountId: 'tron-account-1', + assetId: 'tron:728126428/slip44:195', + }); + expect(result.current.isSubmitting).toBe(false); + expect(result.current.errors).toBeUndefined(); + }); + + it('sets errors when claimUnstakedTrx returns errors', async () => { + mockClaimUnstakedTrx.mockResolvedValue({ + valid: false, + errors: ['Insufficient energy'], + }); + + const { result } = renderHook(() => + useTronClaimUnstakedTrx({ chainId: 'tron:728126428' }), + ); + + await act(async () => { + await result.current.handleClaimUnstakedTrx(); + }); + + expect(result.current.errors).toEqual(['Insufficient energy']); + }); + + it('sets empty errors array when claimUnstakedTrx returns valid:false without errors', async () => { + mockClaimUnstakedTrx.mockResolvedValue({ valid: false }); + + const { result } = renderHook(() => + useTronClaimUnstakedTrx({ chainId: 'tron:728126428' }), + ); + + await act(async () => { + await result.current.handleClaimUnstakedTrx(); + }); + + expect(result.current.errors).toEqual([]); + expect(result.current.isSubmitting).toBe(false); + }); + + it('sets errors when claimUnstakedTrx throws', async () => { + mockClaimUnstakedTrx.mockRejectedValue(new Error('Network error')); + + const { result } = renderHook(() => + useTronClaimUnstakedTrx({ chainId: 'tron:728126428' }), + ); + + await act(async () => { + await result.current.handleClaimUnstakedTrx(); + }); + + expect(result.current.errors).toEqual(['Network error']); + expect(result.current.isSubmitting).toBe(false); + }); + + it('does nothing when no account is selected', async () => { + mockSelectSelectedInternalAccountByScope.mockReturnValue(null); + + const { result } = renderHook(() => + useTronClaimUnstakedTrx({ chainId: 'tron:728126428' }), + ); + + await act(async () => { + await result.current.handleClaimUnstakedTrx(); + }); + + expect(mockClaimUnstakedTrx).not.toHaveBeenCalled(); + }); +}); diff --git a/app/components/UI/Earn/hooks/useTronClaimUnstakedTrx.ts b/app/components/UI/Earn/hooks/useTronClaimUnstakedTrx.ts new file mode 100644 index 00000000000..e73cb9103f8 --- /dev/null +++ b/app/components/UI/Earn/hooks/useTronClaimUnstakedTrx.ts @@ -0,0 +1,61 @@ +import type { CaipAssetType } from '@metamask/snaps-sdk'; +import type { CaipChainId } from '@metamask/utils'; +import { useCallback, useState } from 'react'; +import { useSelector } from 'react-redux'; +import Logger from '../../../../util/Logger'; +import { selectSelectedInternalAccountByScope } from '../../../../selectors/multichainAccounts/accounts'; +import { claimUnstakedTrx } from '../utils/tron-staking-snap'; + +interface UseTronClaimUnstakedTrxParams { + chainId: CaipChainId; +} + +interface UseTronClaimUnstakedTrxReturn { + handleClaimUnstakedTrx: () => Promise; + isSubmitting: boolean; + errors?: string[]; +} + +const useTronClaimUnstakedTrx = ({ + chainId, +}: UseTronClaimUnstakedTrxParams): UseTronClaimUnstakedTrxReturn => { + const selectedTronAccount = useSelector(selectSelectedInternalAccountByScope)( + chainId, + ); + + const [isSubmitting, setIsSubmitting] = useState(false); + const [errors, setErrors] = useState(undefined); + + const handleClaimUnstakedTrx = useCallback(async () => { + if (!selectedTronAccount?.id || !chainId) return; + + setIsSubmitting(true); + setErrors(undefined); + + try { + const assetId = `${chainId}/slip44:195` as CaipAssetType; + + const result = await claimUnstakedTrx(selectedTronAccount, { + fromAccountId: selectedTronAccount.id, + assetId, + }); + + if (!result?.valid) { + setErrors(result?.errors ?? []); + } + } catch (error) { + Logger.error(error as Error, '[Tron Claim] Failed to claim unstaked TRX'); + setErrors([(error as Error).message]); + } finally { + setIsSubmitting(false); + } + }, [selectedTronAccount, chainId]); + + return { + handleClaimUnstakedTrx, + isSubmitting, + errors, + }; +}; + +export default useTronClaimUnstakedTrx; diff --git a/app/components/UI/Earn/types/tron-staking.types.ts b/app/components/UI/Earn/types/tron-staking.types.ts index 47f8181bce5..a1795e71c9d 100644 --- a/app/components/UI/Earn/types/tron-staking.types.ts +++ b/app/components/UI/Earn/types/tron-staking.types.ts @@ -39,6 +39,16 @@ export interface TronUnstakeResult { errors?: string[]; } +export interface TronClaimUnstakedTrxParams { + fromAccountId: string; + assetId: CaipAssetType; +} + +export interface TronClaimUnstakedTrxResult { + valid: boolean; + errors?: string[]; +} + export interface ComputeFeeParams { transaction: string; accountId: string; diff --git a/app/components/UI/Earn/utils/tron-staking-snap.ts b/app/components/UI/Earn/utils/tron-staking-snap.ts index 17d2ee78a14..75fdda098b7 100644 --- a/app/components/UI/Earn/utils/tron-staking-snap.ts +++ b/app/components/UI/Earn/utils/tron-staking-snap.ts @@ -10,6 +10,8 @@ import type { TronUnstakeValidateParams, TronUnstakeConfirmParams, TronUnstakeResult, + TronClaimUnstakedTrxParams, + TronClaimUnstakedTrxResult, ComputeFeeParams, ComputeFeeResult, ComputeStakeFeeParams, @@ -110,3 +112,18 @@ export async function confirmTronUnstake( }, })) as TronUnstakeResult; } + +export async function claimUnstakedTrx( + fromAccount: InternalAccount, + params: TronClaimUnstakedTrxParams, +): Promise { + return (await handleSnapRequest(controllerMessenger, { + snapId: fromAccount.metadata?.snap?.id as SnapId, + origin: 'metamask', + handler: HandlerType.OnClientRequest, + request: { + method: 'claimUnstakedTrx', + params, + }, + })) as TronClaimUnstakedTrxResult; +} diff --git a/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx b/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx index 593f760d235..d1c9e525418 100644 --- a/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx +++ b/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx @@ -58,7 +58,7 @@ import { useMarketInsights, selectMarketInsightsEnabled, } from '../../MarketInsights'; -import { isCaipAssetType, type Hex } from '@metamask/utils'; +import { isCaipAssetType, type CaipChainId, type Hex } from '@metamask/utils'; import { formatAddressToAssetId } from '@metamask/bridge-controller'; import type { TokenSecurityData } from '@metamask/assets-controllers'; import SecurityTrustEntryCard from '../../SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard'; @@ -154,10 +154,6 @@ const styleSheet = (params: { theme: Theme }) => { perpsPositionTitle: { marginBottom: 8, } as TextStyle, - bannerWrapper: { - paddingHorizontal: 16, - marginTop: 8, - } as ViewStyle, }); }; @@ -852,39 +848,42 @@ const AssetOverviewContent: React.FC = ({ { ///: BEGIN:ONLY_INCLUDE_IF(tron) isTronNative && readyForWithdrawalBalance && ( - - - + + + ) ///: END:ONLY_INCLUDE_IF } { ///: BEGIN:ONLY_INCLUDE_IF(tron) isTronNative && inLockPeriodBalance && ( - + - + ) ///: END:ONLY_INCLUDE_IF } { ///: BEGIN:ONLY_INCLUDE_IF(tron) isTronNative && stakedTrxAsset && ( - + - + ) ///: END:ONLY_INCLUDE_IF } { ///: BEGIN:ONLY_INCLUDE_IF(tron) isTronNative && !stakedTrxAsset && ( - + - + ) ///: END:ONLY_INCLUDE_IF } diff --git a/locales/languages/en.json b/locales/languages/en.json index 85548001c3a..c9a4e545956 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -6158,8 +6158,16 @@ "errors": { "insufficient_balance": "You don't have enough resource balance to do this action." }, - "trx_unstaking_in_progress": "Unstaking {{amount}} TRX in progress. It takes 14 days for unstaking.", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "Unstaking {{amount}} TRX in progress", + "description": "It will take 14 days for unstaking" + }, + "unstaked_banner": { + "title": "Unstaking {{amount}} TRX complete", + "description": "Your unstaked TRX can now be withdrawn", + "button": "Withdraw", + "error": "Withdrawal failed" + } }, "stake_eth": "Stake ETH", "unstake_eth": "Unstake ETH", diff --git a/package.json b/package.json index 5174aadb955..99f297c6145 100644 --- a/package.json +++ b/package.json @@ -306,7 +306,7 @@ "@metamask/swaps-controller": "^15.0.0", "@metamask/transaction-controller": "^62.22.0", "@metamask/transaction-pay-controller": "^17.0.0", - "@metamask/tron-wallet-snap": "1.22.1", + "@metamask/tron-wallet-snap": "1.24.0", "@metamask/utils": "^11.8.1", "@myx-trade/sdk": "^0.1.265", "@ngraveio/bc-ur": "^1.1.6", diff --git a/yarn.lock b/yarn.lock index ce0fb4844f3..3c1a232692f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10197,10 +10197,10 @@ __metadata: languageName: node linkType: hard -"@metamask/tron-wallet-snap@npm:1.22.1": - version: 1.22.1 - resolution: "@metamask/tron-wallet-snap@npm:1.22.1" - checksum: 10/035e044a023f887377da09e0431749aced5700903bb492191631f7f9f5b2ac1f81240885e795755dd48fb603ec6034063424ef734a8ebe4ded820b31049c4877 +"@metamask/tron-wallet-snap@npm:1.24.0": + version: 1.24.0 + resolution: "@metamask/tron-wallet-snap@npm:1.24.0" + checksum: 10/a081a52b24da36cd060413d07549e28ab3222a869e8a7c4a3e41cf0b09b89d7f90a482fd9d154a837afe1c07f229f931ebdc31f9fc307bbf7b1bfa97581a9bd8 languageName: node linkType: hard @@ -35560,7 +35560,7 @@ __metadata: "@metamask/test-dapp-solana": "npm:^0.3.0" "@metamask/transaction-controller": "npm:^62.22.0" "@metamask/transaction-pay-controller": "npm:^17.0.0" - "@metamask/tron-wallet-snap": "npm:1.22.1" + "@metamask/tron-wallet-snap": "npm:1.24.0" "@metamask/utils": "npm:^11.8.1" "@myx-trade/sdk": "npm:^0.1.265" "@ngraveio/bc-ur": "npm:^1.1.6" From 7666d6accc7a5043a6cd196ed66fec8081f92550 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Wed, 18 Mar 2026 16:46:18 +0100 Subject: [PATCH 095/206] fix: fix padding in security screen header (#27621) ## **Description** fix padding for header in security screen ## **Changelog** CHANGELOG entry: fix padding in security screen header ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Low risk UI-only change that adds bottom padding to the `SecurityTrustScreen` header layout; no logic, data, or navigation behavior is modified. > > **Overview** > Adjusts the `SecurityTrustScreen` header spacing by adding bottom padding (`pb-3`) to the top `Box`, improving visual layout around the safe-area inset without changing any screen behavior. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 50873251cb72ffc4248bad790e21028bf254498e. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/components/UI/SecurityTrust/Views/SecurityTrustScreen.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/UI/SecurityTrust/Views/SecurityTrustScreen.tsx b/app/components/UI/SecurityTrust/Views/SecurityTrustScreen.tsx index 22e18821e9b..70b4687e6b2 100644 --- a/app/components/UI/SecurityTrust/Views/SecurityTrustScreen.tsx +++ b/app/components/UI/SecurityTrust/Views/SecurityTrustScreen.tsx @@ -144,7 +144,7 @@ const SecurityTrustScreen: React.FC = () => { flexDirection={BoxFlexDirection.Row} alignItems={BoxAlignItems.Center} justifyContent={BoxJustifyContent.Between} - twClassName="px-4" + twClassName="px-4 pb-3" style={{ paddingTop: insets.top + 8 }} > Date: Thu, 19 Mar 2026 00:05:08 +0800 Subject: [PATCH 096/206] fix: skip handle oauth-redirect deeplink [TO-592] (#27511) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Url redirect Modal is prompted after rehydration with social login. It is due to oauth redirect back to the app after successful social login with pathname oauth-redirect. The universal link is already handled by OauthService before the wallet is created and should be skipped when user reach the wallet home. This pr add pathname `oauth-redirect` to the skip handling deeplink ## **Changelog** CHANGELOG entry: Skip handling universal link for redirect-oauth ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user Login with Apple in Android When user rehydrated the wallet Then the url-redirect modal is not prompted ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/e4309e89-8512-428f-b150-a250bb266586 ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Low risk: small conditional change to universal link early-exit logic plus targeted test coverage; impact is limited to preventing `oauth-redirect` links from being processed by the legacy handler. > > **Overview** > Prevents legacy universal-link handling from processing `oauth-redirect` deep links by adding an early-return path when the parsed action equals `ACTIONS.OAUTH_REDIRECT`. > > Updates `handleUniversalLink` tests to treat `oauth-redirect` links (with and without `code`/`state` query params) as skip/exit-early cases, ensuring signature verification and interstitial handling aren’t triggered for these redirects. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit c4e4cc813a773f896c183d7c7471f03dbbd244aa. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../legacy/__tests__/handleUniversalLink.test.ts | 10 +++++++++- .../handlers/legacy/handleUniversalLink.ts | 15 ++++++++++----- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/app/core/DeeplinkManager/handlers/legacy/__tests__/handleUniversalLink.test.ts b/app/core/DeeplinkManager/handlers/legacy/__tests__/handleUniversalLink.test.ts index cc13afbf57b..58503ef59ee 100644 --- a/app/core/DeeplinkManager/handlers/legacy/__tests__/handleUniversalLink.test.ts +++ b/app/core/DeeplinkManager/handlers/legacy/__tests__/handleUniversalLink.test.ts @@ -1702,7 +1702,7 @@ describe('handleUniversalLink', () => { }); }); - describe('skips handling deeplinks without pathname and query params', () => { + describe('skips handling deeplinks that should exit early', () => { // Link cases to test for skipping handling const testLinkCases = [ { @@ -1733,6 +1733,14 @@ describe('handleUniversalLink', () => { link: 'metamask://action?query=value', shouldSkip: false, }, + { + link: `https://link.metamask.io/${ACTIONS.OAUTH_REDIRECT}`, + shouldSkip: true, + }, + { + link: `https://link.metamask.io/${ACTIONS.OAUTH_REDIRECT}?code=test-code&state=test-state`, + shouldSkip: true, + }, ]; testLinkCases.forEach((testCase) => { diff --git a/app/core/DeeplinkManager/handlers/legacy/handleUniversalLink.ts b/app/core/DeeplinkManager/handlers/legacy/handleUniversalLink.ts index e82b3472119..f1f167b3ffd 100644 --- a/app/core/DeeplinkManager/handlers/legacy/handleUniversalLink.ts +++ b/app/core/DeeplinkManager/handlers/legacy/handleUniversalLink.ts @@ -183,9 +183,18 @@ async function handleUniversalLink({ throw new Error('Invalid hostname'); } + const action: SUPPORTED_ACTIONS | ACTIONS.OAUTH_REDIRECT = + validatedUrl.pathname.split('/')[1] as + | SUPPORTED_ACTIONS + | ACTIONS.OAUTH_REDIRECT; + // Skip handling deeplinks that do not have a pathname or query + // Skip handling oauth-login universal links (it is handled by the OAuthService) // Ex. It's common for third party apps to open MetaMask using only the scheme (metamask://) - if (!validatedUrl.pathname.replace('/', '') && !validatedUrl.search) { + if ( + (!validatedUrl.pathname.replace('/', '') && !validatedUrl.search) || + action === ACTIONS.OAUTH_REDIRECT + ) { handled(); return; } @@ -193,10 +202,6 @@ async function handleUniversalLink({ let isPrivateLink = false; let isInvalidLink = false; - const action: SUPPORTED_ACTIONS = validatedUrl.pathname.split( - '/', - )[1] as SUPPORTED_ACTIONS; - // Intercept SDK actions and handle them in handleMetaMaskDeeplink if (METAMASK_SDK_ACTIONS.includes(action)) { const mappedUrl = url.replace( From 1db97008f655719a510c9383f8cc3e50d02f0e77 Mon Sep 17 00:00:00 2001 From: tommasini <46944231+tommasini@users.noreply.github.com> Date: Wed, 18 Mar 2026 16:09:16 +0000 Subject: [PATCH 097/206] chore(bridge): add bridge controller to CODEOWNERS for swaps team (#27622) ## **Description** The `app/core/Engine/controllers/bridge-controller` directory was not listed in CODEOWNERS, so changes to it did not automatically request reviews from the responsible team. This PR adds that directory under `@MetaMask/swaps-engineers` ownership so future changes will require review from the swaps team. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** N/A ## **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. ## **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. Made with [Cursor](https://cursor.com) --- > [!NOTE] > **Low Risk** > Low risk: this only updates `CODEOWNERS` ownership rules and does not affect runtime behavior. The main risk is unintended review-routing if the path patterns are incorrect or too broad. > > **Overview** > Updates `.github/CODEOWNERS` to ensure the Swaps team (`@MetaMask/swaps-engineers`) is auto-requested for reviews on Bridge/Swaps UI and the engine bridge controllers. > > Specifically adds ownership for `app/core/Engine/controllers/bridge-controller` and `app/core/Engine/controllers/bridge-status-controller`, and cleans up formatting of the existing Swaps/Bridge UI entries. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit e82364d93c0f16706a86024b58c4b8df51625309. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .github/CODEOWNERS | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 944ef6887ad..7be8ee3b055 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -121,8 +121,10 @@ app/components/Views/AccountSelector @MetaMask/accounts-e **/multichainAccounts/** @MetaMask/accounts-engineers # Swaps Team -app/components/UI/Swaps @MetaMask/swaps-engineers -app/components/UI/Bridge @MetaMask/swaps-engineers +app/components/UI/Swaps @MetaMask/swaps-engineers +app/components/UI/Bridge @MetaMask/swaps-engineers +app/core/Engine/controllers/bridge-controller @MetaMask/swaps-engineers +app/core/Engine/controllers/bridge-status-controller @MetaMask/swaps-engineers # Notifications Team app/components/Views/Notifications @MetaMask/notifications From d8763915f3cac30586fbe5d4feb72a50e7bda09c Mon Sep 17 00:00:00 2001 From: jvbriones <1674192+jvbriones@users.noreply.github.com> Date: Wed, 18 Mar 2026 17:17:20 +0100 Subject: [PATCH 098/206] ci: adapt automated tests metrics logic (#27552) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Updates QA stats collection to emit richer, more explicit metrics. Example: ``` ✅ QA stats written to ./qa-stats.json: { unit: { total_tests_run: 42997, predict_tests_run: 3604, selectors_tests_run: 1141, core_tests_run: 5153, other_tests_run: 4923, store_tests_run: 1047, tokens_tests_run: 240, bridge_tests_run: 1796, earn_tests_run: 1519, rewards_tests_run: 2037, util_tests_run: 2328, perps_tests_run: 5439, networksmanagement_tests_run: 234, reducers_tests_run: 641, card_tests_run: 2738, homepage_tests_run: 284, controllers_tests_run: 1440, confirmations_tests_run: 2502, components_base_tests_run: 323, components_hooks_tests_run: 784, settings_tests_run: 229, browsertab_tests_run: 232, multichainaccounts_tests_run: 356, ramp_tests_run: 2128, stake_tests_run: 207, component_library_tests_run: 1006, trending_tests_run: 325, total_tests_defined: 40267, total_tests_skipped: 17, line_coverage: 87.4, statement_coverage: 87, branch_coverage: 79.1, function_coverage: 83.3 }, component_view: { total_tests_run: 104, perps_tests_run: 32, earn_tests_run: 28, confirmations_tests_run: 5, predict_tests_run: 17, wallet_tests_run: 5, assetdetails_tests_run: 1, bridge_tests_run: 9, trendingview_tests_run: 6, walletactions_tests_run: 1, total_tests_defined: 102, total_tests_skipped: 0, line_coverage: 15.8, statement_coverage: 15.7, branch_coverage: 9.4, function_coverage: 13 }, e2e: { main_tests_run: 140, main_android_tests_run: 140, main_ios_tests_run: 140, flask_tests_run: 72, flask_android_tests_run: 72, flask_ios_tests_run: 72, total_tests_run: 212, multichain_api_tests_run: 12, prediction_market_tests_run: 10, wallet_platform_tests_run: 35, network_expansion_tests_run: 10, confirmations_tests_run: 31, trade_tests_run: 12, identity_tests_run: 6, network_abstraction_tests_run: 12, accounts_tests_run: 2, ramps_tests_run: 3, card_tests_run: 6, perps_tests_run: 1, total_tests_defined: 225, total_tests_skipped: 18 }, performance: { total_tests_defined: 22, total_tests_skipped: 1, login_tests_defined: 11, mm_connect_tests_defined: 6, onboarding_tests_defined: 5 } } ``` ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [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. ## **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** > Moderate risk because it changes how QA metrics are keyed/collected and updates CI artifact production/retention; mismatches in artifact names or parsing could silently drop metrics or skew dashboards. > > **Overview** > Updates the QA stats pipeline to generate **richer, explicitly named metrics** (e.g., `*_tests_run`, `total_tests_defined`, `total_tests_skipped`) and adds static source scanning to estimate defined/skipped tests across unit, component-view, E2E smoke, and performance suites. > > The collector no longer depends on a passed `WORKFLOW_RUN_ID`; it now resolves the **latest successful `ci.yml` run on `main`** via the GitHub API and pulls additional artifacts to include **coverage percentages** for unit and component-view tests. > > CI workflows are adjusted to support this by producing/uploading new coverage-summary artifacts and setting **7-day retention** on several uploaded artifacts, and the `qa-stats.yml` workflow switches from `workflow_run` triggering to a **daily scheduled run**. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit ea63d48fec2b43369a2fe303a1c61737aaa5b9d7. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .github/scripts/collect-qa-stats.mjs | 287 +++++++++++++++--- .github/workflows/ci.yml | 32 +- .github/workflows/qa-stats.yml | 12 +- .../run-e2e-smoke-tests-android-flask.yml | 1 + .../workflows/run-e2e-smoke-tests-android.yml | 2 + .../run-e2e-smoke-tests-ios-flask.yml | 1 + .github/workflows/run-e2e-smoke-tests-ios.yml | 2 + 7 files changed, 292 insertions(+), 45 deletions(-) diff --git a/.github/scripts/collect-qa-stats.mjs b/.github/scripts/collect-qa-stats.mjs index 58239909e11..1c87e076d04 100644 --- a/.github/scripts/collect-qa-stats.mjs +++ b/.github/scripts/collect-qa-stats.mjs @@ -1,28 +1,34 @@ #!/usr/bin/env node /** * - * Collects QA metrics from a CI run and writes qa-stats.json, key: value format. + * Collects QA metrics into a qa-stats.json file, key: value format. * Metrics that could not be collected (missing artifacts, tests did not run) - * are omitted from the output — they will never appear as zero. + * are omitted from the output, i.e., they will not appear in the output file. * * Required env vars: * GITHUB_TOKEN — GitHub Actions token for API access - * WORKFLOW_RUN_ID — ID of the main CI run that produced tests artifacts + * GITHUB_REPOSITORY — Repository in "owner/repo" format (set automatically in Actions) * * How to add a new metric: * 1. Add a collector function that returns a plain object * 2. Register it in the collectors array in main() * - * The only rule: never rename existing keys. The DB key is (project, run_id, namespace, metric_key). + * The only rule: never rename existing keys. The DB key used for storing the metrics is (project, run_id, namespace, metric_key). * Renaming a key in the JSON creates a new series in the DB while the old name stops getting new data, * which breaks the Grafana time series continuity. Adding and removing keys is fine. - * + * + * Artifact names used below are coupled to `name:` fields in ci.yml and run-e2e-workflow.yml — + * renaming either side silently drops that metric from the output. + * * Example output: * { - * "component_view": { "tests_count": 94 }, - * "unit": { "tests_count": 41957 }, - * "e2e": { "tests_count": 420, "main_tests_count": 276, "confirmations_tests_count": 62, "flask_tests_count": 144 }, - * "performance": { "tests_count": 21, "login_tests_count": 11, "onboarding_tests_count": 4, "mm_connect_tests_count": 6 } + * "unit": { "total_tests_run": 41957, "total_tests_skipped": 17, "bridge_tests_run": 5000, "other_tests_run": 1000 }, + * "component_view":{ "total_tests_run": 94, "total_tests_skipped": 0 }, + * "e2e": { "total_tests_run": 420, "total_tests_skipped": 27, + * "main_tests_run": 276, "main_android_tests_run": 276, "main_ios_tests_run": 276, + * "flask_tests_run": 144, "confirmations_tests_run": 62 }, + * "performance": { "total_tests_defined": 21, "total_tests_skipped": 1, + * "login_tests_defined": 11, "onboarding_tests_defined": 4, "mm_connect_tests_defined": 6 } * } */ @@ -31,18 +37,67 @@ import { execSync } from 'child_process'; import { join } from 'path'; const GITHUB_TOKEN = process.env.GITHUB_TOKEN; -const WORKFLOW_RUN_ID = process.env.WORKFLOW_RUN_ID; +const GITHUB_REPOSITORY = process.env.GITHUB_REPOSITORY ?? 'MetaMask/metamask-mobile'; -if (!WORKFLOW_RUN_ID) throw new Error('Missing required WORKFLOW_RUN_ID env var'); if (!GITHUB_TOKEN) throw new Error('Missing required GITHUB_TOKEN env var'); +// --------------------------------------------------------------------------- +// Static-scan targets +// Update these (easy with AI) if the repository directory structure or file-naming conventions +// change — the collectors below rely on them for skip/defined counts. +// --------------------------------------------------------------------------- +const SCAN_APP_DIR = 'app'; +const SCAN_E2E_SMOKE_DIRS = ['tests/smoke']; +const SCAN_PERFORMANCE_DIR = 'tests/performance'; + +const PATTERN_CV_TEST_FILE = /\.view(?:\..+)?\.test\.[jt]sx?$/; +const PATTERN_UNIT_TEST_FILE = /\.test\.[jt]sx?$/; +const PATTERN_E2E_SPEC_FILE = /\.spec\.[jt]sx?$/; +const PATTERN_PERF_SPEC_FILE = /\.spec\.js$/; + + // --------------------------------------------------------------------------- // GitHub artifact helpers // --------------------------------------------------------------------------- +let _runId = null; let _artifactList = null; +/** + * Fetches the ID of the latest successful CI workflow run on `main`. + * + * @returns {Promise} + */ +async function getLatestCiRunId() { + const url = `https://api.github.com/repos/${GITHUB_REPOSITORY}/actions/workflows/ci.yml/runs?branch=main&status=success&per_page=1`; + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${GITHUB_TOKEN}`, + Accept: 'application/vnd.github+json', + }, + }); + + if (!res.ok) { + throw new Error(`Failed to fetch CI workflow runs: ${res.status} ${res.statusText}`); + } + + const data = await res.json(); + const run = data.workflow_runs?.[0]; + if (!run) { + throw new Error('No successful CI workflow runs found on main'); + } + + console.log(`[run] Using latest successful ci run #${run.run_number} (id=${run.id}, ${run.created_at})`); + return String(run.id); +} + +async function getRunId() { + if (_runId) return _runId; + _runId = await getLatestCiRunId(); + return _runId; +} + /** * Fetches (and caches) the list of artifact names for the triggering CI run. * First call fetches and stores, every subsequent call returns the cached value. @@ -52,11 +107,12 @@ let _artifactList = null; async function getArtifactList() { if (_artifactList) return _artifactList; + const runId = await getRunId(); const artifacts = []; let page = 1; while (true) { - const url = `https://api.github.com/repos/MetaMask/metamask-mobile/actions/runs/${WORKFLOW_RUN_ID}/artifacts?per_page=100&page=${page}`; + const url = `https://api.github.com/repos/${GITHUB_REPOSITORY}/actions/runs/${runId}/artifacts?per_page=100&page=${page}`; const res = await fetch(url, { headers: { Authorization: `Bearer ${GITHUB_TOKEN}`, @@ -89,10 +145,11 @@ async function getArtifactList() { async function downloadArtifact(artifactName) { const artifacts = await getArtifactList(); const artifact = artifacts.find((a) => a.name === artifactName); + const runId = await getRunId(); if (!artifact) { throw new Error( - `Artifact "${artifactName}" not found in run ${WORKFLOW_RUN_ID}`, + `Artifact "${artifactName}" not found in run ${runId}`, ); } @@ -131,6 +188,88 @@ async function downloadArtifact(artifactName) { // Collectors — one async function per metric source // --------------------------------------------------------------------------- +/** + * Counts the number of individual test cases that are skipped in a source string. + * + * Two categories: + * 1. Explicit: `it.skip()` / `test.skip()` outside any `describe.skip` block — 1 skipped test each. + * 2. Implicit: every `it()` / `test()` call (including `.skip` variants) inside a `describe.skip` + * block, because the whole block is skipped by the runner. + * + * `describe.skip` blocks are extracted via brace matching so their contents are + * not double-counted against the explicit-skip pass. + * + * @param {string} source + * @returns {number} + */ +function countSkips(source) { + // Find all describe.skip blocks using brace matching. + const describeBlocks = []; + const re = /\bdescribe\.skip\s*\(/g; + let m; + while ((m = re.exec(source)) !== null) { + const braceStart = source.indexOf('{', m.index + m[0].length); + if (braceStart === -1) continue; + let depth = 1, pos = braceStart + 1; + while (pos < source.length && depth > 0) { + if (source[pos] === '{') depth++; + else if (source[pos] === '}') depth--; + pos++; + } + describeBlocks.push({ start: m.index, end: pos, content: source.slice(braceStart + 1, pos - 1) }); + } + + // Strip describe.skip regions from source (reverse order to preserve indices). + let outside = source; + for (let i = describeBlocks.length - 1; i >= 0; i--) { + outside = outside.slice(0, describeBlocks[i].start) + outside.slice(describeBlocks[i].end); + } + + // Part 1: it.skip / test.skip outside describe.skip blocks. + const explicitSkips = (outside.match(/\b(?:it|test)\.skip\s*\(/g) ?? []).length; + + // Part 2: all it() / test() (including .skip) inside describe.skip blocks. + const implicitSkips = describeBlocks.reduce( + (sum, { content }) => sum + (content.match(/\b(?:it|test)(?:\.skip)?\s*\(/g) ?? []).length, + 0, + ); + + return explicitSkips + implicitSkips; +} + +/** + * Counts all individual test definitions in a source string — both active and + * skipped. Matches it(, it.skip(, test(, test.skip(. + * Excludes describe() which is a grouper, not an individual test. + * + * @param {string} source + * @returns {number} + */ +function countDefinedTests(source) { + return (source.match(/\b(?:it|test)(?:\.skip)?\s*\(/g) ?? []).length; +} + +/** + * Recursively collects file paths under `dir` that satisfy `predicate(filename)`. + * + * @param {string} dir + * @param {(name: string) => boolean} predicate + * @returns {Promise} + */ +async function walkFiles(dir, predicate) { + const results = []; + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...(await walkFiles(fullPath, predicate))); + } else if (entry.isFile() && predicate(entry.name)) { + results.push(fullPath); + } + } + return results; +} + /** * Extracts a feature folder name from a Jest test file path. * @@ -197,12 +336,12 @@ async function collectShardCounts(artifactPattern, label, minFolderCount = 0) { } console.log(`[${label}] total: ${total}`); - const result = { tests_count: total }; + const result = { total_tests_run: total }; for (const [folder, count] of Object.entries(folderCounts)) { if (minFolderCount > 0 && count < minFolderCount) { - result.other_tests_count = (result.other_tests_count ?? 0) + count; + result.other_tests_run = (result.other_tests_run ?? 0) + count; } else { - result[`${folder}_tests_count`] = count; + result[`${folder}_tests_run`] = count; } } return result; @@ -210,14 +349,78 @@ async function collectShardCounts(artifactPattern, label, minFolderCount = 0) { async function collectComponentViewTestCount() { console.log('[component-view] collecting per-suite counts from shard artifacts...'); - return collectShardCounts(/^coverage-cv-\d+$/, 'component-view'); + const result = await collectShardCounts(/^coverage-cv-\d+$/, 'component-view'); + if (Object.keys(result).length === 0) return result; + + const isViewTestFile = (name) => PATTERN_CV_TEST_FILE.test(name); + const files = await walkFiles(SCAN_APP_DIR, isViewTestFile); + let defined = 0, skips = 0; + for (const f of files) { + const source = await readFile(f, 'utf8'); + defined += countDefinedTests(source); + skips += countSkips(source); + } + result.total_tests_defined = defined; + result.total_tests_skipped = skips; + + // Coverage from the pre-computed nyc json-summary report produced by + // the merge-unit-and-component-view-tests job in ci.yml. + try { + const destDir = await downloadArtifact('cv-test-coverage-summary'); + const summary = JSON.parse(await readFile(join(destDir, 'coverage-summary.json'), 'utf8')); + const { lines, statements, branches, functions } = summary.total; + result.coverage_line = Math.round(lines.pct * 10) / 10; + result.coverage_statement = Math.round(statements.pct * 10) / 10; + result.coverage_branch = Math.round(branches.pct * 10) / 10; + result.coverage_function = Math.round(functions.pct * 10) / 10; + console.log( + `[component-view] coverage — line: ${result.coverage_line}%, stmt: ${result.coverage_statement}%, branch: ${result.coverage_branch}%, fn: ${result.coverage_function}%`, + ); + } catch (err) { + console.warn(`[component-view] coverage summary not available, skipping: ${err.message}`); + } + + return result; } async function collectUnitTestCount() { console.log('[unit] collecting per-suite counts from shard artifacts...'); // minFolderCount=200: buckets individual component-level folders into `other`, // keeping only meaningful team-level categories (bridge, perps, confirmations, etc.) - return collectShardCounts(/^coverage-unit-\d+$/, 'unit', 200); + const result = await collectShardCounts(/^coverage-unit-\d+$/, 'unit', 200); + if (Object.keys(result).length === 0) return result; + + // Unit test files: *.test.{ts,tsx,js} excluding *.view[.*].test.* + const isUnitTestFile = (name) => + PATTERN_UNIT_TEST_FILE.test(name) && !PATTERN_CV_TEST_FILE.test(name); + const files = await walkFiles(SCAN_APP_DIR, isUnitTestFile); + let defined = 0, skips = 0; + for (const f of files) { + const source = await readFile(f, 'utf8'); + defined += countDefinedTests(source); + skips += countSkips(source); + } + result.total_tests_defined = defined; + result.total_tests_skipped = skips; + + // Coverage from the pre-computed nyc json-summary report produced by + // the merge-unit-and-component-view-tests job in ci.yml. + try { + const destDir = await downloadArtifact('unit-test-coverage-summary'); + const summary = JSON.parse(await readFile(join(destDir, 'coverage-summary.json'), 'utf8')); + const { lines, statements, branches, functions } = summary.total; + result.coverage_line = Math.round(lines.pct * 10) / 10; + result.coverage_statement = Math.round(statements.pct * 10) / 10; + result.coverage_branch = Math.round(branches.pct * 10) / 10; + result.coverage_function = Math.round(functions.pct * 10) / 10; + console.log( + `[unit] coverage — line: ${result.coverage_line}%, stmt: ${result.coverage_statement}%, branch: ${result.coverage_branch}%, fn: ${result.coverage_function}%`, + ); + } catch (err) { + console.warn(`[unit] coverage summary not available, skipping: ${err.message}`); + } + + return result; } /** @@ -313,20 +516,34 @@ async function collectE2ECounts() { // Canonical unique counts (Android as source of truth — same tests run on iOS) // A missing key means that channel did not run; present-but-zero means it ran and found nothing. if (androidMain > 0 || iosMain > 0) { - result.main_tests_count = androidMain; // unique count - result.main_android_tests_count = androidMain; // platform health signal - result.main_ios_tests_count = iosMain; // drops to 0 if iOS infrastructure is broken + result.main_tests_run = androidMain; // unique count + result.main_android_tests_run = androidMain; // platform health signal + result.main_ios_tests_run = iosMain; // drops to 0 if iOS infrastructure is broken } if (androidFlask > 0 || iosFlask > 0) { - result.flask_tests_count = androidFlask; // unique count - result.flask_android_tests_count = androidFlask; - result.flask_ios_tests_count = iosFlask; + result.flask_tests_run = androidFlask; // unique count + result.flask_android_tests_run = androidFlask; + result.flask_ios_tests_run = iosFlask; } - result.tests_count = androidMain + androidFlask; + result.total_tests_run = androidMain + androidFlask; for (const [tag, count] of Object.entries(suiteCounts)) { - result[`${tag}_tests_count`] = count; + result[`${tag}_tests_run`] = count; + } + + // Static scan — independent of which platform/channel ran + const isSpecTs = (name) => PATTERN_E2E_SPEC_FILE.test(name); + let defined = 0, skips = 0; + for (const dir of SCAN_E2E_SMOKE_DIRS) { + const files = await walkFiles(dir, isSpecTs); + for (const f of files) { + const source = await readFile(f, 'utf8'); + defined += countDefinedTests(source); + skips += countSkips(source); + } } + result.total_tests_defined = defined; + result.total_tests_skipped = skips; return result; } @@ -342,6 +559,7 @@ async function collectPerformanceTestCounts() { console.log('[performance] scanning tests/performance/ for scenarios...'); const categoryCounts = {}; + let totalSkips = 0; async function scanDir(dir, category) { const entries = await readdir(dir, { withFileTypes: true }); @@ -350,29 +568,30 @@ async function collectPerformanceTestCounts() { if (entry.isDirectory()) { // Top-level subdirectory determines the category await scanDir(fullPath, category ?? entry.name); - } else if (entry.isFile() && entry.name.endsWith('.spec.js')) { + } else if (entry.isFile() && PATTERN_PERF_SPEC_FILE.test(entry.name)) { const source = await readFile(fullPath, 'utf8'); - // Count test() calls, excluding test.skip() - const matches = source.match(/^\s*test\s*\(/gm) ?? []; + // Count all test() calls — including test.skip() — for total_tests_defined + const matches = source.match(/^\s*test(?:\.skip)?\s*\(/gm) ?? []; const count = matches.length; if (count > 0 && category) { const key = category.replace(/-/g, '_'); categoryCounts[key] = (categoryCounts[key] ?? 0) + count; } + totalSkips += countSkips(source); } } } - await scanDir('tests/performance', null); + await scanDir(SCAN_PERFORMANCE_DIR, null); const total = Object.values(categoryCounts).reduce((s, n) => s + n, 0); - const result = { tests_count: total }; + const result = { total_tests_defined: total, total_tests_skipped: totalSkips }; for (const [cat, count] of Object.entries(categoryCounts)) { - result[`${cat}_tests_count`] = count; + result[`${cat}_tests_defined`] = count; console.log(`[performance] ${cat}: ${count}`); } - console.log(`[performance] total: ${total}`); + console.log(`[performance] total: ${total}, skips: ${totalSkips}`); return result; } @@ -384,8 +603,8 @@ async function main() { const stats = {}; const collectors = [ - { namespace: 'component_view', collect: collectComponentViewTestCount }, { namespace: 'unit', collect: collectUnitTestCount }, + { namespace: 'component_view', collect: collectComponentViewTestCount }, { namespace: 'e2e', collect: collectE2ECounts }, { namespace: 'performance', collect: collectPerformanceTestCounts }, ]; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 661be2d0bfd..257a165406a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -190,6 +190,7 @@ jobs: with: name: ios-bundle path: ios/main.jsbundle + retention-days: 7 ship-js-bundle-size-check: runs-on: ubuntu-latest @@ -262,6 +263,7 @@ jobs: name: coverage-unit-${{ matrix.shard }} path: ./tests/coverage/ if-no-files-found: error + retention-days: 7 - name: Require clean working directory shell: bash run: | @@ -272,6 +274,8 @@ jobs: echo "No changes detected" fi + # We need to merge both unit and component view tests into a single coverage report so the PR coverage + # threshold calculation is accurate. merge-unit-and-component-view-tests: runs-on: ubuntu-latest needs: [unit-tests, component-view-tests] @@ -321,6 +325,11 @@ jobs: [ -f "$file" ] && cp "$file" ./tests/coverage-cv-merged/ done + mkdir -p tests/coverage-unit-merged + for file in ./tests/coverage/coverage-unit-*/coverage-unit-*.json; do + [ -f "$file" ] && cp "$file" ./tests/coverage-unit-merged/ + done + find ./tests/coverage/coverage-* -name 'coverage-*.json' -exec mv {} ./tests/coverage/ \; - run: yarn test:merge-coverage - run: yarn test:validate-coverage @@ -329,23 +338,41 @@ jobs: name: lcov.info path: ./tests/merged-coverage/lcov.info if-no-files-found: error + retention-days: 7 - uses: actions/upload-artifact@v4 with: name: cv-test-stats path: ./cv-test-stats.json if-no-files-found: error + retention-days: 7 - uses: actions/upload-artifact@v4 with: name: unit-test-stats path: ./unit-test-stats.json if-no-files-found: error - - name: Generate CV test HTML coverage report - run: yarn nyc report --temp-dir ./tests/coverage-cv-merged --report-dir ./tests/coverage-cv-lcov --reporter html + retention-days: 7 + - name: Generate CV test coverage report + run: yarn nyc report --temp-dir ./tests/coverage-cv-merged --report-dir ./tests/coverage-cv-lcov --reporter html --reporter json-summary - uses: actions/upload-artifact@v4 with: name: cv-test-coverage-html path: ./tests/coverage-cv-lcov/ if-no-files-found: error + retention-days: 7 + - uses: actions/upload-artifact@v4 + with: + name: cv-test-coverage-summary + path: ./tests/coverage-cv-lcov/coverage-summary.json + if-no-files-found: error + retention-days: 7 + - name: Generate unit test coverage summary + run: yarn nyc report --temp-dir ./tests/coverage-unit-merged --report-dir ./tests/coverage-unit-lcov --reporter json-summary + - uses: actions/upload-artifact@v4 + with: + name: unit-test-coverage-summary + path: ./tests/coverage-unit-lcov/coverage-summary.json + if-no-files-found: error + retention-days: 7 - name: Require clean working directory shell: bash run: | @@ -397,6 +424,7 @@ jobs: name: coverage-cv-${{ matrix.shard }} path: ./tests/coverage/ if-no-files-found: error + retention-days: 7 needs_e2e_build: uses: ./.github/workflows/needs-e2e-build.yml diff --git a/.github/workflows/qa-stats.yml b/.github/workflows/qa-stats.yml index 7eeb9b21dc1..4d8051e7b1c 100644 --- a/.github/workflows/qa-stats.yml +++ b/.github/workflows/qa-stats.yml @@ -1,19 +1,13 @@ name: QA Stats on: - workflow_run: - workflows: - - ci - types: - - completed - branches: - - main + schedule: + - cron: '0 4 * * *' jobs: collect-qa-stats: name: Collect QA Stats runs-on: ubuntu-latest - if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_repository.full_name == github.repository }} steps: - uses: actions/checkout@v6 @@ -26,7 +20,7 @@ jobs: run: node .github/scripts/collect-qa-stats.mjs env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }} + GITHUB_REPOSITORY: ${{ github.repository }} - name: Upload QA stats artifact uses: actions/upload-artifact@v6 diff --git a/.github/workflows/run-e2e-smoke-tests-android-flask.yml b/.github/workflows/run-e2e-smoke-tests-android-flask.yml index 68c68cdb5d4..47925476bf7 100644 --- a/.github/workflows/run-e2e-smoke-tests-android-flask.yml +++ b/.github/workflows/run-e2e-smoke-tests-android-flask.yml @@ -205,3 +205,4 @@ jobs: with: name: e2e-smoke-android-flask-all-test-artifacts path: all-test-artifacts/ + retention-days: 7 diff --git a/.github/workflows/run-e2e-smoke-tests-android.yml b/.github/workflows/run-e2e-smoke-tests-android.yml index 8a21808c8ee..4ff58cfb717 100644 --- a/.github/workflows/run-e2e-smoke-tests-android.yml +++ b/.github/workflows/run-e2e-smoke-tests-android.yml @@ -259,6 +259,7 @@ jobs: with: name: e2e-smoke-android-all-test-artifacts path: all-test-artifacts/ + retention-days: 7 - name: Create mobile JSON test report id: create-json-report @@ -290,3 +291,4 @@ jobs: with: name: test-e2e-android-json-report path: test/test-results/test-runs-android.json + retention-days: 7 diff --git a/.github/workflows/run-e2e-smoke-tests-ios-flask.yml b/.github/workflows/run-e2e-smoke-tests-ios-flask.yml index 269371640a7..878475da903 100644 --- a/.github/workflows/run-e2e-smoke-tests-ios-flask.yml +++ b/.github/workflows/run-e2e-smoke-tests-ios-flask.yml @@ -181,3 +181,4 @@ jobs: with: name: e2e-smoke-ios-flask-all-test-artifacts path: all-test-artifacts/ + retention-days: 7 diff --git a/.github/workflows/run-e2e-smoke-tests-ios.yml b/.github/workflows/run-e2e-smoke-tests-ios.yml index f9fba4f7be1..b4f03e15b70 100644 --- a/.github/workflows/run-e2e-smoke-tests-ios.yml +++ b/.github/workflows/run-e2e-smoke-tests-ios.yml @@ -283,6 +283,7 @@ jobs: with: name: e2e-smoke-ios-all-test-artifacts path: all-test-artifacts/ + retention-days: 7 - name: Create mobile JSON test report id: create-json-report @@ -314,3 +315,4 @@ jobs: with: name: test-e2e-ios-json-report path: test/test-results/test-runs-ios.json + retention-days: 7 From e8e693d9c7d038e301f7a8b900a4569c643ecbe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Santos?= Date: Wed, 18 Mar 2026 17:25:58 +0100 Subject: [PATCH 099/206] fix: market insights animation bug cp-7.70.0 (#27617) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR fixes #27612 by replacing the video in the AI insights animation with a static image after the video ends. This prevents the dark rectangle from displaying when a user returns to the AI insights page from the browser. On Android, when a Video component loses focus (e.g., user opens the in-app browser via a trend link), the native video surface is destroyed and renders black. When the user navigates back, the video can't recover its last frame. By replacing the Video with a static Image of the last frame once the video finishes playing (onEnd), we avoid the black screen entirely — there's no video surface left to be destroyed. ## **Changelog** CHANGELOG entry: Fixed a bug that was causing the AI summary animation from rendering. ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/27612 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/b9118bfb-c6a8-496b-b41a-b61e17dce912 ### **After** https://github.com/user-attachments/assets/ab493904-ec09-4651-998b-5c37614134cd ## **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. ## **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** > Low risk UI-only change that alters how the Market Insights background animation renders after playback; main risk is visual regressions across light/dark mode or on slower devices due to the new timing/state logic. > > **Overview** > Fixes the Market Insights background animation rendering issue by **showing a static last-frame image** and **stopping the `react-native-video` component after `onEnd`** to avoid the blank/dark rectangle when returning to the screen. > > Adds light/dark last-frame PNG assets, introduces `videoEnded`/`showLastFrame` state, and layers an `Image` behind the video (enabled shortly after mount) so the background remains stable once playback completes. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit e1d7ecc27449ca76f5b7cb8c2a47cfb0202e9e6b. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../MarketInsightsView/MarketInsightsView.tsx | 56 ++++++++++++++---- ...et-insights-background-dark-last-frame.png | Bin 0 -> 71786 bytes ...t-insights-background-light-last-frame.png | Bin 0 -> 40804 bytes 3 files changed, 46 insertions(+), 10 deletions(-) create mode 100644 app/components/UI/MarketInsights/animations/market-insights-background-dark-last-frame.png create mode 100644 app/components/UI/MarketInsights/animations/market-insights-background-light-last-frame.png diff --git a/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx b/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx index c1ae22fe028..8be139e2622 100644 --- a/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx +++ b/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx @@ -11,6 +11,7 @@ import { Linking, Pressable, Animated, + Image, useColorScheme, } from 'react-native'; import Video from 'react-native-video'; @@ -18,6 +19,10 @@ import Video from 'react-native-video'; const MarketInsightsBackgroundVideoLight = require('../../animations/market-insights-background-light.mp4'); // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import/no-commonjs const MarketInsightsBackgroundVideoDark = require('../../animations/market-insights-background-dark.mp4'); +// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import/no-commonjs +const MarketInsightsBackgroundLastFrameLight = require('../../animations/market-insights-background-light-last-frame.png'); +// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import/no-commonjs +const MarketInsightsBackgroundLastFrameDark = require('../../animations/market-insights-background-dark-last-frame.png'); import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import { useSelector } from 'react-redux'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; @@ -200,6 +205,27 @@ const MarketInsightsView: React.FC = () => { const { trackEvent, createEventBuilder } = useAnalytics(); const { toastRef } = useContext(ToastContext); const theme = useAppThemeFromContext(); + const [videoEnded, setVideoEnded] = useState(false); + const [showLastFrame, setShowLastFrame] = useState(false); + const lastFrameImage = useMemo( + () => + isDarkMode + ? MarketInsightsBackgroundLastFrameDark + : MarketInsightsBackgroundLastFrameLight, + [isDarkMode], + ); + + useEffect(() => { + const timeout = setTimeout(() => { + setShowLastFrame(true); + }, 100); + return () => clearTimeout(timeout); + }, []); + + const handleVideoEnd = useCallback(() => { + setVideoEnded(true); + }, []); + const hasTrackedViewRef = useRef(false); const [selectedTrend, setSelectedTrend] = useState(null); @@ -507,16 +533,26 @@ const MarketInsightsView: React.FC = () => { showsVerticalScrollIndicator={false} > - diff --git a/app/components/UI/MarketInsights/animations/market-insights-background-dark-last-frame.png b/app/components/UI/MarketInsights/animations/market-insights-background-dark-last-frame.png new file mode 100644 index 0000000000000000000000000000000000000000..e3e217ec67a38dfb3b05d1343f4268bd08b3b792 GIT binary patch literal 71786 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%0A89Q_~?q`~t{a!{aI6MhDB5+4Z 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 Date: Wed, 18 Mar 2026 16:33:25 -0230 Subject: [PATCH 100/206] chore: Update `@metamask/eslint-config-typescript` to v13 (#26125) ## **Description** The ESLint configuration for TypeScript has been updated to prepare for ESLint v9 (this is the last major version before ESLint v9 is required). Various related libraries needed to be updated as well. The most disruptive part is that in v13, `eslint-plugin-import` was replaced with `eslint-plugin-import-x`. This required widespread changes to any reference to an `import/` rule (it's now `import-x/`), but there should be no functional changes. `eslint-plugin-import-x` is a drop-in replacement for `eslint-plugin-import`. ## **Changelog** CHANGELOG entry: null ## **Related issues** N/A ## **Manual testing steps** N/A ## **Screenshots/Recordings** 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. ## **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** > Primarily tooling/config changes (ESLint rules and disable comments) with no intended runtime behavior changes; risk is limited to potential CI/lint or developer workflow breakage if rules are misconfigured. > > **Overview** > Updates ESLint tooling to support `@metamask/eslint-config-typescript` v13 by switching from `eslint-plugin-import` to the drop-in `eslint-plugin-import-x`, including updating rule names, resolver settings, and inline `eslint-disable` comments across the codebase. > > Adds temporary rule overrides (mostly turning off newer `@typescript-eslint`/`react-hooks` rules) to preserve pre-ESLint-v9 behavior during the migration, and updates `.depcheckrc.yml` ignores to treat ESLint-related packages as intentionally used. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit a8b5ec9dafeb8910d09df5d783120eee6047dc14. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .depcheckrc.yml | 10 +- .eslintrc.js | 114 ++- app/__mocks__/@metamask/native-utils.js | 4 +- app/__mocks__/mp4Mock.js | 2 +- app/__mocks__/pngMock.js | 2 +- app/__mocks__/react-native-i18n.ts | 2 +- app/__mocks__/spinnerMock.js | 2 +- app/actions/navigation/index.ts | 2 +- app/actions/security/index.ts | 2 +- .../TagBase/TagBase.constants.tsx | 2 +- .../MainActionButton.constants.ts | 2 +- .../NonEvmAggregatedPercentage.test.tsx | 2 +- .../SegmentedControl.constants.ts | 2 +- .../TagColored/TagColored.constants.ts | 2 +- .../Avatars/Avatar/Avatar.constants.ts | 2 +- .../AvatarBase/AvatarBase.constants.ts | 2 +- .../AvatarAccount/AvatarAccount.constants.ts | 2 +- .../AvatarFavicon/AvatarFavicon.constants.ts | 2 +- .../AvatarIcon/AvatarIcon.constants.ts | 2 +- .../AvatarNetwork/AvatarNetwork.constants.ts | 2 +- .../AvatarToken/AvatarToken.constants.ts | 2 +- .../AvatarGroup/AvatarGroup.constants.ts | 2 +- .../BadgeNetwork/BadgeNetwork.constants.ts | 2 +- .../BadgeStatus/BadgeStatus.constants.ts | 2 +- .../BadgeWrapper/BadgeWrapper.constants.tsx | 2 +- .../Banners/Banner/Banner.constants.ts | 2 +- .../BannerBase/BannerBase.constants.tsx | 2 +- .../BannerAlert/BannerAlert.constants.ts | 2 +- .../variants/BannerTip/BannerTip.constants.ts | 4 +- .../BottomSheetDialog.constants.ts | 2 +- .../BottomSheetFooter.constants.ts | 2 +- .../Buttons/Button/Button.constants.ts | 2 +- .../ButtonBase/ButtonBase.constants.ts | 2 +- .../ButtonLink/ButtonLink.constants.ts | 2 +- .../ButtonPrimary/ButtonPrimary.constants.ts | 2 +- .../ButtonSecondary.constants.ts | 2 +- .../ButtonIcon/ButtonIcon.constants.ts | 2 +- .../components/Cells/Cell/Cell.constants.ts | 2 +- .../components/Checkbox/Checkbox.constants.ts | 2 +- .../Form/HelpText/HelpText.constants.ts | 2 +- .../components/Form/Label/Label.constants.ts | 2 +- .../foundation/Input/Input.constants.ts | 2 +- .../components/Icons/Icon/Icon.assets.ts | 2 +- .../components/Icons/Icon/Icon.constants.ts | 2 +- .../Icons/Icon/scripts/generate-assets.ts | 4 +- .../List/ListItem/ListItem.constants.ts | 2 +- .../ListItemColumn.constants.ts | 2 +- .../ListItemMultiSelect.constants.ts | 2 +- .../ListItemSelect.constants.ts | 2 +- .../ModalConfirmation.constants.ts | 2 +- .../Navigation/TabBar/TabBar.constants.ts | 2 +- .../TabBarItem/TabBarItem.constants.ts | 2 +- .../components/Overlay/Overlay.constants.ts | 2 +- .../PickerAccount/PickerAccount.constants.ts | 2 +- .../PickerNetwork/PickerNetwork.constants.ts | 2 +- .../RadioButton/RadioButton.constants.ts | 2 +- .../SelectButton/SelectButton.constants.ts | 2 +- .../foundation/SelectButtonBase.constants.tsx | 2 +- .../SelectOption/SelectOption.constants.ts | 2 +- .../SelectValue/SelectValue.constants.ts | 2 +- .../Tags/TagUrl/TagUrl.constants.ts | 2 +- .../components/Texts/Text/Text.constants.ts | 2 +- .../TextWithPrefixIcon.constants.ts | 2 +- .../components/Toast/Toast.constants.ts | 2 +- .../constants/animation.constants.ts | 2 +- app/component-library/hooks/index.ts | 2 +- app/component-library/hooks/useStyles.ts | 2 +- .../InstallSnapConnectionRequest/index.ts | 2 +- .../components/InstallSnapError/index.ts | 2 +- .../InstallSnapPermissionsRequest/index.ts | 2 +- .../components/InstallSnapSuccess/index.ts | 2 +- .../InstallSnapApproval/components/index.ts | 2 +- app/components/Nav/Main/index.test.tsx | 4 +- app/components/UI/AssetList/index.js | 2 +- .../MarketClosedActionButton.constants.ts | 2 +- .../MarketDetailsList.test.tsx | 2 +- .../TokenDetails/TokenDetails.test.tsx | 2 +- app/components/UI/Box/box.types.ts | 2 - .../useAutoUpdateDestToken.test.ts | 4 +- .../useSwitchTokens/useSwitchTokens.test.ts | 2 +- app/components/UI/Button/index.js | 2 +- .../fetchCarouselSlidesFromContentful.test.ts | 2 +- app/components/UI/Carousel/index.test.tsx | 2 +- .../AdvancedChart/webview/chartLogicString.ts | 2 +- .../AdvancedChart/webview/syncChartLogic.js | 4 +- .../CollectibleMedia.test.tsx | 2 +- .../UI/DeepLinkModal/DeepLinkModal.styles.ts | 2 +- app/components/UI/DeepLinkModal/constant.ts | 2 +- app/components/UI/DeepLinkModal/index.ts | 2 +- app/components/UI/DeleteWalletModal/styles.ts | 2 +- .../EarnInputView/EarnInputView.test.tsx | 8 +- ...LendingWithdrawalConfirmationView.test.tsx | 2 +- .../EarnTokenList/EarnTokenList.test.tsx | 2 +- .../Earn/selectors/featureFlags/index.test.ts | 2 +- app/components/UI/FoxScreen/index.js | 2 +- .../HardwareWallet/AccountDetails/styles.tsx | 2 +- .../HardwareWallet/AccountSelector/styles.tsx | 2 +- .../LedgerModals/Steps/ConfirmationStep.tsx | 2 +- app/components/UI/LedgerModals/styles.ts | 2 +- .../UI/LoginOptionsSwitch/styles.ts | 2 +- .../MarketInsightsView/MarketInsightsView.tsx | 8 +- .../UI/Notification/Empty/styles.ts | 2 +- .../UI/Notification/List/index.test.tsx | 2 +- app/components/UI/Notification/List/styles.ts | 2 +- .../NotificationMenuItem/index.tsx | 2 +- .../__mocks__/mock_notifications.ts | 2 +- .../UI/OTAUpdatesModal/OTAUpdatesModal.tsx | 2 +- app/components/UI/OTAUpdatesModal/index.ts | 2 +- .../PerpsDiscoveryBanner.tsx | 2 +- .../PerpsTabControlBar.test.tsx | 2 +- .../PerpsTutorialCarousel.tsx | 6 +- .../Perps/hooks/usePerpsNetworkManagement.ts | 2 +- .../UI/Perps/hooks/usePerpsPositions.test.ts | 2 +- .../selectors/featureFlags/index.test.ts | 2 +- .../hooks/usePredictNetworkManagement.ts | 2 +- .../selectors/featureFlags/index.test.ts | 2 +- app/components/UI/Predict/types/index.ts | 1 - .../UI/QRHardware/AnimatedQRScanner.tsx | 2 +- .../Views/Quotes/Quotes.constants.ts | 2 +- .../Aggregator/components/ApplePayButton.tsx | 4 +- .../Aggregator/components/OrderDetails.tsx | 2 +- .../OrderListItem/OrderListItem.tsx | 4 +- .../UI/Ramp/Aggregator/utils/index.test.ts | 2 +- .../UI/Ramp/hooks/useTokenBuyability.test.ts | 2 +- app/components/UI/ReviewModal/styles.ts | 2 +- .../RewardPointsAnimation/index.tsx | 2 +- app/components/UI/SDKLoading/index.tsx | 2 +- .../UI/SecurityOptionToggle/styles.ts | 2 +- .../BatchApprovalRow.test.tsx | 4 +- .../SimulationDetails.test.tsx | 2 +- app/components/UI/SliderButton/index.js | 4 +- app/components/UI/SlippageSlider/index.js | 4 +- .../UI/Stake/sdk/stakeSdkProvider.test.tsx | 2 +- app/components/UI/StyledButton/index.js | 2 +- .../hooks/useTokenTransactions.ts | 4 +- app/components/UI/Tokens/index.test.tsx | 12 +- app/components/UI/TransactionElement/index.js | 4 +- .../useSearchRequest/useSearchRequest.test.ts | 2 +- .../useTrendingRequest.test.ts | 2 +- .../UI/TurnOffRememberMeModal/index.ts | 2 +- .../UI/TurnOffRememberMeModal/styles.ts | 2 +- .../UI/UpdateNeeded/UpdateNeeded.tsx | 2 +- app/components/UI/UpdateNeeded/index.ts | 2 +- app/components/UI/UpdateNeeded/styles.ts | 2 +- .../WhatsNewModal/WhatsNewModal.constants.ts | 2 +- .../AccountActions/AccountActions.test.tsx | 4 +- .../Views/ActivityView/index.test.tsx | 2 +- .../AddCustomCollectible.test.tsx | 2 +- .../ConnectQRHardware/Instruction/index.tsx | 2 +- .../Views/ImportPrivateKey/styles.ts | 2 +- app/components/Views/LockScreen/index.tsx | 2 +- .../Views/ManualBackupStep1/styles.ts | 2 +- .../Views/MultiRpcModal/MultiRpcModal.tsx | 2 +- .../IntroModal/LearnMoreBottomSheet.test.tsx | 4 +- .../NFTAutoDetectionModal.tsx | 2 +- .../NetworkSelector/NetworkSelector.test.tsx | 2 +- .../Details/Fields/TransactionField.test.tsx | 2 +- .../Notifications/Details/index.test.tsx | 2 +- .../Notifications/OptIn/OptIn.hooks.test.tsx | 2 +- .../Views/Notifications/OptIn/index.test.tsx | 4 +- .../Views/Notifications/OptIn/styles.ts | 2 +- .../Views/Notifications/index.test.tsx | 2 +- app/components/Views/OfflineMode/index.js | 2 +- app/components/Views/QRScanner/index.tsx | 2 +- .../Views/Quiz/QuizContent/index.ts | 2 +- app/components/Views/Quiz/SRPQuiz/SRPQuiz.tsx | 2 +- app/components/Views/Quiz/SRPQuiz/index.ts | 2 +- app/components/Views/Quiz/index.ts | 2 +- .../Views/RestoreWallet/RestoreWallet.tsx | 4 +- .../Views/RestoreWallet/WalletRestored.tsx | 2 +- app/components/Views/RestoreWallet/styles.ts | 2 +- .../Views/RevealPrivateCredential/index.ts | 2 +- .../Views/RevealPrivateCredential/styles.ts | 2 +- .../Views/Settings/AppInformation/index.js | 2 +- .../AccountsList.hooks.test.tsx | 4 +- .../AccountsList.test.tsx | 6 +- .../FeatureAnnouncementToggle.test.tsx | 2 +- .../MainNotificationToggle.test.tsx | 2 +- .../NotificationOptionToggle/styles.ts | 2 +- .../PushNotificationToggle.hooks.test.tsx | 4 +- .../PushNotificationToggle.test.tsx | 2 +- .../Sections/ChangePassword/styles.ts | 2 +- .../Sections/ClearPrivacy/styles.ts | 2 +- .../Sections/ProtectYourWallet/styles.ts | 2 +- app/components/Views/SimpleWebview/index.tsx | 2 +- .../Views/Snaps/SnapSettings/index.ts | 2 +- .../Views/Snaps/SnapsSettingsList/index.ts | 2 +- .../Snaps/components/SnapDescription/index.ts | 2 +- .../Snaps/components/SnapDetails/index.ts | 2 +- .../Snaps/components/SnapElement/index.ts | 2 +- .../components/SnapPermissionCell/index.ts | 2 +- .../Snaps/components/SnapPermissions/index.ts | 2 +- .../Snaps/components/SnapVersionTag/index.ts | 2 +- .../SocialLoginErrorSheet.tsx | 2 +- .../approve-and-permit2.test.tsx | 2 +- .../increase-decrease-allowance.test.tsx | 2 +- ...nfirmation-asset-polling-provider.test.tsx | 2 +- .../components/footer/footer.test.tsx | 2 +- .../components/info-root/info-root.test.tsx | 2 +- .../contract-interaction.test.tsx | 4 +- .../info/typed-sign-v3v4/message.test.tsx | 2 +- .../ledger-sign-modal.test.tsx | 4 +- .../switch-account-type-modal.test.tsx | 2 +- .../components/nft-list/nft-list.test.tsx | 2 +- .../components/qr-info/qr-info.test.tsx | 2 +- .../amount-keyboard/amount-keyboard.test.tsx | 2 +- .../smart-contract-with-logo.tsx | 2 +- .../components/token-list/token-list.test.tsx | 2 +- .../ledger-context/ledger-context.test.tsx | 2 +- .../qr-hardware-context.test.tsx | 4 +- .../useBatchApproveBalanceChanges.test.ts | 2 +- .../hooks/7702/useEIP7702Accounts.test.tsx | 2 +- .../useBatchedUnusedApprovalsAlert.test.tsx | 4 +- .../hooks/send/useAmountValidation.test.ts | 2 +- .../hooks/send/useNameValidation.test.ts | 4 +- .../hooks/send/usePercentageAmount.test.ts | 2 +- .../hooks/send/useSendActions.test.ts | 8 +- .../hooks/send/useSnapAmounOnInput.test.ts | 4 +- ...useTokenDecimalsInTypedSignRequest.test.ts | 2 +- .../hooks/useConfirmActions.test.ts | 4 +- .../hooks/useIsInsufficientBalance.test.ts | 2 +- .../utils/multichain-snaps.test.ts | 2 +- .../utils/send-address-validations.test.ts | 2 +- .../Views/confirmations/utils/send.test.ts | 4 +- .../LoopingScrollAnimation.tsx | 2 +- app/components/hooks/useStyles.ts | 2 +- app/constants/permissions.ts | 2 +- app/constants/snaps.ts | 2 +- .../perps/services/AccountService.ts | 2 +- .../perps/services/DataLakeService.ts | 2 +- .../FeatureFlagConfigurationService.ts | 2 +- .../HyperLiquidSubscriptionService.ts | 4 +- .../perps/services/MarketDataService.ts | 2 +- .../perps/utils/hyperLiquidAdapter.ts | 8 +- .../perps/utils/marketDataTransform.ts | 2 +- .../perps/utils/orderCalculations.ts | 6 +- app/controllers/perps/utils/rewardsUtils.ts | 2 +- .../Authentication/Authentication.test.ts | 2 +- app/core/BackgroundBridge/BackgroundBridge.js | 4 +- app/core/BackgroundBridge/Port.ts | 2 +- app/core/BackgroundBridge/RemotePort.ts | 2 +- .../BackgroundBridge/WalletConnectPort.ts | 2 +- .../legacy/__tests__/handleApproveUrl.test.ts | 2 +- .../legacy/__tests__/handleAssetUrl.test.ts | 2 +- .../__tests__/handleUniversalLink.test.ts | 2 +- app/core/DrawerStatusTracker.js | 2 +- .../messenger-action-handlers.test.ts | 2 +- .../network-controller/utils.test.ts | 2 +- ...ification-services-push-controller.test.ts | 2 +- ...ification-services-controller-init.test.ts | 2 +- ...tion-services-push-controller-init.test.ts | 2 +- .../snaps/execution-service-init.test.ts | 2 +- .../snaps/execution-service-init.ts | 2 +- app/core/EntryScriptWeb3.js | 2 +- app/core/MobilePortStream.js | 2 +- app/core/NativeModules.ts | 2 +- app/core/Performance/UIStartup.test.ts | 2 +- app/core/Performance/index.ts | 2 +- app/core/Permissions/index.test.ts | 2 +- .../RPCMethods/eth_sendTransaction.test.ts | 2 +- app/core/RPCMethods/eth_sendTransaction.ts | 1 - app/core/RPCMethods/wallet_watchAsset.test.ts | 2 +- app/core/SecureKeychain.test.ts | 2 +- app/core/SecureKeychain.ts | 2 +- app/core/Snaps/SnapBridge.test.ts | 2 +- app/core/Snaps/SnapBridge.ts | 2 +- app/core/Snaps/location/npm.ts | 2 +- .../WalletConnect/WalletConnectV2.test.ts | 2 +- app/core/WalletConnect/wc-utils.test.ts | 2 +- .../e2e/pages/SampleFeatureView.ts | 2 +- app/lib/ppom/ppom-util.test.ts | 4 +- app/lib/snaps/SnapsExecutionWebView.tsx | 4 +- .../collectibles/collectibles.test.ts | 2 +- app/reducers/swaps/swaps.test.ts | 2 +- app/selectors/accountsController.test.ts | 2 +- app/selectors/currencyRateController.test.ts | 2 +- .../earnController/earn/index.test.ts | 2 +- .../fullPageAccountList/index.test.ts | 2 +- .../homepage/index.test.ts | 2 +- .../marketInsights/perps.test.ts | 2 +- .../otaUpdates/index.test.ts | 2 +- .../rewards/rewardsEnabled.test.ts | 2 +- .../trxStakingEnabled/index.test.ts | 2 +- app/selectors/tokenBalancesController.ts | 2 +- app/selectors/tokenRatesController.ts | 2 +- app/store/index.ts | 2 +- app/util/blockaid/index.test.ts | 2 +- app/util/environment.ts | 2 +- .../useAuthentication/useAutoSignIn.test.ts | 2 +- .../useAuthentication/useAutoSignOut.test.ts | 2 +- .../hooks/useAuthentication/useSignIn.test.ts | 2 +- .../useAuthentication/useSignOut.test.ts | 2 +- .../useBackupAndSync/useBackupAndSync.test.ts | 2 +- .../useContactSyncing.test.tsx | 2 +- app/util/jsonRpcRequest.ts | 2 +- app/util/logs/index.ts | 6 +- app/util/navigation/navUtils.ts | 1 - app/util/networks/customNetworks.tsx | 4 +- app/util/notifications/hooks/index.test.tsx | 2 +- .../hooks/useNotifications.test.tsx | 6 +- .../hooks/usePushNotifications.test.ts | 6 +- .../useStartupNotificationsEffect.test.ts | 16 +- .../hooks/useSwitchNotifications.test.tsx | 6 +- .../notifications/services/FCMService.test.ts | 2 +- .../useCompletedOnboardingEffect.test.ts | 2 +- app/util/onboarding/index.ts | 2 +- app/util/sentry/utils.ts | 2 +- app/util/smart-transactions/index.test.ts | 2 +- .../smart-publish-hook.test.ts | 4 +- app/util/streams.js | 2 +- app/util/test/assetFileTransformer.js | 2 +- app/util/test/testSetup.js | 4 +- app/util/test/testSetupView.js | 6 +- app/util/transaction-controller/index.test.ts | 2 +- app/util/transactions/index.test.ts | 2 +- babel.config.js | 2 +- babel.config.tests.js | 2 +- fingerprint.config.js | 2 +- index.js | 2 +- jest.config.js | 2 +- jest.config.view.js | 2 +- locales/i18n.js | 2 +- locales/update-script.js | 4 +- metro.config.js | 10 +- metro.transform.js | 8 +- ota.config.js | 2 +- package.json | 18 +- ...amask+eslint-config-typescript+9.0.1.patch | 14 - react-native.config.js | 2 +- scripts/generate-rc-commits.mjs | 6 +- scripts/perps/agentic/cdp-bridge.js | 2 +- scripts/post-rc-build-comment.mjs | 2 +- scripts/setup.mjs | 2 +- scripts/slack-rc-notification.mjs | 2 +- shim.js | 8 +- shimPerf.js | 6 +- tailwind.config.js | 2 +- tests/component-view/allowedMockModules.js | 2 +- .../component-view/api-mocking/nockHelpers.ts | 2 +- tests/component-view/api-mocking/trending.ts | 2 +- tests/framework/.eslintrc.js | 2 +- tests/framework/Constants.ts | 2 +- tests/framework/DappServer.ts | 2 +- tests/framework/PortManager.test.ts | 2 +- tests/framework/PortManager.ts | 2 +- tests/framework/Utilities.ts | 2 +- tests/framework/config/ConfigHandler.ts | 2 +- tests/framework/fixtures/FixtureHelper.ts | 2 +- tests/framework/fixtures/FixtureUtils.ts | 2 +- .../framework/fixtures/fixture-validation.ts | 4 +- tests/framework/fixtures/helpers.ts | 2 +- tests/framework/quality-gates/helpers.ts | 2 +- .../framework/services/appium/AppiumServer.ts | 2 +- .../services/appium/EmulatorHelpers.ts | 2 +- .../browserstack/BrowserStackConfigBuilder.ts | 2 +- tests/framework/utils/MobileBrowser.js | 2 +- tests/helpers.js | 2 +- tests/jest.e2e.detox.config.js | 2 +- tests/performance/mm-connect/utils.js | 2 +- .../fixtures/fixture-validation.spec.ts | 4 +- .../reporters/DetoxPerformanceTestReporter.ts | 2 +- tests/reporters/PerformanceReporter.test.ts | 2 +- tests/reporters/PerformanceReporter.ts | 2 +- .../generators/JsonReportGenerator.test.ts | 2 +- .../generators/JsonReportGenerator.ts | 2 +- .../sentry/PerformanceSentryPublisher.ts | 2 +- tests/resources/networks.e2e.js | 2 +- .../check-ab-testing-compliance.test.ts | 2 +- tests/seeder/anvil-manager.ts | 2 +- .../api-specs/ConfirmationsRejectionRule.js | 2 +- tests/smoke/api-specs/json-rpc-coverage.js | 2 +- tests/smoke/api-specs/run-api-spec-tests.js | 2 +- .../userStorageMockttpController.ts | 2 +- .../websocket/account-activity-mocks.test.ts | 2 +- tests/websocket/account-activity-mocks.ts | 2 +- tests/websocket/server.test.ts | 2 +- tests/websocket/server.ts | 2 +- wdyr.js | 2 +- yarn.lock | 874 ++++++++++-------- 379 files changed, 1071 insertions(+), 886 deletions(-) delete mode 100644 patches/@metamask+eslint-config-typescript+9.0.1.patch diff --git a/.depcheckrc.yml b/.depcheckrc.yml index c89a37c15a9..6b4106aa80b 100644 --- a/.depcheckrc.yml +++ b/.depcheckrc.yml @@ -47,6 +47,13 @@ ignores: - '@metamask/perps-controller' # Used in scripts/repack for CI optimization - '@expo/repack-app' + + # ESLint plugins, resolvers, parsers, configuration, etc. + - 'eslint-config-prettier' + - 'eslint-import-resolver-typescript' + - 'eslint-plugin-prettier' + - 'eslint-plugin-react-native' + # Note: Everything below this line should be removed after investigation # TODO: Investigate each dependency to see whether it's used @@ -63,9 +70,6 @@ ignores: - 'babel-core' - 'babel-loader' - 'chromedriver' - - 'eslint-config-prettier' - - 'eslint-plugin-prettier' - - 'eslint-plugin-react-native' - 'execa' - 'jetifier' - 'metro-react-native-babel-preset' diff --git a/.eslintrc.js b/.eslintrc.js index 0a0b0be65b3..1cdc300210f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,4 +1,4 @@ -/* eslint-disable import/no-commonjs */ +/* eslint-disable import-x/no-commonjs */ module.exports = { root: true, parser: '@typescript-eslint/parser', @@ -9,7 +9,7 @@ module.exports = { '@react-native', 'eslint:recommended', // '@metamask/eslint-config', // TODO: Enable when ready - 'plugin:import/warnings', + 'plugin:import-x/warnings', 'plugin:react/recommended', ], // ESLint can find the plugin without the `eslint-plugin-` prefix. Ex. `eslint-plugin-react-compiler` -> `react-compiler` @@ -51,6 +51,65 @@ module.exports = { allow: ['Text'], }, ], + + // These rule modifications are removing changes to our shared ESLint config made after + // version v9. This is a temporary measure to get us to ESLint v9 compatible versions, + // at which point we can restore the intended rules and use error suppression instead. + // + // TODO: Remove these modifications after the ESLint v9 update + '@typescript-eslint/await-thenable': 'off', + '@typescript-eslint/consistent-type-imports': 'off', + '@typescript-eslint/consistent-type-exports': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/naming-convention': 'off', + '@typescript-eslint/no-base-to-string': 'off', + '@typescript-eslint/no-duplicate-type-constituents': 'off', + '@typescript-eslint/no-empty-object-type': 'off', + '@typescript-eslint/no-floating-promises': 'off', + '@typescript-eslint/no-implied-eval': 'off', + '@typescript-eslint/no-misused-promises': 'off', + '@typescript-eslint/no-redundant-type-constituents': 'off', + '@typescript-eslint/no-throw-literal': 'off', + '@typescript-eslint/no-unnecessary-type-assertion': 'off', + '@typescript-eslint/no-unnecessary-type-arguments': 'off', + '@typescript-eslint/no-unsafe-enum-comparison': 'off', + '@typescript-eslint/no-unnecessary-boolean-literal-compare': 'off', + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/no-wrapper-object-types': 'off', + '@typescript-eslint/only-throw-error': 'off', + '@typescript-eslint/prefer-enum-initializers': 'off', + '@typescript-eslint/prefer-includes': 'off', + '@typescript-eslint/prefer-nullish-coalescing': 'off', + '@typescript-eslint/prefer-optional-chain': 'off', + '@typescript-eslint/prefer-promise-reject-errors': 'off', + '@typescript-eslint/prefer-readonly': 'off', + '@typescript-eslint/prefer-reduce-type-parameter': 'off', + '@typescript-eslint/prefer-string-starts-ends-with': 'off', + '@typescript-eslint/promise-function-async': 'off', + '@typescript-eslint/restrict-plus-operands': 'off', + '@typescript-eslint/restrict-template-expressions': 'off', + '@typescript-eslint/switch-exhaustiveness-check': 'off', + '@typescript-eslint/unbound-method': 'off', + 'no-restricted-syntax': [ + 'error', + { + selector: 'WithStatement', + message: 'With statements are not allowed', + }, + { + selector: 'SequenceExpression', + message: 'Sequence expressions are not allowed', + }, + // { + // selector: "BinaryExpression[operator='in']", + // message: 'The "in" operator is not allowed', + // }, + // { + // selector: + // "PropertyDefinition[accessibility='private'], MethodDefinition[accessibility='private'], TSParameterProperty[accessibility='private']", + // message: 'Use a hash name instead.', + // }, + ], }, }, { @@ -86,8 +145,8 @@ module.exports = { }, rules: { 'no-console': 'off', - 'import/no-commonjs': 'off', - 'import/no-nodejs-modules': 'off', + 'import-x/no-commonjs': 'off', + 'import-x/no-nodejs-modules': 'off', }, }, { @@ -98,8 +157,8 @@ module.exports = { ], rules: { 'no-console': 'off', - 'import/no-commonjs': 'off', - 'import/no-nodejs-modules': 'off', + 'import-x/no-commonjs': 'off', + 'import-x/no-nodejs-modules': 'off', }, }, { @@ -361,10 +420,13 @@ module.exports = { '@typescript-eslint/no-floating-promises': 'error', '@typescript-eslint/restrict-template-expressions': 'error', - // === Import rules (using 'import' plugin, not 'import-x') === - 'import/consistent-type-specifier-style': ['error', 'prefer-top-level'], - 'import/no-named-as-default': 'error', - 'import/order': [ + // === Import rules === + 'import-x/consistent-type-specifier-style': [ + 'error', + 'prefer-top-level', + ], + 'import-x/no-named-as-default': 'error', + 'import-x/order': [ 'error', { groups: [ @@ -441,10 +503,10 @@ module.exports = { }, settings: { - 'import/resolver': { + 'import-x/resolver': { typescript: {}, // this loads /tsconfig.json to eslint }, - 'import/internal-regex': '^@metamask/perps-controller', + 'import-x/internal-regex': '^@metamask/perps-controller', }, rules: { @@ -466,7 +528,7 @@ module.exports = { 'no-bitwise': 'off', 'class-methods-use-this': 'off', 'eol-last': 'warn', - 'import/no-named-as-default': 'off', + 'import-x/no-named-as-default': 'off', 'no-invalid-this': 'off', 'no-new': 'off', 'react/jsx-handler-names': 'off', @@ -477,14 +539,14 @@ module.exports = { 'arrow-body-style': 'error', 'dot-notation': 'error', eqeqeq: 'error', - 'import/no-amd': 'error', - 'import/no-commonjs': 'error', - 'import/no-duplicates': 'error', - 'import/no-extraneous-dependencies': ['error', { packageDir: ['./'] }], - 'import/no-mutable-exports': 'error', - 'import/no-namespace': 'error', - 'import/no-nodejs-modules': 'error', - 'import/prefer-default-export': 'off', + 'import-x/no-amd': 'error', + 'import-x/no-commonjs': 'error', + 'import-x/no-duplicates': 'error', + 'import-x/no-extraneous-dependencies': ['error', { packageDir: ['./'] }], + 'import-x/no-mutable-exports': 'error', + 'import-x/no-namespace': 'error', + 'import-x/no-nodejs-modules': 'error', + 'import-x/prefer-default-export': 'off', 'no-alert': 'error', 'no-constant-condition': [ 'error', @@ -532,7 +594,7 @@ module.exports = { 'prefer-const': 'error', 'prefer-rest-params': 'error', 'prefer-spread': 'error', - 'import/no-unresolved': 'error', + 'import-x/no-unresolved': 'error', 'eslint-comments/no-unlimited-disable': 'off', 'eslint-comments/no-unused-disable': 'off', 'react-native/no-color-literals': 'error', @@ -559,6 +621,14 @@ module.exports = { 'react/prefer-es6-class': 'error', '@metamask/design-tokens/color-no-hex': 'warn', radix: 'off', + + // These rule modifications are removing changes to our shared ESLint config made after + // version v9. This is a temporary measure to get us to ESLint v9 compatible versions, + // at which point we can restore the intended rules and use error suppression instead. + // + // TODO: Remove these modifications after the ESLint v9 update + 'react-hooks/rules-of-hooks': 'off', + 'no-loss-of-precision': 'off', }, ignorePatterns: ['wdio.conf.js', 'app/util/termsOfUse/termsOfUseContent.ts'], diff --git a/app/__mocks__/@metamask/native-utils.js b/app/__mocks__/@metamask/native-utils.js index 0fb68eb792c..1fec3202d4f 100644 --- a/app/__mocks__/@metamask/native-utils.js +++ b/app/__mocks__/@metamask/native-utils.js @@ -1,5 +1,5 @@ -/* eslint-disable import/no-extraneous-dependencies */ -/* eslint-disable import/no-commonjs */ +/* eslint-disable import-x/no-extraneous-dependencies */ +/* eslint-disable import-x/no-commonjs */ /** * Mock for @metamask/native-utils * diff --git a/app/__mocks__/mp4Mock.js b/app/__mocks__/mp4Mock.js index 86b87e1d9c2..7677d067ead 100644 --- a/app/__mocks__/mp4Mock.js +++ b/app/__mocks__/mp4Mock.js @@ -1,3 +1,3 @@ // When required, assets in React Native returns a number -// eslint-disable-next-line import/no-commonjs +// eslint-disable-next-line import-x/no-commonjs module.exports = 1; diff --git a/app/__mocks__/pngMock.js b/app/__mocks__/pngMock.js index 86b87e1d9c2..7677d067ead 100644 --- a/app/__mocks__/pngMock.js +++ b/app/__mocks__/pngMock.js @@ -1,3 +1,3 @@ // When required, assets in React Native returns a number -// eslint-disable-next-line import/no-commonjs +// eslint-disable-next-line import-x/no-commonjs module.exports = 1; diff --git a/app/__mocks__/react-native-i18n.ts b/app/__mocks__/react-native-i18n.ts index 95f9fe03dbb..67632872b69 100644 --- a/app/__mocks__/react-native-i18n.ts +++ b/app/__mocks__/react-native-i18n.ts @@ -1,4 +1,4 @@ -// eslint-disable-next-line import/no-extraneous-dependencies +// eslint-disable-next-line import-x/no-extraneous-dependencies import I18nJs from 'i18n-js'; I18nJs.locale = 'en'; // a locale from your available translations diff --git a/app/__mocks__/spinnerMock.js b/app/__mocks__/spinnerMock.js index f5180f503dd..2a703545d15 100644 --- a/app/__mocks__/spinnerMock.js +++ b/app/__mocks__/spinnerMock.js @@ -1,4 +1,4 @@ -/* eslint-disable import/no-commonjs */ +/* eslint-disable import-x/no-commonjs */ module.exports = { Spinner: () => null, diff --git a/app/actions/navigation/index.ts b/app/actions/navigation/index.ts index b4c82b9a98e..54ec2477824 100644 --- a/app/actions/navigation/index.ts +++ b/app/actions/navigation/index.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import { type OnNavigationReadyAction, type SetCurrentRouteAction, diff --git a/app/actions/security/index.ts b/app/actions/security/index.ts index 8d6252df358..27bcfc0da26 100644 --- a/app/actions/security/index.ts +++ b/app/actions/security/index.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import type { Action as ReduxAction } from 'redux'; export enum ActionType { diff --git a/app/component-library/base-components/TagBase/TagBase.constants.tsx b/app/component-library/base-components/TagBase/TagBase.constants.tsx index 364c82dd513..37195707032 100644 --- a/app/component-library/base-components/TagBase/TagBase.constants.tsx +++ b/app/component-library/base-components/TagBase/TagBase.constants.tsx @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // Third library dependencies. import React from 'react'; diff --git a/app/component-library/components-temp/MainActionButton/MainActionButton.constants.ts b/app/component-library/components-temp/MainActionButton/MainActionButton.constants.ts index 56784d277a7..032e5ce60a6 100644 --- a/app/component-library/components-temp/MainActionButton/MainActionButton.constants.ts +++ b/app/component-library/components-temp/MainActionButton/MainActionButton.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // Test IDs export const MAINACTIONBUTTON_TEST_ID = 'main-action-button'; diff --git a/app/component-library/components-temp/Price/AggregatedPercentage/NonEvmAggregatedPercentage.test.tsx b/app/component-library/components-temp/Price/AggregatedPercentage/NonEvmAggregatedPercentage.test.tsx index 9a9f7155262..9ce68779164 100644 --- a/app/component-library/components-temp/Price/AggregatedPercentage/NonEvmAggregatedPercentage.test.tsx +++ b/app/component-library/components-temp/Price/AggregatedPercentage/NonEvmAggregatedPercentage.test.tsx @@ -8,7 +8,7 @@ import { FORMATTED_PERCENTAGE_TEST_ID, } from './AggregatedPercentage.constants'; import NonEvmAggregatedPercentage from './NonEvmAggregatedPercentage'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as multichain from '../../../../selectors/multichain/multichain'; import { selectMultichainAssetsRates } from '../../../../selectors/multichain/multichain'; diff --git a/app/component-library/components-temp/SegmentedControl/SegmentedControl.constants.ts b/app/component-library/components-temp/SegmentedControl/SegmentedControl.constants.ts index a15a4dd1742..908eccfa41c 100644 --- a/app/component-library/components-temp/SegmentedControl/SegmentedControl.constants.ts +++ b/app/component-library/components-temp/SegmentedControl/SegmentedControl.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { ButtonSize } from '../../components/Buttons/Button/Button.types'; diff --git a/app/component-library/components-temp/TagColored/TagColored.constants.ts b/app/component-library/components-temp/TagColored/TagColored.constants.ts index f4468459326..e3099810112 100644 --- a/app/component-library/components-temp/TagColored/TagColored.constants.ts +++ b/app/component-library/components-temp/TagColored/TagColored.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies import { TextVariant } from '../../components/Texts/Text'; diff --git a/app/component-library/components/Avatars/Avatar/Avatar.constants.ts b/app/component-library/components/Avatars/Avatar/Avatar.constants.ts index b42b1c27b55..b0b01e357a2 100644 --- a/app/component-library/components/Avatars/Avatar/Avatar.constants.ts +++ b/app/component-library/components/Avatars/Avatar/Avatar.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { IconSize } from '../../Icons/Icon'; import { SAMPLE_AVATARACCOUNT_PROPS } from './variants/AvatarAccount/AvatarAccount.constants'; diff --git a/app/component-library/components/Avatars/Avatar/foundation/AvatarBase/AvatarBase.constants.ts b/app/component-library/components/Avatars/Avatar/foundation/AvatarBase/AvatarBase.constants.ts index 33921324e38..a81553c2d67 100644 --- a/app/component-library/components/Avatars/Avatar/foundation/AvatarBase/AvatarBase.constants.ts +++ b/app/component-library/components/Avatars/Avatar/foundation/AvatarBase/AvatarBase.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // Third party dependencies. import { ImageSourcePropType } from 'react-native'; diff --git a/app/component-library/components/Avatars/Avatar/variants/AvatarAccount/AvatarAccount.constants.ts b/app/component-library/components/Avatars/Avatar/variants/AvatarAccount/AvatarAccount.constants.ts index 8e4e9739d17..39a186be8a2 100644 --- a/app/component-library/components/Avatars/Avatar/variants/AvatarAccount/AvatarAccount.constants.ts +++ b/app/component-library/components/Avatars/Avatar/variants/AvatarAccount/AvatarAccount.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { AvatarSize } from '../../Avatar.types'; diff --git a/app/component-library/components/Avatars/Avatar/variants/AvatarFavicon/AvatarFavicon.constants.ts b/app/component-library/components/Avatars/Avatar/variants/AvatarFavicon/AvatarFavicon.constants.ts index 0e6c3c5068a..6607fee14ef 100644 --- a/app/component-library/components/Avatars/Avatar/variants/AvatarFavicon/AvatarFavicon.constants.ts +++ b/app/component-library/components/Avatars/Avatar/variants/AvatarFavicon/AvatarFavicon.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // Third party dependencies. import { ImageSourcePropType } from 'react-native'; diff --git a/app/component-library/components/Avatars/Avatar/variants/AvatarIcon/AvatarIcon.constants.ts b/app/component-library/components/Avatars/Avatar/variants/AvatarIcon/AvatarIcon.constants.ts index 554a030c6eb..5eba8a1be11 100644 --- a/app/component-library/components/Avatars/Avatar/variants/AvatarIcon/AvatarIcon.constants.ts +++ b/app/component-library/components/Avatars/Avatar/variants/AvatarIcon/AvatarIcon.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { mockTheme } from '../../../../../../util/theme'; import { AvatarSize } from '../../Avatar.types'; diff --git a/app/component-library/components/Avatars/Avatar/variants/AvatarNetwork/AvatarNetwork.constants.ts b/app/component-library/components/Avatars/Avatar/variants/AvatarNetwork/AvatarNetwork.constants.ts index 18572d1f561..271a41f7349 100644 --- a/app/component-library/components/Avatars/Avatar/variants/AvatarNetwork/AvatarNetwork.constants.ts +++ b/app/component-library/components/Avatars/Avatar/variants/AvatarNetwork/AvatarNetwork.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // Third party dependencies. import { ImageSourcePropType, Platform } from 'react-native'; diff --git a/app/component-library/components/Avatars/Avatar/variants/AvatarToken/AvatarToken.constants.ts b/app/component-library/components/Avatars/Avatar/variants/AvatarToken/AvatarToken.constants.ts index 88a16614c98..e04cc8aa67b 100644 --- a/app/component-library/components/Avatars/Avatar/variants/AvatarToken/AvatarToken.constants.ts +++ b/app/component-library/components/Avatars/Avatar/variants/AvatarToken/AvatarToken.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // Third party dependences. import { ImageSourcePropType } from 'react-native'; diff --git a/app/component-library/components/Avatars/AvatarGroup/AvatarGroup.constants.ts b/app/component-library/components/Avatars/AvatarGroup/AvatarGroup.constants.ts index b463d1b907c..237f9a717a4 100644 --- a/app/component-library/components/Avatars/AvatarGroup/AvatarGroup.constants.ts +++ b/app/component-library/components/Avatars/AvatarGroup/AvatarGroup.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies import { AvatarSize, AvatarProps, AvatarVariant } from '../Avatar/Avatar.types'; diff --git a/app/component-library/components/Badges/Badge/variants/BadgeNetwork/BadgeNetwork.constants.ts b/app/component-library/components/Badges/Badge/variants/BadgeNetwork/BadgeNetwork.constants.ts index 2eb4cf0af34..913f05cab82 100644 --- a/app/component-library/components/Badges/Badge/variants/BadgeNetwork/BadgeNetwork.constants.ts +++ b/app/component-library/components/Badges/Badge/variants/BadgeNetwork/BadgeNetwork.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { AvatarSize } from '../../../../Avatars/Avatar'; diff --git a/app/component-library/components/Badges/Badge/variants/BadgeStatus/BadgeStatus.constants.ts b/app/component-library/components/Badges/Badge/variants/BadgeStatus/BadgeStatus.constants.ts index 1a3eb7d4415..c7886a6bc1e 100644 --- a/app/component-library/components/Badges/Badge/variants/BadgeStatus/BadgeStatus.constants.ts +++ b/app/component-library/components/Badges/Badge/variants/BadgeStatus/BadgeStatus.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // Internal dependencies. import { BadgeStatusState, BadgeStatusProps } from './BadgeStatus.types'; diff --git a/app/component-library/components/Badges/BadgeWrapper/BadgeWrapper.constants.tsx b/app/component-library/components/Badges/BadgeWrapper/BadgeWrapper.constants.tsx index fb68f4be8b2..16feae3665c 100644 --- a/app/component-library/components/Badges/BadgeWrapper/BadgeWrapper.constants.tsx +++ b/app/component-library/components/Badges/BadgeWrapper/BadgeWrapper.constants.tsx @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import React from 'react'; import { View } from 'react-native'; diff --git a/app/component-library/components/Banners/Banner/Banner.constants.ts b/app/component-library/components/Banners/Banner/Banner.constants.ts index 83822488ad6..67f8756179c 100644 --- a/app/component-library/components/Banners/Banner/Banner.constants.ts +++ b/app/component-library/components/Banners/Banner/Banner.constants.ts @@ -1,5 +1,5 @@ /* eslint-disable no-console */ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { DEFAULT_BANNERALERT_SEVERITY } from './variants/BannerAlert/BannerAlert.constants'; import { ButtonVariants } from '../../Buttons/Button'; diff --git a/app/component-library/components/Banners/Banner/foundation/BannerBase/BannerBase.constants.tsx b/app/component-library/components/Banners/Banner/foundation/BannerBase/BannerBase.constants.tsx index fd9f5bdd09f..3cced4d840a 100644 --- a/app/component-library/components/Banners/Banner/foundation/BannerBase/BannerBase.constants.tsx +++ b/app/component-library/components/Banners/Banner/foundation/BannerBase/BannerBase.constants.tsx @@ -1,5 +1,5 @@ /* eslint-disable no-console */ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // Third library dependencies. import React from 'react'; diff --git a/app/component-library/components/Banners/Banner/variants/BannerAlert/BannerAlert.constants.ts b/app/component-library/components/Banners/Banner/variants/BannerAlert/BannerAlert.constants.ts index 3bc44b02212..7b4de9caf1f 100644 --- a/app/component-library/components/Banners/Banner/variants/BannerAlert/BannerAlert.constants.ts +++ b/app/component-library/components/Banners/Banner/variants/BannerAlert/BannerAlert.constants.ts @@ -1,5 +1,5 @@ /* eslint-disable no-console */ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { ButtonVariants } from '../../../../Buttons/Button'; diff --git a/app/component-library/components/Banners/Banner/variants/BannerTip/BannerTip.constants.ts b/app/component-library/components/Banners/Banner/variants/BannerTip/BannerTip.constants.ts index 5bf215f91e6..61496f8adca 100644 --- a/app/component-library/components/Banners/Banner/variants/BannerTip/BannerTip.constants.ts +++ b/app/component-library/components/Banners/Banner/variants/BannerTip/BannerTip.constants.ts @@ -1,8 +1,8 @@ /* eslint-disable no-console */ /* eslint-disable @typescript-eslint/no-require-imports */ -/* eslint-disable import/no-commonjs */ +/* eslint-disable import-x/no-commonjs */ /* eslint-disable @typescript-eslint/no-var-requires */ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { ButtonVariants } from '../../../../Buttons/Button'; diff --git a/app/component-library/components/BottomSheets/BottomSheet/foundation/BottomSheetDialog/BottomSheetDialog.constants.ts b/app/component-library/components/BottomSheets/BottomSheet/foundation/BottomSheetDialog/BottomSheetDialog.constants.ts index 7e7bade3f07..fe2c0cb5acf 100644 --- a/app/component-library/components/BottomSheets/BottomSheet/foundation/BottomSheetDialog/BottomSheetDialog.constants.ts +++ b/app/component-library/components/BottomSheets/BottomSheet/foundation/BottomSheetDialog/BottomSheetDialog.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { AnimationDuration } from '../../../../../constants/animation.constants'; diff --git a/app/component-library/components/BottomSheets/BottomSheetFooter/BottomSheetFooter.constants.ts b/app/component-library/components/BottomSheets/BottomSheetFooter/BottomSheetFooter.constants.ts index 8b9e586d2ef..5d951d72de4 100644 --- a/app/component-library/components/BottomSheets/BottomSheetFooter/BottomSheetFooter.constants.ts +++ b/app/component-library/components/BottomSheets/BottomSheetFooter/BottomSheetFooter.constants.ts @@ -1,5 +1,5 @@ /* eslint-disable no-console */ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { ButtonVariants } from '../../Buttons/Button'; diff --git a/app/component-library/components/Buttons/Button/Button.constants.ts b/app/component-library/components/Buttons/Button/Button.constants.ts index 45d811f0c3a..56d740ae46b 100644 --- a/app/component-library/components/Buttons/Button/Button.constants.ts +++ b/app/component-library/components/Buttons/Button/Button.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { SAMPLE_BUTTONSECONDARY_PROPS } from './variants/ButtonSecondary/ButtonSecondary.constants'; diff --git a/app/component-library/components/Buttons/Button/foundation/ButtonBase/ButtonBase.constants.ts b/app/component-library/components/Buttons/Button/foundation/ButtonBase/ButtonBase.constants.ts index c62398b478a..9f0f65e9361 100644 --- a/app/component-library/components/Buttons/Button/foundation/ButtonBase/ButtonBase.constants.ts +++ b/app/component-library/components/Buttons/Button/foundation/ButtonBase/ButtonBase.constants.ts @@ -1,5 +1,5 @@ /* eslint-disable no-console */ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { ButtonBaseProps } from './ButtonBase.types'; import { IconName, IconSize } from '../../../../Icons/Icon'; diff --git a/app/component-library/components/Buttons/Button/variants/ButtonLink/ButtonLink.constants.ts b/app/component-library/components/Buttons/Button/variants/ButtonLink/ButtonLink.constants.ts index e3e50054634..f8f51ef7615 100644 --- a/app/component-library/components/Buttons/Button/variants/ButtonLink/ButtonLink.constants.ts +++ b/app/component-library/components/Buttons/Button/variants/ButtonLink/ButtonLink.constants.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { ButtonSize } from '../../Button.types'; diff --git a/app/component-library/components/Buttons/Button/variants/ButtonPrimary/ButtonPrimary.constants.ts b/app/component-library/components/Buttons/Button/variants/ButtonPrimary/ButtonPrimary.constants.ts index 73fb3ca6317..742dec6c978 100644 --- a/app/component-library/components/Buttons/Button/variants/ButtonPrimary/ButtonPrimary.constants.ts +++ b/app/component-library/components/Buttons/Button/variants/ButtonPrimary/ButtonPrimary.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies import { SAMPLE_BUTTONBASE_PROPS } from '../../foundation/ButtonBase/ButtonBase.constants'; diff --git a/app/component-library/components/Buttons/Button/variants/ButtonSecondary/ButtonSecondary.constants.ts b/app/component-library/components/Buttons/Button/variants/ButtonSecondary/ButtonSecondary.constants.ts index a4ddaf31f02..cc760d3e1dd 100644 --- a/app/component-library/components/Buttons/Button/variants/ButtonSecondary/ButtonSecondary.constants.ts +++ b/app/component-library/components/Buttons/Button/variants/ButtonSecondary/ButtonSecondary.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { SAMPLE_BUTTONBASE_PROPS } from '../../foundation/ButtonBase/ButtonBase.constants'; diff --git a/app/component-library/components/Buttons/ButtonIcon/ButtonIcon.constants.ts b/app/component-library/components/Buttons/ButtonIcon/ButtonIcon.constants.ts index e2c8813aa78..300385a2d7f 100644 --- a/app/component-library/components/Buttons/ButtonIcon/ButtonIcon.constants.ts +++ b/app/component-library/components/Buttons/ButtonIcon/ButtonIcon.constants.ts @@ -1,5 +1,5 @@ /* eslint-disable no-console */ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { IconSize, IconName, IconColor } from '../../Icons/Icon'; diff --git a/app/component-library/components/Cells/Cell/Cell.constants.ts b/app/component-library/components/Cells/Cell/Cell.constants.ts index 5a6ef93b3f4..c642c631d98 100644 --- a/app/component-library/components/Cells/Cell/Cell.constants.ts +++ b/app/component-library/components/Cells/Cell/Cell.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { AvatarVariant, AvatarAccountType } from '../../Avatars/Avatar'; import { AvatarProps } from '../../Avatars/Avatar/Avatar.types'; diff --git a/app/component-library/components/Checkbox/Checkbox.constants.ts b/app/component-library/components/Checkbox/Checkbox.constants.ts index 590defd5c7f..66d38d632de 100644 --- a/app/component-library/components/Checkbox/Checkbox.constants.ts +++ b/app/component-library/components/Checkbox/Checkbox.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { IconName, IconSize } from '../Icons/Icon'; diff --git a/app/component-library/components/Form/HelpText/HelpText.constants.ts b/app/component-library/components/Form/HelpText/HelpText.constants.ts index 82f898e92a0..be8d2d077b8 100644 --- a/app/component-library/components/Form/HelpText/HelpText.constants.ts +++ b/app/component-library/components/Form/HelpText/HelpText.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { TextColor, TextVariant } from '../../Texts/Text'; diff --git a/app/component-library/components/Form/Label/Label.constants.ts b/app/component-library/components/Form/Label/Label.constants.ts index c8b831884c7..735d98db099 100644 --- a/app/component-library/components/Form/Label/Label.constants.ts +++ b/app/component-library/components/Form/Label/Label.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // Internal dependencies. import { TextVariant } from '../../Texts/Text'; diff --git a/app/component-library/components/Form/TextField/foundation/Input/Input.constants.ts b/app/component-library/components/Form/TextField/foundation/Input/Input.constants.ts index 5e02b0bf256..16f7dca9be3 100644 --- a/app/component-library/components/Form/TextField/foundation/Input/Input.constants.ts +++ b/app/component-library/components/Form/TextField/foundation/Input/Input.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { DEFAULT_TEXT_VARIANT } from '../../../../Texts/Text/Text.constants'; diff --git a/app/component-library/components/Icons/Icon/Icon.assets.ts b/app/component-library/components/Icons/Icon/Icon.assets.ts index d9ef73bea5e..a6eae2d7aeb 100644 --- a/app/component-library/components/Icons/Icon/Icon.assets.ts +++ b/app/component-library/components/Icons/Icon/Icon.assets.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ /////////////////////////////////////////////////////// // This is a generated file // DO NOT EDIT - Use generate-assets.js diff --git a/app/component-library/components/Icons/Icon/Icon.constants.ts b/app/component-library/components/Icons/Icon/Icon.constants.ts index 8f8ed52556f..5a7231ccfb4 100644 --- a/app/component-library/components/Icons/Icon/Icon.constants.ts +++ b/app/component-library/components/Icons/Icon/Icon.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // Internal dependencies. import { IconName, IconProps, IconSize, IconColor } from './Icon.types'; diff --git a/app/component-library/components/Icons/Icon/scripts/generate-assets.ts b/app/component-library/components/Icons/Icon/scripts/generate-assets.ts index 22c90fe0523..3a9699a214d 100644 --- a/app/component-library/components/Icons/Icon/scripts/generate-assets.ts +++ b/app/component-library/components/Icons/Icon/scripts/generate-assets.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -/* eslint-disable import/no-commonjs, import/no-nodejs-modules, import/no-nodejs-modules, no-console */ +/* eslint-disable import-x/no-commonjs, import-x/no-nodejs-modules, import-x/no-nodejs-modules, no-console */ import fs from 'fs'; import path from 'path'; @@ -41,7 +41,7 @@ const main = async () => { fs.appendFileSync( assetsModulePath, - `/* eslint-disable import/prefer-default-export */`, + `/* eslint-disable import-x/prefer-default-export */`, ); fs.appendFileSync( diff --git a/app/component-library/components/List/ListItem/ListItem.constants.ts b/app/component-library/components/List/ListItem/ListItem.constants.ts index 8ee1fb524fd..8a1a615d962 100644 --- a/app/component-library/components/List/ListItem/ListItem.constants.ts +++ b/app/component-library/components/List/ListItem/ListItem.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // Internal dependencies. import { VerticalAlignment, ListItemProps } from './ListItem.types'; diff --git a/app/component-library/components/List/ListItemColumn/ListItemColumn.constants.ts b/app/component-library/components/List/ListItemColumn/ListItemColumn.constants.ts index 4bb8ea1daf9..6ec017c8726 100644 --- a/app/component-library/components/List/ListItemColumn/ListItemColumn.constants.ts +++ b/app/component-library/components/List/ListItemColumn/ListItemColumn.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // Internal dependencies. import { ListItemColumnProps, WidthType } from './ListItemColumn.types'; diff --git a/app/component-library/components/List/ListItemMultiSelect/ListItemMultiSelect.constants.ts b/app/component-library/components/List/ListItemMultiSelect/ListItemMultiSelect.constants.ts index c7e2493b897..f405bbb3e76 100644 --- a/app/component-library/components/List/ListItemMultiSelect/ListItemMultiSelect.constants.ts +++ b/app/component-library/components/List/ListItemMultiSelect/ListItemMultiSelect.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { SAMPLE_LISTITEM_PROPS } from '../../List/ListItem/ListItem.constants'; diff --git a/app/component-library/components/List/ListItemSelect/ListItemSelect.constants.ts b/app/component-library/components/List/ListItemSelect/ListItemSelect.constants.ts index 6ffca867f4d..cae6123f4a0 100644 --- a/app/component-library/components/List/ListItemSelect/ListItemSelect.constants.ts +++ b/app/component-library/components/List/ListItemSelect/ListItemSelect.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { SAMPLE_LISTITEM_PROPS } from '../ListItem/ListItem.constants'; diff --git a/app/component-library/components/Modals/ModalConfirmation/ModalConfirmation.constants.ts b/app/component-library/components/Modals/ModalConfirmation/ModalConfirmation.constants.ts index 8f2a4335258..dac3e624b38 100644 --- a/app/component-library/components/Modals/ModalConfirmation/ModalConfirmation.constants.ts +++ b/app/component-library/components/Modals/ModalConfirmation/ModalConfirmation.constants.ts @@ -1,5 +1,5 @@ /* eslint-disable no-console */ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import { ModalConfirmationRoute, diff --git a/app/component-library/components/Navigation/TabBar/TabBar.constants.ts b/app/component-library/components/Navigation/TabBar/TabBar.constants.ts index 2c9e1731917..596f860e929 100644 --- a/app/component-library/components/Navigation/TabBar/TabBar.constants.ts +++ b/app/component-library/components/Navigation/TabBar/TabBar.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // Third party dependencies. import { IconName } from '../../Icons/Icon'; diff --git a/app/component-library/components/Navigation/TabBarItem/TabBarItem.constants.ts b/app/component-library/components/Navigation/TabBarItem/TabBarItem.constants.ts index 761600e1a3c..131f2f016a6 100644 --- a/app/component-library/components/Navigation/TabBarItem/TabBarItem.constants.ts +++ b/app/component-library/components/Navigation/TabBarItem/TabBarItem.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ /* eslint-disable no-console */ // External dependencies. import { IconName } from '../../Icons/Icon'; diff --git a/app/component-library/components/Overlay/Overlay.constants.ts b/app/component-library/components/Overlay/Overlay.constants.ts index 8346ff19926..4662d38c599 100644 --- a/app/component-library/components/Overlay/Overlay.constants.ts +++ b/app/component-library/components/Overlay/Overlay.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { AnimationDuration } from '../../constants/animation.constants'; diff --git a/app/component-library/components/Pickers/PickerAccount/PickerAccount.constants.ts b/app/component-library/components/Pickers/PickerAccount/PickerAccount.constants.ts index 27d744cb8a5..f31d2c00130 100644 --- a/app/component-library/components/Pickers/PickerAccount/PickerAccount.constants.ts +++ b/app/component-library/components/Pickers/PickerAccount/PickerAccount.constants.ts @@ -1,5 +1,5 @@ /* eslint-disable no-console */ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // Internal dependencies. import { PickerAccountProps } from './PickerAccount.types'; diff --git a/app/component-library/components/Pickers/PickerNetwork/PickerNetwork.constants.ts b/app/component-library/components/Pickers/PickerNetwork/PickerNetwork.constants.ts index 8e9539e0436..29c94ab6979 100644 --- a/app/component-library/components/Pickers/PickerNetwork/PickerNetwork.constants.ts +++ b/app/component-library/components/Pickers/PickerNetwork/PickerNetwork.constants.ts @@ -1,5 +1,5 @@ /* eslint-disable no-console */ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // Internal dependencies. import { PickerNetworkProps } from './PickerNetwork.types'; diff --git a/app/component-library/components/RadioButton/RadioButton.constants.ts b/app/component-library/components/RadioButton/RadioButton.constants.ts index 16aaaa7f606..d37aec6e83a 100644 --- a/app/component-library/components/RadioButton/RadioButton.constants.ts +++ b/app/component-library/components/RadioButton/RadioButton.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { TextVariant, TextColor } from '../Texts/Text'; diff --git a/app/component-library/components/Select/SelectButton/SelectButton.constants.ts b/app/component-library/components/Select/SelectButton/SelectButton.constants.ts index 7944cf22527..8dfd9a495af 100644 --- a/app/component-library/components/Select/SelectButton/SelectButton.constants.ts +++ b/app/component-library/components/Select/SelectButton/SelectButton.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { TextVariant, TextColor } from '../../Texts/Text'; import { IconName, IconColor, IconSize } from '../../Icons/Icon'; diff --git a/app/component-library/components/Select/SelectButton/foundation/SelectButtonBase.constants.tsx b/app/component-library/components/Select/SelectButton/foundation/SelectButtonBase.constants.tsx index aeaf590617f..9b9ada3e81a 100644 --- a/app/component-library/components/Select/SelectButton/foundation/SelectButtonBase.constants.tsx +++ b/app/component-library/components/Select/SelectButton/foundation/SelectButtonBase.constants.tsx @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // Third party dependencies. import React from 'react'; diff --git a/app/component-library/components/Select/SelectOption/SelectOption.constants.ts b/app/component-library/components/Select/SelectOption/SelectOption.constants.ts index b4f6fd1dbbb..d6816e2361b 100644 --- a/app/component-library/components/Select/SelectOption/SelectOption.constants.ts +++ b/app/component-library/components/Select/SelectOption/SelectOption.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { SAMPLE_AVATAR_PROPS } from '../../Avatars/Avatar/Avatar.constants'; diff --git a/app/component-library/components/Select/SelectValue/SelectValue.constants.ts b/app/component-library/components/Select/SelectValue/SelectValue.constants.ts index e935ad3fe72..cdc2ac0ed70 100644 --- a/app/component-library/components/Select/SelectValue/SelectValue.constants.ts +++ b/app/component-library/components/Select/SelectValue/SelectValue.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { TextVariant, TextColor } from '../../Texts/Text'; import { SAMPLE_AVATAR_PROPS } from '../../Avatars/Avatar/Avatar.constants'; diff --git a/app/component-library/components/Tags/TagUrl/TagUrl.constants.ts b/app/component-library/components/Tags/TagUrl/TagUrl.constants.ts index d5a186f1c2a..c7850483900 100644 --- a/app/component-library/components/Tags/TagUrl/TagUrl.constants.ts +++ b/app/component-library/components/Tags/TagUrl/TagUrl.constants.ts @@ -1,5 +1,5 @@ /* eslint-disable no-console */ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // Internal dependencies. import { TagUrlProps } from './TagUrl.types'; diff --git a/app/component-library/components/Texts/Text/Text.constants.ts b/app/component-library/components/Texts/Text/Text.constants.ts index ceed074a307..cceac48de29 100644 --- a/app/component-library/components/Texts/Text/Text.constants.ts +++ b/app/component-library/components/Texts/Text/Text.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // Internal dependencies. import { TextColor, TextVariant, TextProps } from './Text.types'; diff --git a/app/component-library/components/Texts/TextWithPrefixIcon/TextWithPrefixIcon.constants.ts b/app/component-library/components/Texts/TextWithPrefixIcon/TextWithPrefixIcon.constants.ts index 8943612ce00..52d44783833 100644 --- a/app/component-library/components/Texts/TextWithPrefixIcon/TextWithPrefixIcon.constants.ts +++ b/app/component-library/components/Texts/TextWithPrefixIcon/TextWithPrefixIcon.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { TextColor, TextVariant } from '../Text/Text.types'; import { IconName, IconSize, IconColor } from '../../Icons/Icon'; diff --git a/app/component-library/components/Toast/Toast.constants.ts b/app/component-library/components/Toast/Toast.constants.ts index 586545288e6..c87097e7921 100644 --- a/app/component-library/components/Toast/Toast.constants.ts +++ b/app/component-library/components/Toast/Toast.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // External dependencies. import { AvatarAccountType } from '../Avatars/Avatar/variants/AvatarAccount'; diff --git a/app/component-library/constants/animation.constants.ts b/app/component-library/constants/animation.constants.ts index f0015dac3a6..3e89d40c635 100644 --- a/app/component-library/constants/animation.constants.ts +++ b/app/component-library/constants/animation.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ /** * Animation Tokens in miliseconds. diff --git a/app/component-library/hooks/index.ts b/app/component-library/hooks/index.ts index 580203e91fe..f1ec4238b8c 100644 --- a/app/component-library/hooks/index.ts +++ b/app/component-library/hooks/index.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ export { useStyles } from './useStyles'; export { useComponentSize } from './useComponentSize'; export { diff --git a/app/component-library/hooks/useStyles.ts b/app/component-library/hooks/useStyles.ts index 2f53b4338f4..a056aeee034 100644 --- a/app/component-library/hooks/useStyles.ts +++ b/app/component-library/hooks/useStyles.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import { useMemo } from 'react'; import { useAppThemeFromContext } from '../../util/theme'; import { Theme } from '../../util/theme/models'; diff --git a/app/components/Approvals/InstallSnapApproval/components/InstallSnapConnectionRequest/index.ts b/app/components/Approvals/InstallSnapApproval/components/InstallSnapConnectionRequest/index.ts index 8b9e4ed3f2c..4d64d33cad4 100644 --- a/app/components/Approvals/InstallSnapApproval/components/InstallSnapConnectionRequest/index.ts +++ b/app/components/Approvals/InstallSnapApproval/components/InstallSnapConnectionRequest/index.ts @@ -1,5 +1,5 @@ ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import InstallSnapConnectionRequest from './InstallSnapConnectionRequest'; export { InstallSnapConnectionRequest }; diff --git a/app/components/Approvals/InstallSnapApproval/components/InstallSnapError/index.ts b/app/components/Approvals/InstallSnapApproval/components/InstallSnapError/index.ts index 0cd446d0392..a05b61881ea 100644 --- a/app/components/Approvals/InstallSnapApproval/components/InstallSnapError/index.ts +++ b/app/components/Approvals/InstallSnapApproval/components/InstallSnapError/index.ts @@ -1,5 +1,5 @@ ///: BEGIN:ONLY_INCLUDE_IF(external-snaps) -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import InstallSnapError from './InstallSnapError'; export { InstallSnapError }; diff --git a/app/components/Approvals/InstallSnapApproval/components/InstallSnapPermissionsRequest/index.ts b/app/components/Approvals/InstallSnapApproval/components/InstallSnapPermissionsRequest/index.ts index a7070c8e222..d539af3d383 100644 --- a/app/components/Approvals/InstallSnapApproval/components/InstallSnapPermissionsRequest/index.ts +++ b/app/components/Approvals/InstallSnapApproval/components/InstallSnapPermissionsRequest/index.ts @@ -1,5 +1,5 @@ ///: BEGIN:ONLY_INCLUDE_IF(external-snaps) -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import InstallSnapPermissionsRequest from './InstallSnapPermissionsRequest'; export { InstallSnapPermissionsRequest }; diff --git a/app/components/Approvals/InstallSnapApproval/components/InstallSnapSuccess/index.ts b/app/components/Approvals/InstallSnapApproval/components/InstallSnapSuccess/index.ts index 0f09c54d7b2..bbef9d69f0d 100644 --- a/app/components/Approvals/InstallSnapApproval/components/InstallSnapSuccess/index.ts +++ b/app/components/Approvals/InstallSnapApproval/components/InstallSnapSuccess/index.ts @@ -1,5 +1,5 @@ ///: BEGIN:ONLY_INCLUDE_IF(external-snaps) -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import InstallSnapSuccess from './InstallSnapSuccess'; export { InstallSnapSuccess }; diff --git a/app/components/Approvals/InstallSnapApproval/components/index.ts b/app/components/Approvals/InstallSnapApproval/components/index.ts index 94543d785e1..a6223470ba5 100644 --- a/app/components/Approvals/InstallSnapApproval/components/index.ts +++ b/app/components/Approvals/InstallSnapApproval/components/index.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps) import { InstallSnapConnectionRequest } from './InstallSnapConnectionRequest'; ///: END:ONLY_INCLUDE_IF diff --git a/app/components/Nav/Main/index.test.tsx b/app/components/Nav/Main/index.test.tsx index 6ff38770376..cb716a56d57 100644 --- a/app/components/Nav/Main/index.test.tsx +++ b/app/components/Nav/Main/index.test.tsx @@ -1,7 +1,7 @@ -/* eslint-disable import/no-nodejs-modules */ +/* eslint-disable import-x/no-nodejs-modules */ import React from 'react'; import { shallow } from 'enzyme'; -// eslint-disable-next-line import/named +// eslint-disable-next-line import-x/named import { NavigationContainer } from '@react-navigation/native'; import Main from './'; import configureMockStore from 'redux-mock-store'; diff --git a/app/components/UI/AssetList/index.js b/app/components/UI/AssetList/index.js index f5f736236de..7cae25052d8 100644 --- a/app/components/UI/AssetList/index.js +++ b/app/components/UI/AssetList/index.js @@ -2,7 +2,7 @@ import React, { PureComponent } from 'react'; import { View, StyleSheet } from 'react-native'; import PropTypes from 'prop-types'; import { strings } from '../../../../locales/i18n'; -import StyledButton from '../StyledButton'; // eslint-disable-line import/no-unresolved +import StyledButton from '../StyledButton'; // eslint-disable-line import-x/no-unresolved import AssetIcon from '../AssetIcon'; import { fontStyles } from '../../../styles/common'; import Text from '../../Base/Text'; diff --git a/app/components/UI/AssetOverview/MarketClosedActionButton/MarketClosedActionButton.constants.ts b/app/components/UI/AssetOverview/MarketClosedActionButton/MarketClosedActionButton.constants.ts index 320f98a7ae0..6ce3b3feb0b 100644 --- a/app/components/UI/AssetOverview/MarketClosedActionButton/MarketClosedActionButton.constants.ts +++ b/app/components/UI/AssetOverview/MarketClosedActionButton/MarketClosedActionButton.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ // Test IDs export const MARKETCLOSED_ACTIONBUTTON_TEST_ID = 'market-closed-action-button'; diff --git a/app/components/UI/AssetOverview/TokenDetails/MarketDetailsList/MarketDetailsList.test.tsx b/app/components/UI/AssetOverview/TokenDetails/MarketDetailsList/MarketDetailsList.test.tsx index 21335cd837b..3a09ce1f03b 100644 --- a/app/components/UI/AssetOverview/TokenDetails/MarketDetailsList/MarketDetailsList.test.tsx +++ b/app/components/UI/AssetOverview/TokenDetails/MarketDetailsList/MarketDetailsList.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { render } from '@testing-library/react-native'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as reactRedux from 'react-redux'; import MarketDetailsList from '.'; diff --git a/app/components/UI/AssetOverview/TokenDetails/TokenDetails.test.tsx b/app/components/UI/AssetOverview/TokenDetails/TokenDetails.test.tsx index d77f6d04331..87916f9cd5a 100644 --- a/app/components/UI/AssetOverview/TokenDetails/TokenDetails.test.tsx +++ b/app/components/UI/AssetOverview/TokenDetails/TokenDetails.test.tsx @@ -14,7 +14,7 @@ import { selectContractExchangeRates } from '../../../../selectors/tokenRatesCon import { backgroundState } from '../../../../util/test/initial-root-state'; import renderWithProvider from '../../../../util/test/renderWithProvider'; import TokenDetails from './'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as reactRedux from 'react-redux'; import { selectMultichainAssetsRates } from '../../../../selectors/multichain'; import { selectIsEvmNetworkSelected } from '../../../../selectors/multichainNetworkController'; diff --git a/app/components/UI/Box/box.types.ts b/app/components/UI/Box/box.types.ts index 4a1aab53d59..ff388aab921 100644 --- a/app/components/UI/Box/box.types.ts +++ b/app/components/UI/Box/box.types.ts @@ -427,14 +427,12 @@ type PropsToOmit = keyof (AsProp & P); */ type PolymorphicComponentProp< C extends React.ElementType, - // eslint-disable-next-line @typescript-eslint/ban-types Props = {}, > = React.PropsWithChildren> & Omit, PropsToOmit>; export type PolymorphicComponentPropWithRef< C extends React.ElementType, - // eslint-disable-next-line @typescript-eslint/ban-types Props = {}, > = PolymorphicComponentProp & { ref?: PolymorphicRef }; diff --git a/app/components/UI/Bridge/hooks/useAutoUpdateDestToken/useAutoUpdateDestToken.test.ts b/app/components/UI/Bridge/hooks/useAutoUpdateDestToken/useAutoUpdateDestToken.test.ts index 603c21aa959..03e497dff2c 100644 --- a/app/components/UI/Bridge/hooks/useAutoUpdateDestToken/useAutoUpdateDestToken.test.ts +++ b/app/components/UI/Bridge/hooks/useAutoUpdateDestToken/useAutoUpdateDestToken.test.ts @@ -8,9 +8,9 @@ import { useAutoUpdateDestToken } from '.'; import { BridgeToken } from '../../types'; import { Hex } from '@metamask/utils'; import { BtcScope, SolScope } from '@metamask/keyring-api'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as bridgeSlice from '../../../../../core/redux/slices/bridge'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as tokenUtils from '../../utils/tokenUtils'; describe('useAutoUpdateDestToken', () => { diff --git a/app/components/UI/Bridge/hooks/useSwitchTokens/useSwitchTokens.test.ts b/app/components/UI/Bridge/hooks/useSwitchTokens/useSwitchTokens.test.ts index 3618da99f6e..ceaaf05101c 100644 --- a/app/components/UI/Bridge/hooks/useSwitchTokens/useSwitchTokens.test.ts +++ b/app/components/UI/Bridge/hooks/useSwitchTokens/useSwitchTokens.test.ts @@ -5,7 +5,7 @@ import { waitFor } from '@testing-library/react-native'; import { BridgeToken } from '../../types'; import { Hex } from '@metamask/utils'; import { SolScope } from '@metamask/keyring-api'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as bridgeSlice from '../../../../../core/redux/slices/bridge'; import Engine from '../../../../../core/Engine'; diff --git a/app/components/UI/Button/index.js b/app/components/UI/Button/index.js index c32ac8b4c27..b168233fc06 100644 --- a/app/components/UI/Button/index.js +++ b/app/components/UI/Button/index.js @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { StyleSheet } from 'react-native'; -import GenericButton from '../GenericButton'; // eslint-disable-line import/no-unresolved +import GenericButton from '../GenericButton'; // eslint-disable-line import-x/no-unresolved import { useTheme } from '../../../util/theme'; import { ViewPropTypes } from 'deprecated-react-native-prop-types'; diff --git a/app/components/UI/Carousel/fetchCarouselSlidesFromContentful.test.ts b/app/components/UI/Carousel/fetchCarouselSlidesFromContentful.test.ts index 162116b71d2..b86e02fc344 100644 --- a/app/components/UI/Carousel/fetchCarouselSlidesFromContentful.test.ts +++ b/app/components/UI/Carousel/fetchCarouselSlidesFromContentful.test.ts @@ -1,5 +1,5 @@ import { ContentfulClientApi, createClient } from 'contentful'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as DeviceInfoModule from 'react-native-device-info'; import { fetchCarouselSlidesFromContentful, diff --git a/app/components/UI/Carousel/index.test.tsx b/app/components/UI/Carousel/index.test.tsx index b4dc6202023..f1d84fdfea4 100644 --- a/app/components/UI/Carousel/index.test.tsx +++ b/app/components/UI/Carousel/index.test.tsx @@ -15,7 +15,7 @@ import { backgroundState } from '../../../util/test/initial-root-state'; import Engine from '../../../core/Engine'; import { fetchCarouselSlidesFromContentful } from './fetchCarouselSlidesFromContentful'; import { CarouselSlide } from './types'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as FeatureFlagSelectorsModule from './selectors/featureFlags'; import { RootState } from '../../../reducers'; import { selectLastSelectedSolanaAccount } from '../../../selectors/accountsController'; diff --git a/app/components/UI/Charts/AdvancedChart/webview/chartLogicString.ts b/app/components/UI/Charts/AdvancedChart/webview/chartLogicString.ts index 7017c990069..65ba0c3e613 100644 --- a/app/components/UI/Charts/AdvancedChart/webview/chartLogicString.ts +++ b/app/components/UI/Charts/AdvancedChart/webview/chartLogicString.ts @@ -6,7 +6,7 @@ * node app/components/UI/Charts/AdvancedChart/webview/syncChartLogic.js */ -// eslint-disable-next-line import/no-default-export +// eslint-disable-next-line import-x/no-default-export export default `/** * TradingView Chart WebView Logic * diff --git a/app/components/UI/Charts/AdvancedChart/webview/syncChartLogic.js b/app/components/UI/Charts/AdvancedChart/webview/syncChartLogic.js index 78ff071c1b8..4248f683bcf 100644 --- a/app/components/UI/Charts/AdvancedChart/webview/syncChartLogic.js +++ b/app/components/UI/Charts/AdvancedChart/webview/syncChartLogic.js @@ -1,5 +1,5 @@ #!/usr/bin/env node -/* eslint-disable import/no-commonjs, import/no-nodejs-modules, no-console */ +/* eslint-disable import-x/no-commonjs, import-x/no-nodejs-modules, no-console */ /** * Sync script that reads chartLogic.js and exports it as a string in chartLogicString.ts * @@ -23,7 +23,7 @@ const tsContent = `/** * node app/components/UI/Charts/AdvancedChart/webview/syncChartLogic.js */ -// eslint-disable-next-line import/no-default-export +// eslint-disable-next-line import-x/no-default-export export default \`${jsContent.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\${/g, '\\${')}\`; `; diff --git a/app/components/UI/CollectibleMedia/CollectibleMedia.test.tsx b/app/components/UI/CollectibleMedia/CollectibleMedia.test.tsx index 3bbbf18189d..0d22d334101 100644 --- a/app/components/UI/CollectibleMedia/CollectibleMedia.test.tsx +++ b/app/components/UI/CollectibleMedia/CollectibleMedia.test.tsx @@ -8,7 +8,7 @@ import CollectibleMedia from './CollectibleMedia'; import renderWithProvider from '../../../util/test/renderWithProvider'; import { backgroundState } from '../../../util/test/initial-root-state'; import { mockNetworkState } from '../../../util/test/network'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as AssetControllers from '@metamask/assets-controllers'; const mockInitialState = { diff --git a/app/components/UI/DeepLinkModal/DeepLinkModal.styles.ts b/app/components/UI/DeepLinkModal/DeepLinkModal.styles.ts index 56522481fdf..2ffdd22d24f 100644 --- a/app/components/UI/DeepLinkModal/DeepLinkModal.styles.ts +++ b/app/components/UI/DeepLinkModal/DeepLinkModal.styles.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import { StyleSheet } from 'react-native'; import { Theme } from '../../../util/theme/models'; diff --git a/app/components/UI/DeepLinkModal/constant.ts b/app/components/UI/DeepLinkModal/constant.ts index 9b9adbf9c06..2e0e0d6684f 100644 --- a/app/components/UI/DeepLinkModal/constant.ts +++ b/app/components/UI/DeepLinkModal/constant.ts @@ -1,7 +1,7 @@ import { createNavigationDetails } from '../../../util/navigation/navUtils'; import Routes from '../../../constants/navigation/Routes'; -/* eslint-disable import/no-commonjs, @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ +/* eslint-disable import-x/no-commonjs, @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ export const foxLogo = require('../../../images/branding/fox.png'); export const pageNotFound = require('images/page-not-found.png'); export const createDeepLinkModalNavDetails = createNavigationDetails( diff --git a/app/components/UI/DeepLinkModal/index.ts b/app/components/UI/DeepLinkModal/index.ts index 1feb893e1b4..74ea24f2424 100644 --- a/app/components/UI/DeepLinkModal/index.ts +++ b/app/components/UI/DeepLinkModal/index.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ export { default as DeepLinkModal } from './DeepLinkModal'; export { DeepLinkModalLinkType, type DeepLinkModalParams } from './types'; export { createDeepLinkModalNavDetails } from './constant'; diff --git a/app/components/UI/DeleteWalletModal/styles.ts b/app/components/UI/DeleteWalletModal/styles.ts index 6b43ec4567c..3f65b94d357 100644 --- a/app/components/UI/DeleteWalletModal/styles.ts +++ b/app/components/UI/DeleteWalletModal/styles.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import { fontStyles } from '../../../styles/common'; import { StyleSheet } from 'react-native'; diff --git a/app/components/UI/Earn/Views/EarnInputView/EarnInputView.test.tsx b/app/components/UI/Earn/Views/EarnInputView/EarnInputView.test.tsx index 089f2f17609..e5ab4bf2c60 100644 --- a/app/components/UI/Earn/Views/EarnInputView/EarnInputView.test.tsx +++ b/app/components/UI/Earn/Views/EarnInputView/EarnInputView.test.tsx @@ -34,14 +34,14 @@ import { import { MOCK_VAULT_APY_AVERAGES } from '../../../Stake/components/PoolStakingLearnMoreModal/mockVaultRewards'; import { EVENT_PROVIDERS } from '../../../Stake/constants/events'; import { EVENT_LOCATIONS } from '../../constants/events/earnEvents'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as useBalance from '../../../Stake/hooks/useBalance'; import usePoolStakedDeposit from '../../../Stake/hooks/usePoolStakedDeposit'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import Engine from '../../../../../core/Engine'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as useEarnGasFee from '../../../Earn/hooks/useEarnGasFee'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as multichainAccountsSelectors from '../../../../../selectors/multichainAccounts/accounts'; import { createMockToken, diff --git a/app/components/UI/Earn/Views/EarnLendingWithdrawalConfirmationView/EarnLendingWithdrawalConfirmationView.test.tsx b/app/components/UI/Earn/Views/EarnLendingWithdrawalConfirmationView/EarnLendingWithdrawalConfirmationView.test.tsx index 6bb239b215a..2faad081aa6 100644 --- a/app/components/UI/Earn/Views/EarnLendingWithdrawalConfirmationView/EarnLendingWithdrawalConfirmationView.test.tsx +++ b/app/components/UI/Earn/Views/EarnLendingWithdrawalConfirmationView/EarnLendingWithdrawalConfirmationView.test.tsx @@ -24,7 +24,7 @@ import { } from '@metamask/transaction-controller'; import { AnalyticsEventBuilder } from '../../../../../util/analytics/AnalyticsEventBuilder'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as NavbarUtils from '../../../Navbar'; import { MOCK_USDC_MAINNET_ASSET } from '../../../Stake/__mocks__/stakeMockData'; import useEarnToken from '../../hooks/useEarnToken'; diff --git a/app/components/UI/Earn/components/EarnTokenList/EarnTokenList.test.tsx b/app/components/UI/Earn/components/EarnTokenList/EarnTokenList.test.tsx index 1afbf808532..8a7b7b6e81e 100644 --- a/app/components/UI/Earn/components/EarnTokenList/EarnTokenList.test.tsx +++ b/app/components/UI/Earn/components/EarnTokenList/EarnTokenList.test.tsx @@ -1,4 +1,4 @@ -/* eslint-disable import/no-namespace */ +/* eslint-disable import-x/no-namespace */ import { act, fireEvent } from '@testing-library/react-native'; import { TrxScope } from '@metamask/keyring-api'; import React from 'react'; diff --git a/app/components/UI/Earn/selectors/featureFlags/index.test.ts b/app/components/UI/Earn/selectors/featureFlags/index.test.ts index 0bd392cd5b5..0df35e95e25 100644 --- a/app/components/UI/Earn/selectors/featureFlags/index.test.ts +++ b/app/components/UI/Earn/selectors/featureFlags/index.test.ts @@ -25,7 +25,7 @@ import { VersionGatedFeatureFlag, validatedVersionGatedFeatureFlag, } from '../../../../../util/remoteFeatureFlag'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as remoteFeatureFlagModule from '../../../../../util/remoteFeatureFlag'; jest.mock('react-native-device-info', () => ({ diff --git a/app/components/UI/FoxScreen/index.js b/app/components/UI/FoxScreen/index.js index 7975b87c19e..25066e4c19f 100644 --- a/app/components/UI/FoxScreen/index.js +++ b/app/components/UI/FoxScreen/index.js @@ -22,7 +22,7 @@ const createStyles = (colors) => }, }); -const foxImage = require('../../../images/branding/fox.png'); // eslint-disable-line import/no-commonjs +const foxImage = require('../../../images/branding/fox.png'); // eslint-disable-line import-x/no-commonjs /** * View component that displays the MetaMask fox diff --git a/app/components/UI/HardwareWallet/AccountDetails/styles.tsx b/app/components/UI/HardwareWallet/AccountDetails/styles.tsx index 6dbcde9501b..ea00d7735e4 100644 --- a/app/components/UI/HardwareWallet/AccountDetails/styles.tsx +++ b/app/components/UI/HardwareWallet/AccountDetails/styles.tsx @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import { StyleSheet } from 'react-native'; import { fontStyles } from '../../../../styles/common'; import Device from '../../../../util/device'; diff --git a/app/components/UI/HardwareWallet/AccountSelector/styles.tsx b/app/components/UI/HardwareWallet/AccountSelector/styles.tsx index 556c29aab75..7dba84661fb 100644 --- a/app/components/UI/HardwareWallet/AccountSelector/styles.tsx +++ b/app/components/UI/HardwareWallet/AccountSelector/styles.tsx @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import { StyleSheet } from 'react-native'; import { fontStyles } from '../../../../styles/common'; import Device from '../../../../util/device'; diff --git a/app/components/UI/LedgerModals/Steps/ConfirmationStep.tsx b/app/components/UI/LedgerModals/Steps/ConfirmationStep.tsx index 10aa46ddb4f..e50e3f74fad 100644 --- a/app/components/UI/LedgerModals/Steps/ConfirmationStep.tsx +++ b/app/components/UI/LedgerModals/Steps/ConfirmationStep.tsx @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-require-imports */ -/* eslint-disable import/no-commonjs */ +/* eslint-disable import-x/no-commonjs */ /* eslint-disable @typescript-eslint/no-var-requires */ import React, { useMemo } from 'react'; import { ActivityIndicator, Image, StyleSheet, View } from 'react-native'; diff --git a/app/components/UI/LedgerModals/styles.ts b/app/components/UI/LedgerModals/styles.ts index 6de560c41a7..a3a8ad31956 100644 --- a/app/components/UI/LedgerModals/styles.ts +++ b/app/components/UI/LedgerModals/styles.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import { StyleSheet } from 'react-native'; import { Colors } from '../../../util/theme/models'; diff --git a/app/components/UI/LoginOptionsSwitch/styles.ts b/app/components/UI/LoginOptionsSwitch/styles.ts index 41b6d96bf23..b11477b5bf6 100644 --- a/app/components/UI/LoginOptionsSwitch/styles.ts +++ b/app/components/UI/LoginOptionsSwitch/styles.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import { fontStyles } from '../../../styles/common'; import { StyleSheet } from 'react-native'; diff --git a/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx b/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx index 8be139e2622..facf0801787 100644 --- a/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx +++ b/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx @@ -15,13 +15,13 @@ import { useColorScheme, } from 'react-native'; import Video from 'react-native-video'; -// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import/no-commonjs +// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import-x/no-commonjs const MarketInsightsBackgroundVideoLight = require('../../animations/market-insights-background-light.mp4'); -// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import/no-commonjs +// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import-x/no-commonjs const MarketInsightsBackgroundVideoDark = require('../../animations/market-insights-background-dark.mp4'); -// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import/no-commonjs +// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import-x/no-commonjs const MarketInsightsBackgroundLastFrameLight = require('../../animations/market-insights-background-light-last-frame.png'); -// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import/no-commonjs +// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import-x/no-commonjs const MarketInsightsBackgroundLastFrameDark = require('../../animations/market-insights-background-dark-last-frame.png'); import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import { useSelector } from 'react-redux'; diff --git a/app/components/UI/Notification/Empty/styles.ts b/app/components/UI/Notification/Empty/styles.ts index 1b3f7638e0b..56c0ef08bdb 100644 --- a/app/components/UI/Notification/Empty/styles.ts +++ b/app/components/UI/Notification/Empty/styles.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import { StyleSheet } from 'react-native'; export const styles = StyleSheet.create({ diff --git a/app/components/UI/Notification/List/index.test.tsx b/app/components/UI/Notification/List/index.test.tsx index ea9d7f450fa..ec966dc41c1 100644 --- a/app/components/UI/Notification/List/index.test.tsx +++ b/app/components/UI/Notification/List/index.test.tsx @@ -21,7 +21,7 @@ import { mockNotificationsWithMetaData } from '../__mocks__/mock_notifications'; import { createNavigationProps } from '../../../../util/testUtils'; import { NotificationsViewSelectorsIDs } from '../../../Views/Notifications/NotificationsView.testIds'; import { NotificationMenuViewSelectorsIDs } from '../../../Views/Notifications/NotificationMenuView.testIds'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as UseNotificationsModule from '../../../../util/notifications/hooks/useNotifications'; const mockNavigation = createNavigationProps({}); diff --git a/app/components/UI/Notification/List/styles.ts b/app/components/UI/Notification/List/styles.ts index 857c56b18ac..7ef7e8c2f04 100644 --- a/app/components/UI/Notification/List/styles.ts +++ b/app/components/UI/Notification/List/styles.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import { StyleSheet } from 'react-native'; import type { Theme } from '@metamask/design-tokens'; diff --git a/app/components/UI/Notification/NotificationMenuItem/index.tsx b/app/components/UI/Notification/NotificationMenuItem/index.tsx index 90e38745b35..cb690a3bcb9 100644 --- a/app/components/UI/Notification/NotificationMenuItem/index.tsx +++ b/app/components/UI/Notification/NotificationMenuItem/index.tsx @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import NotificationRoot from './Root'; import NotificationIcon from './Icon'; import NotificationContent from './Content'; diff --git a/app/components/UI/Notification/__mocks__/mock_notifications.ts b/app/components/UI/Notification/__mocks__/mock_notifications.ts index 92588632adf..2d40cea3e2c 100644 --- a/app/components/UI/Notification/__mocks__/mock_notifications.ts +++ b/app/components/UI/Notification/__mocks__/mock_notifications.ts @@ -1,5 +1,5 @@ import { processNotification } from '@metamask/notification-services-controller/notification-services'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as Mocks from '@metamask/notification-services-controller/notification-services/mocks'; export const MOCK_ON_CHAIN_NOTIFICATIONS = [ diff --git a/app/components/UI/OTAUpdatesModal/OTAUpdatesModal.tsx b/app/components/UI/OTAUpdatesModal/OTAUpdatesModal.tsx index 57897835100..fc872f5ab82 100644 --- a/app/components/UI/OTAUpdatesModal/OTAUpdatesModal.tsx +++ b/app/components/UI/OTAUpdatesModal/OTAUpdatesModal.tsx @@ -23,7 +23,7 @@ import BottomSheet, { } from '../../../component-library/components/BottomSheets/BottomSheet'; import HeaderCompactStandard from '../../../component-library/components-temp/HeaderCompactStandard'; -/* eslint-disable import/no-commonjs, @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ +/* eslint-disable import-x/no-commonjs, @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ const foxLogo = require('../../../images/branding/fox.png'); const metamaskNameLightMode = require('../../../images/branding/metamask-name.png'); const metamaskNameDarkMode = require('../../../images/branding/metamask-name-white.png'); diff --git a/app/components/UI/OTAUpdatesModal/index.ts b/app/components/UI/OTAUpdatesModal/index.ts index 973b7c7da3a..da33a841d8a 100644 --- a/app/components/UI/OTAUpdatesModal/index.ts +++ b/app/components/UI/OTAUpdatesModal/index.ts @@ -1,2 +1,2 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ export { default as OTAUpdatesModal } from './OTAUpdatesModal'; diff --git a/app/components/UI/Perps/components/PerpsDiscoveryBanner/PerpsDiscoveryBanner.tsx b/app/components/UI/Perps/components/PerpsDiscoveryBanner/PerpsDiscoveryBanner.tsx index 171fd79c963..5e492b81ee5 100644 --- a/app/components/UI/Perps/components/PerpsDiscoveryBanner/PerpsDiscoveryBanner.tsx +++ b/app/components/UI/Perps/components/PerpsDiscoveryBanner/PerpsDiscoveryBanner.tsx @@ -13,7 +13,7 @@ import { Image, Pressable, StyleSheet } from 'react-native'; import { useStyles } from '../../../../../component-library/hooks'; import type { PerpsDiscoveryBannerProps } from './PerpsDiscoveryBanner.types'; -// eslint-disable-next-line import/no-commonjs, @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires +// eslint-disable-next-line import-x/no-commonjs, @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires const perpsLogo = require('../../../../../images/perps-home-empty-state.png'); const styleSheet = () => diff --git a/app/components/UI/Perps/components/PerpsTabControlBar/PerpsTabControlBar.test.tsx b/app/components/UI/Perps/components/PerpsTabControlBar/PerpsTabControlBar.test.tsx index 20ced09b025..23b97ca9b15 100644 --- a/app/components/UI/Perps/components/PerpsTabControlBar/PerpsTabControlBar.test.tsx +++ b/app/components/UI/Perps/components/PerpsTabControlBar/PerpsTabControlBar.test.tsx @@ -1,4 +1,4 @@ -/* eslint-disable import/no-namespace */ +/* eslint-disable import-x/no-namespace */ import { fireEvent, render, diff --git a/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.tsx b/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.tsx index 5295a373530..6f3428b726d 100644 --- a/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.tsx +++ b/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.tsx @@ -44,11 +44,11 @@ import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking'; import { PerpsConnectionManager } from '../../services/PerpsConnectionManager'; import createStyles from './PerpsTutorialCarousel.styles'; import Rive, { Alignment, Fit } from 'rive-react-native'; -// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import/no-commonjs +// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import-x/no-commonjs const PerpsOnboardingAnimationLight = require('../../animations/perps-onboarding-carousel-light.riv'); -// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import/no-commonjs +// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import-x/no-commonjs const PerpsOnboardingAnimationDark = require('../../animations/perps-onboarding-carousel-dark.riv'); -// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import/no-commonjs +// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import-x/no-commonjs import Character from '../../../../../images/character_3x.png'; import { PerpsTutorialSelectorsIDs } from '../../Perps.testIds'; import { selectPerpsEligibility } from '../../selectors/perpsController'; diff --git a/app/components/UI/Perps/hooks/usePerpsNetworkManagement.ts b/app/components/UI/Perps/hooks/usePerpsNetworkManagement.ts index fa6702c147d..34e3b997b25 100644 --- a/app/components/UI/Perps/hooks/usePerpsNetworkManagement.ts +++ b/app/components/UI/Perps/hooks/usePerpsNetworkManagement.ts @@ -11,7 +11,7 @@ import { } from '@metamask/perps-controller'; import { usePerpsNetwork } from './usePerpsNetwork'; -/* eslint-disable @typescript-eslint/no-require-imports, import/no-commonjs */ +/* eslint-disable @typescript-eslint/no-require-imports, import-x/no-commonjs */ const InfuraKey = process.env.MM_INFURA_PROJECT_ID; const infuraProjectId = InfuraKey === 'null' ? '' : InfuraKey; diff --git a/app/components/UI/Perps/hooks/usePerpsPositions.test.ts b/app/components/UI/Perps/hooks/usePerpsPositions.test.ts index a4219637032..ca2f048bd7e 100644 --- a/app/components/UI/Perps/hooks/usePerpsPositions.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsPositions.test.ts @@ -14,7 +14,7 @@ jest.mock('@react-navigation/native', () => ({ useFocusEffect: jest.fn(), })); -// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports, import/no-commonjs +// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports, import-x/no-commonjs const { useFocusEffect } = require('@react-navigation/native') as { useFocusEffect: jest.Mock; }; diff --git a/app/components/UI/Perps/selectors/featureFlags/index.test.ts b/app/components/UI/Perps/selectors/featureFlags/index.test.ts index bb44ee4b103..c95bd6552f6 100644 --- a/app/components/UI/Perps/selectors/featureFlags/index.test.ts +++ b/app/components/UI/Perps/selectors/featureFlags/index.test.ts @@ -22,7 +22,7 @@ import { VersionGatedFeatureFlag, validatedVersionGatedFeatureFlag, } from '../../../../../util/remoteFeatureFlag'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as remoteFeatureFlagModule from '../../../../../util/remoteFeatureFlag'; jest.mock('react-native-device-info', () => ({ diff --git a/app/components/UI/Predict/hooks/usePredictNetworkManagement.ts b/app/components/UI/Predict/hooks/usePredictNetworkManagement.ts index f8418590203..a17447854ef 100644 --- a/app/components/UI/Predict/hooks/usePredictNetworkManagement.ts +++ b/app/components/UI/Predict/hooks/usePredictNetworkManagement.ts @@ -13,7 +13,7 @@ import { POLYGON_MAINNET_CAIP_CHAIN_ID, } from '../providers/polymarket/constants'; -/* eslint-disable @typescript-eslint/no-require-imports, import/no-commonjs */ +/* eslint-disable @typescript-eslint/no-require-imports, import-x/no-commonjs */ const InfuraKey = process.env.MM_INFURA_PROJECT_ID; const infuraProjectId = InfuraKey === 'null' ? '' : InfuraKey; diff --git a/app/components/UI/Predict/selectors/featureFlags/index.test.ts b/app/components/UI/Predict/selectors/featureFlags/index.test.ts index e665e97304d..32576547c90 100644 --- a/app/components/UI/Predict/selectors/featureFlags/index.test.ts +++ b/app/components/UI/Predict/selectors/featureFlags/index.test.ts @@ -14,7 +14,7 @@ import { VersionGatedFeatureFlag, validatedVersionGatedFeatureFlag, } from '../../../../../util/remoteFeatureFlag'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as remoteFeatureFlagModule from '../../../../../util/remoteFeatureFlag'; jest.mock('react-native-device-info', () => ({ diff --git a/app/components/UI/Predict/types/index.ts b/app/components/UI/Predict/types/index.ts index cfabfc6b0cb..0bd8fab6710 100644 --- a/app/components/UI/Predict/types/index.ts +++ b/app/components/UI/Predict/types/index.ts @@ -164,7 +164,6 @@ export type PredictGamePeriod = | 'OT' // Overtime | 'FT' // Final | 'VFT' // Verified fulltime (when closed=true) - // eslint-disable-next-line @typescript-eslint/ban-types | (string & {}); // Escape hatch for future sports with different period formats // Game data attached to market diff --git a/app/components/UI/QRHardware/AnimatedQRScanner.tsx b/app/components/UI/QRHardware/AnimatedQRScanner.tsx index 2b7cf78cda1..76f5578a6f0 100644 --- a/app/components/UI/QRHardware/AnimatedQRScanner.tsx +++ b/app/components/UI/QRHardware/AnimatedQRScanner.tsx @@ -143,7 +143,7 @@ const createStyles = (theme: Theme) => }, }); -const frameImage = require('../../../images/frame.png'); // eslint-disable-line import/no-commonjs +const frameImage = require('../../../images/frame.png'); // eslint-disable-line import-x/no-commonjs interface AnimatedQRScannerProps { visible: boolean; diff --git a/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.constants.ts b/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.constants.ts index 5a584144d97..1743acdd852 100644 --- a/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.constants.ts +++ b/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.constants.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import { QuoteError, QuoteResponse } from '@consensys/on-ramp-sdk'; import { DeepPartial } from '../../../../../../util/test/renderWithProvider'; diff --git a/app/components/UI/Ramp/Aggregator/components/ApplePayButton.tsx b/app/components/UI/Ramp/Aggregator/components/ApplePayButton.tsx index 131f54b5b25..46f1db2b46e 100644 --- a/app/components/UI/Ramp/Aggregator/components/ApplePayButton.tsx +++ b/app/components/UI/Ramp/Aggregator/components/ApplePayButton.tsx @@ -5,10 +5,10 @@ import { useAssetFromTheme } from '../../../../../util/theme'; import Text from '../../../../Base/Text'; import StyledButton from '../../../StyledButton'; -/* eslint-disable import/no-commonjs, @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ +/* eslint-disable import-x/no-commonjs, @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ const ApplePayLogoLight = require('images/ApplePayLogo-light.png'); const ApplePayLogoDark = require('images/ApplePayLogo-dark.png'); -/* eslint-enable import/no-commonjs, @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ +/* eslint-enable import-x/no-commonjs, @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ const styles = StyleSheet.create({ applePayButton: { diff --git a/app/components/UI/Ramp/Aggregator/components/OrderDetails.tsx b/app/components/UI/Ramp/Aggregator/components/OrderDetails.tsx index 46f54c4cf6b..33e1ef4ecf6 100644 --- a/app/components/UI/Ramp/Aggregator/components/OrderDetails.tsx +++ b/app/components/UI/Ramp/Aggregator/components/OrderDetails.tsx @@ -38,7 +38,7 @@ import Text, { } from '../../../../../component-library/components/Texts/Text'; import ListItemColumnEnd from './ListItemColumnEnd'; -/* eslint-disable-next-line import/no-commonjs, @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ +/* eslint-disable-next-line import-x/no-commonjs, @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ const failedIcon = require('./images/TransactionIcon_Failed.png'); // TODO: Replace "any" with type diff --git a/app/components/UI/Ramp/Aggregator/components/OrderListItem/OrderListItem.tsx b/app/components/UI/Ramp/Aggregator/components/OrderListItem/OrderListItem.tsx index 8b16823d49c..9c491f6fcb4 100644 --- a/app/components/UI/Ramp/Aggregator/components/OrderListItem/OrderListItem.tsx +++ b/app/components/UI/Ramp/Aggregator/components/OrderListItem/OrderListItem.tsx @@ -27,10 +27,10 @@ import Badge, { } from '../../../../../../component-library/components/Badges/Badge'; import { getNetworkImageSource } from '../../../../../../util/networks'; -/* eslint-disable import/no-commonjs, @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ +/* eslint-disable import-x/no-commonjs, @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ const transactionIconReceived = require('images/transaction-icons/receive.png'); const transactionIconSent = require('images/transaction-icons/receive-inverted.png'); -/* eslint-enable import/no-commonjs, @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ +/* eslint-enable import-x/no-commonjs, @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ interface Props { readonly order: FiatOrder; diff --git a/app/components/UI/Ramp/Aggregator/utils/index.test.ts b/app/components/UI/Ramp/Aggregator/utils/index.test.ts index eea03d0da7d..afa455b94b6 100644 --- a/app/components/UI/Ramp/Aggregator/utils/index.test.ts +++ b/app/components/UI/Ramp/Aggregator/utils/index.test.ts @@ -31,7 +31,7 @@ import { import { FIAT_ORDER_STATES } from '../../../../../constants/on-ramp'; import { FiatOrder, RampType } from '../../../../../reducers/fiatOrders/types'; import { QuoteSortBy } from '@consensys/on-ramp-sdk/dist/IOnRampSdk'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as IntlModule from '../../../../../util/intl'; describe('timeToDescription', () => { diff --git a/app/components/UI/Ramp/hooks/useTokenBuyability.test.ts b/app/components/UI/Ramp/hooks/useTokenBuyability.test.ts index c0a8bfd706f..d489f046a8b 100644 --- a/app/components/UI/Ramp/hooks/useTokenBuyability.test.ts +++ b/app/components/UI/Ramp/hooks/useTokenBuyability.test.ts @@ -1,5 +1,5 @@ import { renderHook } from '@testing-library/react-native'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as tokenBuyabilityModule from './useTokenBuyability'; import { useRampTokens, diff --git a/app/components/UI/ReviewModal/styles.ts b/app/components/UI/ReviewModal/styles.ts index 28156e4c40b..33d3b78aaa6 100644 --- a/app/components/UI/ReviewModal/styles.ts +++ b/app/components/UI/ReviewModal/styles.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import { StyleSheet } from 'react-native'; // TODO: Replace "any" with type diff --git a/app/components/UI/Rewards/components/RewardPointsAnimation/index.tsx b/app/components/UI/Rewards/components/RewardPointsAnimation/index.tsx index 1049fd95815..c65cd27afd5 100644 --- a/app/components/UI/Rewards/components/RewardPointsAnimation/index.tsx +++ b/app/components/UI/Rewards/components/RewardPointsAnimation/index.tsx @@ -18,7 +18,7 @@ import { } from '../../hooks/useRewardsAnimation'; import styleSheet from './index.styles'; -// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import/no-commonjs +// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import-x/no-commonjs const RewardsIconAnimation = require('../../../../../animations/rewards_icon_animations.riv'); /** diff --git a/app/components/UI/SDKLoading/index.tsx b/app/components/UI/SDKLoading/index.tsx index 5025dc02cea..f87b5411770 100644 --- a/app/components/UI/SDKLoading/index.tsx +++ b/app/components/UI/SDKLoading/index.tsx @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports, import/no-commonjs */ +/* eslint-disable @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports, import-x/no-commonjs */ import type { ThemeColors } from '@metamask/design-tokens'; import LottieView from 'lottie-react-native'; import React from 'react'; diff --git a/app/components/UI/SecurityOptionToggle/styles.ts b/app/components/UI/SecurityOptionToggle/styles.ts index 73b35c8fcd8..dc6692e3dcd 100644 --- a/app/components/UI/SecurityOptionToggle/styles.ts +++ b/app/components/UI/SecurityOptionToggle/styles.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +/* eslint-disable import-x/prefer-default-export */ import { StyleSheet } from 'react-native'; export const createStyles = () => diff --git a/app/components/UI/SimulationDetails/BatchApprovalRow/BatchApprovalRow.test.tsx b/app/components/UI/SimulationDetails/BatchApprovalRow/BatchApprovalRow.test.tsx index a1aff88a20f..f27c5151294 100644 --- a/app/components/UI/SimulationDetails/BatchApprovalRow/BatchApprovalRow.test.tsx +++ b/app/components/UI/SimulationDetails/BatchApprovalRow/BatchApprovalRow.test.tsx @@ -7,9 +7,9 @@ import { getAppStateForConfirmation, upgradeAccountConfirmation, } from '../../../../util/test/confirm-data-helpers'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as AlertContextFunctions from '../../../Views/confirmations/context/alert-system-context/alert-system-context'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as BatchApprovalUtils from '../../../Views/confirmations/hooks/7702/useBatchApproveBalanceChanges'; import { AlertKeys } from '../../../Views/confirmations/constants/alerts'; import { RowAlertKey } from '../../../Views/confirmations/components/UI/info-row/alert-row/constants'; diff --git a/app/components/UI/SimulationDetails/SimulationDetails.test.tsx b/app/components/UI/SimulationDetails/SimulationDetails.test.tsx index a3f8406176e..de7ab82d0fa 100644 --- a/app/components/UI/SimulationDetails/SimulationDetails.test.tsx +++ b/app/components/UI/SimulationDetails/SimulationDetails.test.tsx @@ -16,7 +16,7 @@ import { getAppStateForConfirmation, } from '../../../util/test/confirm-data-helpers'; import { MMM_ORIGIN } from '../../Views/confirmations/constants/confirmations'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as BatchApprovalUtils from '../../Views/confirmations/hooks/7702/useBatchApproveBalanceChanges'; import AnimatedSpinner from '../AnimatedSpinner'; import SimulationDetails from './SimulationDetails'; diff --git a/app/components/UI/SliderButton/index.js b/app/components/UI/SliderButton/index.js index 6e38be6769d..024ff7b0048 100644 --- a/app/components/UI/SliderButton/index.js +++ b/app/components/UI/SliderButton/index.js @@ -18,10 +18,10 @@ import { fontStyles } from '../../../styles/common'; import Device from '../../../util/device'; import { useTheme } from '../../../util/theme'; -/* eslint-disable import/no-commonjs */ +/* eslint-disable import-x/no-commonjs */ const SliderBgImg = require('./assets/slider_button_gradient.png'); const SliderShineImg = require('./assets/slider_button_shine.png'); -/* eslint-enable import/no-commonjs */ +/* eslint-enable import-x/no-commonjs */ const DIAMETER = 60; const MARGIN = DIAMETER * 0.16; diff --git a/app/components/UI/SlippageSlider/index.js b/app/components/UI/SlippageSlider/index.js index 83bfedb3250..873e4eb9b0b 100644 --- a/app/components/UI/SlippageSlider/index.js +++ b/app/components/UI/SlippageSlider/index.js @@ -18,9 +18,9 @@ import { fontStyles } from '../../../styles/common'; import { useTheme } from '../../../util/theme'; import Svg, { Path } from 'react-native-svg'; -/* eslint-disable import/no-commonjs */ +/* eslint-disable import-x/no-commonjs */ const SlippageSliderBgImg = require('../../../images/slippage-slider-bg.png'); -/* eslint-enable import/no-commonjs */ +/* eslint-enable import-x/no-commonjs */ const DIAMETER = 30; const TRACK_PADDING = 2; diff --git a/app/components/UI/Stake/sdk/stakeSdkProvider.test.tsx b/app/components/UI/Stake/sdk/stakeSdkProvider.test.tsx index ecb47cabd3a..5ca92483384 100644 --- a/app/components/UI/Stake/sdk/stakeSdkProvider.test.tsx +++ b/app/components/UI/Stake/sdk/stakeSdkProvider.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import renderWithProvider from '../../../../util/test/renderWithProvider'; import { backgroundState } from '../../../../util/test/initial-root-state'; import { StakeSDKProvider } from '../sdk/stakeSdkProvider'; -// eslint-disable-next-line import/no-namespace +// eslint-disable-next-line import-x/no-namespace import * as useStakeContextHook from '../hooks/useStakeContext'; import { View } from 'react-native'; import Text from '../../../../component-library/components/Texts/Text'; diff --git a/app/components/UI/StyledButton/index.js b/app/components/UI/StyledButton/index.js index abc72eacf5e..43543a0cad1 100644 --- a/app/components/UI/StyledButton/index.js +++ b/app/components/UI/StyledButton/index.js @@ -1,4 +1,4 @@ -import StyledButton from './StyledButton'; // eslint-disable-line import/no-unresolved +import StyledButton from './StyledButton'; // eslint-disable-line import-x/no-unresolved /** * @deprecated The `` component has been deprecated in favor of ` ) : null} {!showChangeProvider && providerName && providerSupportUrl ? ( ) : null} ); diff --git a/app/components/UI/Ramp/Views/Modals/ErrorDetailsModal/__snapshots__/ErrorDetailsModal.test.tsx.snap b/app/components/UI/Ramp/Views/Modals/ErrorDetailsModal/__snapshots__/ErrorDetailsModal.test.tsx.snap index 0e93d9d6a1c..acdb5568719 100644 --- a/app/components/UI/Ramp/Views/Modals/ErrorDetailsModal/__snapshots__/ErrorDetailsModal.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Modals/ErrorDetailsModal/__snapshots__/ErrorDetailsModal.test.tsx.snap @@ -320,11 +320,11 @@ exports[`ErrorDetailsModal renders correctly and matches snapshot 1`] = ` "flexDirection": "row", "gap": 16, "minHeight": 56, + "paddingLeft": 8, + "paddingRight": 8, }, false, - { - "paddingHorizontal": 16, - }, + undefined, ] } testID="header" @@ -373,13 +373,17 @@ exports[`ErrorDetailsModal renders correctly and matches snapshot 1`] = ` Error details @@ -390,39 +394,74 @@ exports[`ErrorDetailsModal renders correctly and matches snapshot 1`] = ` - - + @@ -445,13 +484,19 @@ exports[`ErrorDetailsModal renders correctly and matches snapshot 1`] = ` This is a test error message. @@ -468,43 +513,93 @@ exports[`ErrorDetailsModal renders correctly and matches snapshot 1`] = ` } } > - Got it - + @@ -838,11 +933,11 @@ exports[`ErrorDetailsModal renders with a multiline error message 1`] = ` "flexDirection": "row", "gap": 16, "minHeight": 56, + "paddingLeft": 8, + "paddingRight": 8, }, false, - { - "paddingHorizontal": 16, - }, + undefined, ] } testID="header" @@ -891,13 +986,17 @@ exports[`ErrorDetailsModal renders with a multiline error message 1`] = ` Error details @@ -908,39 +1007,74 @@ exports[`ErrorDetailsModal renders with a multiline error message 1`] = ` - - + @@ -963,13 +1097,19 @@ exports[`ErrorDetailsModal renders with a multiline error message 1`] = ` Error on line 1. @@ -988,43 +1128,93 @@ Additional context for debugging. } } > - Got it - + @@ -1358,11 +1548,11 @@ exports[`ErrorDetailsModal renders with an empty error message 1`] = ` "flexDirection": "row", "gap": 16, "minHeight": 56, + "paddingLeft": 8, + "paddingRight": 8, }, false, - { - "paddingHorizontal": 16, - }, + undefined, ] } testID="header" @@ -1411,13 +1601,17 @@ exports[`ErrorDetailsModal renders with an empty error message 1`] = ` Error details @@ -1428,39 +1622,74 @@ exports[`ErrorDetailsModal renders with an empty error message 1`] = ` - - + @@ -1483,13 +1712,19 @@ exports[`ErrorDetailsModal renders with an empty error message 1`] = ` @@ -1504,43 +1739,93 @@ exports[`ErrorDetailsModal renders with an empty error message 1`] = ` } } > - Got it - + @@ -1874,11 +2159,11 @@ exports[`ErrorDetailsModal renders with change provider button and matches snaps "flexDirection": "row", "gap": 16, "minHeight": 56, + "paddingLeft": 8, + "paddingRight": 8, }, false, - { - "paddingHorizontal": 16, - }, + undefined, ] } testID="header" @@ -1927,13 +2212,17 @@ exports[`ErrorDetailsModal renders with change provider button and matches snaps Error details @@ -1944,39 +2233,74 @@ exports[`ErrorDetailsModal renders with change provider button and matches snaps - - + @@ -1999,13 +2323,19 @@ exports[`ErrorDetailsModal renders with change provider button and matches snaps No quotes available. @@ -2022,82 +2352,182 @@ exports[`ErrorDetailsModal renders with change provider button and matches snaps } } > - Change provider - - + Got it - + @@ -2431,11 +2861,11 @@ exports[`ErrorDetailsModal renders with provider support info and matches snapsh "flexDirection": "row", "gap": 16, "minHeight": 56, + "paddingLeft": 8, + "paddingRight": 8, }, false, - { - "paddingHorizontal": 16, - }, + undefined, ] } testID="header" @@ -2484,13 +2914,17 @@ exports[`ErrorDetailsModal renders with provider support info and matches snapsh Error details @@ -2501,39 +2935,74 @@ exports[`ErrorDetailsModal renders with provider support info and matches snapsh - - + @@ -2556,13 +3025,19 @@ exports[`ErrorDetailsModal renders with provider support info and matches snapsh Provider error occurred. @@ -2579,82 +3054,182 @@ exports[`ErrorDetailsModal renders with provider support info and matches snapsh } } > - Contact Transak support - - + Got it - + diff --git a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentMethodListItem.tsx b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentMethodListItem.tsx index c990ff3f02e..828df3b9a55 100644 --- a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentMethodListItem.tsx +++ b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentMethodListItem.tsx @@ -4,10 +4,12 @@ import ListItemSelect from '../../../../../../component-library/components/List/ import ListItemColumn, { WidthType, } from '../../../../../../component-library/components/List/ListItemColumn'; -import Text, { +import { + Text, TextVariant, TextColor, -} from '../../../../../../component-library/components/Texts/Text'; + FontWeight, +} from '@metamask/design-system-react-native'; import { PaymentType } from '@consensys/on-ramp-sdk'; import PaymentMethodIcon from '../../../Aggregator/components/PaymentMethodIcon'; import QuoteDisplay from './QuoteDisplay'; @@ -97,9 +99,11 @@ const PaymentMethodListItem: React.FC = ({ - {paymentMethod.name} + + {paymentMethod.name} + {delayText ? ( - + {delayText} ) : null} diff --git a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionAlert.tsx b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionAlert.tsx index 91cba3b16ba..c754e2fbdaa 100644 --- a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionAlert.tsx +++ b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionAlert.tsx @@ -1,9 +1,7 @@ import React from 'react'; import BannerAlert from '../../../../../../component-library/components/Banners/Banner/variants/BannerAlert/BannerAlert'; import { BannerAlertSeverity } from '../../../../../../component-library/components/Banners/Banner/variants/BannerAlert/BannerAlert.types'; -import Text, { - TextVariant, -} from '../../../../../../component-library/components/Texts/Text'; +import { Text, TextVariant } from '@metamask/design-system-react-native'; interface PaymentSelectionAlertProps { message: string; @@ -15,7 +13,7 @@ const PaymentSelectionAlert: React.FC = ({ severity = BannerAlertSeverity.Error, }) => ( {message}} + description={{message}} severity={severity} /> ); diff --git a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.test.tsx b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.test.tsx index ca34faee664..40bad701f4b 100644 --- a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.test.tsx +++ b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { fireEvent, waitFor } from '@testing-library/react-native'; import PaymentSelectionModal from './PaymentSelectionModal'; +import { PAYMENT_SELECTION_MODAL_TEST_IDS } from './PaymentSelectionModal.testIds'; import { renderScreen } from '../../../../../../util/test/renderWithProvider'; import { backgroundState } from '../../../../../../util/test/initial-root-state'; jest.mock('../../../../../Base/RemoteImage', () => jest.fn(() => null)); @@ -208,6 +209,16 @@ describe('PaymentSelectionModal', () => { expect(getByText('fiat_on_ramp.pay_with')).toBeOnTheScreen(); }); + it('calls onCloseBottomSheet when header close is pressed', () => { + const { getByTestId } = renderWithProvider(PaymentSelectionModal); + + fireEvent.press( + getByTestId(PAYMENT_SELECTION_MODAL_TEST_IDS.HEADER_CLOSE_BUTTON), + ); + + expect(mockOnCloseBottomSheet).toHaveBeenCalledTimes(1); + }); + it('displays payment methods list', () => { const { getAllByText } = renderWithProvider(PaymentSelectionModal); diff --git a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.testIds.ts b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.testIds.ts new file mode 100644 index 00000000000..a747216b609 --- /dev/null +++ b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.testIds.ts @@ -0,0 +1,3 @@ +export const PAYMENT_SELECTION_MODAL_TEST_IDS = { + HEADER_CLOSE_BUTTON: 'payment-selection-modal-header-close', +} as const; diff --git a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.tsx b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.tsx index fa8c139b9a2..82811b843bd 100644 --- a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.tsx +++ b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.tsx @@ -7,16 +7,16 @@ import { useNavigation } from '@react-navigation/native'; import BottomSheet, { BottomSheetRef, } from '../../../../../../component-library/components/BottomSheets/BottomSheet'; -import Text, { - TextVariant, - TextColor, -} from '../../../../../../component-library/components/Texts/Text'; -import { BannerAlertSeverity } from '../../../../../../component-library/components/Banners/Banner/variants/BannerAlert/BannerAlert.types'; import { Box, BoxAlignItems, BoxJustifyContent, + Text, + TextVariant, + TextColor, } from '@metamask/design-system-react-native'; +import { BannerAlertSeverity } from '../../../../../../component-library/components/Banners/Banner/variants/BannerAlert/BannerAlert.types'; +import HeaderCompactStandard from '../../../../../../component-library/components-temp/HeaderCompactStandard'; import { useStyles } from '../../../../../hooks/useStyles'; import { strings } from '../../../../../../../locales/i18n'; import styleSheet from './PaymentSelectionModal.styles'; @@ -28,6 +28,7 @@ import { import PaymentMethodListItem from './PaymentMethodListItem'; import PaymentMethodListSkeleton from './PaymentMethodListSkeleton'; import PaymentSelectionAlert from './PaymentSelectionAlert'; +import { PAYMENT_SELECTION_MODAL_TEST_IDS } from './PaymentSelectionModal.testIds'; import { useRampsController } from '../../../hooks/useRampsController'; import { useRampsQuotes } from '../../../hooks/useRampsQuotes'; import useRampAccountAddress from '../../../hooks/useRampAccountAddress'; @@ -247,15 +248,13 @@ function PaymentSelectionModal() { - - - {strings('fiat_on_ramp.pay_with')} - - + sheetRef.current?.onCloseBottomSheet()} + closeButtonProps={{ + testID: PAYMENT_SELECTION_MODAL_TEST_IDS.HEADER_CLOSE_BUTTON, + }} + /> {renderListContent()} {selectedProvider ? ( @@ -264,16 +263,19 @@ function PaymentSelectionModal() { justifyContent={BoxJustifyContent.Center} style={styles.footer} > - + {strings('fiat_on_ramp.buying_via', { providerName: selectedProvider.name, })}{' '} = ({ if (quoteUnavailable) { return ( - + {strings('fiat_on_ramp.quote_unavailable')} @@ -89,10 +91,12 @@ const QuoteDisplay: React.FC = ({ return ( {cryptoAmount ? ( - {cryptoAmount} + + {cryptoAmount} + ) : null} {fiatAmount !== null ? ( - + {fiatAmount} ) : null} diff --git a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/__snapshots__/PaymentMethodListItem.test.tsx.snap b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/__snapshots__/PaymentMethodListItem.test.tsx.snap index 3a032a3a15f..2fd15f8efa4 100644 --- a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/__snapshots__/PaymentMethodListItem.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/__snapshots__/PaymentMethodListItem.test.tsx.snap @@ -97,13 +97,17 @@ exports[`PaymentMethodListItem matches snapshot 1`] = ` Debit or Credit @@ -111,13 +115,17 @@ exports[`PaymentMethodListItem matches snapshot 1`] = ` 5 - 10 mins @@ -244,13 +252,17 @@ exports[`PaymentMethodListItem renders as selected when isSelected is true 1`] = Debit or Credit @@ -258,13 +270,17 @@ exports[`PaymentMethodListItem renders as selected when isSelected is true 1`] = 5 - 10 mins diff --git a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/__snapshots__/PaymentSelectionAlert.test.tsx.snap b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/__snapshots__/PaymentSelectionAlert.test.tsx.snap index e596edd5bde..284629446c2 100644 --- a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/__snapshots__/PaymentSelectionAlert.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/__snapshots__/PaymentSelectionAlert.test.tsx.snap @@ -46,13 +46,17 @@ exports[`PaymentSelectionAlert matches snapshot with default severity 1`] = ` Something went wrong. @@ -107,13 +111,17 @@ exports[`PaymentSelectionAlert matches snapshot with warning severity 1`] = ` No payment methods are available. diff --git a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/__snapshots__/PaymentSelectionModal.test.tsx.snap b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/__snapshots__/PaymentSelectionModal.test.tsx.snap index a1734121911..f32d759fe7f 100644 --- a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/__snapshots__/PaymentSelectionModal.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/__snapshots__/PaymentSelectionModal.test.tsx.snap @@ -333,31 +333,142 @@ exports[`PaymentSelectionModal matches snapshot 1`] = ` [ { "alignItems": "center", - "display": "flex", - "justifyContent": "center", - "paddingBottom": 12, - "paddingLeft": 16, - "paddingRight": 16, - "paddingTop": 12, + "flexDirection": "row", + "gap": 16, + "minHeight": 56, + "paddingLeft": 8, + "paddingRight": 8, }, + false, undefined, ] } + testID="header" > - + + + - fiat_on_ramp.pay_with - + + + fiat_on_ramp.pay_with + + + + + + + + + + Debit or Credit @@ -669,13 +784,17 @@ exports[`PaymentSelectionModal matches snapshot 1`] = ` Debit or Credit @@ -725,13 +844,17 @@ exports[`PaymentSelectionModal matches snapshot 1`] = ` fiat_on_ramp.buying_via @@ -740,13 +863,17 @@ exports[`PaymentSelectionModal matches snapshot 1`] = ` accessibilityRole="text" onPress={[Function]} style={ - { - "color": "#4459ff", - "fontFamily": "Geist-Regular", - "fontSize": 14, - "letterSpacing": 0, - "lineHeight": 22, - } + [ + { + "color": "#4459ff", + "fontFamily": "Geist-Regular", + "fontSize": 14, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 22, + }, + undefined, + ] } > fiat_on_ramp.change_provider @@ -1099,31 +1226,142 @@ exports[`PaymentSelectionModal matches snapshot when no payment methods are avai [ { "alignItems": "center", - "display": "flex", - "justifyContent": "center", - "paddingBottom": 12, - "paddingLeft": 16, - "paddingRight": 16, - "paddingTop": 12, + "flexDirection": "row", + "gap": 16, + "minHeight": 56, + "paddingLeft": 8, + "paddingRight": 8, }, + false, undefined, ] } + testID="header" > - + + + - fiat_on_ramp.pay_with - + + + fiat_on_ramp.pay_with + + + + + + + + + + fiat_on_ramp.no_payment_methods_available @@ -1221,13 +1463,17 @@ exports[`PaymentSelectionModal matches snapshot when no payment methods are avai fiat_on_ramp.buying_via @@ -1236,13 +1482,17 @@ exports[`PaymentSelectionModal matches snapshot when no payment methods are avai accessibilityRole="text" onPress={[Function]} style={ - { - "color": "#4459ff", - "fontFamily": "Geist-Regular", - "fontSize": 14, - "letterSpacing": 0, - "lineHeight": 22, - } + [ + { + "color": "#4459ff", + "fontFamily": "Geist-Regular", + "fontSize": 14, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 22, + }, + undefined, + ] } > fiat_on_ramp.change_provider @@ -1595,31 +1845,142 @@ exports[`PaymentSelectionModal matches snapshot when payment methods are loading [ { "alignItems": "center", - "display": "flex", - "justifyContent": "center", - "paddingBottom": 12, - "paddingLeft": 16, - "paddingRight": 16, - "paddingTop": 12, + "flexDirection": "row", + "gap": 16, + "minHeight": 56, + "paddingLeft": 8, + "paddingRight": 8, }, + false, undefined, ] } + testID="header" > - + + + - fiat_on_ramp.pay_with - + + + fiat_on_ramp.pay_with + + + + + + + + + + - + + + - fiat_on_ramp.pay_with - + + + fiat_on_ramp.pay_with + + + + + + + + + + Failed to fetch payment methods @@ -4005,13 +4481,17 @@ exports[`PaymentSelectionModal matches snapshot when payment methods fail to loa fiat_on_ramp.buying_via @@ -4019,13 +4499,17 @@ exports[`PaymentSelectionModal matches snapshot when payment methods fail to loa fiat_on_ramp.change_provider diff --git a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/__snapshots__/QuoteDisplay.test.tsx.snap b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/__snapshots__/QuoteDisplay.test.tsx.snap index e661ece5428..1d723ca95d0 100644 --- a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/__snapshots__/QuoteDisplay.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/__snapshots__/QuoteDisplay.test.tsx.snap @@ -56,13 +56,17 @@ exports[`QuoteDisplay matches snapshot when quote is unavailable 1`] = ` Quote unavailable. @@ -85,13 +89,17 @@ exports[`QuoteDisplay matches snapshot with crypto and fiat 1`] = ` 0.05 ETH @@ -99,13 +107,17 @@ exports[`QuoteDisplay matches snapshot with crypto and fiat 1`] = ` $100.00 @@ -128,13 +140,17 @@ exports[`QuoteDisplay matches snapshot with crypto only 1`] = ` 1.5 USDC diff --git a/app/components/UI/Ramp/Views/Modals/ProcessingInfoModal/ProcessingInfoModal.test.tsx b/app/components/UI/Ramp/Views/Modals/ProcessingInfoModal/ProcessingInfoModal.test.tsx index d99d2d15ba6..96d28d1b56e 100644 --- a/app/components/UI/Ramp/Views/Modals/ProcessingInfoModal/ProcessingInfoModal.test.tsx +++ b/app/components/UI/Ramp/Views/Modals/ProcessingInfoModal/ProcessingInfoModal.test.tsx @@ -1,12 +1,46 @@ import React from 'react'; -import { screen } from '@testing-library/react-native'; -import ProcessingInfoModal from './ProcessingInfoModal'; +import { fireEvent, waitFor, screen } from '@testing-library/react-native'; +import InAppBrowser from 'react-native-inappbrowser-reborn'; +import ProcessingInfoModal, { + type ProcessingInfoModalParams, +} from './ProcessingInfoModal'; import renderWithProvider from '../../../../../../util/test/renderWithProvider'; import { backgroundState } from '../../../../../../util/test/initial-root-state'; +import { MetaMetricsEvents } from '../../../../../../core/Analytics'; +const mockOnCloseBottomSheet = jest.fn((callback?: () => void) => { + callback?.(); +}); + +jest.mock( + '../../../../../../component-library/components/BottomSheets/BottomSheet', + () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return ReactActual.forwardRef( + ( + { + children, + testID, + }: { + children: React.ReactNode; + testID?: string; + }, + ref: React.Ref<{ onCloseBottomSheet: (cb?: () => void) => void }>, + ) => { + ReactActual.useImperativeHandle(ref, () => ({ + onCloseBottomSheet: mockOnCloseBottomSheet, + })); + return {children}; + }, + ); + }, +); + +const mockNavigate = jest.fn(); jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), - useNavigation: () => ({ navigate: jest.fn(), goBack: jest.fn() }), + useNavigation: () => ({ navigate: mockNavigate, goBack: jest.fn() }), })); jest.mock('react-native-safe-area-context', () => { @@ -27,17 +61,38 @@ jest.mock('react-native-inappbrowser-reborn', () => ({ open: jest.fn(), })); -jest.mock('../../../../../../util/navigation/navUtils', () => ({ - ...jest.requireActual('../../../../../../util/navigation/navUtils'), - useParams: () => ({ +const mockUseParams = jest.fn( + (): ProcessingInfoModalParams => ({ providerName: 'Transak', providerSupportUrl: 'https://transak.com/support', statusDescription: 'Card purchases typically take a few minutes. You can contact support if you have questions.', }), +); + +jest.mock('../../../../../../util/navigation/navUtils', () => ({ + ...jest.requireActual('../../../../../../util/navigation/navUtils'), + useParams: () => mockUseParams(), +})); + +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn(); +const mockAddProperties = jest.fn(); +const mockBuild = jest.fn(); + +jest.mock('../../../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: jest.fn(() => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + })), })); -function render() { +function renderModal() { + mockCreateEventBuilder.mockReturnValue({ + addProperties: mockAddProperties, + build: mockBuild, + }); + mockAddProperties.mockReturnValue({ build: mockBuild }); return renderWithProvider(, { state: { engine: { backgroundState } }, }); @@ -46,23 +101,29 @@ function render() { describe('ProcessingInfoModal', () => { beforeEach(() => { jest.clearAllMocks(); + mockUseParams.mockReturnValue({ + providerName: 'Transak', + providerSupportUrl: 'https://transak.com/support', + statusDescription: + 'Card purchases typically take a few minutes. You can contact support if you have questions.', + }); }); it('renders correctly', () => { - render(); + renderModal(); expect(screen.getByTestId('processing-info-modal')).toBeOnTheScreen(); expect(screen.toJSON()).toMatchSnapshot(); }); it('renders the close button', () => { - render(); + renderModal(); expect( screen.getByTestId('processing-info-modal-close-button'), ).toBeOnTheScreen(); }); it('renders description text', () => { - render(); + renderModal(); expect( screen.getByText( 'Card purchases typically take a few minutes. You can contact support if you have questions.', @@ -71,7 +132,111 @@ describe('ProcessingInfoModal', () => { }); it('renders support button with provider name', () => { - render(); + renderModal(); expect(screen.getByText('Go to Transak support page')).toBeOnTheScreen(); }); + + it('opens InAppBrowser and closes sheet when support is pressed and browser is available', async () => { + (InAppBrowser.isAvailable as jest.Mock).mockResolvedValue(true); + (InAppBrowser.open as jest.Mock).mockResolvedValue(undefined); + + renderModal(); + + fireEvent.press(screen.getByText('Go to Transak support page')); + + await waitFor(() => { + expect(mockTrackEvent).toHaveBeenCalled(); + }); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.RAMPS_EXTERNAL_LINK_CLICKED, + ); + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + location: 'Order Details', + external_link_description: 'Provider Support', + url_domain: 'transak.com', + ramp_type: 'UNIFIED_BUY_2', + }), + ); + + await waitFor(() => { + expect(mockOnCloseBottomSheet).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(InAppBrowser.open).toHaveBeenCalledWith( + 'https://transak.com/support', + ); + }); + }); + + it('navigates to SimpleWebview when InAppBrowser is not available', async () => { + (InAppBrowser.isAvailable as jest.Mock).mockResolvedValue(false); + + renderModal(); + + fireEvent.press(screen.getByText('Go to Transak support page')); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('Webview', { + screen: 'SimpleWebview', + params: { + url: 'https://transak.com/support', + title: 'Transak', + }, + }); + }); + expect(mockOnCloseBottomSheet).not.toHaveBeenCalled(); + }); + + it('uses raw support URL as url_domain when URL parsing fails', async () => { + (InAppBrowser.isAvailable as jest.Mock).mockResolvedValue(true); + mockUseParams.mockReturnValue({ + providerName: 'P', + providerSupportUrl: 'not-a-valid-url', + statusDescription: 'Status', + }); + + renderModal(); + + fireEvent.press(screen.getByText('Go to P support page')); + + await waitFor(() => { + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + url_domain: 'not-a-valid-url', + }), + ); + }); + }); + + it('renders without description when statusDescription is absent', () => { + mockUseParams.mockReturnValue({ + providerName: 'MoonPay', + providerSupportUrl: 'https://moonpay.com/help', + }); + + renderModal(); + + expect( + screen.queryByText( + 'Card purchases typically take a few minutes. You can contact support if you have questions.', + ), + ).toBeNull(); + expect(screen.getByText('Go to MoonPay support page')).toBeOnTheScreen(); + }); + + it('does not open browser when providerSupportUrl is missing', () => { + mockUseParams.mockReturnValue({ + providerName: 'Transak', + statusDescription: 'Waiting', + }); + + renderModal(); + + fireEvent.press(screen.getByText('Go to Transak support page')); + + expect(mockTrackEvent).not.toHaveBeenCalled(); + expect(InAppBrowser.open).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); }); diff --git a/app/components/UI/Ramp/Views/Modals/ProcessingInfoModal/ProcessingInfoModal.tsx b/app/components/UI/Ramp/Views/Modals/ProcessingInfoModal/ProcessingInfoModal.tsx index 2005f639b7d..fca4cd18c9c 100644 --- a/app/components/UI/Ramp/Views/Modals/ProcessingInfoModal/ProcessingInfoModal.tsx +++ b/app/components/UI/Ramp/Views/Modals/ProcessingInfoModal/ProcessingInfoModal.tsx @@ -4,17 +4,16 @@ import InAppBrowser from 'react-native-inappbrowser-reborn'; import BottomSheet, { BottomSheetRef, } from '../../../../../../component-library/components/BottomSheets/BottomSheet'; -import BottomSheetHeader from '../../../../../../component-library/components/BottomSheets/BottomSheetHeader'; -import Text, { +import HeaderCompactStandard from '../../../../../../component-library/components-temp/HeaderCompactStandard'; +import { + Text, TextVariant, TextColor, -} from '../../../../../../component-library/components/Texts/Text'; -import Button, { - ButtonVariants, - ButtonSize, - ButtonWidthTypes, -} from '../../../../../../component-library/components/Buttons/Button'; -import { Box } from '@metamask/design-system-react-native'; + Button, + ButtonVariant, + ButtonBaseSize, + Box, +} from '@metamask/design-system-react-native'; import { strings } from '../../../../../../../locales/i18n'; import { createNavigationDetails, @@ -104,7 +103,7 @@ function ProcessingInfoModal() { isInteractable={false} testID={PROCESSING_INFO_MODAL_TEST_IDS.MODAL} > - {statusDescription} @@ -125,14 +124,15 @@ function ProcessingInfoModal() { ); diff --git a/app/components/UI/Ramp/Views/Modals/ProcessingInfoModal/__snapshots__/ProcessingInfoModal.test.tsx.snap b/app/components/UI/Ramp/Views/Modals/ProcessingInfoModal/__snapshots__/ProcessingInfoModal.test.tsx.snap index 8c77cb861f2..7b28b00e45f 100644 --- a/app/components/UI/Ramp/Views/Modals/ProcessingInfoModal/__snapshots__/ProcessingInfoModal.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Modals/ProcessingInfoModal/__snapshots__/ProcessingInfoModal.test.tsx.snap @@ -1,261 +1,252 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +// Jest Snapshot v1, https://goo.gl/fbAQLP exports[`ProcessingInfoModal renders correctly 1`] = ` - + + + + + + + + + + + + Card purchases typically take a few minutes. You can contact support if you have questions. + + + - - - - - - - - - - - - - - - - Card purchases typically take a few minutes. You can contact support if you have questions. - - - - - - Go to Transak support page - - - + Go to Transak support page + diff --git a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelection.test.tsx b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelection.test.tsx index 52a237f7a80..685faaec938 100644 --- a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelection.test.tsx +++ b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelection.test.tsx @@ -107,6 +107,21 @@ function createMockQuote( const mockOnBack = jest.fn(); +const moonpayProvider: Provider = { + id: '/providers/moonpay', + name: 'MoonPay', + environmentType: 'PRODUCTION', + description: 'MoonPay', + hqAddress: 'MP', + links: [], + logos: { + light: '', + dark: '', + height: 24, + width: 90, + }, +}; + interface RenderOptions { providers?: Provider[]; selectedProvider?: Provider | null; @@ -114,6 +129,7 @@ interface RenderOptions { quotesLoading?: boolean; quotesError?: string | null; showQuotes?: boolean; + ordersProviders?: string[]; } function renderWithProvider( @@ -126,6 +142,7 @@ function renderWithProvider( quotesLoading = false, quotesError = null, showQuotes, + ordersProviders, } = options; jest.mocked(useRampsController).mockReturnValue({ @@ -142,6 +159,7 @@ function renderWithProvider( onProviderSelect={jest.fn()} onBack={mockOnBack} {...(showQuotes !== undefined && { showQuotes })} + {...(ordersProviders !== undefined && { ordersProviders })} /> ), { @@ -320,4 +338,100 @@ describe('ProviderSelection', () => { }); expect(queryByText('Stripe')).toBeNull(); }); + + it('renders empty state when there are no providers', () => { + const { getByText } = renderWithProvider([], null, { + showQuotes: true, + quotes: { + success: [], + sorted: [], + error: [], + customActions: [], + }, + }); + + expect(getByText('No providers available.')).toBeOnTheScreen(); + }); + + it('renders Other options separator between quoted and non-quoted providers', async () => { + jest.mocked(useRampsController).mockReturnValue({ + ...defaultMockController, + userRegion: mockUserRegion, + selectedToken: mockSelectedToken, + providers: [transakProvider, moonpayProvider], + selectedProvider: transakProvider, + }); + + const transakQuote = createMockQuote('/providers/transak', 'Transak'); + + const { getByText } = renderWithProvider( + [transakProvider, moonpayProvider], + transakProvider, + { + quotes: { + success: [transakQuote], + sorted: [{ sortBy: 'reliability', ids: ['/providers/transak'] }], + error: [], + customActions: [], + }, + }, + ); + + await waitFor(() => { + expect(getByText('Other options')).toBeOnTheScreen(); + }); + expect(getByText('MoonPay')).toBeOnTheScreen(); + }); + + it('shows Best rate tag when quote metadata has isBestRate', async () => { + jest.mocked(useRampsController).mockReturnValue({ + ...defaultMockController, + userRegion: mockUserRegion, + selectedToken: mockSelectedToken, + providers: mockProviders, + selectedProvider: null, + }); + + const bestRateQuote = { + ...createMockQuote('/providers/transak', 'Transak'), + metadata: { tags: { isBestRate: true } }, + }; + + const { getByText } = renderWithProvider(mockProviders, null, { + quotes: { + success: [bestRateQuote], + sorted: [], + error: [], + customActions: [], + }, + }); + + await waitFor(() => { + expect(getByText('Best rate')).toBeOnTheScreen(); + }); + }); + + it('shows Previously used tag when provider is in ordersProviders', async () => { + jest.mocked(useRampsController).mockReturnValue({ + ...defaultMockController, + userRegion: mockUserRegion, + selectedToken: mockSelectedToken, + providers: mockProviders, + selectedProvider: null, + }); + + const { getByText } = renderWithProvider(mockProviders, null, { + ordersProviders: ['/providers/transak'], + quotes: { + success: [createMockQuote('/providers/transak', 'Transak')], + sorted: [], + error: [], + customActions: [], + }, + }); + + await waitFor(() => { + expect(getByText('Previously used')).toBeOnTheScreen(); + }); + }); }); diff --git a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelection.tsx b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelection.tsx index 4c8ef5b491d..889b2da507f 100644 --- a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelection.tsx +++ b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelection.tsx @@ -274,7 +274,7 @@ const ProviderSelection: React.FC = ({ accessible > - + {provider.name} {tag ? ( diff --git a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/__snapshots__/ProviderSelection.test.tsx.snap b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/__snapshots__/ProviderSelection.test.tsx.snap index f32d36e1aa5..c4734668918 100644 --- a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/__snapshots__/ProviderSelection.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/__snapshots__/ProviderSelection.test.tsx.snap @@ -572,8 +572,8 @@ exports[`ProviderSelection filters out custom-action quotes when displaying prov { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, - "fontWeight": 500, + "fontSize": 16, + "fontWeight": 400, "letterSpacing": 0, "lineHeight": 24, }, @@ -615,13 +615,17 @@ exports[`ProviderSelection filters out custom-action quotes when displaying prov $98.00 @@ -1234,8 +1238,8 @@ exports[`ProviderSelection matches snapshot when no quotes are available 1`] = ` { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, - "fontWeight": 500, + "fontSize": 16, + "fontWeight": 400, "letterSpacing": 0, "lineHeight": 24, }, @@ -3076,13 +3080,17 @@ exports[`ProviderSelection matches snapshot when quotes fail to load 1`] = ` Failed to load quotes @@ -3194,8 +3202,8 @@ exports[`ProviderSelection matches snapshot when quotes fail to load 1`] = ` { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, - "fontWeight": 500, + "fontSize": 16, + "fontWeight": 400, "letterSpacing": 0, "lineHeight": 24, }, @@ -3812,8 +3820,8 @@ exports[`ProviderSelection renders providers directly when quotes load but none { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, - "fontWeight": 500, + "fontSize": 16, + "fontWeight": 400, "letterSpacing": 0, "lineHeight": 24, }, diff --git a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/__snapshots__/ProviderSelectionModal.test.tsx.snap b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/__snapshots__/ProviderSelectionModal.test.tsx.snap index 2ac38e7b82d..b1896a47e72 100644 --- a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/__snapshots__/ProviderSelectionModal.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/__snapshots__/ProviderSelectionModal.test.tsx.snap @@ -597,8 +597,8 @@ exports[`ProviderSelectionModal matches snapshot 1`] = ` { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, - "fontWeight": 500, + "fontSize": 16, + "fontWeight": 400, "letterSpacing": 0, "lineHeight": 24, }, @@ -679,8 +679,8 @@ exports[`ProviderSelectionModal matches snapshot 1`] = ` { "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 20, - "fontWeight": 500, + "fontSize": 16, + "fontWeight": 400, "letterSpacing": 0, "lineHeight": 24, }, diff --git a/app/components/UI/Ramp/Views/Modals/SettingsModal/SettingsModal.tsx b/app/components/UI/Ramp/Views/Modals/SettingsModal/SettingsModal.tsx index 3483401c002..31d2560c975 100644 --- a/app/components/UI/Ramp/Views/Modals/SettingsModal/SettingsModal.tsx +++ b/app/components/UI/Ramp/Views/Modals/SettingsModal/SettingsModal.tsx @@ -22,7 +22,7 @@ import { ToastVariants, } from '../../../../../../component-library/components/Toast'; import Logger from '../../../../../../util/Logger'; -import BottomSheetHeader from '../../../../../../component-library/components/BottomSheets/BottomSheetHeader'; +import HeaderCompactStandard from '../../../../../../component-library/components-temp/HeaderCompactStandard'; import MenuItem from '../../../components/MenuItem'; import { useRampsController } from '../../../hooks/useRampsController'; import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics'; @@ -194,9 +194,10 @@ function SettingsModal() { return ( - - {strings('fiat_on_ramp.build_quote_settings_modal.title')} - + - - Settings - + + Settings + + - - + diff --git a/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.tsx b/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.tsx index 0a4cc00f467..93f0d50603b 100644 --- a/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.tsx +++ b/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.tsx @@ -1,19 +1,18 @@ import React, { useCallback, useEffect, useRef } from 'react'; import { View } from 'react-native'; import { useNavigation } from '@react-navigation/native'; -import Text, { +import { + Text, TextVariant, TextColor, -} from '../../../../../../component-library/components/Texts/Text'; + Button, + ButtonVariant, + ButtonBaseSize, +} from '@metamask/design-system-react-native'; import BottomSheet, { BottomSheetRef, } from '../../../../../../component-library/components/BottomSheets/BottomSheet'; -import BottomSheetHeader from '../../../../../../component-library/components/BottomSheets/BottomSheetHeader'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../../component-library/components/Buttons/Button'; +import HeaderCompactStandard from '../../../../../../component-library/components-temp/HeaderCompactStandard'; import { strings } from '../../../../../../../locales/i18n'; import { createNavigationDetails, @@ -134,19 +133,16 @@ function TokenNotAvailableModal() { onClose={handleDismiss} testID={TOKEN_NOT_AVAILABLE_MODAL_TEST_IDS.MODAL} > - - - {strings('fiat_on_ramp.token_unavailable_modal.title')} - - + /> - + {strings('fiat_on_ramp.token_unavailable_modal.description', { token: tokenName, provider: providerName, @@ -157,25 +153,25 @@ function TokenNotAvailableModal() { diff --git a/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/__snapshots__/TokenNotAvailableModal.test.tsx.snap b/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/__snapshots__/TokenNotAvailableModal.test.tsx.snap index fcf5121f41c..6264e99772b 100644 --- a/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/__snapshots__/TokenNotAvailableModal.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/__snapshots__/TokenNotAvailableModal.test.tsx.snap @@ -320,11 +320,11 @@ exports[`TokenNotAvailableModal matches snapshot 1`] = ` "flexDirection": "row", "gap": 16, "minHeight": 56, + "paddingLeft": 8, + "paddingRight": 8, }, false, - { - "paddingHorizontal": 16, - }, + undefined, ] } testID="header" @@ -348,58 +348,109 @@ exports[`TokenNotAvailableModal matches snapshot 1`] = ` ] } > - - Not available - + + Not available + + - - + @@ -414,13 +465,17 @@ exports[`TokenNotAvailableModal matches snapshot 1`] = ` USD Coin is not available with Transak in your region. @@ -443,45 +498,93 @@ exports[`TokenNotAvailableModal matches snapshot 1`] = ` } } > - Change token - + - Change provider - + @@ -861,11 +1012,11 @@ exports[`TokenNotAvailableModal matches snapshot with missing provider and token "flexDirection": "row", "gap": 16, "minHeight": 56, + "paddingLeft": 8, + "paddingRight": 8, }, false, - { - "paddingHorizontal": 16, - }, + undefined, ] } testID="header" @@ -889,58 +1040,109 @@ exports[`TokenNotAvailableModal matches snapshot with missing provider and token ] } > - - Not available - + + Not available + + - - + @@ -955,13 +1157,17 @@ exports[`TokenNotAvailableModal matches snapshot with missing provider and token is not available with in your region. @@ -984,45 +1190,93 @@ exports[`TokenNotAvailableModal matches snapshot with missing provider and token } } > - Change token - + - Change provider - + diff --git a/app/components/UI/Ramp/Views/Modals/UnsupportedTokenModal/UnsupportedTokenModal.tsx b/app/components/UI/Ramp/Views/Modals/UnsupportedTokenModal/UnsupportedTokenModal.tsx index fbcf39f45e0..21333b6581b 100644 --- a/app/components/UI/Ramp/Views/Modals/UnsupportedTokenModal/UnsupportedTokenModal.tsx +++ b/app/components/UI/Ramp/Views/Modals/UnsupportedTokenModal/UnsupportedTokenModal.tsx @@ -1,13 +1,11 @@ import React, { useRef } from 'react'; import { View } from 'react-native'; -import Text, { - TextVariant, -} from '../../../../../../component-library/components/Texts/Text'; +import { Text, TextVariant } from '@metamask/design-system-react-native'; import BottomSheet, { BottomSheetRef, } from '../../../../../../component-library/components/BottomSheets/BottomSheet'; -import BottomSheetHeader from '../../../../../../component-library/components/BottomSheets/BottomSheetHeader'; +import HeaderCompactStandard from '../../../../../../component-library/components-temp/HeaderCompactStandard'; import { strings } from '../../../../../../../locales/i18n'; import styleSheet from './UnsupportedTokenModal.styles'; import { useStyles } from '../../../../../hooks/useStyles'; @@ -26,15 +24,14 @@ function UnsupportedTokenModal() { return ( - sheetRef.current?.onCloseBottomSheet()} closeButtonProps={{ testID: 'bottomsheetheader-close-button' }} - > - {strings('deposit.token_modal.unsupported_token_title')} - + /> - + {strings('deposit.token_modal.unsupported_token_description')} diff --git a/app/components/UI/Ramp/Views/Modals/UnsupportedTokenModal/__snapshots__/UnsupportedTokenModal.test.tsx.snap b/app/components/UI/Ramp/Views/Modals/UnsupportedTokenModal/__snapshots__/UnsupportedTokenModal.test.tsx.snap index b8a0d82e294..86b6446941c 100644 --- a/app/components/UI/Ramp/Views/Modals/UnsupportedTokenModal/__snapshots__/UnsupportedTokenModal.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Modals/UnsupportedTokenModal/__snapshots__/UnsupportedTokenModal.test.tsx.snap @@ -320,11 +320,11 @@ exports[`UnsupportedTokenModal renders the modal with correct title and descript "flexDirection": "row", "gap": 16, "minHeight": 56, + "paddingLeft": 8, + "paddingRight": 8, }, false, - { - "paddingHorizontal": 16, - }, + undefined, ] } testID="header" @@ -348,65 +348,109 @@ exports[`UnsupportedTokenModal renders the modal with correct title and descript ] } > - - Not available - + + Not available + + - - + @@ -421,13 +465,17 @@ exports[`UnsupportedTokenModal renders the modal with correct title and descript This token may not be available in your region or supported by any local payment providers From f41c069e91c0a6b9dbd8111b6df96a0730b3710c Mon Sep 17 00:00:00 2001 From: George Marshall Date: Wed, 18 Mar 2026 12:19:10 -0700 Subject: [PATCH 104/206] chore: remove stale CentraNo1 font references from QA and Flask plists (#27634) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Removes the remaining CentraNo1 font references from the iOS QA and Flask Info.plist files. These fonts are no longer used — the app now uses expo-font for font loading, making these native plist entries stale. The main Info.plist was already cleaned in a previous PR (#19855), but the QA and Flask variants were missed. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: N/A — cleanup of stale font references ## **Manual testing steps** ```gherkin Feature: Font rendering after CentraNo1 plist cleanup Scenario: App renders fonts correctly on QA build Given the user has a QA build installed When the user opens the app Then all text renders correctly with the expected fonts (MMSans, Geist, etc.) And no missing font warnings appear in the console ``` ## **Screenshots/Recordings** Not applicable — plist-only change with no visual impact. ### **Before** MetaMask-QA-Info.plist and MetaMask-Flask-Info.plist contained 6 stale CentraNo1 font entries each. Screenshot 2026-03-18 at 9 52 51 AM ### **After** CentraNo1 entries removed. Zero CentraNo1 references remain in the codebase. Screenshot 2026-03-18 at 9 52 23 AM ## **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. ## **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** > Low risk plist cleanup that only removes unused font references; main risk is missing fonts if any build still relies on native registration. > > **Overview** > Removes the six `CentraNo1-*.otf` entries from `UIAppFonts` in `MetaMask-QA-Info.plist` and `MetaMask-Flask-Info.plist`, eliminating stale native font registrations now that these fonts are no longer used. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 341d870b38f6229eb06f125bcb0b75d09aefdff0. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- ios/MetaMask/MetaMask-Flask-Info.plist | 6 ------ ios/MetaMask/MetaMask-QA-Info.plist | 6 ------ 2 files changed, 12 deletions(-) diff --git a/ios/MetaMask/MetaMask-Flask-Info.plist b/ios/MetaMask/MetaMask-Flask-Info.plist index 32d46afb5f1..c62f49f9985 100644 --- a/ios/MetaMask/MetaMask-Flask-Info.plist +++ b/ios/MetaMask/MetaMask-Flask-Info.plist @@ -91,12 +91,6 @@ FontAwesome5_Brands.ttf FontAwesome5_Regular.ttf FontAwesome5_Solid.ttf - CentraNo1-Bold.otf - CentraNo1-BoldItalic.otf - CentraNo1-Book.otf - CentraNo1-BookItalic.otf - CentraNo1-Medium.otf - CentraNo1-MediumItalic.otf MMPoly-Regular.otf MMSans-Bold.otf MMSans-Medium.otf diff --git a/ios/MetaMask/MetaMask-QA-Info.plist b/ios/MetaMask/MetaMask-QA-Info.plist index ba92432f023..2b49cf8a0d4 100644 --- a/ios/MetaMask/MetaMask-QA-Info.plist +++ b/ios/MetaMask/MetaMask-QA-Info.plist @@ -91,12 +91,6 @@ FontAwesome5_Brands.ttf FontAwesome5_Regular.ttf FontAwesome5_Solid.ttf - CentraNo1-Bold.otf - CentraNo1-BoldItalic.otf - CentraNo1-Book.otf - CentraNo1-BookItalic.otf - CentraNo1-Medium.otf - CentraNo1-MediumItalic.otf MMPoly-Regular.otf MMSans-Bold.otf MMSans-Medium.otf From 285da874e6fa56fbc76ca4a943a8278543deef54 Mon Sep 17 00:00:00 2001 From: tommasini <46944231+tommasini@users.noreply.github.com> Date: Wed, 18 Mar 2026 19:25:46 +0000 Subject: [PATCH 105/206] ci: add expo dev build GitHub Actions workflow (#27639) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adds a new GitHub Actions workflow (`expo-dev-build.yml`) that replaces the Bitrise `expo_dev_pipeline` triggered on pushes to `main`. The workflow calls the reusable `build.yml` with `build_name: main-dev` and `platform: both`, which builds: - **iOS**: simulator `.app` (zipped) — Debug configuration - **Android**: debug APK No version bump or TestFlight upload is needed since this is a dev/simulator build. Build artifacts are uploaded as GitHub Actions artifacts (`ios-main-dev` and `android-main-dev`). ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** N/A — CI-only change. Verify by pushing to `main` and confirming the "Expo Dev Build" workflow runs successfully in the Actions tab, producing `ios-main-dev` and `android-main-dev` artifacts. ## **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. ## **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. Made with [Cursor](https://cursor.com) --- > [!NOTE] > **Low Risk** > Low risk: CI-only change that adds a new workflow without modifying app code or release/versioning logic; main impact is additional CI load on `main` pushes. > > **Overview** > Adds a new `Expo Dev Build` GitHub Actions workflow triggered on pushes to `main` (and manually) to replace the prior Bitrise dev pipeline. > > The workflow invokes the reusable `build.yml` with `build_name: main-dev`, `platform: both`, and `skip_version_bump: true` to produce and upload iOS simulator and Android debug artifacts. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit e766b129e643b2c9d19cf81446989b7383a632fd. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .github/workflows/expo-dev-build.yml | 34 ++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/expo-dev-build.yml diff --git a/.github/workflows/expo-dev-build.yml b/.github/workflows/expo-dev-build.yml new file mode 100644 index 00000000000..96fcaeb12de --- /dev/null +++ b/.github/workflows/expo-dev-build.yml @@ -0,0 +1,34 @@ +############################################################################################## +# +# Expo Dev Build — replaces the Bitrise expo_dev_pipeline. +# +# Triggered on every push to main. Builds the main-dev configuration (Debug, simulator) +# for both iOS and Android using the reusable build.yml workflow. +# +# No version bump or TestFlight upload — this is a dev/simulator build only. +# Artifacts (iOS .app zip + Android APK) are uploaded as GitHub Actions artifacts. +# +# [skip ci] commits (e.g. version bumps) are automatically skipped by GitHub Actions. +# +############################################################################################## +name: Expo Dev Build + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + id-token: write + +jobs: + build-dev: + name: Expo dev build (main-dev) + uses: ./.github/workflows/build.yml + with: + build_name: main-dev + platform: both + skip_version_bump: true + secrets: inherit From 8990394b8f6f18f5a6ac3d5ae085276d52f930a5 Mon Sep 17 00:00:00 2001 From: VGR Date: Wed, 18 Mar 2026 21:07:50 +0100 Subject: [PATCH 106/206] feat(rewards): add campaign opt-in flow with details, mechanics, and how-it-works views (#27619) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds `CampaignOptInSheet`, `CampaignHowItWorks`, `CampaignDetailsView`, and `CampaignMechanicsView` components for the campaign opt-in flow - Wires new routes (`CampaignDetails`, `CampaignMechanics`) into `RewardsNavigator` - Updates `CampaignTile` and `CampaignsPreview` to support opt-in navigation - Extends `RewardsController` and `rewards-data-service` with `CampaignParticipantStatus` handling and revert logic when feature flag is off - Adds i18n strings for all new UI ## **Changelog** CHANGELOG entry: Added campaign opt-in flow with details and mechanics screens in the Rewards section ## Test plan - [ ] All 6 test suites pass (80 tests) - [ ] `yarn format:check` passes - [ ] ESLint: 0 errors on staged files 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- > [!NOTE] > **Medium Risk** > Adds new Rewards campaign navigation/screens and changes campaign opt-in API/controller behavior (including new cache/event logic and 409 handling), which could affect user enrollment state and campaign UI across the app. > > **Overview** > Adds a new in-app **campaign opt-in flow** in Rewards, including `CampaignDetailsView` (status/how-it-works + “Join campaign” CTA and opt-in bottom sheet) and `CampaignMechanicsView` (expanded “how it works” + parsed notes), plus a reusable `CampaignHowItWorks` renderer. > > Wires new routes (`CAMPAIGN_DETAILS`, `CAMPAIGN_MECHANICS`) into `RewardsNavigator`, updates `CampaignTile` to navigate to details and show an **“Entered”** state based on participant status, and enhances `CampaignsPreview` with loading skeleton/spinner and retryable error banner. > > Updates backend plumbing for opt-in: `rewards-data-service` treats HTTP `409` on opt-in as “already opted in” by fetching participant status, and `RewardsController.optInToCampaign` only emits `campaignOptedIn` when transitioning from not-opted-in to opted-in (reducing redundant refetches), with a small cache-write refactor for campaigns. Adds corresponding tests and new i18n strings. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 11ee14a023eadc08f140009cf1083057a6bdfdf9. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: sophieqgu Co-authored-by: Claude Sonnet 4.6 --- .../UI/Rewards/RewardsNavigator.test.tsx | 37 ++ .../UI/Rewards/RewardsNavigator.tsx | 16 +- .../Views/CampaignDetailsView.test.tsx | 436 ++++++++++++++++++ .../UI/Rewards/Views/CampaignDetailsView.tsx | 157 +++++++ .../Views/CampaignMechanicsView.test.tsx | 300 ++++++++++++ .../Rewards/Views/CampaignMechanicsView.tsx | 153 ++++++ .../Campaigns/CampaignHowItWorks.test.tsx | 178 +++++++ .../Campaigns/CampaignHowItWorks.tsx | 109 +++++ .../Campaigns/CampaignOptInSheet.test.tsx | 237 ++++++++++ .../Campaigns/CampaignOptInSheet.tsx | 132 ++++++ .../Campaigns/CampaignTile.test.tsx | 96 +++- .../components/Campaigns/CampaignTile.tsx | 55 ++- .../components/Campaigns/CampaignsPreview.tsx | 29 +- app/constants/navigation/Routes.ts | 1 + .../rewards-controller/RewardsController.ts | 86 ++-- .../services/rewards-data-service.test.ts | 23 +- .../services/rewards-data-service.ts | 5 + locales/languages/en.json | 12 +- 18 files changed, 1992 insertions(+), 70 deletions(-) create mode 100644 app/components/UI/Rewards/Views/CampaignDetailsView.test.tsx create mode 100644 app/components/UI/Rewards/Views/CampaignDetailsView.tsx create mode 100644 app/components/UI/Rewards/Views/CampaignMechanicsView.test.tsx create mode 100644 app/components/UI/Rewards/Views/CampaignMechanicsView.tsx create mode 100644 app/components/UI/Rewards/components/Campaigns/CampaignHowItWorks.test.tsx create mode 100644 app/components/UI/Rewards/components/Campaigns/CampaignHowItWorks.tsx create mode 100644 app/components/UI/Rewards/components/Campaigns/CampaignOptInSheet.test.tsx create mode 100644 app/components/UI/Rewards/components/Campaigns/CampaignOptInSheet.tsx diff --git a/app/components/UI/Rewards/RewardsNavigator.test.tsx b/app/components/UI/Rewards/RewardsNavigator.test.tsx index ec5b133b891..beea1bf411d 100644 --- a/app/components/UI/Rewards/RewardsNavigator.test.tsx +++ b/app/components/UI/Rewards/RewardsNavigator.test.tsx @@ -69,6 +69,30 @@ jest.mock('./Views/RewardsSettingsView', () => { }; }); +jest.mock('./Views/CampaignDetailsView', () => { + const ReactActual = jest.requireActual('react'); + const { View, Text } = jest.requireActual('react-native'); + return function MockCampaignDetailsView() { + return ReactActual.createElement( + View, + { testID: 'campaign-details-view' }, + ReactActual.createElement(Text, null, 'Campaign Details View'), + ); + }; +}); + +jest.mock('./Views/CampaignMechanicsView', () => { + const ReactActual = jest.requireActual('react'); + const { View, Text } = jest.requireActual('react-native'); + return function MockCampaignMechanicsView() { + return ReactActual.createElement( + View, + { testID: 'campaign-mechanics-view' }, + ReactActual.createElement(Text, null, 'Campaign Mechanics View'), + ); + }; +}); + // Mock Skeleton component jest.mock( '../../../component-library/components-temp/Skeleton/Skeleton', @@ -405,6 +429,19 @@ describe('RewardsNavigator', () => { expect(queryByTestId('rewards-dashboard-view')).toBeNull(); }); }); + + it('registers CAMPAIGN_DETAILS and CAMPAIGN_MECHANICS routes when subscription exists', async () => { + // Both views are registered inside the subscriptionId-guarded block, + // so they are present in the navigator only when the user is enrolled. + mockSelectRewardsSubscriptionId.mockReturnValue('test-subscription-id'); + + // Rendering should not throw even with the new screens registered + const { getByTestId } = renderWithNavigation(); + + await waitFor(() => { + expect(getByTestId('rewards-dashboard-view')).toBeOnTheScreen(); + }); + }); }); // Note: Removed AuthErrorView tests as they don't match the actual implementation diff --git a/app/components/UI/Rewards/RewardsNavigator.tsx b/app/components/UI/Rewards/RewardsNavigator.tsx index 3361637e7bc..c75ef4136de 100644 --- a/app/components/UI/Rewards/RewardsNavigator.tsx +++ b/app/components/UI/Rewards/RewardsNavigator.tsx @@ -6,6 +6,8 @@ import RewardsDashboard from './Views/RewardsDashboard'; import ReferralRewardsView from './Views/RewardsReferralView'; import RewardsSettingsView from './Views/RewardsSettingsView'; import CampaignsView from './Views/CampaignsView'; +import CampaignDetailsView from './Views/CampaignDetailsView'; +import CampaignMechanicsView from './Views/CampaignMechanicsView'; import PreviousSeasonView from './Views/PreviousSeasonView'; import { useSelector } from 'react-redux'; import { selectRewardsSubscriptionId } from '../../../selectors/rewards'; @@ -13,7 +15,6 @@ import { useCandidateSubscriptionId } from './hooks/useCandidateSubscriptionId'; import { useNavigation } from '@react-navigation/native'; import { useSeasonStatus } from './hooks/useSeasonStatus'; import { useGeoRewardsMetadata } from './hooks/useGeoRewardsMetadata'; -import { useRewardCampaigns } from './hooks/useRewardCampaigns'; const Stack = createStackNavigator(); const RewardsNavigator: React.FC = () => { @@ -29,9 +30,6 @@ const RewardsNavigator: React.FC = () => { // Fetch geo rewards metadata so optinAllowedForGeo is available across all rewards screens useGeoRewardsMetadata({}); - // Fetch all campaigns - useRewardCampaigns(); - // Determine initial route - always start with onboarding intro step initially const getInitialRoute = () => { // If user has already opted in and has a valid subscription candidate ID, go to dashboard @@ -85,6 +83,16 @@ const RewardsNavigator: React.FC = () => { component={PreviousSeasonView} options={{ headerShown: false }} /> + + ) : null} diff --git a/app/components/UI/Rewards/Views/CampaignDetailsView.test.tsx b/app/components/UI/Rewards/Views/CampaignDetailsView.test.tsx new file mode 100644 index 00000000000..a68efde45f7 --- /dev/null +++ b/app/components/UI/Rewards/Views/CampaignDetailsView.test.tsx @@ -0,0 +1,436 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import CampaignDetailsView, { + CAMPAIGN_DETAILS_TEST_IDS, +} from './CampaignDetailsView'; +import { + type CampaignDto, + CampaignType, +} from '../../../../core/Engine/controllers/rewards-controller/types'; +import { useRewardCampaigns } from '../hooks/useRewardCampaigns'; +import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus'; +import Routes from '../../../../constants/navigation/Routes'; + +const mockGoBack = jest.fn(); +const mockNavigate = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ goBack: mockGoBack, navigate: mockNavigate }), + useRoute: () => ({ params: { campaignId: 'campaign-1' } }), +})); + +jest.mock('react-native-safe-area-context', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + const actual = jest.requireActual('react-native-safe-area-context'); + return { + ...actual, + useSafeAreaInsets: jest.fn(() => ({ + top: 0, + right: 0, + bottom: 0, + left: 0, + })), + SafeAreaView: ({ + children, + testID, + ...props + }: { + children: React.ReactNode; + testID?: string; + }) => ReactActual.createElement(View, { ...props, testID }, children), + }; +}); + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + return { ...actual }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock( + '../../../../component-library/components-temp/HeaderCompactStandard', + () => { + const ReactActual = jest.requireActual('react'); + const { View, Text, Pressable } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + title, + onBack, + endButtonIconProps, + }: { + title: string; + onBack: () => void; + endButtonIconProps?: { testID?: string; onPress?: () => void }[]; + }) => + ReactActual.createElement( + View, + { testID: 'header' }, + ReactActual.createElement(Text, null, title), + ReactActual.createElement(Pressable, { + onPress: onBack, + testID: 'header-back-button', + }), + ...(endButtonIconProps ?? []).map((btn, i) => + ReactActual.createElement(Pressable, { + key: i, + onPress: btn.onPress, + testID: btn.testID ?? `end-button-${i}`, + }), + ), + ), + }; + }, +); + +jest.mock('../../../Views/ErrorBoundary', () => { + const ReactActual = jest.requireActual('react'); + return { + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => + ReactActual.createElement(ReactActual.Fragment, null, children), + }; +}); + +jest.mock('../components/Campaigns/CampaignStatus', () => { + const ReactActual = jest.requireActual('react'); + const { View, Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ campaign }: { campaign: { name: string } }) => + ReactActual.createElement( + View, + { testID: 'campaign-status' }, + ReactActual.createElement(Text, null, campaign.name), + ), + }; +}); + +jest.mock('../components/Campaigns/CampaignHowItWorks', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + ReactActual.createElement(View, { testID: 'campaign-how-it-works' }), + }; +}); + +jest.mock('../components/Campaigns/CampaignOptInSheet', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ onClose: _onClose }: { onClose?: () => void }) => + ReactActual.createElement(View, { + testID: 'campaign-opt-in-sheet', + // expose onClose so tests can trigger it + accessible: true, + accessibilityLabel: 'opt-in-sheet', + }), + }; +}); + +jest.mock('../components/RewardsErrorBanner', () => { + const ReactActual = jest.requireActual('react'); + const { View, Text, Pressable } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + title, + onConfirm, + confirmButtonLabel, + }: { + title: string; + description: string; + onConfirm?: () => void; + confirmButtonLabel?: string; + }) => + ReactActual.createElement( + View, + { testID: 'error-banner' }, + ReactActual.createElement(Text, null, title), + confirmButtonLabel && + ReactActual.createElement( + Pressable, + { onPress: onConfirm, testID: 'error-retry-button' }, + ReactActual.createElement(Text, null, confirmButtonLabel), + ), + ), + }; +}); + +jest.mock('../hooks/useRewardCampaigns'); +const mockUseRewardCampaigns = useRewardCampaigns as jest.MockedFunction< + typeof useRewardCampaigns +>; + +jest.mock('../hooks/useGetCampaignParticipantStatus'); +const mockUseGetCampaignParticipantStatus = + useGetCampaignParticipantStatus as jest.MockedFunction< + typeof useGetCampaignParticipantStatus + >; + +jest.mock('../../../../../locales/i18n', () => ({ + strings: (key: string) => { + const translations: Record = { + 'rewards.campaigns_view.error_title': 'Unable to load', + 'rewards.campaigns_view.error_description': 'Please try again.', + 'rewards.campaigns_view.retry_button': 'Retry', + 'rewards.campaign_details.checking_opt_in_status': 'Checking...', + 'rewards.campaign_details.join_campaign': 'Join Campaign', + }; + return translations[key] || key; + }, +})); + +const createTestCampaign = ( + overrides: Partial = {}, +): CampaignDto => ({ + id: 'campaign-1', + type: CampaignType.ONDO_HOLDING, + name: 'Test Campaign', + startDate: '2027-01-01T00:00:00.000Z', + endDate: '2027-12-31T23:59:59.999Z', + termsAndConditions: null, + excludedRegions: [], + statusLabel: 'Active', + details: null, + ...overrides, +}); + +const mockFetchCampaigns = jest.fn(); +const emptyCategorized = { active: [], upcoming: [], previous: [] }; +const hookDefaults = { + campaigns: [], + categorizedCampaigns: emptyCategorized, + isLoading: false, + hasError: false, + fetchCampaigns: mockFetchCampaigns, +}; + +describe('CampaignDetailsView', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseRewardCampaigns.mockReturnValue(hookDefaults); + mockUseGetCampaignParticipantStatus.mockReturnValue({ + status: null, + isLoading: false, + hasError: false, + refetch: jest.fn(), + }); + }); + + it('renders the container', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [createTestCampaign()], + }); + const { getByTestId } = render(); + expect(getByTestId(CAMPAIGN_DETAILS_TEST_IDS.CONTAINER)).toBeDefined(); + }); + + it('renders campaign name in the header', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [createTestCampaign({ name: 'My Special Campaign' })], + }); + const { getAllByText } = render(); + // Name appears in both the header title and the CampaignStatus mock + expect(getAllByText('My Special Campaign').length).toBeGreaterThan(0); + }); + + describe('loading state', () => { + it('shows no error banner or campaign status while loading with no campaign', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [], + isLoading: true, + }); + const { queryByTestId } = render(); + expect(queryByTestId('error-banner')).toBeNull(); + expect(queryByTestId('campaign-status')).toBeNull(); + }); + }); + + describe('error state', () => { + it('shows error banner when hasError and no campaign', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [], + hasError: true, + }); + const { getByTestId } = render(); + expect(getByTestId('error-banner')).toBeDefined(); + }); + + it('calls fetchCampaigns when retry is pressed', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [], + hasError: true, + }); + const { getByTestId } = render(); + fireEvent.press(getByTestId('error-retry-button')); + expect(mockFetchCampaigns).toHaveBeenCalledTimes(1); + }); + + it('does not show error banner when campaign is found even with hasError', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [createTestCampaign()], + hasError: true, + }); + const { queryByTestId } = render(); + expect(queryByTestId('error-banner')).toBeNull(); + }); + }); + + describe('campaign content', () => { + it('renders CampaignStatus when campaign is found', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [createTestCampaign()], + }); + const { getByTestId } = render(); + expect(getByTestId('campaign-status')).toBeDefined(); + }); + + it('renders CampaignHowItWorks when campaign has howItWorks details', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [ + createTestCampaign({ + details: { + image: { + lightModeUrl: 'https://example.com/light.png', + darkModeUrl: 'https://example.com/dark.png', + }, + howItWorks: { + title: 'How it works', + description: 'Description', + phases: [], + }, + }, + }), + ], + }); + const { getByTestId } = render(); + expect(getByTestId('campaign-how-it-works')).toBeDefined(); + }); + + it('does not render CampaignHowItWorks when campaign has no details', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [createTestCampaign({ details: null })], + }); + const { queryByTestId } = render(); + expect(queryByTestId('campaign-how-it-works')).toBeNull(); + }); + }); + + describe('opt-in CTA', () => { + it('renders the join CTA when participant status is null', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [createTestCampaign()], + }); + // status null → participantStatus?.optedIn !== true + const { getByTestId } = render(); + expect(getByTestId(CAMPAIGN_DETAILS_TEST_IDS.CTA_BUTTON)).toBeDefined(); + }); + + it('renders the join CTA when participant is not opted in', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [createTestCampaign()], + }); + mockUseGetCampaignParticipantStatus.mockReturnValue({ + status: { optedIn: false, participantCount: 0 }, + isLoading: false, + hasError: false, + refetch: jest.fn(), + }); + const { getByTestId } = render(); + expect(getByTestId(CAMPAIGN_DETAILS_TEST_IDS.CTA_BUTTON)).toBeDefined(); + }); + + it('does not render the CTA when participant is already opted in', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [createTestCampaign()], + }); + mockUseGetCampaignParticipantStatus.mockReturnValue({ + status: { optedIn: true, participantCount: 1 }, + isLoading: false, + hasError: false, + refetch: jest.fn(), + }); + const { queryByTestId } = render(); + expect(queryByTestId(CAMPAIGN_DETAILS_TEST_IDS.CTA_BUTTON)).toBeNull(); + }); + + it('renders the CTA as disabled and loading when participant status is loading', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [createTestCampaign()], + }); + mockUseGetCampaignParticipantStatus.mockReturnValue({ + status: null, + isLoading: true, + hasError: false, + refetch: jest.fn(), + }); + const { getByTestId, getByText } = render(); + const cta = getByTestId(CAMPAIGN_DETAILS_TEST_IDS.CTA_BUTTON); + expect(cta).toBeDefined(); + expect( + cta.props.isDisabled ?? cta.props.accessibilityState?.disabled, + ).toBeTruthy(); + expect(getByText('Checking...')).toBeDefined(); + }); + + it('does not render CTA when no campaign is loaded', () => { + const { queryByTestId } = render(); + expect(queryByTestId(CAMPAIGN_DETAILS_TEST_IDS.CTA_BUTTON)).toBeNull(); + }); + + it('opens the opt-in sheet when CTA is pressed', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [createTestCampaign()], + }); + const { getByTestId } = render(); + fireEvent.press(getByTestId(CAMPAIGN_DETAILS_TEST_IDS.CTA_BUTTON)); + expect(getByTestId('campaign-opt-in-sheet')).toBeDefined(); + }); + }); + + describe('navigation', () => { + it('navigates back when the back button is pressed', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [createTestCampaign()], + }); + const { getByTestId } = render(); + fireEvent.press(getByTestId('header-back-button')); + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('navigates to campaign mechanics when the mechanics button is pressed', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [createTestCampaign()], + }); + const { getByTestId } = render(); + fireEvent.press(getByTestId('campaign-details-mechanics-button')); + expect(mockNavigate).toHaveBeenCalledWith(Routes.CAMPAIGN_MECHANICS, { + campaignId: 'campaign-1', + }); + }); + }); +}); diff --git a/app/components/UI/Rewards/Views/CampaignDetailsView.tsx b/app/components/UI/Rewards/Views/CampaignDetailsView.tsx new file mode 100644 index 00000000000..6c037d238d7 --- /dev/null +++ b/app/components/UI/Rewards/Views/CampaignDetailsView.tsx @@ -0,0 +1,157 @@ +import React, { useMemo, useState } from 'react'; +import { ScrollView } from 'react-native'; +import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { + Box, + Button, + ButtonVariant, + ButtonSize, + IconName, + Skeleton, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard'; +import ErrorBoundary from '../../../Views/ErrorBoundary'; +import CampaignStatus from '../components/Campaigns/CampaignStatus'; +import CampaignHowItWorks from '../components/Campaigns/CampaignHowItWorks'; +import CampaignOptInSheet from '../components/Campaigns/CampaignOptInSheet'; +import RewardsErrorBanner from '../components/RewardsErrorBanner'; +import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus'; +import { useRewardCampaigns } from '../hooks/useRewardCampaigns'; +import { strings } from '../../../../../locales/i18n'; +import Routes from '../../../../constants/navigation/Routes'; + +// ParamListBase requires an index signature, which interfaces don't support +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type CampaignDetailsRouteParams = { + CampaignDetails: { campaignId: string }; +}; + +export const CAMPAIGN_DETAILS_TEST_IDS = { + CONTAINER: 'campaign-details-container', + CTA_BUTTON: 'campaign-details-cta-button', +} as const; + +const CampaignDetailsView: React.FC = () => { + const tw = useTailwind(); + const navigation = useNavigation(); + const route = + useRoute>(); + const { campaignId } = route.params; + + const [isOptInSheetOpen, setIsOptInSheetOpen] = useState(false); + + const { campaigns, isLoading, hasError, fetchCampaigns } = + useRewardCampaigns(); + + const campaign = useMemo( + () => campaigns.find((c) => c.id === campaignId) ?? null, + [campaigns, campaignId], + ); + + const { status: participantStatus, isLoading: isStatusLoading } = + useGetCampaignParticipantStatus(campaignId); + + return ( + + + navigation.goBack()} + backButtonProps={{ testID: 'campaign-details-back-button' }} + endButtonIconProps={ + campaign + ? [ + { + iconName: IconName.Question, + onPress: () => + navigation.navigate(Routes.CAMPAIGN_MECHANICS, { + campaignId, + }), + testID: 'campaign-details-mechanics-button', + }, + ] + : undefined + } + includesTopInset + /> + + + {isLoading && !campaign && ( + + + + + )} + + {!isLoading && hasError && !campaign && ( + + + + )} + + {campaign && ( + <> + + + {campaign.details?.howItWorks && ( + <> + + + + + + )} + + )} + + + {campaign && participantStatus?.optedIn !== true && ( + + + + )} + + {isOptInSheetOpen && campaign && ( + setIsOptInSheetOpen(false)} + /> + )} + + + ); +}; + +export default CampaignDetailsView; diff --git a/app/components/UI/Rewards/Views/CampaignMechanicsView.test.tsx b/app/components/UI/Rewards/Views/CampaignMechanicsView.test.tsx new file mode 100644 index 00000000000..30b80c6b81d --- /dev/null +++ b/app/components/UI/Rewards/Views/CampaignMechanicsView.test.tsx @@ -0,0 +1,300 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import CampaignMechanicsView, { + CAMPAIGN_MECHANICS_TEST_IDS, +} from './CampaignMechanicsView'; +import { + type CampaignDto, + CampaignType, +} from '../../../../core/Engine/controllers/rewards-controller/types'; +import { useRewardCampaigns } from '../hooks/useRewardCampaigns'; + +const mockGoBack = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ goBack: mockGoBack }), + useRoute: () => ({ params: { campaignId: 'campaign-1' } }), +})); + +jest.mock('react-native-safe-area-context', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + const actual = jest.requireActual('react-native-safe-area-context'); + return { + ...actual, + useSafeAreaInsets: jest.fn(() => ({ + top: 0, + right: 0, + bottom: 0, + left: 0, + })), + SafeAreaView: ({ + children, + testID, + ...props + }: { + children: React.ReactNode; + testID?: string; + }) => ReactActual.createElement(View, { ...props, testID }, children), + }; +}); + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + return { ...actual }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock( + '../../../../component-library/components-temp/HeaderCompactStandard', + () => { + const ReactActual = jest.requireActual('react'); + const { View, Text, Pressable } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ title, onBack }: { title: string; onBack: () => void }) => + ReactActual.createElement( + View, + { testID: 'header' }, + ReactActual.createElement(Text, null, title), + ReactActual.createElement(Pressable, { + onPress: onBack, + testID: 'header-back-button', + }), + ), + }; + }, +); + +jest.mock('../../../Views/ErrorBoundary', () => { + const ReactActual = jest.requireActual('react'); + return { + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => + ReactActual.createElement(ReactActual.Fragment, null, children), + }; +}); + +jest.mock('../components/Campaigns/CampaignHowItWorks', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + ReactActual.createElement(View, { testID: 'campaign-how-it-works' }), + }; +}); + +jest.mock('../hooks/useRewardCampaigns'); +const mockUseRewardCampaigns = useRewardCampaigns as jest.MockedFunction< + typeof useRewardCampaigns +>; + +jest.mock('../../../../../locales/i18n', () => ({ + strings: (key: string) => { + const translations: Record = { + 'rewards.campaign_mechanics.title': 'How it works', + }; + return translations[key] || key; + }, +})); + +const createTestCampaign = ( + overrides: Partial = {}, +): CampaignDto => ({ + id: 'campaign-1', + type: CampaignType.ONDO_HOLDING, + name: 'Test Campaign', + startDate: '2027-01-01T00:00:00.000Z', + endDate: '2027-12-31T23:59:59.999Z', + termsAndConditions: null, + excludedRegions: [], + statusLabel: 'Active', + details: null, + ...overrides, +}); + +const emptyCategorized = { active: [], upcoming: [], previous: [] }; +const hookDefaults = { + campaigns: [], + categorizedCampaigns: emptyCategorized, + isLoading: false, + hasError: false, + fetchCampaigns: jest.fn(), +}; + +describe('CampaignMechanicsView', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseRewardCampaigns.mockReturnValue(hookDefaults); + }); + + it('renders the container', () => { + const { getByTestId } = render(); + expect(getByTestId(CAMPAIGN_MECHANICS_TEST_IDS.CONTAINER)).toBeDefined(); + }); + + it('renders the header title', () => { + const { getByText } = render(); + expect(getByText('How it works')).toBeDefined(); + }); + + it('navigates back when the back button is pressed', () => { + const { getByTestId } = render(); + fireEvent.press(getByTestId('header-back-button')); + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + describe('howItWorks section', () => { + it('renders the howItWorks section when campaign has howItWorks', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [ + createTestCampaign({ + details: { + image: { + lightModeUrl: 'https://example.com/light.png', + darkModeUrl: 'https://example.com/dark.png', + }, + howItWorks: { + title: 'How it works', + description: 'Earn rewards', + phases: [], + }, + }, + }), + ], + }); + const { getByTestId } = render(); + expect( + getByTestId(CAMPAIGN_MECHANICS_TEST_IDS.HOW_IT_WORKS_SECTION), + ).toBeDefined(); + expect(getByTestId('campaign-how-it-works')).toBeDefined(); + }); + + it('does not render howItWorks section when campaign has no details', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [createTestCampaign({ details: null })], + }); + const { queryByTestId } = render(); + expect( + queryByTestId(CAMPAIGN_MECHANICS_TEST_IDS.HOW_IT_WORKS_SECTION), + ).toBeNull(); + }); + + it('does not render howItWorks section when campaign is not found', () => { + // No campaigns in list → useMemo returns null + const { queryByTestId } = render(); + expect( + queryByTestId(CAMPAIGN_MECHANICS_TEST_IDS.HOW_IT_WORKS_SECTION), + ).toBeNull(); + }); + }); + + describe('notes section', () => { + it('renders notes section when notes has valid shape', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [ + createTestCampaign({ + details: { + image: { + lightModeUrl: 'https://example.com/light.png', + darkModeUrl: 'https://example.com/dark.png', + }, + howItWorks: { + title: 'How it works', + description: 'Earn rewards', + phases: [], + notes: { + title: 'Important notes', + description: 'Please read carefully', + items: [ + { title: 'Note 1', description: 'Detail 1' }, + { title: 'Note 2', description: 'Detail 2' }, + ], + }, + }, + }, + }), + ], + }); + const { getByTestId, getByText } = render(); + expect( + getByTestId(CAMPAIGN_MECHANICS_TEST_IDS.NOTES_SECTION), + ).toBeDefined(); + expect( + getByTestId(CAMPAIGN_MECHANICS_TEST_IDS.NOTES_TITLE), + ).toHaveTextContent('Important notes'); + expect( + getByTestId(CAMPAIGN_MECHANICS_TEST_IDS.NOTES_DESCRIPTION), + ).toHaveTextContent('Please read carefully'); + expect( + getByTestId(`${CAMPAIGN_MECHANICS_TEST_IDS.NOTE_ITEM}-0`), + ).toBeDefined(); + expect( + getByTestId(`${CAMPAIGN_MECHANICS_TEST_IDS.NOTE_ITEM_TITLE}-0`), + ).toHaveTextContent('Note 1'); + expect( + getByTestId(`${CAMPAIGN_MECHANICS_TEST_IDS.NOTE_ITEM_DESCRIPTION}-0`), + ).toHaveTextContent('Detail 1'); + expect(getByText('Note 2')).toBeDefined(); + }); + + it('does not render notes section when notes is null', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [ + createTestCampaign({ + details: { + image: { + lightModeUrl: 'https://example.com/light.png', + darkModeUrl: 'https://example.com/dark.png', + }, + howItWorks: { + title: 'How it works', + description: 'Earn rewards', + phases: [], + notes: null, + }, + }, + }), + ], + }); + const { queryByTestId } = render(); + expect( + queryByTestId(CAMPAIGN_MECHANICS_TEST_IDS.NOTES_SECTION), + ).toBeNull(); + }); + + it('does not render notes section when notes has invalid shape', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [ + createTestCampaign({ + details: { + image: { + lightModeUrl: 'https://example.com/light.png', + darkModeUrl: 'https://example.com/dark.png', + }, + howItWorks: { + title: 'How it works', + description: 'Earn rewards', + phases: [], + notes: { title: 'Only title' }, // missing items + }, + }, + }), + ], + }); + const { queryByTestId } = render(); + expect( + queryByTestId(CAMPAIGN_MECHANICS_TEST_IDS.NOTES_SECTION), + ).toBeNull(); + }); + }); +}); diff --git a/app/components/UI/Rewards/Views/CampaignMechanicsView.tsx b/app/components/UI/Rewards/Views/CampaignMechanicsView.tsx new file mode 100644 index 00000000000..63147bc3a90 --- /dev/null +++ b/app/components/UI/Rewards/Views/CampaignMechanicsView.tsx @@ -0,0 +1,153 @@ +import React, { useMemo } from 'react'; +import { ScrollView } from 'react-native'; +import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { + Box, + Text, + TextVariant, + TextColor, + FontWeight, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard'; +import ErrorBoundary from '../../../Views/ErrorBoundary'; +import CampaignHowItWorks from '../components/Campaigns/CampaignHowItWorks'; +import { useRewardCampaigns } from '../hooks/useRewardCampaigns'; +import { strings } from '../../../../../locales/i18n'; + +// ParamListBase requires an index signature, which interfaces don't support +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type CampaignMechanicsRouteParams = { + CampaignMechanics: { campaignId: string }; +}; + +interface CampaignNoteItem { + title: string; + description: string; +} + +interface CampaignNotes { + title: string; + description: string; + items: CampaignNoteItem[]; +} + +function parseCampaignNotes(notes: unknown): CampaignNotes | null { + if ( + notes !== null && + typeof notes === 'object' && + !Array.isArray(notes) && + 'title' in notes && + 'description' in notes && + 'items' in notes && + Array.isArray((notes as { items: unknown }).items) + ) { + return notes as CampaignNotes; + } + return null; +} + +export const CAMPAIGN_MECHANICS_TEST_IDS = { + CONTAINER: 'campaign-mechanics-container', + HOW_IT_WORKS_SECTION: 'campaign-mechanics-how-it-works', + NOTES_SECTION: 'campaign-mechanics-notes', + NOTES_TITLE: 'campaign-mechanics-notes-title', + NOTES_DESCRIPTION: 'campaign-mechanics-notes-description', + NOTE_ITEM: 'campaign-mechanics-note-item', + NOTE_ITEM_TITLE: 'campaign-mechanics-note-item-title', + NOTE_ITEM_DESCRIPTION: 'campaign-mechanics-note-item-description', +} as const; + +const CampaignMechanicsView: React.FC = () => { + const tw = useTailwind(); + const navigation = useNavigation(); + const route = + useRoute>(); + const { campaignId } = route.params; + + const { campaigns } = useRewardCampaigns(); + const campaign = useMemo( + () => campaigns.find((c) => c.id === campaignId) ?? null, + [campaigns, campaignId], + ); + + const howItWorks = campaign?.details?.howItWorks ?? null; + const notes = parseCampaignNotes(howItWorks?.notes); + + return ( + + + navigation.goBack()} + backButtonProps={{ testID: 'campaign-mechanics-back-button' }} + includesTopInset + /> + + {howItWorks && ( + + + + )} + + {notes && ( + + + {notes.title} + + + {notes.description} + + {notes.items.map((item, index) => ( + + + {item.title} + + + {item.description} + + + ))} + + )} + + + + ); +}; + +export default CampaignMechanicsView; diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignHowItWorks.test.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignHowItWorks.test.tsx new file mode 100644 index 00000000000..7a28eb08707 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignHowItWorks.test.tsx @@ -0,0 +1,178 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import CampaignHowItWorks, { + CAMPAIGN_HOW_IT_WORKS_TEST_IDS, +} from './CampaignHowItWorks'; +import type { OndoCampaignHowItWorks } from '../../../../../core/Engine/controllers/rewards-controller/types'; + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + ...actual, + // Icon maps its name prop to an SVG component via enum lookup; mock it to + // avoid "type is invalid" errors when getIconName returns a plain string. + Icon: ({ testID }: { testID?: string }) => + ReactActual.createElement(View, { testID }), + }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock('../../utils/formatUtils', () => ({ + getIconName: (name: string) => name, +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string) => { + const translations: Record = { + 'rewards.campaign_details.how_it_works': 'How it works', + }; + return translations[key] || key; + }, +})); + +const createHowItWorks = ( + overrides: Partial = {}, +): OndoCampaignHowItWorks => ({ + title: 'How it works', + description: 'Hold tokens to earn rewards', + phases: [ + { + name: 'Phase 1', + daysLabel: 'Days 1-30', + sortOrder: 1, + steps: [ + { + iconName: 'star', + title: 'Step 1', + description: 'Do step 1', + }, + ], + }, + ], + ...overrides, +}); + +describe('CampaignHowItWorks', () => { + it('renders the container', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId(CAMPAIGN_HOW_IT_WORKS_TEST_IDS.CONTAINER)).toBeDefined(); + }); + + it('renders the title', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId(CAMPAIGN_HOW_IT_WORKS_TEST_IDS.TITLE)).toHaveTextContent( + 'How it works', + ); + }); + + it('renders a phase chip with daysLabel', () => { + const { getByTestId } = render( + , + ); + expect( + getByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.PHASE_CHIP}-0`), + ).toHaveTextContent('Days 1-30'); + }); + + it('renders a step title and description', () => { + const { getByTestId } = render( + , + ); + expect( + getByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.STEP_TITLE}-0-0`), + ).toHaveTextContent('Step 1'); + expect( + getByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.STEP_DESCRIPTION}-0-0`), + ).toHaveTextContent('Do step 1'); + }); + + it('sorts phases by sortOrder ascending', () => { + const howItWorks = createHowItWorks({ + phases: [ + { name: 'Phase B', daysLabel: 'Days 31-60', sortOrder: 2, steps: [] }, + { name: 'Phase A', daysLabel: 'Days 1-30', sortOrder: 1, steps: [] }, + ], + }); + const { getByTestId } = render( + , + ); + expect( + getByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.PHASE_CHIP}-0`), + ).toHaveTextContent('Days 1-30'); + expect( + getByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.PHASE_CHIP}-1`), + ).toHaveTextContent('Days 31-60'); + }); + + it('renders multiple phases', () => { + const howItWorks = createHowItWorks({ + phases: [ + { name: 'Phase 1', daysLabel: 'Days 1-30', sortOrder: 1, steps: [] }, + { name: 'Phase 2', daysLabel: 'Days 31-60', sortOrder: 2, steps: [] }, + ], + }); + const { getByTestId } = render( + , + ); + expect( + getByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.PHASE}-0`), + ).toBeDefined(); + expect( + getByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.PHASE}-1`), + ).toBeDefined(); + }); + + it('renders multiple steps in a phase', () => { + const howItWorks = createHowItWorks({ + phases: [ + { + name: 'Phase 1', + daysLabel: 'Days 1-30', + sortOrder: 1, + steps: [ + { iconName: 'star', title: 'Step A', description: 'Desc A' }, + { iconName: 'circle', title: 'Step B', description: 'Desc B' }, + ], + }, + ], + }); + const { getByTestId } = render( + , + ); + expect( + getByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.STEP_TITLE}-0-0`), + ).toHaveTextContent('Step A'); + expect( + getByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.STEP_TITLE}-0-1`), + ).toHaveTextContent('Step B'); + }); + + it('renders gracefully with no phases', () => { + const howItWorks = createHowItWorks({ phases: [] }); + const { getByTestId, queryByTestId } = render( + , + ); + expect(getByTestId(CAMPAIGN_HOW_IT_WORKS_TEST_IDS.CONTAINER)).toBeDefined(); + expect( + queryByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.PHASE}-0`), + ).toBeNull(); + }); + + it('renders step icon for each step', () => { + const { getByTestId } = render( + , + ); + expect( + getByTestId(`${CAMPAIGN_HOW_IT_WORKS_TEST_IDS.STEP_ICON}-0-0`), + ).toBeDefined(); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignHowItWorks.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignHowItWorks.tsx new file mode 100644 index 00000000000..bc68a1957fa --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignHowItWorks.tsx @@ -0,0 +1,109 @@ +import React, { useMemo } from 'react'; +import { + Box, + BoxFlexDirection, + BoxAlignItems, + Text, + TextVariant, + Icon, + IconColor, + IconSize, + FontWeight, +} from '@metamask/design-system-react-native'; +import type { + OndoCampaignHowItWorks, + OndoCampaignPhase, +} from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { strings } from '../../../../../../locales/i18n'; +import { getIconName } from '../../utils/formatUtils'; + +export const CAMPAIGN_HOW_IT_WORKS_TEST_IDS = { + CONTAINER: 'campaign-how-it-works-container', + TITLE: 'campaign-how-it-works-title', + PHASE: 'campaign-how-it-works-phase', + PHASE_CHIP: 'campaign-how-it-works-phase-chip', + STEP: 'campaign-how-it-works-step', + STEP_ICON: 'campaign-how-it-works-step-icon', + STEP_TITLE: 'campaign-how-it-works-step-title', + STEP_DESCRIPTION: 'campaign-how-it-works-step-description', +} as const; + +interface CampaignHowItWorksProps { + howItWorks: OndoCampaignHowItWorks; +} + +const CampaignHowItWorks: React.FC = ({ + howItWorks, +}) => { + const sortedPhases = useMemo( + () => [...howItWorks.phases].sort((a, b) => a.sortOrder - b.sortOrder), + [howItWorks.phases], + ); + + return ( + + + {strings('rewards.campaign_details.how_it_works')} + + + {sortedPhases.map((phase: OndoCampaignPhase, phaseIndex: number) => ( + + + + {phase.daysLabel} + + + + {phase.steps.map((step, stepIndex) => ( + + + + + + + {step.title} + + + {step.description} + + + + ))} + + ))} + + ); +}; + +export default CampaignHowItWorks; diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignOptInSheet.test.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignOptInSheet.test.tsx new file mode 100644 index 00000000000..c07b18895a6 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignOptInSheet.test.tsx @@ -0,0 +1,237 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { Linking } from 'react-native'; +import CampaignOptInSheet from './CampaignOptInSheet'; +import { + type CampaignDto, + CampaignType, +} from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { useOptInToCampaign } from '../../hooks/useOptInToCampaign'; + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + return { ...actual }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock('../../hooks/useOptInToCampaign'); +const mockUseOptInToCampaign = useOptInToCampaign as jest.MockedFunction< + typeof useOptInToCampaign +>; + +jest.mock( + '../../../../../component-library/components/BottomSheets/BottomSheet', + () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + children, + testID, + }: { + children?: React.ReactNode; + testID?: string; + }) => ReactActual.createElement(View, { testID }, children), + }; + }, +); + +jest.mock('../RewardsErrorBanner', () => { + const ReactActual = jest.requireActual('react'); + const { View, Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + title, + description, + testID, + }: { + title: string; + description: string; + testID?: string; + }) => + ReactActual.createElement( + View, + { testID: testID ?? 'error-banner' }, + ReactActual.createElement(Text, null, title), + ReactActual.createElement(Text, null, description), + ), + }; +}); + +jest.mock('../Onboarding/constants', () => ({ + REWARDS_ONBOARD_TERMS_URL: 'https://go.metamask.io/rewards-terms', +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string) => { + const translations: Record = { + 'rewards.campaign.opt_in_sheet_title': 'Join Campaign', + 'rewards.campaign.opt_in_sheet_description_pre_link': + 'By joining you agree to the', + 'rewards.campaign.opt_in_sheet_link_text': 'Terms', + 'rewards.campaign.opt_in_sheet_description_post_link': + 'You can opt out at any time.', + 'rewards.campaign_details.opt_in_error': 'Failed to join campaign', + 'rewards.campaign.opt_in_cta': 'Join', + }; + return translations[key] || key; + }, +})); + +const createTestCampaign = ( + overrides: Partial = {}, +): CampaignDto => ({ + id: 'campaign-1', + type: CampaignType.ONDO_HOLDING, + name: 'Test Campaign', + startDate: '2027-01-01T00:00:00.000Z', + endDate: '2027-12-31T23:59:59.999Z', + termsAndConditions: null, + excludedRegions: [], + statusLabel: 'Active', + details: null, + ...overrides, +}); + +const mockOptInToCampaign = jest.fn(); + +describe('CampaignOptInSheet', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined); + mockUseOptInToCampaign.mockReturnValue({ + optInToCampaign: mockOptInToCampaign, + isOptingIn: false, + optInError: undefined, + clearOptInError: jest.fn(), + }); + }); + + it('renders the sheet title', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId('campaign-opt-in-sheet-title')).toHaveTextContent( + 'Join Campaign', + ); + }); + + it('renders the description container', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId('campaign-opt-in-sheet-description')).toBeDefined(); + }); + + it('renders the terms link with correct text', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId('campaign-opt-in-sheet-terms-link')).toHaveTextContent( + 'Terms', + ); + }); + + it('opens the terms URL when terms link is pressed', () => { + const { getByTestId } = render( + , + ); + fireEvent.press(getByTestId('campaign-opt-in-sheet-terms-link')); + expect(Linking.openURL).toHaveBeenCalledWith( + 'https://go.metamask.io/rewards-terms', + ); + }); + + it('renders the CTA button', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId('campaign-opt-in-cta')).toBeDefined(); + }); + + it('calls optInToCampaign with the campaign id when CTA is pressed', () => { + mockOptInToCampaign.mockResolvedValue({ optedIn: true }); + const { getByTestId } = render( + , + ); + fireEvent.press(getByTestId('campaign-opt-in-cta')); + expect(mockOptInToCampaign).toHaveBeenCalledWith('campaign-1'); + }); + + it('calls onClose after successful opt-in', async () => { + const onClose = jest.fn(); + mockOptInToCampaign.mockResolvedValue({ optedIn: true }); + const { getByTestId } = render( + , + ); + fireEvent.press(getByTestId('campaign-opt-in-cta')); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('does not call onClose when opt-in throws', async () => { + const onClose = jest.fn(); + mockOptInToCampaign.mockRejectedValue(new Error('API error')); + const { getByTestId } = render( + , + ); + fireEvent.press(getByTestId('campaign-opt-in-cta')); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('renders the close button', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId('campaign-opt-in-sheet-close')).toBeDefined(); + }); + + it('calls onClose when close button is pressed', () => { + const onClose = jest.fn(); + const { getByTestId } = render( + , + ); + fireEvent.press(getByTestId('campaign-opt-in-sheet-close')); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('shows error banner when optInError is set', () => { + mockUseOptInToCampaign.mockReturnValue({ + optInToCampaign: mockOptInToCampaign, + isOptingIn: false, + optInError: 'Something went wrong', + clearOptInError: jest.fn(), + }); + const { getByTestId } = render( + , + ); + expect(getByTestId('campaign-opt-in-error-banner')).toBeDefined(); + }); + + it('does not show error banner when there is no error', () => { + const { queryByTestId } = render( + , + ); + expect(queryByTestId('campaign-opt-in-error-banner')).toBeNull(); + }); + + it('shows loading state on the CTA while opting in', () => { + mockUseOptInToCampaign.mockReturnValue({ + optInToCampaign: mockOptInToCampaign, + isOptingIn: true, + optInError: undefined, + clearOptInError: jest.fn(), + }); + const { getByTestId } = render( + , + ); + // Button still renders while loading + expect(getByTestId('campaign-opt-in-cta')).toBeDefined(); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignOptInSheet.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignOptInSheet.tsx new file mode 100644 index 00000000000..095b5392d80 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignOptInSheet.tsx @@ -0,0 +1,132 @@ +import React, { useCallback } from 'react'; +import { Linking } from 'react-native'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + BoxJustifyContent, + Button, + ButtonIcon, + ButtonSize, + ButtonVariant, + IconColor, + IconName, + Text, + TextVariant, + FontWeight, +} from '@metamask/design-system-react-native'; +import BottomSheet from '../../../../../component-library/components/BottomSheets/BottomSheet'; +import type { CampaignDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { useOptInToCampaign } from '../../hooks/useOptInToCampaign'; +import { strings } from '../../../../../../locales/i18n'; +import { REWARDS_ONBOARD_TERMS_URL } from '../Onboarding/constants'; +import RewardsErrorBanner from '../RewardsErrorBanner'; + +interface CampaignOptInSheetProps { + campaign: CampaignDto; + onClose?: () => void; +} + +/** + * Bottom sheet shown when a user taps a campaign tile they haven't opted into yet. + * Shows the campaign title, a legal disclaimer with a tappable terms link, and an opt-in CTA. + */ +const CampaignOptInSheet: React.FC = ({ + campaign, + onClose, +}) => { + const { optInToCampaign, isOptingIn, optInError } = useOptInToCampaign(); + + const handleOptIn = useCallback(async () => { + try { + await optInToCampaign(campaign.id); + onClose?.(); + } catch { + // Error is handled by the hook; sheet stays open so user can retry + } + }, [optInToCampaign, campaign.id, onClose]); + + const handleTermsPress = useCallback(() => { + Linking.openURL(REWARDS_ONBOARD_TERMS_URL); + }, []); + + return ( + + + {/* Header: centered title + close button */} + + {/* Left spacer to balance the close button */} + + + + {strings('rewards.campaign.opt_in_sheet_title')} + + + + + + {/* Legal disclaimer with tappable link */} + + + {strings('rewards.campaign.opt_in_sheet_description_pre_link')}{' '} + + {strings('rewards.campaign.opt_in_sheet_link_text')} + + {'. '} + {strings('rewards.campaign.opt_in_sheet_description_post_link')} + + + + {optInError && ( + + + + )} + + {/* Opt-in CTA */} + + + + ); +}; + +export default CampaignOptInSheet; diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignTile.test.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignTile.test.tsx index 39ecb98ccc5..b9bc6588829 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignTile.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/CampaignTile.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render } from '@testing-library/react-native'; +import { render, fireEvent } from '@testing-library/react-native'; import { useSelector } from 'react-redux'; import CampaignTile from './CampaignTile'; import { @@ -8,10 +8,30 @@ import { } from '../../../../../core/Engine/controllers/rewards-controller/types'; import { getCampaignStatusInfo } from './CampaignTile.utils'; import { selectCampaignParticipantCount } from '../../../../../reducers/rewards/selectors'; +import useGetCampaignParticipantStatus from '../../hooks/useGetCampaignParticipantStatus'; +import Routes from '../../../../../constants/navigation/Routes'; + jest.mock('react-redux', () => ({ useSelector: jest.fn(), })); +const mockNavigate = jest.fn(); +jest.mock('@react-navigation/native', () => ({ + useNavigation: jest.fn().mockReturnValue({ + navigate: (...args: unknown[]) => mockNavigate(...args), + }), +})); + +jest.mock('../../hooks/useGetCampaignParticipantStatus', () => ({ + __esModule: true, + default: jest.fn(), +})); + +const mockUseGetCampaignParticipantStatus = + useGetCampaignParticipantStatus as jest.MockedFunction< + typeof useGetCampaignParticipantStatus + >; + jest.mock('@metamask/design-system-react-native', () => { const actual = jest.requireActual('@metamask/design-system-react-native'); return { ...actual }; @@ -43,6 +63,7 @@ jest.mock('../../../../../../locales/i18n', () => ({ strings: (key: string, params?: Record) => { const translations: Record = { 'rewards.campaign.enter_now': 'Enter now', + 'rewards.campaign.entered': 'Entered', 'rewards.campaign.participant_count': `#${params?.count ?? ''}`, }; return translations[key] || key; @@ -77,6 +98,15 @@ function setupParticipantCount(count: number | null) { }); } +function setupParticipantStatus(optedIn: boolean) { + mockUseGetCampaignParticipantStatus.mockReturnValue({ + status: { optedIn, participantCount: 0 }, + isLoading: false, + hasError: false, + refetch: jest.fn(), + }); +} + describe('CampaignTile', () => { beforeEach(() => { jest.clearAllMocks(); @@ -87,6 +117,12 @@ describe('CampaignTile', () => { dateLabelIcon: 'Clock', }); setupParticipantCount(null); + mockUseGetCampaignParticipantStatus.mockReturnValue({ + status: null, + isLoading: false, + hasError: false, + refetch: jest.fn(), + }); }); it('renders campaign name via campaign-tile-name testID', () => { @@ -242,4 +278,62 @@ describe('CampaignTile', () => { expect(queryByTestId('campaign-tile-enter-now')).toBeNull(); }); }); + + describe('entered label', () => { + it('shows "Entered" label when participant is opted in', () => { + setupParticipantStatus(true); + const campaign = createTestCampaign(); + + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId('campaign-tile-entered-label')).toHaveTextContent( + 'Entered', + ); + expect(queryByTestId('campaign-tile-enter-now')).toBeNull(); + }); + + it('shows status label when participant is not opted in', () => { + setupParticipantStatus(false); + const campaign = createTestCampaign(); + + const { queryByTestId, getByTestId } = render( + , + ); + + expect(queryByTestId('campaign-tile-entered-label')).toBeNull(); + expect(getByTestId('campaign-tile-status-label')).toHaveTextContent( + /Active/, + ); + }); + + it('shows "Entered" label alongside participant count when opted in', () => { + setupParticipantStatus(true); + setupParticipantCount(42); + const campaign = createTestCampaign(); + + const { getByTestId } = render(); + + expect(getByTestId('campaign-tile-entered-label')).toHaveTextContent( + 'Entered', + ); + expect(getByTestId('campaign-tile-participant-count')).toHaveTextContent( + '#42', + ); + }); + }); + + describe('navigation', () => { + it('navigates to campaign details when the tile is pressed', () => { + const campaign = createTestCampaign({ id: 'camp-42' }); + + const { getByTestId } = render(); + fireEvent.press(getByTestId('campaign-tile-camp-42')); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.CAMPAIGN_DETAILS, { + campaignId: 'camp-42', + }); + }); + }); }); diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignTile.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignTile.tsx index 378045d3f4a..4a1a2f8680d 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignTile.tsx +++ b/app/components/UI/Rewards/components/Campaigns/CampaignTile.tsx @@ -1,6 +1,8 @@ import React, { useMemo } from 'react'; import { ImageBackground, Pressable, useColorScheme } from 'react-native'; import { useSelector } from 'react-redux'; +import { useNavigation } from '@react-navigation/native'; +import Routes from '../../../../../constants/navigation/Routes'; import { Box, BoxFlexDirection, @@ -33,17 +35,22 @@ interface CampaignTileProps { const CampaignTile: React.FC = ({ campaign }) => { const tw = useTailwind(); const colorScheme = useColorScheme(); + const navigation = useNavigation(); - useGetCampaignParticipantStatus(campaign.id); + const { status: participantStatus } = useGetCampaignParticipantStatus( + campaign.id, + ); const participantCount = useSelector( selectCampaignParticipantCount(campaign.id), ); - const { status, statusLabel, dateLabel, dateLabelIcon } = useMemo( - () => getCampaignStatusInfo(campaign), - [campaign], - ); + const { + status: campaignStatus, + statusLabel, + dateLabel, + dateLabelIcon, + } = useMemo(() => getCampaignStatusInfo(campaign), [campaign]); const backgroundImageUrl = colorScheme === 'dark' @@ -51,7 +58,7 @@ const CampaignTile: React.FC = ({ campaign }) => { : campaign.details?.image?.lightModeUrl; const handlePress = () => { - // TODO: Implement campaign details screen + navigation.navigate(Routes.CAMPAIGN_DETAILS, { campaignId: campaign.id }); }; return ( @@ -104,17 +111,28 @@ const CampaignTile: React.FC = ({ campaign }) => { twClassName="gap-1" testID="campaign-tile-status-label" > - - {statusLabel} - + {participantStatus?.optedIn === true ? ( + + {strings('rewards.campaign.entered')} + + ) : ( + + {statusLabel} + + )} {participantCount != null ? ( = ({ campaign }) => { })} - ) : status === 'active' ? ( + ) : campaignStatus === 'active' && + participantStatus?.optedIn !== true ? ( { const tw = useTailwind(); const navigation = useNavigation(); - const { categorizedCampaigns } = useRewardCampaigns(); + const { colors } = useTheme(); + const { categorizedCampaigns, isLoading, hasError, fetchCampaigns } = + useRewardCampaigns(); const activeCampaign = useMemo( () => categorizedCampaigns.active[0] ?? null, @@ -47,7 +52,7 @@ const CampaignsPreview: React.FC = () => { navigation.navigate(Routes.CAMPAIGNS_VIEW); }, [navigation]); - if (!activeCampaign && !upcomingCampaign) { + if (!isLoading && !hasError && !activeCampaign && !upcomingCampaign) { return null; } @@ -60,8 +65,11 @@ const CampaignsPreview: React.FC = () => { + {isLoading && !activeCampaign && !upcomingCampaign && ( + + )} {strings('rewards.campaigns_preview.title')} @@ -69,6 +77,19 @@ const CampaignsPreview: React.FC = () => {
+ {isLoading && !activeCampaign && !upcomingCampaign && ( + + )} + + {!isLoading && hasError && !activeCampaign && !upcomingCampaign && ( + + )} + {activeCampaign && } {upcomingCampaign && ( diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index 7054ad59592..114181a6f3b 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -102,6 +102,7 @@ const Routes = { CAMPAIGNS_VIEW: 'CampaignsView', PREVIOUS_SEASON_VIEW: 'PreviousSeasonView', CAMPAIGN_DETAILS: 'CampaignDetails', + CAMPAIGN_MECHANICS: 'CampaignMechanics', TRENDING_VIEW: 'TrendingView', TRENDING_FEED: 'TrendingFeed', SITES_FULL_VIEW: 'SitesFullView', diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.ts index 76a014c9224..fab99bd81ee 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.ts @@ -708,7 +708,7 @@ export class RewardsController extends BaseController< if (!this.canChangeRewardsEnvUrl()) { return; } - this.update((state: RewardsControllerState) => { + this.update((state) => { state.rewardsEnvUrl = url; }); this.messenger.call('RewardsDataService:setRewardsEnvUrl', url); @@ -853,7 +853,7 @@ export class RewardsController extends BaseController< const accountState = this.#getAccountState(caipAccount); if (!accountState) return; - this.update((state: RewardsControllerState) => { + this.update((state) => { state.activeAccount = accountState; }); } @@ -1139,7 +1139,7 @@ export class RewardsController extends BaseController< ): Promise { if (!internalAccount) { if (shouldBecomeActiveAccount) { - this.update((state: RewardsControllerState) => { + this.update((state) => { state.activeAccount = null; }); } @@ -1158,7 +1158,7 @@ export class RewardsController extends BaseController< let accountState = this.#getAccountState(account as CaipAccountId); if (accountState) { // Update last authenticated account - this.update((state: RewardsControllerState) => { + this.update((state) => { if (shouldBecomeActiveAccount) { state.activeAccount = accountState; } @@ -1172,7 +1172,7 @@ export class RewardsController extends BaseController< perpsFeeDiscount: null, // Default value, will be updated when fetched lastPerpsDiscountRateFetched: null, }; - this.update((state: RewardsControllerState) => { + this.update((state) => { state.accounts[account as CaipAccountId] = accountState as RewardsAccountState; if (shouldBecomeActiveAccount) { @@ -1202,7 +1202,7 @@ export class RewardsController extends BaseController< // Account hasn't opted in, don't proceed with login subscription = null; // Update state to reflect not opted in - this.update((state: RewardsControllerState) => { + this.update((state) => { if (!account) { return; } @@ -1323,7 +1323,7 @@ export class RewardsController extends BaseController< } } finally { // Update state - this.update((state: RewardsControllerState) => { + this.update((state) => { if (!account) { return; } @@ -1395,7 +1395,7 @@ export class RewardsController extends BaseController< coercedAccount = account; } - this.update((state: RewardsControllerState) => { + this.update((state) => { // Create account state if it doesn't exist if (!state.accounts[coercedAccount]) { state.accounts[coercedAccount] = { @@ -1581,7 +1581,7 @@ export class RewardsController extends BaseController< this.convertInternalAccountToCaipAccountId(internalAccount); if (caipAccount) { const lastFreshOptInStatusCheck = Date.now(); - this.update((state: RewardsControllerState) => { + this.update((state) => { // Update or create account state with fresh opt-in status and subscription ID if (!state.accounts[caipAccount]) { state.accounts[caipAccount] = { @@ -1786,7 +1786,7 @@ export class RewardsController extends BaseController< return pointsEvents; }, params.subscriptionId), writeCache: (key, pointsEventsDto) => { - this.update((state: RewardsControllerState) => { + this.update((state) => { state.pointsEvents[key] = this.#convertPointsEventsToState(pointsEventsDto); }); @@ -2005,7 +2005,7 @@ export class RewardsController extends BaseController< responseBonusBips: response.bonusBips, }; - this.update((state: RewardsControllerState) => { + this.update((state) => { // Add new entry at the beginning (most recent first) state.pointsEstimateHistory.unshift(entry); @@ -2137,7 +2137,7 @@ export class RewardsController extends BaseController< return seasonStateWithTimestamp; } - this.update((state: RewardsControllerState) => { + this.update((state) => { delete state.seasons[type]; }); @@ -2146,7 +2146,7 @@ export class RewardsController extends BaseController< ); }, writeCache: (key, value) => { - this.update((state: RewardsControllerState) => { + this.update((state) => { state.seasons[key] = value; state.seasons[value.id] = value; }); @@ -2208,7 +2208,7 @@ export class RewardsController extends BaseController< return this.#convertSeasonStatusToSubscriptionState(seasonStatus); } catch (error) { if (error instanceof SeasonNotFoundError) { - this.update((state: RewardsControllerState) => { + this.update((state) => { state.seasons = {}; }); throw error; @@ -2221,7 +2221,7 @@ export class RewardsController extends BaseController< } }, subscriptionId), writeCache: (key, subscriptionSeasonStatus) => { - this.update((state: RewardsControllerState) => { + this.update((state) => { // Update season status with composite key state.seasonStatuses[key] = subscriptionSeasonStatus; }); @@ -2240,7 +2240,7 @@ export class RewardsController extends BaseController< async invalidateSubscriptionAndAccounts( subscriptionId: string, ): Promise { - this.update((state: RewardsControllerState) => { + this.update((state) => { // Remove the failing subscription delete state.subscriptions[subscriptionId]; @@ -2325,7 +2325,7 @@ export class RewardsController extends BaseController< }; }, subscriptionId), writeCache: (key, payload) => { - this.update((state: RewardsControllerState) => { + this.update((state) => { state.subscriptionReferralDetails[key] = payload; }); }, @@ -2573,7 +2573,7 @@ export class RewardsController extends BaseController< try { const currentActiveAccount = this.state.activeAccount?.account; this.resetState(); - this.update((state: RewardsControllerState) => { + this.update((state) => { if (currentActiveAccount) { state.activeAccount = { account: currentActiveAccount, @@ -3015,7 +3015,7 @@ export class RewardsController extends BaseController< ); // Update store with accounts and subscriptions (but not activeAccount) - this.update((state: RewardsControllerState) => { + this.update((state) => { // Update accounts state state.accounts[caipAccount] = { account: caipAccount, @@ -3217,7 +3217,7 @@ export class RewardsController extends BaseController< return response.boosts; }, subscriptionId), writeCache: (key, payload) => { - this.update((state: RewardsControllerState) => { + this.update((state) => { state.activeBoosts[key] = { boosts: payload, lastFetched: Date.now(), @@ -3270,7 +3270,7 @@ export class RewardsController extends BaseController< return response || []; }, subscriptionId), writeCache: (key, payload) => { - this.update((state: RewardsControllerState) => { + this.update((state) => { state.unlockedRewards[key] = { rewards: payload, lastFetched: Date.now(), @@ -3357,7 +3357,7 @@ export class RewardsController extends BaseController< } }, writeCache: (key, payload) => { - this.update((state: RewardsControllerState) => { + this.update((state) => { ( state.offDeviceSubscriptionAccounts as Record< string, @@ -3409,19 +3409,22 @@ export class RewardsController extends BaseController< )) as CampaignDto[]; return response || []; }, subscriptionId), - writeCache: (key, payload) => { - this.update((state: RewardsControllerState) => { - state.campaigns[key] = { - campaigns: payload, - lastFetched: Date.now(), - }; - }); + writeCache: (key: string, payload: CampaignDto[]) => { + this.#writeCampaignsCache(key, payload); }, }); return result; } + #writeCampaignsCache(key: string, campaigns: CampaignDto[]): void { + const cacheEntry = { campaigns, lastFetched: Date.now() }; + this.update((state) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (state.campaigns as Record)[key] = cacheEntry as any; + }); + } + /** * Opt a subscription into a campaign. * @param campaignId - The campaign ID to opt into. @@ -3435,6 +3438,9 @@ export class RewardsController extends BaseController< if (!this.isRewardsFeatureEnabled() || !this.#isCampaignsEnabled()) { return { optedIn: false, participantCount: 0 }; } + const key = `${subscriptionId}:${campaignId}`; + const wasAlreadyOptedIn = + this.state.campaignParticipantStatus[key]?.optedIn === true; const result = await this.#withAuthRetry(async () => { Logger.log('RewardsController: Opting into campaign', campaignId); return (await this.messenger.call( @@ -3444,14 +3450,16 @@ export class RewardsController extends BaseController< )) as CampaignParticipantStatusDto; }, subscriptionId); // Invalidate the participant status cache so the next fetch gets fresh data - const key = `${subscriptionId}:${campaignId}`; - this.update((state: RewardsControllerState) => { + this.update((state) => { delete state.campaignParticipantStatus[key]; }); - this.messenger.publish('RewardsController:campaignOptedIn', { - campaignId, - subscriptionId, - }); + // Only emit if the user wasn't already opted in, to avoid redundant refetches + if (!wasAlreadyOptedIn) { + this.messenger.publish('RewardsController:campaignOptedIn', { + campaignId, + subscriptionId, + }); + } return result; } @@ -3495,7 +3503,7 @@ export class RewardsController extends BaseController< )) as CampaignParticipantStatusDto; }, subscriptionId), writeCache: (k, payload) => { - this.update((state: RewardsControllerState) => { + this.update((state) => { state.campaignParticipantStatus[k] = { optedIn: payload.optedIn, participantCount: payload.participantCount, @@ -3679,7 +3687,7 @@ export class RewardsController extends BaseController< * @param subscriptionId - The subscription ID to invalidate cache for */ invalidateReferralDetailsCache(subscriptionId: string): void { - this.update((state: RewardsControllerState) => { + this.update((state) => { Object.keys(state.subscriptionReferralDetails).forEach((key) => { if (key.includes(subscriptionId)) { delete state.subscriptionReferralDetails[key]; @@ -3705,7 +3713,7 @@ export class RewardsController extends BaseController< seasonId, subscriptionId, ); - this.update((state: RewardsControllerState) => { + this.update((state) => { delete state.seasonStatuses[compositeKey]; delete state.unlockedRewards[compositeKey]; delete state.activeBoosts[compositeKey]; @@ -3714,7 +3722,7 @@ export class RewardsController extends BaseController< }); } else { // Invalidate all seasons for this subscription - this.update((state: RewardsControllerState) => { + this.update((state) => { Object.keys(state.seasonStatuses).forEach((key) => { if (key.includes(subscriptionId)) { delete state.seasonStatuses[key]; diff --git a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts index 20aaebdda00..2c6629b2597 100644 --- a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts +++ b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts @@ -4418,12 +4418,29 @@ describe('RewardsDataService', () => { expect(result).toEqual(mockStatusResponse); }); - it('throws when response is not ok', async () => { - mockFetch.mockResolvedValue({ ok: false, status: 409 } as Response); + it('returns participant status when 409 response (already opted in)', async () => { + const mockParticipantStatus = { optedIn: true, participantCount: 10 }; + mockFetch + .mockResolvedValueOnce({ ok: false, status: 409 } as Response) + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(mockParticipantStatus), + } as unknown as Response); + + const result = await service.optInToCampaign( + mockSubscriptionId, + mockCampaignId, + ); + + expect(result).toEqual(mockParticipantStatus); + }); + + it('throws when response is not ok with non-409 status', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 500 } as Response); await expect( service.optInToCampaign(mockSubscriptionId, mockCampaignId), - ).rejects.toThrow('Opt-in to campaign failed: 409'); + ).rejects.toThrow('Opt-in to campaign failed: 500'); }); }); diff --git a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts index d51c76ebf94..8c3a7113a20 100644 --- a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts +++ b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts @@ -1324,6 +1324,11 @@ export class RewardsDataService { subscriptionId, ); + if (response.status === 409) { + // Already opted in — fetch and return current status as a graceful success + return this.getCampaignParticipantStatus(subscriptionId, campaignId); + } + if (!response.ok) { throw new Error(`Opt-in to campaign failed: ${response.status}`); } diff --git a/locales/languages/en.json b/locales/languages/en.json index c9a4e545956..a20edd5a093 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -7853,7 +7853,16 @@ "pill_active": "Live", "pill_complete": "Complete", "enter_now": "Enter now", - "participant_count": "#{{count}}" + "entered": "Entered", + "participant_count": "#{{count}}", + "opt_in_cta": "Opt in", + "opt_in_sheet_title": "Join the campaign", + "opt_in_sheet_description_pre_link": "By clicking 'Opt in' you agree to the MetaMask Rewards", + "opt_in_sheet_link_text": "Supplemental Terms of Use and Privacy Notice", + "opt_in_sheet_description_post_link": "We'll track onchain activity to reward you automatically." + }, + "campaign_mechanics": { + "title": "Mechanics" }, "campaign_details": { "start_date": "Starts: {{date}}", @@ -7863,6 +7872,7 @@ "opted_in": "You're opted in to this campaign", "opt_in_error": "Failed to opt in. Please try again.", "join_campaign": "Join the campaign", + "checking_opt_in_status": "Checking opt in status", "swap": "Swap", "how_it_works": "How it works" }, From 0d6ecb7f8ea9e8784d1e1a7cafa965afde7a14b9 Mon Sep 17 00:00:00 2001 From: Cal Leung Date: Wed, 18 Mar 2026 13:47:07 -0700 Subject: [PATCH 107/206] chore: Use AWS secrets for Test flight workflow (#27585) ## **Description** This updates the `upload-to-testflight.yml` workflow to use keys from AWS for protection. Need to merge into main for testing. Not used in production yet. ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/MCWP-417 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Updates CI workflows that handle TestFlight credentials and build checkout refs; misconfiguration could break release uploads or accidentally build the wrong commit. > > **Overview** > Adds a dedicated `upload-to-testflight.yml` workflow that builds iOS from a specified `source_branch` and uploads the resulting `.ipa` to TestFlight. > > Moves App Store Connect API key handling from GitHub Environment secrets to **AWS Secrets Manager via OIDC** (`AWS_ROLE_APPLE_TESTFLIGHT`), including masking and multiline `.p8` injection, and updates upload metadata to track the workflow ref vs the built source ref. > > Extends reusable `build.yml` to accept `source_branch`, ensures full-history checkout (`fetch-depth: 0`), and threads the resolved ref through version-bump and dependency setup so builds run against the intended commit. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 9372c0916c7327c70df569ae866fcc5c07c07dd2. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: metamaskbot --- .github/CODEOWNERS | 1 + .github/workflows/build.yml | 13 ++++- .github/workflows/upload-to-testflight.yml | 65 +++++++++++++++++++--- 3 files changed, 70 insertions(+), 9 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7be8ee3b055..d0e8b00428a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -47,6 +47,7 @@ scripts/build.sh @MetaMask/mobile-pla fingerprint.config.js @MetaMask/mobile-platform builds.yml @MetaMask/mobile-platform .github/workflows/push-eas-update.yml @MetaMask/mobile-admins +.github/workflows/upload-to-testflight.yml @MetaMask/mobile-admins scripts/update-expo-channel.js @MetaMask/mobile-admins certs/certificate.pem @MetaMask/mobile-admins ios/fastlane/ @MetaMask/mobile-admins diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 66495dbea6d..dd694bdd9fc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,6 +13,11 @@ on: required: false type: boolean default: false + source_branch: + description: 'Branch, tag, or SHA to build' + required: false + type: string + default: '' ref: description: 'Git ref to checkout when skip_version_bump is true. Defaults to the triggering event ref.' required: false @@ -60,7 +65,7 @@ jobs: contents: write id-token: write with: - base-branch: ${{ github.ref_name }} + base-branch: ${{ inputs.source_branch != '' && inputs.source_branch || github.ref_name }} secrets: PR_TOKEN: ${{ secrets.PR_TOKEN }} @@ -75,8 +80,12 @@ jobs: signing_aws_role: ${{ steps.config.outputs.signing_aws_role }} signing_aws_secret: ${{ steps.config.outputs.signing_aws_secret }} signing_android_keystore_path: ${{ steps.config.outputs.signing_android_keystore_path }} + checkout_ref_for_setup: ${{ !inputs.skip_version_bump && needs.update-build-version.outputs.commit-hash || (inputs.source_branch != '' && inputs.source_branch || github.ref_name) }} steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ !inputs.skip_version_bump && needs.update-build-version.outputs.commit-hash || (inputs.source_branch != '' && inputs.source_branch || github.ref_name) }} - name: Setup Node.js uses: actions/setup-node@v4 with: @@ -110,6 +119,8 @@ jobs: platform: ${{ inputs.platform == 'both' && fromJSON('["android", "ios"]') || fromJSON(format('["{0}"]', inputs.platform)) }} uses: ./.github/workflows/setup-node-modules.yml with: + ref: ${{ needs.prepare.outputs.checkout_ref_for_setup }} + fetch-depth: 0 checkout-submodules: true platform: ${{ matrix.platform }} build_name: ${{ inputs.build_name }} diff --git a/.github/workflows/upload-to-testflight.yml b/.github/workflows/upload-to-testflight.yml index 85957973d20..a6fa8fab90d 100644 --- a/.github/workflows/upload-to-testflight.yml +++ b/.github/workflows/upload-to-testflight.yml @@ -6,6 +6,11 @@ name: Upload to TestFlight on: workflow_dispatch: inputs: + source_branch: + description: 'Branch, tag, or SHA to build' + required: true + type: string + default: 'main' environment: description: 'Build environment / track' required: true @@ -38,6 +43,7 @@ jobs: build_name: main-${{ inputs.environment || 'rc' }} platform: ios skip_version_bump: false + source_branch: ${{ inputs.source_branch }} secrets: inherit testflight-upload-summary: @@ -48,6 +54,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + ref: ${{ inputs.source_branch }} - name: Display TestFlight upload summary run: | BUILD_VERSION=$(node -p "require('./package.json').version") @@ -56,18 +63,19 @@ jobs: echo "" echo "| Field | Value |" echo "| --- | --- |" + echo "| **Workflow ref** | ${{ github.ref_name }} (required for AWS) |" + echo "| **Source branch** | ${{ inputs.source_branch }} |" echo "| **Build name** | main-${{ inputs.environment || 'rc' }} |" echo "| **Build version** | ${BUILD_VERSION} |" echo "| **TestFlight group** | ${{ inputs.testflight_group || 'MetaMask BETA & Release Candidates' }} |" - echo "| **Branch** | ${{ github.ref_name }} |" } >> "$GITHUB_STEP_SUMMARY" - # Uses GitHub Environment "apple" for App Store Connect API secrets. + # Pulls App Store Connect API keys from AWS Secrets Manager (OIDC). + # Workflow must run from main; build uses inputs.source_branch. upload-ios-testflight: name: Upload iOS to TestFlight needs: [build, testflight-upload-summary] runs-on: ghcr.io/cirruslabs/macos-runner:sequoia-xl - environment: apple steps: - name: Checkout repository uses: actions/checkout@v4 @@ -97,22 +105,63 @@ jobs: case "$IPA" in /*) ABS="$IPA" ;; *) ABS="$PWD/$IPA" ;; esac echo "path=$ABS" >> "$GITHUB_OUTPUT" + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_ROLE_APPLE_TESTFLIGHT }} + aws-region: 'us-east-2' + + - name: Fetch Apple API keys from AWS Secrets Manager + run: | + echo "🔐 Fetching App Store Connect API keys from Secrets Manager..." + secret_id="metamask-mobile-main-apple-api-keys" + secret_json=$(aws secretsmanager get-secret-value \ + --region 'us-east-2' \ + --secret-id "$secret_id" \ + --query SecretString \ + --output text) + + for key in APP_STORE_CONNECT_API_KEY_ISSUER_ID APP_STORE_CONNECT_API_KEY_KEY_ID; do + value=$(echo "$secret_json" | jq -r --arg k "$key" '.[$k] // empty') + if [ -z "$value" ]; then + echo "::error::Missing key in secret: $key" + exit 1 + fi + echo "::add-mask::$value" + echo "${key}=${value}" >> "$GITHUB_ENV" + done + + key=APP_STORE_CONNECT_API_KEY_KEY_CONTENT + value=$(echo "$secret_json" | jq -r --arg k "$key" '.[$k] // empty') + if [ -z "$value" ]; then + echo "::error::Missing key in secret: $key" + exit 1 + fi + while IFS= read -r line || [ -n "$line" ]; do + [ -n "$line" ] && echo "::add-mask::$line" + done <<< "$(printf '%s\n' "$value")" + + delim="APPLEP8$(openssl rand -hex 16)" + { + printf '%s<<%s\n' "$key" "$delim" + printf '%s\n' "$value" + printf '%s\n' "$delim" + } >> "$GITHUB_ENV" + + echo "✅ Apple API keys loaded from AWS" + - name: Setup App Store Connect API Key run: | bash scripts/setup-app-store-connect-api-key.sh \ "$APP_STORE_CONNECT_API_KEY_ISSUER_ID" \ "$APP_STORE_CONNECT_API_KEY_KEY_ID" \ "$APP_STORE_CONNECT_API_KEY_KEY_CONTENT" - env: - APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} - APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_ID }} - APP_STORE_CONNECT_API_KEY_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_CONTENT }} - name: Upload to TestFlight run: | bash scripts/upload-to-testflight.sh \ "github_actions_main-${{ inputs.environment || 'rc' }}" \ - "${{ github.ref_name }}" \ + "${{ inputs.source_branch }}" \ "${{ steps.ipa.outputs.path }}" \ "${{ inputs.testflight_group || 'MetaMask BETA & Release Candidates' }}" From 1fe77d295474ba7ee091a77e8174916b31a8a0db Mon Sep 17 00:00:00 2001 From: sleepytanya <104780023+sleepytanya@users.noreply.github.com> Date: Wed, 18 Mar 2026 17:06:38 -0400 Subject: [PATCH 108/206] test: add AI generated RC manual testing plan (#27492) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR adds an AI-powered release testing plan generator that: - Analyzes release PRs – Fetches PR metadata, changed files, and team sign-offs from GitHub - Categorizes changes – Identifies high-impact files (app/, patches/, manifests) - Uses LLMs – Uses Claude (default), GPT-5, or Gemini with automatic fallback - Auto-detects feature flags – Excludes disabled features from test scenarios - Produces a structured plan – Outputs JSON test plan with scenarios, steps, and risk levels - Generates HTML viewer – Styled, readable test plan deployed to GitHub Pages - The test plan is generated automatically when Bitrise posts "RC Builds Ready for Testing" on release PRs. Each new build with cherry-picks can trigger an updated plan. Test Plan Output Summary includes: - releaseRiskScore (0–100, formula: min(100, round(10 * sqrt(highRisk * 4 + mediumRisk)))) - totalFilesChanged, highImpactFiles - highRiskScenarios, mediumRiskScenarios counts - teamsNeedingSignOff Executive Summary includes: - releaseFocus – One-line release description - keyChanges – 3-5 bullet points - overallRisk – low/medium/high - recommendation – Go/no-go guidance Scenario groups: - initialScenarios – Risky areas from initial release commits - cherryPickScenarios – Risky areas from cherry-pick commits Each scenario includes: - area – Feature area (e.g., "Card", "Swaps", "Send Flow") - riskLevel – high/medium - preconditions – Setup required before testing - testSteps – 5-8 detailed, automation-ready steps - expectedOutcomes – What success looks like - whyThisMatters – References specific code changes CI Workflow: - .github/workflows/generate-rc-test-plan.yml – Triggered by Bitrise comment, generates test plan, deploys to GitHub Pages Test Plan Generation: - modes/generate-test-plan/fast-analyzer.ts – Single-call LLM test plan generation with delta/combined modes - modes/generate-test-plan/handlers.ts – Agentic mode handlers (legacy) - modes/generate-test-plan/prompt.ts – System prompts for test plan generation Utilities: - utils/feature-flags.ts – Auto-detect disabled feature flags from remote API - utils/github-client.ts – GitHub API for PR info, team sign-offs, build numbers - utils/git-utils.ts – Cherry-pick detection between commits, commit validation Provider: - Provider priority: Claude → OpenAI → Gemini - Added usage tracking to Opus streaming responses CI Changes - New workflow triggers on issue_comment for release PRs - Generates test-plan-{version}.json and test-plan-{version}.html - Deploys to GitHub Pages: metamask.github.io/metamask-mobile/test-plans/ - Updates Bitrise comment with test plan links - Uses existing secrets: E2E_CLAUDE_API_KEY, E2E_OPENAI_API_KEY, E2E_GEMINI_API_KEY ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/INFRA-3426?actionerId=6126045c1827d1006848bec4&sourceType=assign&atlOrigin=eyJpIjoiNzQxOTQ5ZDQ4NjExNGI1ZjgzYWFjYTZhYzhhN2JmMzYiLCJwIjoiaiJ9 ## **Manual testing steps** # Export API key export E2E_CLAUDE_API_KEY=sk-... # Run locally against a release PR node -r esbuild-register tests/tools/e2e-ai-analyzer \ --mode generate-test-plan \ --pr 25900 \ --auto-ff # Check output cat release-test-plan.json Verify: - release-test-plan.json includes scenarios with riskLevel, testSteps, whyThisMatters - Executive summary has releaseFocus and recommendation - Disabled feature flags are listed in excludedFeatures ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** https://github.com/user-attachments/assets/cbcdcf77-79be-469c-8056-9e1d85be2c36 ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Adds a new GitHub Actions workflow that runs on PR comments, writes to `gh-pages`, and posts back to PR comments; plus expands the `e2e-ai-analyzer` to call external APIs/LLMs and `gh`/`git` commands, which increases CI and release-pipeline surface area despite some input sanitization. > > **Overview** > Automates RC manual testing documentation by triggering a new workflow (`generate-rc-test-plan.yml`) when Bitrise posts the "RC Builds Ready for Testing" PR comment on `release/*` branches, running the `tests/tools/e2e-ai-analyzer` to generate `release-test-plan.json`, rendering an HTML viewer, publishing both to `gh-pages`, and appending links back onto the originating comment. > > Extends `tests/tools/e2e-ai-analyzer` with a new `generate-test-plan` mode (including new result types and finalize tool), a fast single-call LLM path that pulls PR metadata/files/sign-offs via `gh`, optionally computes cherry-pick deltas between commits/builds, and auto-excludes disabled remote feature flags via a remote-config API call. It also updates provider behavior (Claude-first failover and Anthropic Opus streaming) and ignores generated release artifacts via `.gitignore`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 36764f66d9ed41d04ca30b9de07227d8755d3d40. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .github/workflows/generate-rc-test-plan.yml | 367 ++++++++ .gitignore | 5 + tests/tools/e2e-ai-analyzer/.eslintrc.js | 6 + .../ai-tools/handlers/finalize-test-plan.ts | 11 + .../e2e-ai-analyzer/ai-tools/tool-executor.ts | 4 + .../e2e-ai-analyzer/ai-tools/tool-registry.ts | 207 ++++- .../e2e-ai-analyzer/analysis/analyzer.ts | 26 +- tests/tools/e2e-ai-analyzer/config.ts | 3 +- tests/tools/e2e-ai-analyzer/index.ts | 415 ++++++++- .../modes/generate-test-plan/fast-analyzer.ts | 860 ++++++++++++++++++ .../modes/generate-test-plan/handlers.ts | 736 +++++++++++++++ .../modes/generate-test-plan/prompt.ts | 220 +++++ .../modes/select-tags/prompt.ts | 7 +- .../providers/anthropic-provider.ts | 16 + .../providers/provider-factory.ts | 6 +- .../tools/e2e-ai-analyzer/providers/types.ts | 1 + tests/tools/e2e-ai-analyzer/types/index.ts | 249 ++++- .../e2e-ai-analyzer/utils/feature-flags.ts | 241 +++++ .../tools/e2e-ai-analyzer/utils/git-utils.ts | 326 +++++++ .../e2e-ai-analyzer/utils/github-client.ts | 230 +++++ 20 files changed, 3920 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/generate-rc-test-plan.yml create mode 100644 tests/tools/e2e-ai-analyzer/.eslintrc.js create mode 100644 tests/tools/e2e-ai-analyzer/ai-tools/handlers/finalize-test-plan.ts create mode 100644 tests/tools/e2e-ai-analyzer/modes/generate-test-plan/fast-analyzer.ts create mode 100644 tests/tools/e2e-ai-analyzer/modes/generate-test-plan/handlers.ts create mode 100644 tests/tools/e2e-ai-analyzer/modes/generate-test-plan/prompt.ts create mode 100644 tests/tools/e2e-ai-analyzer/utils/feature-flags.ts create mode 100644 tests/tools/e2e-ai-analyzer/utils/github-client.ts diff --git a/.github/workflows/generate-rc-test-plan.yml b/.github/workflows/generate-rc-test-plan.yml new file mode 100644 index 00000000000..3a8096ba9b8 --- /dev/null +++ b/.github/workflows/generate-rc-test-plan.yml @@ -0,0 +1,367 @@ +name: Generate RC Test Plan + +# Trigger when Bitrise posts "RC Builds Ready for Testing" comment +on: + issue_comment: + types: [created] + +jobs: + generate-test-plan: + name: Generate AI Test Plan + # Only run when: + # 1. Comment is on a PR (not an issue) + # 2. Comment contains "RC Builds Ready for Testing" + # 3. Comment is from github-actions bot (Bitrise posts via this) + if: | + github.event.issue.pull_request && + contains(github.event.comment.body, 'RC Builds Ready for Testing') && + github.event.comment.user.login == 'github-actions[bot]' + runs-on: ubuntu-latest + environment: release-ci + timeout-minutes: 15 + permissions: + contents: write + pull-requests: write + issues: write + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + E2E_CLAUDE_API_KEY: ${{ secrets.E2E_CLAUDE_API_KEY }} + E2E_OPENAI_API_KEY: ${{ secrets.E2E_OPENAI_API_KEY }} + E2E_GEMINI_API_KEY: ${{ secrets.E2E_GEMINI_API_KEY }} + PR_NUMBER: ${{ github.event.issue.number }} + + steps: + - name: Check if release PR + id: check-release + uses: actions/github-script@v7 + with: + script: | + const pr = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: process.env.PR_NUMBER + }); + + const isRelease = pr.data.head.ref.startsWith('release/'); + console.log(`PR branch: ${pr.data.head.ref}, isRelease: ${isRelease}`); + + if (!isRelease) { + console.log('Not a release PR, skipping test plan generation'); + return; + } + + // Extract version from branch name (e.g., release/7.70.0 -> 7.70.0) + // Sanitize to prevent shell injection - only allow semver chars + const rawVersion = pr.data.head.ref.replace('release/', ''); + const version = rawVersion.replace(/[^0-9.]/g, ''); + if (!version || !/^\d+\.\d+\.\d+$/.test(version)) { + console.log(`Invalid version format: ${rawVersion}`); + return; + } + core.setOutput('version', version); + core.setOutput('is_release', 'true'); + core.setOutput('pr_title', pr.data.title); + + - name: Checkout repository + if: steps.check-release.outputs.is_release == 'true' + uses: actions/checkout@v4 + + - name: Setup Node.js + if: steps.check-release.outputs.is_release == 'true' + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'yarn' + + - name: Install dependencies + if: steps.check-release.outputs.is_release == 'true' + run: yarn install --frozen-lockfile + + - name: Extract build number from comment + if: steps.check-release.outputs.is_release == 'true' + id: extract-build + uses: actions/github-script@v7 + with: + script: | + const comment = context.payload.comment.body; + // Match build number from "RC X.Y.Z (BUILD)" pattern specifically + // e.g., "RC 7.65.0 (4025)" captures "4025" + const buildMatch = comment.match(/RC\s+\d+\.\d+\.\d+\s*\((\d+)\)/i); + if (buildMatch) { + const buildNumber = buildMatch[1]; + console.log(`Extracted build number: ${buildNumber}`); + core.setOutput('build_number', buildNumber); + } else { + console.log('Could not extract build number from comment'); + core.setOutput('build_number', ''); + } + + - name: Generate test plan + if: steps.check-release.outputs.is_release == 'true' + id: generate + run: | + VERSION="${{ steps.check-release.outputs.version }}" + RAW_BUILD="${{ steps.extract-build.outputs.build_number }}" + # Sanitize BUILD to only allow digits + BUILD=$(echo "$RAW_BUILD" | tr -cd '0-9') + + echo "Generating test plan for version: $VERSION, build: $BUILD" + + # Sanitize PR_NUMBER to only allow digits + PR_NUM=$(echo "${{ env.PR_NUMBER }}" | tr -cd '0-9') + + # Run the analyzer + if node -r esbuild-register tests/tools/e2e-ai-analyzer \ + --mode generate-test-plan \ + --pr "$PR_NUM" \ + --auto-ff \ + -v "$VERSION"; then + + echo "test_plan_generated=true" >> "${GITHUB_OUTPUT}" + else + echo "Warning: Test plan generation failed" + echo "test_plan_generated=false" >> "${GITHUB_OUTPUT}" + fi + + - name: Generate HTML viewer + if: steps.generate.outputs.test_plan_generated == 'true' + run: | + VERSION="${{ steps.check-release.outputs.version }}" + BUILD="${{ steps.extract-build.outputs.build_number }}" + + # Create test-plans directory + mkdir -p test-plans + + # Move JSON + mv release-test-plan.json "test-plans/test-plan-${VERSION}.json" + + # Generate HTML viewer + node -e " + const fs = require('fs'); + const plan = JSON.parse(fs.readFileSync('test-plans/test-plan-${VERSION}.json', 'utf8')); + + // Escape HTML to prevent XSS from LLM-generated content + const esc = (s) => (s == null ? '' : String(s)).replace(/&/g, '&').replace(//g, '>').replace(/\"/g, '"'); + + const html = \` + + + + + RC \${esc(plan.version) || '${VERSION}'} Test Plan + + + +

🧪 RC \${esc(plan.version) || '${VERSION}'} Test Plan

+

Build: \${plan.buildNumber || '${BUILD}'} | Generated: \${new Date(plan.generatedAt).toLocaleString()}

+ + \${plan.executiveSummary ? \` +
+

📊 Executive Summary

+

\${esc(plan.executiveSummary.releaseFocus)}

+

Key Changes:

+
    \${plan.executiveSummary.keyChanges.map(c => '
  • ' + esc(c) + '
  • ').join('')}
+

Risk Level: \${esc(plan.executiveSummary.overallRisk).toUpperCase()}

+

Recommendation: \${esc(plan.executiveSummary.recommendation)}

+
+ \` : ''} + +
+

📈 Summary

+
+
\${plan.summary?.releaseRiskScore || '0/100'}
Risk Score
+
\${plan.summary?.totalFiles || plan.summary?.totalFilesChanged || 0}
Files Changed
+
\${plan.summary?.highImpactFiles || 0}
High Impact
+
\${plan.summary?.highRiskCount || plan.summary?.highRiskScenarios || 0}
High Risk
+
\${plan.summary?.mediumRiskCount || plan.summary?.mediumRiskScenarios || 0}
Medium Risk
+
+
+ + \${(plan.signOffs?.needsAttention?.length || plan.teamsNeedingSignOff?.length) ? \` +

👥 Teams Needing Sign-off

+
\${(plan.signOffs?.needsAttention || plan.teamsNeedingSignOff || []).map(t => '⏳ ' + esc(t) + '').join('')}
+ \` : ''} + + \${plan.testScenarios?.cherryPickScenarios?.length ? \` +

🍒 Cherry-Pick Scenarios

+ \${plan.testScenarios.cherryPickScenarios.map((s, i) => \` +
+

\${i + 1}. \${esc(s.area)} CHERRY-PICK

+

Why: \${esc(s.whyThisMatters)}

+
Test Steps:
    \${(s.testSteps || []).map(step => '
  1. ' + esc(step) + '
  2. ').join('')}
+
+ \`).join('')} + \` : ''} + +

🔴 High Risk Areas

+ \${(plan.scenarios || plan.testScenarios?.initialScenarios || []).filter(s => s.riskLevel === 'high').map((s, i) => \` +
+

\${i + 1}. \${esc(s.area)} HIGH

+

Why: \${esc(s.whyThisMatters)}

+ \${s.preconditions?.length ? '
Preconditions:
    ' + s.preconditions.map(p => '
  • ' + esc(p) + '
  • ').join('') + '
' : ''} +
Test Steps:
    \${(s.testSteps || []).map(step => '
  1. ' + esc(step) + '
  2. ').join('')}
+ \${s.expectedOutcomes?.length ? '
Expected Outcomes:
    ' + s.expectedOutcomes.map(o => '
  • ✓ ' + esc(o) + '
  • ').join('') + '
' : ''} +
+ \`).join('')} + +

🟡 Medium Risk Areas

+ \${(plan.scenarios || plan.testScenarios?.initialScenarios || []).filter(s => s.riskLevel === 'medium').map((s, i) => \` +
+

\${i + 1}. \${esc(s.area)} MEDIUM

+

Why: \${esc(s.whyThisMatters)}

+ \${s.preconditions?.length ? '
Preconditions:
    ' + s.preconditions.map(p => '
  • ' + esc(p) + '
  • ').join('') + '
' : ''} +
Test Steps:
    \${(s.testSteps || []).map(step => '
  1. ' + esc(step) + '
  2. ').join('')}
+ \${s.expectedOutcomes?.length ? '
Expected Outcomes:
    ' + s.expectedOutcomes.map(o => '
  • ✓ ' + esc(o) + '
  • ').join('') + '
' : ''} +
+ \`).join('')} + +
+

Generated by AI Test Plan Analyzer | MetaMask Mobile

+

Download JSON

+
+ + \`; + + fs.writeFileSync('test-plans/test-plan-${VERSION}.html', html); + console.log('Generated HTML viewer'); + " + + - name: Deploy to GitHub Pages + if: steps.generate.outputs.test_plan_generated == 'true' + run: | + VERSION="${{ steps.check-release.outputs.version }}" + + # Save generated files to temp before switching branches + cp test-plans/test-plan-${VERSION}.json /tmp/ + cp test-plans/test-plan-${VERSION}.html /tmp/ + + # Clean up to avoid conflicts when switching branches + rm -rf test-plans + + # Configure git + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Fetch gh-pages branch or create it + git fetch origin gh-pages:gh-pages 2>/dev/null || echo "gh-pages doesn't exist yet" + + # Switch to gh-pages (create orphan if it doesn't exist) + if git checkout gh-pages 2>/dev/null; then + echo "Switched to existing gh-pages branch" + else + # Create orphan branch and clear the index to avoid committing entire repo + git checkout --orphan gh-pages + git rm -rf . 2>/dev/null || true + git clean -fd 2>/dev/null || true + fi + + # Create test-plans directory + mkdir -p test-plans + + # Copy files from temp (overwrites if exists - handles re-runs) + cp /tmp/test-plan-${VERSION}.json test-plans/ + cp /tmp/test-plan-${VERSION}.html test-plans/ + + # Add and commit + git add test-plans/ + git commit -m "Add test plan for RC ${VERSION}" || echo "No changes to commit" + + # Push to gh-pages + git push origin gh-pages + + - name: Update build comment with test plan links + if: steps.generate.outputs.test_plan_generated == 'true' + uses: actions/github-script@v7 + with: + script: | + const version = '${{ steps.check-release.outputs.version }}'; + const buildNumber = '${{ steps.extract-build.outputs.build_number }}'; + const commentId = context.payload.comment.id; + + // Fetch latest comment body to avoid race conditions + const { data: comment } = await github.rest.issues.getComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId + }); + const currentBody = comment.body; + + const baseUrl = `https://metamask.github.io/metamask-mobile/test-plans`; + const htmlUrl = `${baseUrl}/test-plan-${version}.html`; + const jsonUrl = `${baseUrl}/test-plan-${version}.json`; + + // Add test plan row to the existing comment + const testPlanSection = ` + + --- + 🤖 **AI Test Plan:** [View](${htmlUrl}) | [JSON](${jsonUrl})`; + + // Update the comment + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body: currentBody + testPlanSection + }); + + - name: Post failure notice + if: steps.check-release.outputs.is_release == 'true' && steps.generate.outputs.test_plan_generated != 'true' + uses: actions/github-script@v7 + with: + script: | + const version = '${{ steps.check-release.outputs.version }}'; + const commentId = context.payload.comment.id; + const runId = context.runId; + const logsUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; + + // Fetch latest comment body to avoid race conditions + const { data: comment } = await github.rest.issues.getComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId + }); + const currentBody = comment.body; + + const failureSection = ` + + --- + ⚠️ **AI Test Plan generation failed** - [View logs](${logsUrl})`; + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + body: currentBody + failureSection + }); diff --git a/.gitignore b/.gitignore index c43caf58174..24a1b06d348 100644 --- a/.gitignore +++ b/.gitignore @@ -185,3 +185,8 @@ temp/ tests/coverage-systems/ runway-artifacts/ + +# E2E AI Analyzer output files +release-test-plan.json +release-delta.json +release-signoffs.json diff --git a/tests/tools/e2e-ai-analyzer/.eslintrc.js b/tests/tools/e2e-ai-analyzer/.eslintrc.js new file mode 100644 index 00000000000..5ed2eada415 --- /dev/null +++ b/tests/tools/e2e-ai-analyzer/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + rules: { + // Disable deprecated rule that doesn't exist in current ESLint version + '@typescript-eslint/no-parameter-properties': 'off', + }, +}; diff --git a/tests/tools/e2e-ai-analyzer/ai-tools/handlers/finalize-test-plan.ts b/tests/tools/e2e-ai-analyzer/ai-tools/handlers/finalize-test-plan.ts new file mode 100644 index 00000000000..99744706d60 --- /dev/null +++ b/tests/tools/e2e-ai-analyzer/ai-tools/handlers/finalize-test-plan.ts @@ -0,0 +1,11 @@ +/** + * Finalize Test Plan Tool Handler + * + * Handles the finalization of the AI's test plan generation + */ + +import { ToolInput } from '../../types'; + +export function handleFinalizeTestPlan(input: ToolInput): string { + return JSON.stringify(input); +} diff --git a/tests/tools/e2e-ai-analyzer/ai-tools/tool-executor.ts b/tests/tools/e2e-ai-analyzer/ai-tools/tool-executor.ts index 2c834d1b9d6..6203d1fe99d 100644 --- a/tests/tools/e2e-ai-analyzer/ai-tools/tool-executor.ts +++ b/tests/tools/e2e-ai-analyzer/ai-tools/tool-executor.ts @@ -12,6 +12,7 @@ import { handleListDirectory } from './handlers/list-directory'; import { handleGrepCodebase } from './handlers/grep-codebase'; import { handleLoadSkill } from './handlers/load-skill'; import { handleFinalizeTagSelection } from './handlers/finalize-tag-selection'; +import { handleFinalizeTestPlan } from './handlers/finalize-test-plan'; /** * Tool execution context @@ -57,6 +58,9 @@ export async function executeTool( case 'finalize_tag_selection': return handleFinalizeTagSelection(input); + case 'finalize_test_plan_generation': + return handleFinalizeTestPlan(input); + default: return `Unknown tool: ${toolName}`; } diff --git a/tests/tools/e2e-ai-analyzer/ai-tools/tool-registry.ts b/tests/tools/e2e-ai-analyzer/ai-tools/tool-registry.ts index ea7cd36fa3e..8f0273f6a1e 100644 --- a/tests/tools/e2e-ai-analyzer/ai-tools/tool-registry.ts +++ b/tests/tools/e2e-ai-analyzer/ai-tools/tool-registry.ts @@ -9,10 +9,10 @@ import { LLMTool } from '../providers'; import { TOOL_LIMITS } from '../config'; /** - * Gets all tool definitions for the AI agent + * Gets tool definitions for the AI agent */ export function getToolDefinitions(): LLMTool[] { - return [ + const allTools: LLMTool[] = [ { name: 'read_file', description: @@ -194,5 +194,208 @@ export function getToolDefinitions(): LLMTool[] { ], }, }, + { + name: 'finalize_test_plan_generation', + description: 'Submit the final exploratory test plan for the release', + input_schema: { + type: 'object', + properties: { + summary: { + type: 'object', + description: 'High-level metrics for the test plan', + properties: { + total_changed_files: { type: 'number' }, + total_commits: { type: 'number' }, + critical_areas: { type: 'number' }, + high_risk_areas: { type: 'number' }, + medium_risk_areas: { type: 'number' }, + low_risk_areas: { type: 'number' }, + estimated_testing_hours: { type: 'string' }, + release_version: { type: 'string' }, + }, + required: [ + 'total_changed_files', + 'critical_areas', + 'high_risk_areas', + 'estimated_testing_hours', + ], + }, + feature_areas: { + type: 'array', + description: + 'Prioritized list of feature areas with test scenarios', + items: { + type: 'object', + properties: { + feature_area: { type: 'string' }, + risk_level: { + type: 'string', + enum: ['critical', 'high', 'medium', 'low'], + }, + risk_justification: { type: 'string' }, + impacted_components: { + type: 'array', + items: { type: 'string' }, + }, + exploratory_scenarios: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + title: { type: 'string' }, + description: { type: 'string' }, + preconditions: { + type: 'array', + items: { type: 'string' }, + }, + exploration_guidance: { + type: 'array', + items: { type: 'string' }, + }, + risk_indicators: { + type: 'array', + items: { type: 'string' }, + }, + related_changes: { + type: 'array', + items: { type: 'string' }, + }, + }, + required: ['id', 'title', 'description'], + }, + }, + platform_notes: { + type: 'object', + properties: { + ios: { type: 'array', items: { type: 'string' } }, + android: { type: 'array', items: { type: 'string' } }, + shared: { type: 'array', items: { type: 'string' } }, + }, + }, + priority: { type: 'number' }, + exploratory_priority: { + type: 'number', + description: + 'Score 1-10 indicating how much this area needs exploratory testing', + }, + exploration_charters: { + type: 'array', + description: 'Specific exploration missions for this area', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + mission: { + type: 'string', + description: 'The exploration goal', + }, + context: { + type: 'string', + description: 'Why this matters for this release', + }, + what_ifs: { + type: 'array', + items: { type: 'string' }, + description: 'Specific questions to investigate', + }, + time_box: { + type: 'string', + description: 'Suggested exploration time', + }, + }, + required: ['id', 'mission', 'what_ifs'], + }, + }, + }, + required: ['feature_area', 'risk_level', 'priority'], + }, + }, + cross_cutting_concerns: { + type: 'array', + items: { type: 'string' }, + description: 'Issues that span multiple feature areas', + }, + regression_focus_areas: { + type: 'array', + items: { type: 'string' }, + description: 'Areas requiring extra regression attention', + }, + platform_specific_guidance: { + type: 'object', + properties: { + ios: { type: 'array', items: { type: 'string' } }, + android: { type: 'array', items: { type: 'string' } }, + shared: { type: 'array', items: { type: 'string' } }, + }, + }, + exploration_themes: { + type: 'array', + description: + 'Cross-cutting exploration approaches that apply across features', + items: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Theme name (e.g., "Interruption Testing")', + }, + description: { + type: 'string', + description: 'What this theme covers', + }, + techniques: { + type: 'array', + items: { type: 'string' }, + description: 'Specific testing techniques for this theme', + }, + applicable_areas: { + type: 'array', + items: { type: 'string' }, + description: + 'Feature areas where this theme is especially relevant', + }, + }, + required: ['name', 'description', 'techniques'], + }, + }, + exploratory_focus_areas: { + type: 'array', + description: + 'Top 3-5 areas most deserving of creative exploratory testing', + items: { + type: 'object', + properties: { + feature_area: { type: 'string' }, + exploratory_priority: { + type: 'number', + description: 'Score 1-10', + }, + reason: { + type: 'string', + description: 'Why this area needs exploration', + }, + suggested_time_box: { + type: 'string', + description: 'Recommended exploration time', + }, + }, + required: ['feature_area', 'exploratory_priority', 'reason'], + }, + }, + reasoning: { + type: 'string', + description: 'Explanation of analysis approach and key findings', + }, + confidence: { + type: 'number', + description: 'Confidence score 0-100', + }, + }, + required: ['summary', 'feature_areas', 'reasoning', 'confidence'], + }, + }, ]; + + return allTools; } diff --git a/tests/tools/e2e-ai-analyzer/analysis/analyzer.ts b/tests/tools/e2e-ai-analyzer/analysis/analyzer.ts index dbb21858ee0..c519aaa69fc 100644 --- a/tests/tools/e2e-ai-analyzer/analysis/analyzer.ts +++ b/tests/tools/e2e-ai-analyzer/analysis/analyzer.ts @@ -33,6 +33,16 @@ import { outputAnalysis as outputSelectTagsAnalysis, checkHardRules as checkSelectTagsHardRules, } from '../modes/select-tags/handlers'; +import { + buildSystemPrompt as buildTestPlanSystemPrompt, + buildTaskPrompt as buildTestPlanTaskPrompt, +} from '../modes/generate-test-plan/prompt'; +import { + processAnalysis as processTestPlanAnalysis, + createConservativeResult as createTestPlanConservativeResult, + createEmptyResult as createTestPlanEmptyResult, + outputAnalysis as outputTestPlanAnalysis, +} from '../modes/generate-test-plan/handlers'; /** * Mode Registry — see ModeConfig in types/index.ts for the full interface. @@ -56,6 +66,16 @@ export const MODES: { outputAnalysis: outputSelectTagsAnalysis, checkHardRules: checkSelectTagsHardRules, }, + 'generate-test-plan': { + description: 'Generate exploratory test plan for release testing', + finalizeToolName: 'finalize_test_plan_generation', + systemPromptBuilder: buildTestPlanSystemPrompt, + taskPromptBuilder: buildTestPlanTaskPrompt, + processAnalysis: processTestPlanAnalysis, + createConservativeResult: createTestPlanConservativeResult, + createEmptyResult: createTestPlanEmptyResult, + outputAnalysis: outputTestPlanAnalysis, + }, }; // Type aliases for mode keys and analysis results @@ -112,6 +132,7 @@ export async function analyzeWithAgent( const taskPrompt = modeConfig.taskPromptBuilder( allChangedFiles, criticalFiles, + context, ); const tools = getToolDefinitions(); @@ -229,7 +250,7 @@ export async function analyzeWithAgent( return analysis as ModeAnalysisResult; } - console.log('⚠️ Failed to parse finalize_tag_selection'); + console.log(`⚠️ Failed to parse ${modeConfig.finalizeToolName}`); printTokenReport(); return modeConfig.createConservativeResult() as ModeAnalysisResult; } @@ -245,8 +266,7 @@ export async function analyzeWithAgent( // Update conversation history conversationHistory.push({ role: 'user', - content: - typeof currentMessage === 'string' ? currentMessage : currentMessage, + content: currentMessage, }); conversationHistory.push({ role: 'assistant', diff --git a/tests/tools/e2e-ai-analyzer/config.ts b/tests/tools/e2e-ai-analyzer/config.ts index bc19c568898..3dae989e3ce 100644 --- a/tests/tools/e2e-ai-analyzer/config.ts +++ b/tests/tools/e2e-ai-analyzer/config.ts @@ -28,8 +28,9 @@ export const LLM_CONFIG = { /** * Provider priority order for automatic fallback * The first available provider in this list will be used + * Order: Claude → OpenAI → Gemini (matching Extension team) */ - providerPriority: ['openai', 'anthropic', 'google'] as ProviderType[], + providerPriority: ['anthropic', 'openai', 'google'] as ProviderType[], /** * Per-provider configuration diff --git a/tests/tools/e2e-ai-analyzer/index.ts b/tests/tools/e2e-ai-analyzer/index.ts index 1b036a93e37..a4102a09134 100644 --- a/tests/tools/e2e-ai-analyzer/index.ts +++ b/tests/tools/e2e-ai-analyzer/index.ts @@ -11,6 +11,7 @@ import { getAllChangedFiles, getPRFiles, validatePRNumber, + getCherryPicksBetweenCommits, } from './utils/git-utils'; import { MODES, validateMode, analyzeWithAgent } from './analysis/analyzer'; import { identifyCriticalFiles } from './utils/file-utils'; @@ -20,6 +21,19 @@ import { ProviderType, } from './providers'; import { getSkillsMetadata } from './utils/skill-loader'; +import { + getPullRequestInfo, + getLatestBuildFromPRComments, +} from './utils/github-client'; +import { + analyzeWithSingleCall, + analyzeDeltaWithLLM, + createCombinedTestPlan, +} from './modes/generate-test-plan/fast-analyzer'; +import { + fetchFeatureFlags, + formatFeatureFlagSummary, +} from './utils/feature-flags'; /** * Validates provided files against actual git changes @@ -94,6 +108,52 @@ function parseArgs(args: string[]): ParsedArgs { case '--list-skills': options.listSkills = true; break; + case '--build': { + const val = parseInt(args[++i], 10); + if (!isNaN(val)) options.buildNumber = val; + break; + } + case '--prev-build': { + const val = parseInt(args[++i], 10); + if (!isNaN(val)) options.prevBuildNumber = val; + break; + } + case '--from-commit': + options.fromCommit = args[++i]; + break; + case '--to-commit': + options.toCommit = args[++i]; + break; + case '--version': + case '-v': + options.releaseVersion = args[++i]; + break; + case '--initial-commit': + options.initialCommit = args[++i]; + break; + case '--initial-build': { + const val = parseInt(args[++i], 10); + if (!isNaN(val)) options.initialBuildNumber = val; + break; + } + case '--exclude-features': + case '-ef': { + // Comma-separated list of features to exclude + const featuresArg = args[++i]; + if (featuresArg) { + options.excludedFeatures = featuresArg + .split(',') + .map((f) => f.trim()) + .filter((f) => f.length > 0); + } + break; + } + case '--auto-ff': + options.autoFF = true; + break; + case '--show-ff': + options.showFFStatus = true; + break; } } @@ -140,9 +200,25 @@ Options: -cf --changed-files Provide changed files directly -pr --pr Get changed files from a specific PR -p, --provider Force specific provider (anthropic, openai, google) + --build Current build number (e.g., 3685) + --prev-build Previous build number to compare against (e.g., 3682) + --from-commit Start commit SHA for cherry-pick range (alternative to --prev-build) + --to-commit End commit SHA for cherry-pick range (alternative to --build) + --initial-commit Initial build commit (first RC cut) for combined output + --initial-build Initial build number (first RC cut) for combined output + -v, --version Release version (e.g., 7.65.0) for test plan title + -ef, --exclude-features Features with FF-gated new functionality (tests existing only) + --auto-ff Auto-fetch feature flags from remote API to detect disabled features + --show-ff Show current feature flag status and exit --list-skills List all available skills -h, --help Show this help message +Combined Output Mode: + Use --initial-commit with --pr to generate a combined test plan with: + - Cherry-pick scenarios on top (delta from initial build) + - Initial scenarios below (from RC cut) + This aligns with Extension team format for cross-platform consistency. + Note: Skills are loaded on-demand by the AI agent during analysis. Output: @@ -208,6 +284,25 @@ async function main() { const options = parseArgs(args); + // Handle --show-ff (show feature flag status and exit) + if (options.showFFStatus) { + try { + const ffSummary = fetchFeatureFlags(); + console.log('\n' + formatFeatureFlagSummary(ffSummary)); + console.log('\nDisabled flags:'); + ffSummary.disabledFlags.forEach((flag) => { + console.log(` • ${flag}`); + }); + } catch (error) { + console.error( + '❌ Failed to fetch feature flags:', + error instanceof Error ? error.message : error, + ); + process.exit(1); + } + process.exit(0); + } + // Handle --list-skills if (options.listSkills) { const skillsMetadata = await getSkillsMetadata(); @@ -257,7 +352,7 @@ async function main() { '💡 Tip: Make sure you have uncommitted changes or are on a branch with commits', ); const analysis = MODES[mode].createEmptyResult(); - MODES[mode].outputAnalysis(analysis); + (MODES[mode].outputAnalysis as (a: unknown) => void)(analysis); return; } @@ -279,6 +374,10 @@ async function main() { baseBranch, prNumber: options.prNumber, githubRepo, + buildNumber: options.buildNumber, + prevBuildNumber: options.prevBuildNumber, + fromCommit: options.fromCommit, + toCommit: options.toCommit, }; if (options.prNumber) { @@ -319,7 +418,7 @@ async function main() { console.error(` ${LLM_CONFIG.providers.openai.envKey}`); console.error(` ${LLM_CONFIG.providers.google.envKey}`); const fallbackAnalysis = MODES[mode].createConservativeResult(); - MODES[mode].outputAnalysis(fallbackAnalysis); + (MODES[mode].outputAnalysis as (a: unknown) => void)(fallbackAnalysis); return; } @@ -327,7 +426,313 @@ async function main() { `\n📋 Available providers: ${availableProviders.map((p) => p.type).join(' → ')}`, ); - // Try each available provider in order + // Auto-fetch feature flags if requested + const excludedFeatures = options.excludedFeatures || []; + if (options.autoFF) { + console.log('\n🔍 Auto-detecting disabled feature flags...'); + const ffSummary = fetchFeatureFlags(); + + // Get ALL disabled flags (these are specific features that are OFF) + const disabledFlags = ffSummary.disabledFlags; + + if (disabledFlags.length > 0) { + console.log(` Found ${disabledFlags.length} disabled flags`); + + // Show disabled flags grouped by area + for (const [area, flags] of ffSummary.disabledByArea.entries()) { + console.log(` ${area}: ${flags.length} disabled`); + flags.slice(0, 3).forEach((f) => console.log(` - ${f}`)); + if (flags.length > 3) { + console.log(` ... and ${flags.length - 3} more`); + } + } + + // Merge disabled flag names with manual excludes + const manualSet = new Set(excludedFeatures); + for (const flag of disabledFlags) { + if (!manualSet.has(flag)) { + excludedFeatures.push(flag); + } + } + } else { + console.log(' No disabled flags found'); + } + } + + // Update options with merged excludes + options.excludedFeatures = excludedFeatures; + + // FAST PATH: generate-test-plan mode with PR number + if (mode === 'generate-test-plan' && options.prNumber) { + // COMBINED MODE: initial-commit provided - generate combined output + if (options.initialCommit) { + console.log( + `\n📦 Combined mode: generating full test plan with cherry-picks from initial build`, + ); + console.log(` Initial commit: ${options.initialCommit}`); + + const prInfo = getPullRequestInfo(options.prNumber, githubRepo); + const version = + options.releaseVersion || + prInfo.title.match(/\d+\.\d+\.\d+/)?.[0] || + 'unknown'; + + // Get latest build number + const latestBuild = getLatestBuildFromPRComments( + options.prNumber, + githubRepo, + ); + if (latestBuild) { + console.log(` Current build: ${latestBuild}`); + } + + // Get current HEAD commit + const { execSync } = await import('child_process'); + const currentCommit = execSync('git rev-parse HEAD', { + encoding: 'utf-8', + cwd: baseDir, + }).trim(); + console.log(` Current commit: ${currentCommit.substring(0, 7)}`); + + // Get cherry-picks between initial and current + const cherryPicks = getCherryPicksBetweenCommits( + options.initialCommit, + currentCommit, + baseDir, + ); + console.log(` Cherry-picks since initial: ${cherryPicks.length}`); + + // Try providers + for (let i = 0; i < availableProviders.length; i++) { + const { type: providerType, provider } = availableProviders[i]; + + try { + console.log(`\n🚀 Using ${provider.displayName}...`); + + // Step 1: Generate initial analysis + console.log(`\n📋 Step 1: Generating initial scenarios...`); + const initialResult = await analyzeWithSingleCall( + provider, + prInfo, + latestBuild, + options.excludedFeatures || [], + version, + ); + + // Step 2: Generate delta analysis (if there are cherry-picks) + let deltaResult; + if (cherryPicks.length > 0) { + console.log(`\n🍒 Step 2: Generating cherry-pick scenarios...`); + deltaResult = await analyzeDeltaWithLLM( + provider, + cherryPicks, + prInfo.teamSignOffs, + options.prNumber, + options.initialCommit, + currentCommit, + version, + baseDir, + options.initialBuildNumber, + latestBuild, + ); + } else { + console.log(`\n✅ No cherry-picks since initial build`); + } + + // Step 3: Combine results + console.log(`\n🔗 Step 3: Combining results...`); + const combinedResult = createCombinedTestPlan( + initialResult, + deltaResult, + ); + + // Print summary + console.log(`\n✅ Test plan generated:`); + console.log( + ` Risk score: ${combinedResult.summary.releaseRiskScore}`, + ); + console.log( + ` High risk scenarios: ${combinedResult.summary.highRiskScenarios}`, + ); + console.log( + ` Medium risk scenarios: ${combinedResult.summary.mediumRiskScenarios}`, + ); + if (combinedResult.summary.cherryPickCount) { + console.log( + ` Cherry-picks: ${combinedResult.summary.cherryPickCount}`, + ); + } + + // Write JSON file + const fs = await import('fs'); + fs.writeFileSync( + 'release-test-plan.json', + JSON.stringify(combinedResult, null, 2), + ); + console.log('\n💾 Saved to release-test-plan.json'); + return; + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + console.warn(`\n⚠️ ${providerType} failed: ${err.message}`); + + if (i < availableProviders.length - 1) { + console.log('🔄 Falling back to next provider...'); + } + } + } + + console.error('\n❌ All providers failed for combined analysis.'); + process.exit(1); + } + + // Check if this is delta mode (comparing builds) + if (options.fromCommit && options.toCommit) { + console.log( + `\n🍒 Delta mode: comparing ${options.fromCommit} → ${options.toCommit}`, + ); + + // Get PR info for sign-offs + const prInfo = getPullRequestInfo(options.prNumber, githubRepo); + + // Get cherry-picks between commits + const cherryPicks = getCherryPicksBetweenCommits( + options.fromCommit, + options.toCommit, + baseDir, + ); + + const version = + options.releaseVersion || + prInfo.title.match(/\d+\.\d+\.\d+/)?.[0] || + 'unknown'; + + // Get build number from PR comments + console.log(` Fetching build number from PR comments...`); + const latestBuild = getLatestBuildFromPRComments( + options.prNumber, + githubRepo, + ); + if (latestBuild) { + console.log(` ✓ Latest build: ${latestBuild}`); + } + + if (cherryPicks.length === 0) { + console.log('\n✅ No cherry-picks detected between these commits.'); + console.log('No delta analysis needed.'); + return; + } + + console.log(` Found ${cherryPicks.length} cherry-pick(s)`); + + // Use LLM to generate test scenarios for cherry-picks + for (let i = 0; i < availableProviders.length; i++) { + const { type: providerType, provider } = availableProviders[i]; + + try { + const deltaResult = await analyzeDeltaWithLLM( + provider, + cherryPicks, + prInfo.teamSignOffs, + options.prNumber, + options.fromCommit, + options.toCommit, + version, + baseDir, + options.prevBuildNumber, + options.buildNumber || latestBuild, + ); + + // Print summary + console.log(`\n✅ Delta analysis complete:`); + console.log( + ` Cherry-picks analyzed: ${deltaResult.cherryPicks.length}`, + ); + console.log( + ` Scenarios generated: ${deltaResult.scenarios.length}`, + ); + + // Write JSON file + const fs = await import('fs'); + fs.writeFileSync( + 'release-delta.json', + JSON.stringify(deltaResult, null, 2), + ); + console.log('\n💾 Saved to release-delta.json'); + return; + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + console.warn(`\n⚠️ ${providerType} failed: ${err.message}`); + + if (i < availableProviders.length - 1) { + console.log('🔄 Falling back to next provider...'); + } + } + } + + console.error('\n❌ All providers failed for delta analysis.'); + process.exit(1); + } + + // Fast path: single LLM call for test plan generation + console.log(`\n⚡ Fast mode: single LLM call for test plan generation`); + + const prInfo = getPullRequestInfo(options.prNumber, githubRepo); + + // Fetch build number from PR comments + const buildNumber = getLatestBuildFromPRComments( + options.prNumber, + githubRepo, + ); + if (buildNumber) { + console.log(` ✓ Latest build: ${buildNumber}`); + } + + // Try each provider + for (let i = 0; i < availableProviders.length; i++) { + const { type: providerType, provider } = availableProviders[i]; + + try { + console.log(`\n🚀 Using ${provider.displayName}...`); + + const result = await analyzeWithSingleCall( + provider, + prInfo, + buildNumber, + options.excludedFeatures || [], + options.releaseVersion, + ); + + // Print summary + console.log(`\n✅ Test plan generated:`); + console.log(` Risk score: ${result.summary.releaseRiskScore}`); + console.log(` High risk scenarios: ${result.summary.highRiskCount}`); + console.log( + ` Medium risk scenarios: ${result.summary.mediumRiskCount}`, + ); + + // Write JSON file + const fs = await import('fs'); + fs.writeFileSync( + 'release-test-plan.json', + JSON.stringify(result, null, 2), + ); + console.log('\n💾 Saved to release-test-plan.json'); + return; + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + console.warn(`\n⚠️ ${providerType} failed: ${err.message}`); + + if (i < availableProviders.length - 1) { + console.log('🔄 Falling back to next provider...'); + } + } + } + + console.error('\n❌ All providers failed for fast analysis.'); + process.exit(1); + } + + // LEGACY PATH: agentic loop for other modes let lastError: Error | null = null; for (let i = 0; i < availableProviders.length; i++) { @@ -346,7 +751,7 @@ async function main() { ); // Success - output results and exit - MODES[mode].outputAnalysis(analysis); + (MODES[mode].outputAnalysis as (a: unknown) => void)(analysis); return; } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); @@ -365,7 +770,7 @@ async function main() { } const fallbackAnalysis = MODES[mode].createConservativeResult(); - MODES[mode].outputAnalysis(fallbackAnalysis); + (MODES[mode].outputAnalysis as (a: unknown) => void)(fallbackAnalysis); } main().catch((error) => { diff --git a/tests/tools/e2e-ai-analyzer/modes/generate-test-plan/fast-analyzer.ts b/tests/tools/e2e-ai-analyzer/modes/generate-test-plan/fast-analyzer.ts new file mode 100644 index 00000000000..3847e9d55d8 --- /dev/null +++ b/tests/tools/e2e-ai-analyzer/modes/generate-test-plan/fast-analyzer.ts @@ -0,0 +1,860 @@ +/** + * Fast Test Plan Analyzer + * + * Single LLM call approach (no agentic loop). + * Analyzes PR files and generates test scenarios directly. + */ + +import { ILLMProvider } from '../../providers'; +import { LLM_CONFIG, APP_CONFIG } from '../../config'; +import { + PullRequestInfo, + TeamSignOff, + getSignOffSummary, +} from '../../utils/github-client'; +import { + CherryPickInfo, + getFilePatchesFromAPI, + getCommitDiff, +} from '../../utils/git-utils'; + +// High-impact file patterns that should include diffs +const HIGH_IMPACT_PATTERNS = [ + /^app\//, + /^patches\//, + /^android\/app\/src\/main\/AndroidManifest\.xml/, + /^ios\/.*\.plist$/, +]; + +// Files to exclude from diff analysis (test files, docs, etc.) +const EXCLUDE_PATTERNS = [ + /\.test\.[jt]sx?$/, + /\.spec\.[jt]sx?$/, + /\.snap$/, + /^e2e\//, + /^docs\//, + /\.md$/, + /\.stories\.[jt]sx?$/, + /^\.github\//, + /^bitrise\//, + /^scripts\/.*\.sh$/, +]; + +/** + * Determines if a file is high-impact and should have its diff included + */ +function isHighImpactFile(filename: string): boolean { + // Exclude test/doc files + if (EXCLUDE_PATTERNS.some((p) => p.test(filename))) { + return false; + } + // Include if matches high-impact patterns + return HIGH_IMPACT_PATTERNS.some((p) => p.test(filename)); +} + +/** + * Extracts and summarizes diffs for high-impact files from a PR + * Uses GitHub API to fetch individual file patches (works for large PRs) + * Returns a truncated summary to fit within token limits + */ +function getHighImpactDiffs( + prNumber: number, + files: { filename: string; additions: number; deletions: number }[], + maxTotalLines = 1500, +): string { + // Sort files by change size (most changes first) + const highImpactFiles = files + .filter((f) => isHighImpactFile(f.filename)) + .sort((a, b) => b.additions + b.deletions - (a.additions + a.deletions)); + + if (highImpactFiles.length === 0) { + return ''; + } + + console.log( + ` Found ${highImpactFiles.length} high-impact files, fetching patches...`, + ); + + try { + // Fetch patches for high-impact files using GitHub API (works for large PRs) + const filenames = highImpactFiles.map((f) => f.filename); + const patches = getFilePatchesFromAPI( + prNumber, + APP_CONFIG.githubRepo, + filenames, + ); + + if (patches.size === 0) { + console.log(` No patches returned from API`); + return ''; + } + + console.log(` Got patches for ${patches.size} files`); + + // Build output, respecting max lines + let totalLines = 0; + const output: string[] = []; + + for (const file of highImpactFiles) { + const patch = patches.get(file.filename); + if (!patch) continue; + + // Extract just the changed lines (+ and - lines) + const lines = patch.split('\n'); + const changedLines = lines.filter( + (line) => + line.startsWith('+') || line.startsWith('-') || line.startsWith('@@'), + ); + + // Limit per-file to 100 lines to fit more files + const summary = changedLines.slice(0, 100).join('\n'); + const summaryLines = summary.split('\n').length; + + if (totalLines + summaryLines > maxTotalLines) { + // Truncate this diff to fit + const remaining = maxTotalLines - totalLines; + if (remaining > 20) { + output.push(`### ${file.filename}`); + output.push(changedLines.slice(0, remaining).join('\n')); + output.push('... [truncated]'); + } + break; + } + + output.push(`### ${file.filename}`); + output.push(summary); + output.push(''); + totalLines += summaryLines; + } + + return output.join('\n'); + } catch (error) { + console.warn(' Could not fetch diffs for high-impact files:', error); + return ''; + } +} + +export interface TestScenario { + area: string; + riskLevel: 'high' | 'medium'; + testSteps: string[]; + whyThisMatters: string; + /** Pre-conditions required before testing */ + preconditions?: string[]; + /** Expected outcomes for validation */ + expectedOutcomes?: string[]; +} + +/** Executive summary for quick release overview */ +export interface ExecutiveSummary { + /** One-line release focus */ + releaseFocus: string; + /** Key changes in this release (3-5 bullets) */ + keyChanges: string[]; + /** Critical areas requiring attention */ + criticalAreas: string[]; + /** Overall risk assessment */ + overallRisk: 'low' | 'medium' | 'high'; + /** Go/no-go recommendation */ + recommendation: string; +} + +export interface TestPlanResult { + prNumber: number; + prTitle: string; + version: string; + buildNumber?: number; + generatedAt: string; + model: string; + /** Executive summary for stakeholders */ + executiveSummary?: ExecutiveSummary; + summary: { + totalFiles: number; + highImpactFiles: number; + totalAdditions: number; + totalDeletions: number; + highRiskCount: number; + mediumRiskCount: number; + /** Risk score out of 100 (aligned with Extension format) */ + releaseRiskScore: string; + }; + scenarios: TestScenario[]; + signOffs: { + signedOff: string[]; + needsAttention: string[]; + }; + /** Features excluded from analysis */ + excludedFeatures?: string[]; +} + +/** + * Combined test plan format - aligned with Extension team format + * Cherry-pick scenarios on top, initial scenarios below + */ +export interface CombinedTestPlanResult { + prNumber: number; + prTitle: string; + generatedAt: string; + modelUsed: string; + /** Release version (e.g., "7.65.0") */ + version?: string; + /** Build number */ + buildNumber?: number; + /** Executive summary for stakeholders */ + executiveSummary?: ExecutiveSummary; + summary: { + totalFilesChanged: number; + highImpactFiles?: number; + totalCommitsInRelease?: number; + releaseRiskScore?: string; + highRiskScenarios: number; + mediumRiskScenarios: number; + cherryPickCount?: number; + initialBuild?: number; + currentBuild?: number; + }; + teamsNeedingSignOff: string[]; + testScenarios: { + cherryPickScenarios: CherryPickTestScenario[]; + initialScenarios: TestScenario[]; + }; + /** Features excluded from analysis */ + excludedFeatures?: string[]; +} + +export interface CherryPickTestScenario { + area: string; + riskLevel: 'high' | 'medium'; + testSteps: string[]; + whyThisMatters: string; + cherryPickPR?: string; + cherryPickMessage?: string; +} + +/** + * Builds the prompt for test plan generation + * Now includes actual diff content for high-impact files + */ +function buildPrompt( + prInfo: PullRequestInfo, + diffContent: string, + excludedFeatures: string[] = [], +): string { + const totalAdditions = prInfo.files.reduce((sum, f) => sum + f.additions, 0); + const totalDeletions = prInfo.files.reduce((sum, f) => sum + f.deletions, 0); + + // Build file list with change counts + const fileList = prInfo.files + .map((f) => `- ${f.filename} (+${f.additions} -${f.deletions})`) + .join('\n'); + + // Include diff section if available + const diffSection = diffContent + ? ` +## Actual Code Changes (High-Impact Files) + +Below are the actual diffs for high-impact files. Use these to understand WHAT specifically changed: + +${diffContent} +` + : ''; + + // Build excluded features section - now supports specific flag names + const excludedSection = + excludedFeatures.length > 0 + ? ` +## DISABLED FEATURE FLAGS (do NOT test these specific features) +The following feature flags are DISABLED - these specific capabilities are NOT available to users: +${excludedFeatures.map((f) => `- ${f}`).join('\n')} + +IMPORTANT: +- DO NOT create test steps that test the disabled functionality listed above +- DO create test scenarios for the core feature if it's enabled (e.g., Perps trading works, but MYX provider doesn't) +- Flag names indicate what's disabled: "perpsMyxProviderEnabled" = MYX provider is OFF, "perpsPerpGtmOnboardingModalEnabled" = onboarding modal is OFF +- If a flag says "XyzEnabled" and it's disabled, don't test Xyz functionality +` + : ''; + + return `You are a QA engineer for MetaMask Mobile. Analyze this release PR and identify areas needing exploratory testing. + +## PR Information +- PR #${prInfo.number}: ${prInfo.title} +- Files Changed: ${prInfo.files.length} +- Total Changes: +${totalAdditions} -${totalDeletions} + +## Changed Files +${fileList} +${diffSection}${excludedSection} +## Your Task + +1. First, provide an EXECUTIVE SUMMARY for stakeholders +2. Then, identify risky areas with DETAILED test scenarios (automation-ready) + +### Executive Summary Requirements: +- **releaseFocus**: One sentence describing this release's main focus +- **keyChanges**: 3-5 bullet points of key changes +- **criticalAreas**: List areas requiring most attention +- **overallRisk**: "low", "medium", or "high" +- **recommendation**: Go/no-go recommendation with brief reasoning (do NOT mention feature flags or rollout strategies - focus only on testing readiness) + +### Test Scenario Requirements: +For each scenario provide: +1. **area**: Feature area (e.g., "Card", "Swaps", "Send Flow", "Account Management") +2. **riskLevel**: "high" or "medium" only (skip low risk) +3. **preconditions**: Setup required before testing (e.g., "User has 2+ accounts", "Network is Ethereum mainnet"). Do NOT assume feature flags are enabled - focus on account state, network, and app prerequisites. +4. **testSteps**: 5-8 DETAILED steps (automation-ready, specific actions with expected results) +5. **expectedOutcomes**: What success looks like for each major step +6. **whyThisMatters**: Reference specific code changes that make this risky + +## Focus Areas for MetaMask Mobile +- **Wallet Operations**: Account creation, import, backup, seed phrase +- **Transactions**: Send, receive, swap, bridge, gas estimation +- **Token Management**: Adding tokens, NFTs, balances +- **Network Management**: Adding/switching networks, RPC endpoints +- **Security**: Biometrics, password, permissions +- **Card/Ramps**: Buy, sell, card features +- **Deep Links**: URL handling, notifications +- **Performance**: Large lists, loading states + +## EXCLUDE (don't create scenarios for): +- Test file changes only +- Documentation/comments only +- CI/CD config changes +- Linting/formatting changes + +## Output Format + +Return ONLY valid JSON: +{ + "executiveSummary": { + "releaseFocus": "string", + "keyChanges": ["change 1", "change 2", "change 3"], + "criticalAreas": ["area 1", "area 2"], + "overallRisk": "low" | "medium" | "high", + "recommendation": "string" + }, + "scenarios": [ + { + "area": "string", + "riskLevel": "high" | "medium", + "preconditions": ["condition 1", "condition 2"], + "testSteps": [ + "1. Navigate to X screen", + "2. Tap Y button - verify Z appears", + "3. Enter value A - verify input is accepted", + "..." + ], + "expectedOutcomes": ["outcome 1", "outcome 2"], + "whyThisMatters": "explanation referencing actual code changes" + } + ] +} + +Order scenarios: HIGH risk first, then MEDIUM risk. +Return ONLY JSON, no markdown or explanation.`; +} + +/** + * Extracts version from PR title (e.g., "release: 7.65.0" -> "7.65.0") + */ +function extractVersion(prTitle: string): string { + const match = prTitle.match(/(\d+\.\d+\.\d+)/); + return match ? match[1] : 'unknown'; +} + +/** + * Computes a risk score out of 100 based on high/medium risk scenario counts. + * Formula aligned with Extension team: min(100, round(10 * sqrt(highRisk * 4 + mediumRisk))) + * This gives higher weight to high-risk scenarios while capping at 100. + */ +function computeRiskScore( + highRiskCount: number, + mediumRiskCount: number, +): string { + const raw = 10 * Math.sqrt(highRiskCount * 4 + mediumRiskCount); + const score = Math.min(100, Math.round(raw)); + return `${score}/100`; +} + +/** + * Analyzes PR and generates test plan with single LLM call + * + * @param provider - LLM provider to use + * @param prInfo - PR information including files, sign-offs, etc. + * @param buildNumber - Optional build number for the RC + * @param excludedFeatures - Features behind feature flags to exclude from analysis + * @param releaseVersion - Optional version override (otherwise extracted from PR title) + */ +export async function analyzeWithSingleCall( + provider: ILLMProvider, + prInfo: PullRequestInfo, + buildNumber?: number, + excludedFeatures: string[] = [], + releaseVersion?: string, +): Promise { + console.log(`🤖 Analyzing with ${provider.displayName}...`); + + // Fetch actual diffs for high-impact files + console.log(` Fetching diffs for high-impact files...`); + const diffContent = getHighImpactDiffs(prInfo.number, prInfo.files); + if (diffContent) { + const diffLines = diffContent.split('\n').length; + console.log(` ✓ Got ${diffLines} lines of diff content`); + } else { + console.log(` ⚠ No high-impact diffs found, using file names only`); + } + + if (excludedFeatures.length > 0) { + console.log(` Disabled flags to exclude: ${excludedFeatures.length}`); + } + + const prompt = buildPrompt(prInfo, diffContent, excludedFeatures); + + console.log(` Generating test scenarios...`); + + const response = await provider.createMessage({ + model: provider.getDefaultModel(), + maxTokens: LLM_CONFIG.maxTokens, + temperature: 0, + messages: [{ role: 'user', content: prompt }], + }); + + // Extract text response + const textBlock = response.content.find((b) => b.type === 'text'); + if (!textBlock || textBlock.type !== 'text') { + throw new Error('No text response from LLM'); + } + + // Parse JSON from response (includes executiveSummary and scenarios) + const parsed = parseResponse(textBlock.text); + + console.log(` ✓ Generated ${parsed.scenarios.length} test scenarios`); + if (parsed.executiveSummary) { + console.log( + ` ✓ Executive summary: ${parsed.executiveSummary.overallRisk} risk\n`, + ); + } else { + console.log(''); + } + + // Calculate summary + const totalAdditions = prInfo.files.reduce((sum, f) => sum + f.additions, 0); + const totalDeletions = prInfo.files.reduce((sum, f) => sum + f.deletions, 0); + const highImpactFiles = prInfo.files.filter((f) => + isHighImpactFile(f.filename), + ).length; + + // Count risk levels for score calculation + const highRiskCount = parsed.scenarios.filter( + (s) => s.riskLevel === 'high', + ).length; + const mediumRiskCount = parsed.scenarios.filter( + (s) => s.riskLevel === 'medium', + ).length; + + return { + prNumber: prInfo.number, + prTitle: prInfo.title, + version: releaseVersion || extractVersion(prInfo.title), + buildNumber, + generatedAt: new Date().toISOString(), + model: response.model, + executiveSummary: parsed.executiveSummary, + summary: { + totalFiles: prInfo.actualFileCount || prInfo.files.length, + highImpactFiles, + totalAdditions, + totalDeletions, + highRiskCount, + mediumRiskCount, + releaseRiskScore: computeRiskScore(highRiskCount, mediumRiskCount), + }, + scenarios: parsed.scenarios, + signOffs: getSignOffSummary(prInfo.teamSignOffs), + excludedFeatures: + excludedFeatures.length > 0 ? excludedFeatures : undefined, + }; +} + +/** + * Parsed response from LLM including executive summary and scenarios + */ +interface ParsedLLMResponse { + executiveSummary?: ExecutiveSummary; + scenarios: TestScenario[]; +} + +/** + * Parses LLM response to extract executive summary and scenarios + */ +function parseResponse(responseText: string): ParsedLLMResponse { + // Try to extract JSON from response (may have markdown wrapping) + let jsonText = responseText.trim(); + + // Remove markdown code blocks if present + const jsonMatch = jsonText.match(/```(?:json)?\s*([\s\S]*?)```/); + if (jsonMatch) { + jsonText = jsonMatch[1].trim(); + } + + // Try to find JSON object + const objectMatch = jsonText.match(/\{[\s\S]*\}/); + if (objectMatch) { + jsonText = objectMatch[0]; + } + + try { + const parsed = JSON.parse(jsonText); + + // Extract executive summary if present + let executiveSummary: ExecutiveSummary | undefined; + if (parsed.executiveSummary) { + executiveSummary = { + releaseFocus: parsed.executiveSummary.releaseFocus || '', + keyChanges: parsed.executiveSummary.keyChanges || [], + criticalAreas: parsed.executiveSummary.criticalAreas || [], + overallRisk: parsed.executiveSummary.overallRisk || 'medium', + recommendation: parsed.executiveSummary.recommendation || '', + }; + } + + return { + executiveSummary, + scenarios: parsed.scenarios || [], + }; + } catch (error) { + console.error( + 'Failed to parse LLM response:', + responseText.substring(0, 500), + ); + throw new Error(`Failed to parse LLM response as JSON: ${error}`); + } +} + +// ============================================ +// DELTA MODE - Cherry-pick focused analysis +// ============================================ + +export interface DeltaTestScenario { + area: string; + cherryPick: string; + prNumber: string; + riskLevel: 'high' | 'medium'; + testSteps: string[]; + whyThisMatters: string; +} + +export interface DeltaAnalysisResult { + rcPrNumber: number; + fromCommit: string; + toCommit: string; + fromBuild?: number; + toBuild?: number; + version: string; + generatedAt: string; + model: string; + cherryPicks: CherryPickInfo[]; + scenarios: DeltaTestScenario[]; + signOffs: { + signedOff: string[]; + needsAttention: string[]; + }; +} + +/** + * Gets diffs for cherry-pick commits + * Returns summarized diffs for each cherry-pick, limited by total lines + */ +function getCherryPickDiffs( + cherryPicks: CherryPickInfo[], + baseDir: string, + maxLinesPerCommit = 200, + maxTotalLines = 1200, +): string { + const diffs: string[] = []; + let totalLines = 0; + let processedCount = 0; + + for (const cp of cherryPicks) { + if (totalLines >= maxTotalLines) { + const remaining = cherryPicks.length - processedCount; + if (remaining > 0) { + diffs.push(`\n... [${remaining} more cherry-picks truncated]`); + } + break; + } + processedCount++; + + try { + const diff = getCommitDiff(cp.commit, baseDir, maxLinesPerCommit); + if (diff?.trim()) { + // Filter to only show changed lines (+ and - lines) plus some context + const lines = diff.split('\n'); + const relevantLines = lines.filter( + (line) => + line.startsWith('+') || + line.startsWith('-') || + line.startsWith('@@') || + line.startsWith('diff --git'), + ); + + const summary = relevantLines.slice(0, maxLinesPerCommit).join('\n'); + const label = cp.prNumber || cp.commit.substring(0, 7); + diffs.push(`### ${label}: ${cp.message}\n${summary}`); + totalLines += summary.split('\n').length; + } + } catch { + // Skip commits we can't get diffs for + } + } + + return diffs.join('\n\n'); +} + +/** + * Builds prompt for delta/cherry-pick analysis + * Now includes actual diff content for cherry-picks + */ +function buildDeltaPrompt( + cherryPicks: CherryPickInfo[], + unsignedTeams: string[], + diffContent: string, +): string { + const cherryPickList = cherryPicks + .map((cp) => `- ${cp.prNumber || cp.commit.substring(0, 7)}: ${cp.message}`) + .join('\n'); + + const unsignedSection = + unsignedTeams.length > 0 + ? `\n## Teams that haven't signed off yet (need testing)\n${unsignedTeams.map((t) => `- ${t}`).join('\n')}\n` + : ''; + + const diffSection = diffContent + ? ` +## Actual Code Changes + +Below are the actual diffs for each cherry-pick. Use these to understand WHAT specifically changed: + +${diffContent} +` + : ''; + + return `You are a QA engineer for MetaMask Mobile doing RC (Release Candidate) testing before production release. + +## Context +This is RC testing - the build is installed via TestFlight (iOS) or direct APK (Android). You're testing the actual app behavior, not backend configurations or rollout percentages. + +## Cherry-picks in this build +${cherryPickList} +${unsignedSection}${diffSection} +## Your Task + +Analyze the ACTUAL CODE CHANGES shown above to generate practical test scenarios that QA can execute on an RC build: +1. **Each cherry-pick** - specific user flows affected by the actual code changes +2. **Each unsigned team's area** - smoke tests to verify basic functionality + +For each scenario provide: +1. **area**: Feature area (e.g., "Card", "Swaps", "Earn", "Onboarding") +2. **cherryPick**: The cherry-pick message (or "N/A" for unsigned team areas) +3. **prNumber**: PR number if available (or empty string) +4. **riskLevel**: "high" for cherry-picks, "medium" for unsigned areas +5. **testSteps**: 3-5 specific, actionable test steps based on the actual changes +6. **whyThisMatters**: Reference specific code changes that need testing + +## Important +- Focus on USER ACTIONS that can be performed on the RC build +- Don't suggest testing backend configs, rollout percentages, or A/B tests +- Test steps should be things QA can actually do: tap buttons, navigate screens, verify UI, check transactions +- Base your test steps on the ACTUAL CODE CHANGES, not generic testing + +## MetaMask-specific testing tips +- Feature flags: Check Settings > About MetaMask to verify "Remote Feature Flag Env" and "Remote Feature Flag Distribution" values +- For feature flag changes: Test that the feature controlled by the flag works correctly (appears/hidden based on flag state) +- Deep links: Test by opening links or receiving notifications +- Card/KYC: Test notification tap flows, login states, Baanx integration + +## Output Format + +Return ONLY valid JSON: +{ + "scenarios": [ + { + "area": "string", + "cherryPick": "string", + "prNumber": "string", + "riskLevel": "high" | "medium", + "testSteps": ["1. step one", "2. step two", "3. step three"], + "whyThisMatters": "explanation referencing actual code changes" + } + ] +} + +Return ONLY JSON, no markdown or explanation.`; +} + +/** + * Analyzes cherry-picks with LLM to generate focused test scenarios + */ +export async function analyzeDeltaWithLLM( + provider: ILLMProvider, + cherryPicks: CherryPickInfo[], + teamSignOffs: TeamSignOff[], + rcPrNumber: number, + fromCommit: string, + toCommit: string, + version: string, + baseDir: string, + fromBuild?: number, + toBuild?: number, +): Promise { + // Get teams that haven't signed off + const unsignedTeams = teamSignOffs + .filter((t) => !t.signedOff) + .map((t) => t.team); + + console.log( + `🤖 Analyzing ${cherryPicks.length} cherry-pick(s) + ${unsignedTeams.length} unsigned team(s) with ${provider.displayName}...`, + ); + + // Fetch actual diffs for cherry-picks + console.log(` Fetching diffs for cherry-pick commits...`); + const diffContent = getCherryPickDiffs(cherryPicks, baseDir); + if (diffContent) { + const diffLines = diffContent.split('\n').length; + console.log(` ✓ Got ${diffLines} lines of diff content`); + } else { + console.log(` ⚠ No cherry-pick diffs found, using commit messages only`); + } + + const prompt = buildDeltaPrompt(cherryPicks, unsignedTeams, diffContent); + + console.log(` Generating test scenarios...`); + + const response = await provider.createMessage({ + model: provider.getDefaultModel(), + maxTokens: LLM_CONFIG.maxTokens, + temperature: 0, + messages: [{ role: 'user', content: prompt }], + }); + + // Extract text response + const textBlock = response.content.find((b) => b.type === 'text'); + if (!textBlock || textBlock.type !== 'text') { + throw new Error('No text response from LLM'); + } + + // Parse JSON from response + const scenarios = parseDeltaResponse(textBlock.text); + + console.log( + ` ✓ Generated ${scenarios.length} test scenarios for cherry-picks\n`, + ); + + return { + rcPrNumber, + fromCommit, + toCommit, + fromBuild, + toBuild, + version, + generatedAt: new Date().toISOString(), + model: response.model, + cherryPicks, + scenarios, + signOffs: getSignOffSummary(teamSignOffs), + }; +} + +/** + * Parses LLM response for delta scenarios + */ +function parseDeltaResponse(responseText: string): DeltaTestScenario[] { + let jsonText = responseText.trim(); + + // Remove markdown code blocks if present + const jsonMatch = jsonText.match(/```(?:json)?\s*([\s\S]*?)```/); + if (jsonMatch) { + jsonText = jsonMatch[1].trim(); + } + + // Try to find JSON object + const objectMatch = jsonText.match(/\{[\s\S]*\}/); + if (objectMatch) { + jsonText = objectMatch[0]; + } + + try { + const parsed = JSON.parse(jsonText); + return parsed.scenarios || []; + } catch (error) { + console.error( + 'Failed to parse delta LLM response:', + responseText.substring(0, 500), + ); + throw new Error(`Failed to parse delta LLM response as JSON: ${error}`); + } +} + +// ============================================ +// COMBINED FORMAT - Aligned with Extension team +// ============================================ + +/** + * Combines initial test plan with cherry-pick scenarios into extension-aligned format + */ +export function createCombinedTestPlan( + initialResult: TestPlanResult, + deltaResult?: DeltaAnalysisResult, +): CombinedTestPlanResult { + // Convert delta scenarios to cherry-pick format + const cherryPickScenarios: CherryPickTestScenario[] = deltaResult + ? deltaResult.scenarios + .filter((s) => s.cherryPick && s.cherryPick !== 'N/A') + .map((s) => ({ + area: s.area, + riskLevel: s.riskLevel, + testSteps: s.testSteps, + whyThisMatters: s.whyThisMatters, + cherryPickPR: s.prNumber || undefined, + cherryPickMessage: s.cherryPick, + })) + : []; + + // Calculate risk counts including cherry-picks + const cpHighRisk = cherryPickScenarios.filter( + (s) => s.riskLevel === 'high', + ).length; + const cpMediumRisk = cherryPickScenarios.filter( + (s) => s.riskLevel === 'medium', + ).length; + + // Total risk counts for combined score + const totalHighRisk = initialResult.summary.highRiskCount + cpHighRisk; + const totalMediumRisk = initialResult.summary.mediumRiskCount + cpMediumRisk; + + return { + prNumber: initialResult.prNumber, + prTitle: initialResult.prTitle, + generatedAt: new Date().toISOString(), + modelUsed: initialResult.model, + version: initialResult.version, + buildNumber: initialResult.buildNumber, + executiveSummary: initialResult.executiveSummary, + summary: { + totalFilesChanged: initialResult.summary.totalFiles, + highImpactFiles: initialResult.summary.highImpactFiles, + highRiskScenarios: totalHighRisk, + mediumRiskScenarios: totalMediumRisk, + releaseRiskScore: computeRiskScore(totalHighRisk, totalMediumRisk), + cherryPickCount: deltaResult?.cherryPicks.length || 0, + initialBuild: deltaResult?.fromBuild, + currentBuild: deltaResult?.toBuild || initialResult.buildNumber, + }, + teamsNeedingSignOff: initialResult.signOffs.needsAttention, + testScenarios: { + cherryPickScenarios, + initialScenarios: initialResult.scenarios, + }, + excludedFeatures: initialResult.excludedFeatures, + }; +} diff --git a/tests/tools/e2e-ai-analyzer/modes/generate-test-plan/handlers.ts b/tests/tools/e2e-ai-analyzer/modes/generate-test-plan/handlers.ts new file mode 100644 index 00000000000..8989c8dbc2c --- /dev/null +++ b/tests/tools/e2e-ai-analyzer/modes/generate-test-plan/handlers.ts @@ -0,0 +1,736 @@ +/** + * Mode-specific logic for generating exploratory test plans + */ + +import { writeFileSync } from 'node:fs'; +import { + GenerateTestPlanAnalysis, + FeatureAreaTestPlan, + TestPlanSummary, + PlatformNotes, + TestAreaRisk, + TestingStatus, + BuildChangeInfo, + ExplorationTheme, + ExploratoryFocusArea, +} from '../../types'; +import { smokeTags, flaskTags } from '../../../../tags'; + +/** + * Human-friendly feature area names mapping + */ +const FEATURE_AREA_DISPLAY_NAMES: Record = { + // Tag names (PascalCase) + SmokeAccounts: 'Accounts', + SmokeConfirmations: 'Confirmations (Transactions/Signatures)', + SmokeIdentity: 'Identity (Profile Sync)', + SmokeNetworkAbstractions: 'Networks', + SmokeNetworkExpansion: 'Multi-Chain (Solana/Non-EVM)', + SmokeTrade: 'Trade (Swap/Bridge)', + SmokeWalletPlatform: 'Wallet Platform (Navigation/Core)', + SmokeCard: 'Card', + SmokePerps: 'Perps', + SmokeRamps: 'Buy/Sell (Ramps)', + SmokeMultiChainAPI: 'Multi-Chain API (CAIP-25)', + SmokePredictions: 'Predictions (Polymarket)', + FlaskBuildTests: 'Snaps (Flask)', + // Commit scope names (lowercase from conventional commits) + card: 'Card', + perps: 'Perps', + ramps: 'Buy/Sell (Ramps)', + trade: 'Trade (Swap/Bridge)', + swap: 'Trade (Swap/Bridge)', + bridge: 'Trade (Swap/Bridge)', + accounts: 'Accounts', + confirmations: 'Confirmations (Transactions/Signatures)', + identity: 'Identity (Profile Sync)', + networks: 'Networks', + snaps: 'Snaps (Flask)', + notifications: 'Notifications', + wallet: 'Wallet Platform (Navigation/Core)', +}; + +/** + * Get human-friendly display name for a feature area + */ +export function getFeatureAreaDisplayName(tag: string): string { + return FEATURE_AREA_DISPLAY_NAMES[tag] || tag; +} + +/** + * Feature areas derived from tags for test plan generation + */ +const allTags = { ...smokeTags, ...flaskTags }; + +export const FEATURE_AREAS_CONFIG = Object.values(allTags).map((config) => ({ + tag: config.tag.replace(':', ''), + displayName: + FEATURE_AREA_DISPLAY_NAMES[config.tag.replace(':', '')] || + config.tag.replace(':', ''), + description: config.description, +})); + +/** + * Creates an empty platform notes object + */ +function createEmptyPlatformNotes(): PlatformNotes { + return { ios: [], android: [], shared: [] }; +} + +/** + * Creates default testing status (not tested) + */ +function createDefaultTestingStatus(): TestingStatus { + return { + tested: false, + testedBy: [], + }; +} + +/** + * Creates default build change info (not new) + */ +function createDefaultBuildChangeInfo(): BuildChangeInfo { + return { isNewInBuild: false }; +} + +/** + * Default exploration themes applicable to any mobile app testing + */ +const DEFAULT_EXPLORATION_THEMES: ExplorationTheme[] = [ + { + name: 'Interruption Testing', + description: 'Test how the app handles interruptions during critical flows', + techniques: [ + 'Background the app mid-flow and resume', + 'Kill the app and reopen', + 'Receive a phone call during a transaction', + 'Lock/unlock the device', + 'Switch to another app and back', + ], + applicableAreas: [], + }, + { + name: 'Connectivity Testing', + description: 'Test behavior under various network conditions', + techniques: [ + 'Toggle airplane mode during operations', + 'Switch between WiFi and cellular', + 'Test with slow/throttled network', + 'Test offline → online transitions', + 'Test with VPN connected/disconnected', + ], + applicableAreas: [], + }, + { + name: 'Boundary Testing', + description: 'Test extreme values and edge cases in inputs', + techniques: [ + 'Test with maximum allowed values', + 'Test with minimum/zero values', + 'Test with empty inputs', + 'Test with special characters and unicode', + 'Test with very long strings', + ], + applicableAreas: [], + }, + { + name: 'State Permutation', + description: 'Test different combinations of app/user states', + techniques: [ + 'Test with different wallet types (imported, hardware, fresh)', + 'Test with multiple accounts', + 'Test with different network configurations', + 'Test as new user vs returning user', + 'Test with different permission states', + ], + applicableAreas: [], + }, + { + name: 'Platform-Specific Exploration', + description: 'Explore iOS and Android specific behaviors', + techniques: [ + 'Test gesture navigation vs button navigation (Android)', + 'Test with different notch/Dynamic Island configurations (iOS)', + 'Test with accessibility features enabled', + 'Test with different font sizes/display scales', + 'Test with dark mode vs light mode', + ], + applicableAreas: [], + }, + { + name: 'Hardware Wallet Testing', + description: 'Test flows with connected hardware wallets', + techniques: [ + 'Test with Ledger connected via USB/Bluetooth', + 'Test transaction signing with hardware wallet', + 'Test hardware wallet disconnection mid-flow', + 'Test with multiple hardware wallet accounts', + 'Test hardware wallet firmware edge cases', + ], + applicableAreas: ['SmokeConfirmations', 'SmokeAccounts', 'SmokeTrade'], + }, + { + name: 'Test dApp Exploration', + description: 'Use test dApps to explore wallet-dApp interactions', + techniques: [ + 'Test with MetaMask Test Dapp (test-dapp.metamask.io)', + 'Explore signature request variations', + 'Test transaction parameter edge cases', + 'Test chain switching requests', + 'Test permission request flows', + ], + applicableAreas: ['SmokeConfirmations', 'SmokeMultiChainAPI'], + }, +]; + +/** + * Creates an empty test plan when no changes detected + */ +export function createEmptyResult(): GenerateTestPlanAnalysis { + return { + summary: { + totalChangedFiles: 0, + totalCommits: 0, + criticalAreas: 0, + highRiskAreas: 0, + mediumRiskAreas: 0, + lowRiskAreas: 0, + estimatedTestingHours: '0', + releaseVersion: 'unknown', + areasTestedCount: 0, + areasNotTestedCount: 0, + newInThisBuildCount: 0, + }, + featureAreas: [], + crossCuttingConcerns: [], + regressionFocusAreas: [], + platformSpecificGuidance: createEmptyPlatformNotes(), + explorationThemes: [], + exploratoryFocusAreas: [], + cherryPicks: [], + reasoning: 'No files changed - no test plan needed', + confidence: 100, + generatedAt: new Date().toISOString(), + }; +} + +/** + * Creates a conservative test plan when AI fails + */ +export function createConservativeResult(): GenerateTestPlanAnalysis { + const allFeatureAreas: FeatureAreaTestPlan[] = FEATURE_AREAS_CONFIG.map( + (config, index) => ({ + featureArea: config.tag, + displayName: config.displayName, + riskLevel: 'high' as TestAreaRisk, + riskJustification: + 'AI analysis failed - comprehensive testing recommended', + impactedComponents: ['Unknown - manual review needed'], + exploratoryScenarios: [ + { + id: `${config.tag}-fallback-1`, + title: `Full ${config.displayName} regression`, + description: `Perform comprehensive regression testing for ${config.displayName}`, + preconditions: ['App installed', 'Valid test account available'], + explorationGuidance: [ + 'Test all major user flows', + 'Check edge cases', + ], + riskIndicators: ['AI analysis unavailable'], + relatedChanges: [], + }, + ], + platformNotes: { + ios: ['Test on iOS simulator and device'], + android: ['Test on Android emulator and device'], + shared: ['Verify feature parity between platforms'], + }, + priority: index + 1, + testingStatus: createDefaultTestingStatus(), + buildChangeInfo: createDefaultBuildChangeInfo(), + exploratoryPriority: 5, // Medium priority when AI fails + explorationCharters: [ + { + id: `${config.tag}-charter-1`, + mission: `Explore ${config.displayName} for unexpected behaviors`, + context: 'AI analysis unavailable - broad exploration recommended', + whatIfs: [ + 'What if the user interrupts the flow mid-way?', + 'What if network conditions change?', + 'What if the user has unusual account state?', + ], + timeBox: '30 minutes', + }, + ], + }), + ); + + return { + summary: { + totalChangedFiles: 0, + totalCommits: 0, + criticalAreas: allFeatureAreas.length, + highRiskAreas: allFeatureAreas.length, + mediumRiskAreas: 0, + lowRiskAreas: 0, + estimatedTestingHours: '8-12', + releaseVersion: 'unknown', + areasTestedCount: 0, + areasNotTestedCount: allFeatureAreas.length, + newInThisBuildCount: 0, + }, + featureAreas: allFeatureAreas, + crossCuttingConcerns: ['AI analysis failed - full regression recommended'], + regressionFocusAreas: FEATURE_AREAS_CONFIG.map((c) => c.tag), + platformSpecificGuidance: { + ios: ['Full iOS regression required'], + android: ['Full Android regression required'], + shared: ['Test all shared functionality'], + }, + explorationThemes: DEFAULT_EXPLORATION_THEMES, + exploratoryFocusAreas: allFeatureAreas.slice(0, 5).map((area) => ({ + featureArea: area.featureArea, + displayName: area.displayName, + exploratoryPriority: area.exploratoryPriority, + reason: 'AI analysis unavailable - all areas require exploration', + suggestedTimeBox: '30 minutes', + })), + cherryPicks: [], + reasoning: + 'Fallback: AI analysis did not complete successfully. Comprehensive testing recommended.', + confidence: 0, + generatedAt: new Date().toISOString(), + }; +} + +/** + * Processes AI response and returns test plan analysis + */ +export async function processAnalysis( + aiResponse: string, + _baseDir: string, +): Promise { + // Parse JSON from AI response + const jsonMatch = aiResponse.match( + /\{[\s\S]*"summary"[\s\S]*"feature_areas"[\s\S]*\}/, + ); + + if (!jsonMatch) { + return null; + } + + try { + const parsed = JSON.parse(jsonMatch[0]); + + // Validate required fields + if (!parsed.summary || !Array.isArray(parsed.feature_areas)) { + return null; + } + + // Transform snake_case to camelCase + /* eslint-disable @typescript-eslint/no-explicit-any */ + const featureAreas: FeatureAreaTestPlan[] = parsed.feature_areas.map( + (area: any) => ({ + featureArea: area.feature_area, + displayName: getFeatureAreaDisplayName(area.feature_area), + riskLevel: area.risk_level, + riskJustification: area.risk_justification || '', + impactedComponents: area.impacted_components || [], + exploratoryScenarios: (area.exploratory_scenarios || []).map( + (s: any) => ({ + id: s.id, + title: s.title, + description: s.description, + preconditions: s.preconditions || [], + explorationGuidance: s.exploration_guidance || [], + riskIndicators: s.risk_indicators || [], + relatedChanges: s.related_changes || [], + }), + ), + platformNotes: { + ios: area.platform_notes?.ios || [], + android: area.platform_notes?.android || [], + shared: area.platform_notes?.shared || [], + }, + priority: area.priority || 999, + testingStatus: area.testing_status + ? { + tested: area.testing_status.tested || false, + testedBy: area.testing_status.tested_by || [], + testedDate: area.testing_status.tested_date, + } + : createDefaultTestingStatus(), + buildChangeInfo: area.build_change_info + ? { + isNewInBuild: area.build_change_info.is_new_in_build || false, + buildNumber: area.build_change_info.build_number, + relatedPRs: area.build_change_info.related_prs, + changeType: area.build_change_info.change_type, + } + : createDefaultBuildChangeInfo(), + exploratoryPriority: area.exploratory_priority || 5, + explorationCharters: (area.exploration_charters || []).map( + (charter: any) => ({ + id: charter.id, + mission: charter.mission, + context: charter.context || '', + whatIfs: charter.what_ifs || [], + timeBox: charter.time_box, + }), + ), + }), + ); + /* eslint-enable @typescript-eslint/no-explicit-any */ + + // Calculate testing status counts from feature areas + const areasTestedCount = featureAreas.filter( + (a) => a.testingStatus.tested, + ).length; + const areasNotTestedCount = featureAreas.filter( + (a) => !a.testingStatus.tested, + ).length; + const newInThisBuildCount = featureAreas.filter( + (a) => a.buildChangeInfo.isNewInBuild, + ).length; + + const summary: TestPlanSummary = { + totalChangedFiles: parsed.summary.total_changed_files || 0, + totalCommits: parsed.summary.total_commits || 0, + criticalAreas: parsed.summary.critical_areas || 0, + highRiskAreas: parsed.summary.high_risk_areas || 0, + mediumRiskAreas: parsed.summary.medium_risk_areas || 0, + lowRiskAreas: parsed.summary.low_risk_areas || 0, + estimatedTestingHours: + parsed.summary.estimated_testing_hours || 'unknown', + releaseVersion: parsed.summary.release_version || 'unknown', + buildNumber: parsed.summary.build_number, + previousBuildNumber: parsed.summary.previous_build_number, + areasTestedCount, + areasNotTestedCount, + newInThisBuildCount, + }; + + // Parse exploration themes (use defaults if not provided) + const explorationThemes: ExplorationTheme[] = parsed.exploration_themes + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any + parsed.exploration_themes.map((theme: any) => ({ + name: theme.name, + description: theme.description, + techniques: theme.techniques || [], + applicableAreas: theme.applicable_areas || [], + })) + : DEFAULT_EXPLORATION_THEMES; + + // Parse or derive exploratory focus areas + const exploratoryFocusAreas: ExploratoryFocusArea[] = + parsed.exploratory_focus_areas + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any + parsed.exploratory_focus_areas.map((focus: any) => ({ + featureArea: focus.feature_area, + displayName: getFeatureAreaDisplayName(focus.feature_area), + exploratoryPriority: focus.exploratory_priority, + reason: focus.reason, + suggestedTimeBox: focus.suggested_time_box || '30 minutes', + })) + : // Derive from top 5 feature areas by exploratory priority + featureAreas + .sort((a, b) => b.exploratoryPriority - a.exploratoryPriority) + .slice(0, 5) + .map((area) => ({ + featureArea: area.featureArea, + displayName: area.displayName, + exploratoryPriority: area.exploratoryPriority, + reason: area.riskJustification, + suggestedTimeBox: '30 minutes', + })); + + return { + summary, + featureAreas: [...featureAreas].sort((a, b) => a.priority - b.priority), + crossCuttingConcerns: parsed.cross_cutting_concerns || [], + regressionFocusAreas: parsed.regression_focus_areas || [], + platformSpecificGuidance: { + ios: parsed.platform_specific_guidance?.ios || [], + android: parsed.platform_specific_guidance?.android || [], + shared: parsed.platform_specific_guidance?.shared || [], + }, + explorationThemes, + exploratoryFocusAreas, + cherryPicks: [], // Will be populated by enrichWithPRTracking if build comparison is used + reasoning: parsed.reasoning || '', + confidence: Math.min(100, Math.max(0, parsed.confidence || 0)), + generatedAt: new Date().toISOString(), + }; + } catch { + return null; + } +} + +/** + * Generates markdown formatted test plan + * If cherry-picks exist (build comparison), outputs a focused delta view + */ +function generateMarkdownTestPlan(analysis: GenerateTestPlanAnalysis): string { + const lines: string[] = []; + const generatedDate = analysis.generatedAt.split('T')[0]; + const version = analysis.summary.releaseVersion; + const isDeltaMode = analysis.cherryPicks.length > 0; + + // Delta mode: focused output for build comparison + if (isDeltaMode) { + const currentLabel = + analysis.summary.buildNumber || + analysis.summary.toCommit?.substring(0, 7) || + 'current'; + const previousLabel = + analysis.summary.previousBuildNumber || + analysis.summary.fromCommit?.substring(0, 7) || + 'previous'; + + lines.push(`# 🍒 Build Delta (${previousLabel} → ${currentLabel})`); + lines.push(`Release ${version} | Generated: ${generatedDate}`); + lines.push(''); + + // Areas needing re-testing + const areasWithNewChanges = analysis.featureAreas.filter( + (a) => a.buildChangeInfo.isNewInBuild, + ); + + if (areasWithNewChanges.length > 0) { + lines.push(`## ${areasWithNewChanges.length} area(s) need re-testing`); + lines.push(''); + + for (const area of areasWithNewChanges) { + const testedStatus = area.testingStatus.tested ? '✅' : '⏳'; + const testers = + area.testingStatus.testedBy.length > 0 + ? area.testingStatus.testedBy.join(', ') + : 'Not assigned'; + + lines.push(`### ${area.displayName} ${testedStatus}`); + lines.push(`**Tested by:** ${testers}`); + lines.push(''); + lines.push('**Cherry-picks:**'); + + // Show cherry-picks for this area + const areaCherryPicks = analysis.cherryPicks.filter( + (cp) => cp.featureArea === area.featureArea, + ); + for (const cp of areaCherryPicks) { + lines.push(`- ${cp.prNumber || cp.commit}: ${cp.message}`); + } + lines.push(''); + } + } else { + lines.push('## No areas need re-testing'); + lines.push(''); + } + + // Summary of unchanged areas + const unchangedAreas = analysis.featureAreas.filter( + (a) => !a.buildChangeInfo.isNewInBuild, + ); + if (unchangedAreas.length > 0) { + lines.push(`## ${unchangedAreas.length} area(s) unchanged`); + unchangedAreas.forEach((area) => { + const testedStatus = area.testingStatus.tested ? '✅' : '⏳'; + lines.push(`- ${area.displayName} ${testedStatus}`); + }); + } + + return lines.join('\n'); + } + + // Full test plan mode (no cherry-picks) + const title = + version && version !== 'unknown' + ? `Mobile Release ${version} Testing Plan` + : `Mobile Release Testing Plan`; + lines.push(title); + lines.push(`Generated: ${generatedDate}`); + lines.push(''); + + // Feature areas (no header, just list areas) + analysis.featureAreas.forEach((area) => { + const riskBadge = { + critical: '`CRITICAL`', + high: '`HIGH`', + medium: '`MEDIUM`', + low: '`LOW`', + }[area.riskLevel]; + + // Use displayName for the header, fallback to featureArea + const displayName = area.displayName || area.featureArea; + const exploratoryIndicator = area.exploratoryPriority >= 7 ? ' 🔍' : ''; + lines.push( + `### ${area.priority}. ${displayName} ${riskBadge}${exploratoryIndicator}`, + ); + lines.push(''); + + // Testing status + if (area.testingStatus.tested) { + const testers = area.testingStatus.testedBy.join(', ') || 'Unknown'; + lines.push(`**Tested by:** ${testers} ✅`); + } else { + const testers = + area.testingStatus.testedBy.length > 0 + ? area.testingStatus.testedBy.join(', ') + : 'Pending'; + lines.push(`**Tested by:** ${testers} ⏳`); + } + + // Build change info + if (area.buildChangeInfo.isNewInBuild) { + const buildNum = area.buildChangeInfo.buildNumber + ? ` Build ${area.buildChangeInfo.buildNumber}` + : ''; + const changeType = area.buildChangeInfo.changeType + ? ` (${area.buildChangeInfo.changeType})` + : ''; + const relatedPRsText = area.buildChangeInfo.relatedPRs?.length + ? ` - ${area.buildChangeInfo.relatedPRs.join(', ')}` + : ''; + lines.push(`**New in${buildNum}:** Yes${changeType}${relatedPRsText}`); + } + lines.push(''); + + // Exploratory scenarios - just titles + if (area.exploratoryScenarios.length > 0) { + lines.push(`**Test Scenarios:**`); + area.exploratoryScenarios.forEach((scenario) => { + lines.push(`- ${scenario.title}`); + }); + lines.push(''); + } + + lines.push(''); + }); + + return lines.join('\n'); +} + +/** + * Outputs analysis results to console and files + */ +export function outputAnalysis(analysis: GenerateTestPlanAnalysis): void { + const isDeltaMode = analysis.cherryPicks.length > 0; + const jsonOutputFile = isDeltaMode + ? 'release-delta.json' + : 'release-test-plan.json'; + const mdOutputFile = isDeltaMode + ? 'release-delta.md' + : 'release-test-plan.md'; + + if (isDeltaMode) { + // Delta mode: focused console output + const currentLabel = + analysis.summary.buildNumber || + analysis.summary.toCommit?.substring(0, 7) || + 'current'; + const previousLabel = + analysis.summary.previousBuildNumber || + analysis.summary.fromCommit?.substring(0, 7) || + 'previous'; + + console.log(`\n🍒 Build Delta (${previousLabel} → ${currentLabel})`); + console.log('==================================='); + console.log(` Release: ${analysis.summary.releaseVersion}`); + console.log(` Cherry-picks: ${analysis.cherryPicks.length}`); + + const areasWithNewChanges = analysis.featureAreas.filter( + (a) => a.buildChangeInfo.isNewInBuild, + ); + const unchangedAreas = analysis.featureAreas.filter( + (a) => !a.buildChangeInfo.isNewInBuild, + ); + + if (areasWithNewChanges.length > 0) { + console.log( + `\n🔄 ${areasWithNewChanges.length} area(s) need re-testing:`, + ); + areasWithNewChanges.forEach((area) => { + const testedStatus = area.testingStatus.tested ? '✅' : '⏳'; + const testers = + area.testingStatus.testedBy.length > 0 + ? area.testingStatus.testedBy.join(', ') + : 'Not assigned'; + console.log(` - ${area.displayName} ${testedStatus} (${testers})`); + }); + } else { + console.log(`\n✅ No areas need re-testing`); + } + + console.log(`\n📋 ${unchangedAreas.length} area(s) unchanged`); + } else { + // Full test plan mode + console.log('\n📋 Release Test Plan Generator'); + console.log('==================================='); + console.log(`📊 Summary:`); + console.log(` Release: ${analysis.summary.releaseVersion}`); + if (analysis.summary.buildNumber) { + console.log(` Build: ${analysis.summary.buildNumber}`); + } + console.log(` Files changed: ${analysis.summary.totalChangedFiles}`); + console.log(` Critical areas: ${analysis.summary.criticalAreas}`); + console.log(` High risk areas: ${analysis.summary.highRiskAreas}`); + console.log(`📊 Testing Progress:`); + console.log(` Areas tested: ${analysis.summary.areasTestedCount}`); + console.log(` Areas not tested: ${analysis.summary.areasNotTestedCount}`); + console.log( + ` New in this build: ${analysis.summary.newInThisBuildCount}`, + ); + console.log(`💭 Reasoning: ${analysis.reasoning}`); + + // Exploratory Focus Summary + if (analysis.exploratoryFocusAreas.length > 0) { + console.log('\n🔍 Top Exploratory Testing Focus:'); + analysis.exploratoryFocusAreas.forEach((focus, index) => { + const bar = + '█'.repeat(focus.exploratoryPriority) + + '░'.repeat(10 - focus.exploratoryPriority); + console.log( + ` ${index + 1}. ${focus.displayName} [${bar}] ${focus.exploratoryPriority}/10`, + ); + }); + } + + // Feature areas summary + console.log('\n🎯 Feature Areas by Priority:'); + analysis.featureAreas.forEach((area, index) => { + const riskEmoji = { + critical: '🔴', + high: '🟠', + medium: '🟡', + low: '🟢', + }[area.riskLevel]; + const displayName = area.displayName || area.featureArea; + const testedStatus = area.testingStatus.tested ? '✅' : '⏳'; + const newInBuild = area.buildChangeInfo.isNewInBuild ? ' 🆕' : ''; + const exploratoryFlag = area.exploratoryPriority >= 7 ? ' 🔍' : ''; + console.log( + ` ${index + 1}. ${riskEmoji} ${displayName} (${area.riskLevel}) ${testedStatus}${newInBuild}${exploratoryFlag}`, + ); + console.log(` Scenarios: ${area.exploratoryScenarios.length}`); + }); + } + + // Generate markdown output + const markdown = generateMarkdownTestPlan(analysis); + + // Write outputs + if (process.env.CI === 'true' || process.env.GENERATE_FILES === 'true') { + writeFileSync(jsonOutputFile, JSON.stringify(analysis, null, 2)); + writeFileSync(mdOutputFile, markdown); + console.log(`\n📄 Output written to:`); + console.log(` - ${jsonOutputFile}`); + console.log(` - ${mdOutputFile}`); + } else { + console.log('\n📝 Markdown Preview (first 2000 chars):'); + console.log(markdown.substring(0, 2000)); + if (markdown.length > 2000) { + console.log('...[truncated]'); + } + console.log('\n💡 Set GENERATE_FILES=true to write output files'); + } +} diff --git a/tests/tools/e2e-ai-analyzer/modes/generate-test-plan/prompt.ts b/tests/tools/e2e-ai-analyzer/modes/generate-test-plan/prompt.ts new file mode 100644 index 00000000000..dd95e1e22f6 --- /dev/null +++ b/tests/tools/e2e-ai-analyzer/modes/generate-test-plan/prompt.ts @@ -0,0 +1,220 @@ +/** + * Mode-specific prompts for generating exploratory test plans + */ + +import { FEATURE_AREAS_CONFIG } from './handlers'; +import { + buildCriticalPatternsSection, + buildToolsSection, + buildReasoningSection, +} from '../shared/base-system-prompt'; +import { LLM_CONFIG } from '../../config'; +import { SkillMetadata, AnalysisContext } from '../../types'; + +/** + * Builds the system prompt for test plan generation + * + * @param availableSkills - Metadata for available skills (loaded on-demand) + */ +export function buildSystemPrompt(availableSkills: SkillMetadata[]): string { + const role = `You are a senior QA engineer and test architect specializing in mobile application testing for MetaMask Mobile. Your expertise includes exploratory testing, risk-based testing strategies, and release quality assurance.`; + + const goal = `GOAL: Analyze all code changes in a release branch (compared to main) and generate a comprehensive exploratory test plan. The plan should identify high-risk areas, provide specific test scenarios, and include platform-specific considerations for iOS and Android.`; + + // Build available skills section + const skillsSection = + availableSkills.length > 0 + ? `AVAILABLE SKILLS: + +${availableSkills + .map( + (skill) => + `- ${skill.name}: ${skill.description}${skill.tools ? `\n Tools: ${skill.tools}` : ''}`, + ) + .join('\n')}` + : ''; + + const testPlanPrinciples = `TEST PLAN PRINCIPLES: +1. **Risk-Based Prioritization**: Focus testing effort on areas most likely to have defects or highest user impact +2. **Exploratory Over Scripted**: Provide guidance for exploration rather than step-by-step scripts +3. **Platform Awareness**: Consider iOS and Android differences in rendering, permissions, and behavior +4. **Change Correlation**: Link test scenarios directly to code changes that triggered them +5. **Practical Focus**: Scenarios should be achievable within typical release testing timeframes`; + + const exploratoryGuidance = `EXPLORATORY TEST SCENARIO GUIDELINES: +- Each scenario should have a clear exploration goal +- Include "what to look for" rather than exact steps +- Identify risk indicators (signs of problems) +- Consider edge cases and error conditions +- Think about user workflows, not just features +- Consider performance implications +- Include both happy path and negative testing angles + +EXPLORATORY PRIORITY SCORE (1-10): +Rate each feature area's need for exploratory testing based on: +- **Complexity** (7-10): Many code paths, state combinations, integrations +- **Newness** (7-10): New features or significant refactors +- **Integration Risk** (6-9): Multiple systems interacting +- **Historical Issues** (5-8): Areas that have had bugs before +- **Standard Changes** (3-5): Routine updates, minor fixes +- **Low Risk** (1-3): Documentation, comments, test-only changes`; + + const explorationThemesGuidance = `EXPLORATION THEMES (cross-cutting approaches): +Generate exploration themes that apply across multiple feature areas. These are general testing approaches, not feature-specific. Examples: + +- **Interruption Testing**: Background app, kill app, phone calls, lock/unlock device +- **Connectivity Testing**: Airplane mode, WiFi/cellular switching, slow networks +- **Boundary Testing**: Max/min values, empty inputs, special characters, long strings +- **State Permutation**: Different wallet types, account states, network configurations +- **Hardware Wallet Testing**: Ledger/Trezor flows, disconnection scenarios, signing +- **Test dApp Exploration**: Use test dApps (test-dapp.metamask.io) for dApp interactions + +For each theme, identify which feature areas it's especially relevant for based on the current changes.`; + + const platformGuidance = `PLATFORM CONSIDERATIONS: +iOS-specific: +- Push notification permissions and handling +- Face ID / Touch ID integration +- iOS-specific navigation patterns (swipe gestures) +- App backgrounding and state restoration +- iOS keyboard behaviors +- SafeArea and notch handling + +Android-specific: +- Back button behavior +- Android permissions model +- Fragment lifecycle considerations +- Various screen sizes and densities +- Android keyboard behaviors +- Deep link handling differences + +Cross-platform: +- Feature parity verification +- UI consistency +- Performance comparison +- Network handling differences`; + + const riskAssessmentGuidance = `RISK LEVEL CRITERIA: +- **Critical**: Core wallet functionality (funds, transactions, keys), security-related, affects all users +- **High**: Major features, significant user flows, integration points between systems +- **Medium**: Standard feature changes, UI updates with moderate complexity +- **Low**: Documentation, minor UI tweaks, test-only changes, comments`; + + const outputStructure = `OUTPUT STRUCTURE: +Generate a test plan with: +1. **Summary**: High-level metrics (changed files count, risk area counts, estimated testing hours) +2. **Feature Areas**: Organized by the smoke test categories, prioritized by risk (priority 1 = highest) + - Include exploratory_priority (1-10) for each area +3. **Exploratory Scenarios**: Specific areas to explore within each feature (id, title, description, preconditions, exploration_guidance, risk_indicators, related_changes) +4. **Platform Guidance**: iOS and Android specific notes at both global and feature-area levels`; + + const prompt = [ + role, + goal, + skillsSection, + buildReasoningSection(), + buildToolsSection(), + buildCriticalPatternsSection(), + testPlanPrinciples, + exploratoryGuidance, + explorationThemesGuidance, + platformGuidance, + riskAssessmentGuidance, + outputStructure, + `Maximum iterations: ${LLM_CONFIG.maxIterations}. Investigate thoroughly but finalize before reaching the limit.`, + ] + .filter((section) => section) + .join('\n\n'); + + return prompt; +} + +/** + * Builds the task prompt with changed files and context + * + * @param allFiles - All changed files + * @param criticalFiles - Critical files that need attention + * @param _context - Analysis context (unused) + */ +export function buildTaskPrompt( + allFiles: string[], + criticalFiles: string[], + _context: AnalysisContext, +): string { + // Build feature area coverage list + const featureAreaList = FEATURE_AREAS_CONFIG.map( + (config) => `- ${config.tag}: ${config.description}`, + ).join('\n'); + + // Build file lists + const otherFiles = allFiles.filter((f) => !criticalFiles.includes(f)); + const fileList: string[] = []; + + if (criticalFiles.length > 0) { + fileList.push('⚠️ CRITICAL FILES (require careful analysis):'); + criticalFiles.forEach((f) => fileList.push(` ${f}`)); + fileList.push(''); + } + + if (otherFiles.length > 0) { + fileList.push(`OTHER FILES (${otherFiles.length}):`); + // Group by directory for easier reading + const byDir: Record = {}; + otherFiles.forEach((f) => { + const dir = f.split('/').slice(0, -1).join('/') || '.'; + if (!byDir[dir]) byDir[dir] = []; + byDir[dir].push(f); + }); + Object.entries(byDir).forEach(([dir, files]) => { + fileList.push(` ${dir}/`); + files.forEach((f) => fileList.push(` ${f.split('/').pop()}`)); + }); + } + + const instruction = `Analyze all changed files for this release and generate a comprehensive exploratory test plan. + +Your task: +1. Investigate the changes using available tools (read files, get diffs, find related files) +2. Identify which FEATURE AREAS are impacted +3. Assess risk level for each impacted area (critical, high, medium, low) +4. Generate specific exploratory test scenarios for each impacted area +5. Note any platform-specific (iOS/Android) considerations +6. Identify cross-cutting concerns that span multiple areas + +⛔ DO NOT GUESS file paths - only use paths from: the CHANGED FILES list below, list_directory results, or find_related_files results.`; + + const featureAreasSection = `FEATURE AREAS (map changes to these categories - only include areas that are actually impacted): +${featureAreaList}`; + + const filesSection = `CHANGED FILES IN THIS RELEASE (${allFiles.length} total): +${fileList.join('\n')}`; + + const investigationGuidance = `INVESTIGATION APPROACH: +1. Start with CRITICAL FILES - use get_git_diff to understand what changed +2. For complex changes, use find_related_files to understand impact scope +3. For each impacted area, identify specific user-facing behaviors to test +4. Consider both direct impacts (feature changed) and indirect impacts (dependent features) +5. Use grep_codebase to find usage patterns if needed +6. Use read_file to examine implementation details when risk assessment is unclear`; + + const closing = `When ready, call finalize_test_plan_generation with your complete analysis. Include: +- summary: { total_changed_files, total_commits, critical_areas, high_risk_areas, medium_risk_areas, low_risk_areas, estimated_testing_hours, release_version } +- feature_areas: array of { + feature_area, risk_level, risk_justification, impacted_components, + exploratory_scenarios (MAX 3 - use SPECIFIC action-based titles like "Test swap with insufficient gas" or "Verify bridge transaction on Polygon", NOT vague titles like "Platform Stability"), + platform_notes, priority, + exploratory_priority (1-10 score) + } +- platform_specific_guidance: { ios: [], android: [], shared: [] } (leave empty arrays) +- reasoning: brief explanation of your analysis approach`; + + return [ + instruction, + featureAreasSection, + filesSection, + investigationGuidance, + closing, + ] + .filter((section) => section) + .join('\n\n'); +} diff --git a/tests/tools/e2e-ai-analyzer/modes/select-tags/prompt.ts b/tests/tools/e2e-ai-analyzer/modes/select-tags/prompt.ts index 4a9b23cad36..a858256a093 100644 --- a/tests/tools/e2e-ai-analyzer/modes/select-tags/prompt.ts +++ b/tests/tools/e2e-ai-analyzer/modes/select-tags/prompt.ts @@ -11,7 +11,7 @@ import { buildRiskAssessmentSection, } from '../shared/base-system-prompt'; import { LLM_CONFIG } from '../../config'; -import { SkillMetadata } from '../../types'; +import { SkillMetadata, AnalysisContext } from '../../types'; /** * Builds the system prompt, i.e. the initial system message @@ -73,10 +73,15 @@ Performance tests measure app responsiveness and render times. Select performanc /** * Builds the task prompt, i.e. the initial user message + * + * @param allFiles - All changed files + * @param criticalFiles - Critical files that need attention + * @param _context - Analysis context (unused in select-tags mode) */ export function buildTaskPrompt( allFiles: string[], criticalFiles: string[], + _context: AnalysisContext, ): string { // Build E2E tag coverage list const tagCoverageList = SELECT_TAGS_CONFIG.map( diff --git a/tests/tools/e2e-ai-analyzer/providers/anthropic-provider.ts b/tests/tools/e2e-ai-analyzer/providers/anthropic-provider.ts index e7a8a2f9141..c444159b078 100644 --- a/tests/tools/e2e-ai-analyzer/providers/anthropic-provider.ts +++ b/tests/tools/e2e-ai-analyzer/providers/anthropic-provider.ts @@ -128,6 +128,22 @@ export class AnthropicProvider implements ILLMProvider { anthropicRequest.tools = toAnthropicTools(request.tools); } + // Use streaming for Opus models to avoid timeout issues + const isOpus = request.model.includes('opus'); + if (isOpus) { + const stream = client.messages.stream(anthropicRequest); + const response = await stream.finalMessage(); + return { + content: fromAnthropicContent(response.content), + model: response.model, + stopReason: response.stop_reason || 'end_turn', + usage: { + inputTokens: response.usage.input_tokens, + outputTokens: response.usage.output_tokens, + }, + }; + } + const response = await client.messages.create(anthropicRequest); return { diff --git a/tests/tools/e2e-ai-analyzer/providers/provider-factory.ts b/tests/tools/e2e-ai-analyzer/providers/provider-factory.ts index ed9aa70e0f5..971bd691c37 100644 --- a/tests/tools/e2e-ai-analyzer/providers/provider-factory.ts +++ b/tests/tools/e2e-ai-analyzer/providers/provider-factory.ts @@ -28,11 +28,11 @@ const PROVIDER_CONSTRUCTORS: Record ILLMProvider> = { * @throws Error if provider type is unknown */ export function createProvider(type: ProviderType): ILLMProvider { - const constructor = PROVIDER_CONSTRUCTORS[type]; - if (!constructor) { + const ProviderConstructor = PROVIDER_CONSTRUCTORS[type]; + if (!ProviderConstructor) { throw new Error(`Unknown provider type: ${type}`); } - return constructor(); + return ProviderConstructor(); } /** diff --git a/tests/tools/e2e-ai-analyzer/providers/types.ts b/tests/tools/e2e-ai-analyzer/providers/types.ts index a6827d74988..0f61e56bc04 100644 --- a/tests/tools/e2e-ai-analyzer/providers/types.ts +++ b/tests/tools/e2e-ai-analyzer/providers/types.ts @@ -94,4 +94,5 @@ export interface LLMResponse { export interface ProviderConfig { model: string; envKey: string; + baseUrl?: string; } diff --git a/tests/tools/e2e-ai-analyzer/types/index.ts b/tests/tools/e2e-ai-analyzer/types/index.ts index 805b5551db9..cb969f0a027 100644 --- a/tests/tools/e2e-ai-analyzer/types/index.ts +++ b/tests/tools/e2e-ai-analyzer/types/index.ts @@ -7,6 +7,160 @@ export interface PerformanceTestSelection { reasoning: string; } +/** + * Risk level for test areas in test plan generation + */ +export type TestAreaRisk = 'critical' | 'high' | 'medium' | 'low'; + +/** + * Platform-specific notes for iOS and Android + */ +export interface PlatformNotes { + ios: string[]; + android: string[]; + shared: string[]; +} + +/** + * Individual exploratory test scenario + */ +export interface ExploratoryTestScenario { + id: string; + title: string; + description: string; + preconditions: string[]; + explorationGuidance: string[]; + riskIndicators: string[]; + relatedChanges: string[]; +} + +/** + * Exploration charter - a specific mission for exploratory testing + */ +export interface ExplorationCharter { + id: string; + mission: string; + context: string; + whatIfs: string[]; + timeBox?: string; // e.g., "30 minutes" +} + +/** + * Cross-cutting exploration theme that applies across all features + */ +export interface ExplorationTheme { + name: string; + description: string; + techniques: string[]; + applicableAreas: string[]; // Feature areas where this theme is especially relevant +} + +/** + * Testing status for a feature area + */ +export interface TestingStatus { + tested: boolean; + testedBy: string[]; // e.g., ["team-trade", "@jane.doe"] + testedDate?: string; +} + +/** + * Cherry-pick info for build comparison + */ +export interface CherryPickSummary { + commit: string; + message: string; + prNumber: string | null; + author: string; + date: string; + featureArea?: string; +} + +/** + * Build change info for a feature area + */ +export interface BuildChangeInfo { + isNewInBuild: boolean; + buildNumber?: number; + relatedPRs?: string[]; // e.g., ["#25800", "#25801"] + changeType?: 'cherry-pick' | 'fix' | 'feature'; + cherryPicks?: CherryPickSummary[]; +} + +/** + * Feature area with risk assessment and test scenarios + */ +export interface FeatureAreaTestPlan { + featureArea: string; + displayName: string; + riskLevel: TestAreaRisk; + riskJustification: string; + impactedComponents: string[]; + exploratoryScenarios: ExploratoryTestScenario[]; + platformNotes: PlatformNotes; + priority: number; + testingStatus: TestingStatus; + buildChangeInfo: BuildChangeInfo; + /** Exploratory testing priority score (1-10) based on complexity, newness, integration risk */ + exploratoryPriority: number; + /** Specific exploration missions for this feature area */ + explorationCharters: ExplorationCharter[]; +} + +/** + * Summary statistics for the test plan + */ +export interface TestPlanSummary { + totalChangedFiles: number; + totalCommits: number; + criticalAreas: number; + highRiskAreas: number; + mediumRiskAreas: number; + lowRiskAreas: number; + estimatedTestingHours: string; + releaseVersion: string; + buildNumber?: number; + previousBuildNumber?: number; + /** Commit SHA for current build (when using --to-commit) */ + toCommit?: string; + /** Commit SHA for previous build (when using --from-commit) */ + fromCommit?: string; + areasTestedCount: number; + areasNotTestedCount: number; + newInThisBuildCount: number; +} + +/** + * Top exploratory focus area summary + */ +export interface ExploratoryFocusArea { + featureArea: string; + displayName: string; + exploratoryPriority: number; + reason: string; + suggestedTimeBox: string; +} + +/** + * Complete test plan analysis result + */ +export interface GenerateTestPlanAnalysis { + summary: TestPlanSummary; + featureAreas: FeatureAreaTestPlan[]; + crossCuttingConcerns: string[]; + regressionFocusAreas: string[]; + platformSpecificGuidance: PlatformNotes; + /** Cross-cutting exploration themes that apply across all features */ + explorationThemes: ExplorationTheme[]; + /** Top 3-5 areas most deserving of creative exploratory testing */ + exploratoryFocusAreas: ExploratoryFocusArea[]; + /** Cherry-picks between builds (when --build and --prev-build provided) */ + cherryPicks: CherryPickSummary[]; + reasoning: string; + confidence: number; + generatedAt: string; +} + export interface SkillMetadata { name: string; description: string; @@ -29,6 +183,7 @@ export interface SelectTagsAnalysis { export interface ModeAnalysisTypes { 'select-tags': SelectTagsAnalysis; + 'generate-test-plan': GenerateTestPlanAnalysis; } /** @@ -43,7 +198,11 @@ export interface ModeConfig { description: string; finalizeToolName: string; systemPromptBuilder: (availableSkills: SkillMetadata[]) => string; - taskPromptBuilder: (allFiles: string[], criticalFiles: string[]) => string; + taskPromptBuilder: ( + allFiles: string[], + criticalFiles: string[], + context: AnalysisContext, + ) => string; processAnalysis: (aiResponse: string, baseDir: string) => Promise; createConservativeResult: () => T; createEmptyResult: () => T; @@ -63,6 +222,14 @@ export interface AnalysisContext { baseBranch: string; prNumber?: number; githubRepo?: string; + /** Current build number */ + buildNumber?: number; + /** Previous build number to compare against */ + prevBuildNumber?: number; + /** Start commit for cherry-pick range (alternative to prevBuildNumber) */ + fromCommit?: string; + /** End commit for cherry-pick range (alternative to buildNumber) */ + toCommit?: string; } /** @@ -82,6 +249,26 @@ export interface ParsedArgs { mode?: string; provider?: string; listSkills?: boolean; + /** Current build number to analyze */ + buildNumber?: number; + /** Previous build number to compare against */ + prevBuildNumber?: number; + /** Release version (e.g., "7.65.0") */ + releaseVersion?: string; + /** Start commit for cherry-pick range (alternative to prevBuildNumber) */ + fromCommit?: string; + /** End commit for cherry-pick range (alternative to buildNumber) */ + toCommit?: string; + /** Initial build commit SHA (first build of RC) for delta comparison */ + initialCommit?: string; + /** Initial build number (first build of RC) for delta comparison */ + initialBuildNumber?: number; + /** Features to exclude from analysis (behind feature flags, not releasing) */ + excludedFeatures?: string[]; + /** Automatically fetch feature flags from remote API */ + autoFF?: boolean; + /** Show feature flag status without running analysis */ + showFFStatus?: boolean; } export interface ToolInput { @@ -116,4 +303,64 @@ export interface ToolInput { selected_tags: string[]; reasoning: string; }; + + // finalize_test_plan_generation (generate-test-plan mode) + summary?: { + total_changed_files: number; + total_commits: number; + critical_areas: number; + high_risk_areas: number; + medium_risk_areas: number; + low_risk_areas: number; + estimated_testing_hours: string; + release_version: string; + }; + feature_areas?: { + feature_area: string; + risk_level: TestAreaRisk; + risk_justification: string; + impacted_components: string[]; + exploratory_scenarios: { + id: string; + title: string; + description: string; + preconditions: string[]; + exploration_guidance: string[]; + risk_indicators: string[]; + related_changes: string[]; + }[]; + platform_notes: { + ios: string[]; + android: string[]; + shared: string[]; + }; + priority: number; + exploratory_priority?: number; + exploration_charters?: { + id: string; + mission: string; + context: string; + what_ifs: string[]; + time_box?: string; + }[]; + }[]; + cross_cutting_concerns?: string[]; + regression_focus_areas?: string[]; + platform_specific_guidance?: { + ios: string[]; + android: string[]; + shared: string[]; + }; + exploration_themes?: { + name: string; + description: string; + techniques: string[]; + applicable_areas: string[]; + }[]; + exploratory_focus_areas?: { + feature_area: string; + exploratory_priority: number; + reason: string; + suggested_time_box: string; + }[]; } diff --git a/tests/tools/e2e-ai-analyzer/utils/feature-flags.ts b/tests/tools/e2e-ai-analyzer/utils/feature-flags.ts new file mode 100644 index 00000000000..af8d08d555f --- /dev/null +++ b/tests/tools/e2e-ai-analyzer/utils/feature-flags.ts @@ -0,0 +1,241 @@ +/** + * Feature Flag Utilities + * + * Fetches and parses feature flags from the MetaMask remote config API + * to automatically determine which features are enabled/disabled. + */ + +import { execSync } from 'child_process'; + +const FF_API_URL = + 'https://client-config.api.cx.metamask.io/v1/flags?client=mobile'; + +/** + * Feature area mapping - maps flag prefixes to feature areas + */ +const FLAG_TO_FEATURE_MAP: Record = { + perps: 'Perps', + predict: 'Predict', + earn: 'Earn', + card: 'Card', + galileo: 'Card', // Galileo is Card-related + ramps: 'Ramps', + bridge: 'Bridge', + swap: 'Swaps', + bitcoin: 'Bitcoin', + solana: 'Solana', + tron: 'Tron', + rewards: 'Rewards', + aiSocial: 'Social AI', + assets: 'Assets', + confirmations: 'Confirmations', + multichain: 'Multichain', + staking: 'Staking', +}; + +export interface FeatureFlagStatus { + name: string; + enabled: boolean; + minimumVersion?: string; + featureArea?: string; +} + +export interface FeatureFlagSummary { + /** All flags fetched from API */ + allFlags: FeatureFlagStatus[]; + /** Feature areas with ALL flags disabled */ + fullyDisabledAreas: string[]; + /** Feature areas with SOME flags disabled (partial) */ + partiallyDisabledAreas: string[]; + /** Feature areas with all flags enabled */ + fullyEnabledAreas: string[]; + /** Raw disabled flag names */ + disabledFlags: string[]; + /** Disabled flags grouped by feature area */ + disabledByArea: Map; +} + +/** + * Determines if a flag value indicates "disabled" + */ +function isDisabled(value: unknown): boolean { + if (value === false) return true; + if (value === null) return true; + if (typeof value === 'object' && value !== null) { + const obj = value as Record; + // Check for { enabled: false } pattern + if ('enabled' in obj && obj.enabled === false) return true; + } + return false; +} + +/** + * Maps a flag name to its feature area + */ +function getFeatureArea(flagName: string): string | undefined { + const lowerName = flagName.toLowerCase(); + for (const [prefix, area] of Object.entries(FLAG_TO_FEATURE_MAP)) { + if (lowerName.startsWith(prefix.toLowerCase())) { + return area; + } + } + return undefined; +} + +/** + * Fetches feature flags from the remote API + */ +export function fetchFeatureFlags(): FeatureFlagSummary { + try { + console.log('📡 Fetching feature flags from remote API...'); + + const result = execSync(`curl -s "${FF_API_URL}"`, { + encoding: 'utf-8', + timeout: 10000, + }); + + const flagsArray = JSON.parse(result) as Record[]; + + const allFlags: FeatureFlagStatus[] = []; + const disabledFlags: string[] = []; + const disabledByArea = new Map(); + const enabledByArea = new Map(); + + for (const flagObj of flagsArray) { + // Each element is an object with one key-value pair + const entries = Object.entries(flagObj); + if (entries.length === 0) continue; + + const [name, value] = entries[0]; + const disabled = isDisabled(value); + const featureArea = getFeatureArea(name); + + // Extract minimum version if present + let minimumVersion: string | undefined; + if (typeof value === 'object' && value !== null) { + const obj = value as Record; + if (typeof obj.minimumVersion === 'string') { + minimumVersion = obj.minimumVersion; + } + } + + allFlags.push({ + name, + enabled: !disabled, + minimumVersion, + featureArea, + }); + + if (featureArea) { + if (disabled) { + disabledFlags.push(name); + const existing = disabledByArea.get(featureArea) || []; + existing.push(name); + disabledByArea.set(featureArea, existing); + } else { + const existing = enabledByArea.get(featureArea) || []; + existing.push(name); + enabledByArea.set(featureArea, existing); + } + } else if (disabled) { + disabledFlags.push(name); + } + } + + // Categorize areas by their flag status + const fullyDisabledAreas: string[] = []; + const partiallyDisabledAreas: string[] = []; + const fullyEnabledAreas: string[] = []; + + const allAreas = new Set([ + ...disabledByArea.keys(), + ...enabledByArea.keys(), + ]); + for (const area of allAreas) { + const disabledCount = disabledByArea.get(area)?.length || 0; + const enabledCount = enabledByArea.get(area)?.length || 0; + + if (disabledCount > 0 && enabledCount === 0) { + fullyDisabledAreas.push(area); + } else if (disabledCount > 0 && enabledCount > 0) { + partiallyDisabledAreas.push(area); + } else { + fullyEnabledAreas.push(area); + } + } + + console.log(` ✓ Fetched ${allFlags.length} flags`); + console.log(` ✓ ${disabledFlags.length} disabled flags`); + console.log( + ` ✓ ${fullyDisabledAreas.length} fully disabled, ${partiallyDisabledAreas.length} partially disabled`, + ); + + return { + allFlags, + fullyDisabledAreas, + partiallyDisabledAreas, + fullyEnabledAreas, + disabledFlags, + disabledByArea, + }; + } catch (error) { + console.warn(' ⚠ Could not fetch feature flags:', error); + return { + allFlags: [], + fullyDisabledAreas: [], + partiallyDisabledAreas: [], + fullyEnabledAreas: [], + disabledFlags: [], + disabledByArea: new Map(), + }; + } +} + +/** + * Formats feature flag summary for display + */ +export function formatFeatureFlagSummary(summary: FeatureFlagSummary): string { + const lines: string[] = []; + + lines.push('📊 FEATURE FLAG STATUS'); + lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + if (summary.fullyDisabledAreas.length > 0) { + lines.push(''); + lines.push('🔴 Fully disabled (all flags off):'); + for (const area of summary.fullyDisabledAreas) { + lines.push(` • ${area}`); + } + } + + if (summary.partiallyDisabledAreas.length > 0) { + lines.push(''); + lines.push( + '🟡 Partially disabled (some flags off - new features behind FF):', + ); + for (const area of summary.partiallyDisabledAreas) { + const disabledFlags = summary.disabledByArea.get(area) || []; + lines.push(` • ${area} (${disabledFlags.length} disabled flags)`); + // Show first 3 disabled flags as examples + for (const flag of disabledFlags.slice(0, 3)) { + lines.push(` - ${flag}`); + } + if (disabledFlags.length > 3) { + lines.push(` ... and ${disabledFlags.length - 3} more`); + } + } + } + + if (summary.fullyEnabledAreas.length > 0) { + lines.push(''); + lines.push('🟢 Fully enabled:'); + for (const area of summary.fullyEnabledAreas.slice(0, 10)) { + lines.push(` • ${area}`); + } + if (summary.fullyEnabledAreas.length > 10) { + lines.push(` ... and ${summary.fullyEnabledAreas.length - 10} more`); + } + } + + return lines.join('\n'); +} diff --git a/tests/tools/e2e-ai-analyzer/utils/git-utils.ts b/tests/tools/e2e-ai-analyzer/utils/git-utils.ts index b4cff34a350..b9b39e70cc4 100644 --- a/tests/tools/e2e-ai-analyzer/utils/git-utils.ts +++ b/tests/tools/e2e-ai-analyzer/utils/git-utils.ts @@ -6,6 +6,17 @@ import { execSync } from 'node:child_process'; +/** + * Validates a commit SHA to prevent command injection. + * Only allows hex characters (0-9, a-f) which are valid for git SHAs. + */ +function validateCommitSha(sha: string): string { + if (!/^[0-9a-f]+$/i.test(sha)) { + throw new Error(`Invalid commit SHA: ${sha}`); + } + return sha; +} + /** * Gets the list of changed files between a base branch and HEAD * Uses three-dot syntax (...) to compare against merge base @@ -192,6 +203,51 @@ function filterDiffByFiles(diff: string, files: string[]): string { return fileDiffs.join('\n\n') || 'No diffs found for specified files'; } +/** + * Gets patches for multiple files from a PR using GitHub API + * Works even for large PRs (300+ files) where `gh pr diff` fails + */ +export function getFilePatchesFromAPI( + prNumber: number, + repo: string, + filePaths: string[], +): Map { + const patches = new Map(); + + if (filePaths.length === 0) return patches; + + try { + // Fetch all files with patches in one API call + const result = execSync( + `gh api repos/${repo}/pulls/${prNumber}/files --paginate`, + { + encoding: 'utf-8', + maxBuffer: 50 * 1024 * 1024, + }, + ); + + // Parse JSON arrays (--paginate returns newline-separated arrays) + const jsonArrays = result + .trim() + .split('\n') + .filter((line) => line.startsWith('[')); + const fileSet = new Set(filePaths); + + for (const jsonStr of jsonArrays) { + const files = JSON.parse(jsonStr); + for (const file of files) { + if (fileSet.has(file.filename) && file.patch) { + patches.set(file.filename, file.patch); + } + } + } + + return patches; + } catch { + return patches; + } +} + /** * Validates and sanitizes a PR number to prevent command injection * @param input - The input to validate (can be string or number) @@ -213,3 +269,273 @@ export function validatePRNumber(input: unknown): number | null { return num; } + +/** + * Find commit SHA for a build number by searching for version bump commits + * Looks for commits like "[skip ci] Bump version number to 3678" + */ +export function getCommitForBuild( + buildNumber: number, + baseDir: string, +): string | null { + try { + const commit = execSync( + `git log --oneline --grep="Bump version number to ${buildNumber}" --format="%H" -1`, + { + encoding: 'utf-8', + cwd: baseDir, + stdio: ['ignore', 'pipe', 'ignore'], + }, + ).trim(); + + return commit || null; + } catch { + return null; + } +} + +/** + * Cherry-pick info extracted from git log + */ +export interface CherryPickInfo { + commit: string; + message: string; + prNumber: string | null; + author: string; + date: string; +} + +/** + * Get diff for a specific commit + */ +export function getCommitDiff( + commitSha: string, + baseDir: string, + linesLimit = 500, +): string { + try { + const safeSha = validateCommitSha(commitSha); + const diff = execSync(`git show ${safeSha} --format="" --patch`, { + encoding: 'utf-8', + cwd: baseDir, + maxBuffer: 10 * 1024 * 1024, + }); + + const lines = diff.split('\n'); + if (lines.length > linesLimit) { + return lines.slice(0, linesLimit).join('\n') + '\n... [truncated]'; + } + return diff; + } catch { + return ''; + } +} + +/** + * Get cherry-picks between two builds + * Returns commits that were added after prevBuild and up to currentBuild + * First tries to get from PR (for release branches), falls back to local git + */ +export function getCherryPicksBetweenBuilds( + prevBuildNumber: number, + currentBuildNumber: number, + baseDir: string, + prNumber?: number, + repo?: string, +): CherryPickInfo[] { + // Try PR-based lookup first (for release PRs) + if (prNumber && repo) { + const prCherryPicks = getCherryPicksFromPR( + prNumber, + repo, + prevBuildNumber, + currentBuildNumber, + ); + if (prCherryPicks.length > 0) { + return prCherryPicks; + } + } + + // Fall back to local git log + const prevCommit = getCommitForBuild(prevBuildNumber, baseDir); + const currentCommit = getCommitForBuild(currentBuildNumber, baseDir); + + if (!prevCommit || !currentCommit) { + console.warn( + `Could not find commits for builds ${prevBuildNumber} or ${currentBuildNumber}`, + ); + return []; + } + + try { + const safePrev = validateCommitSha(prevCommit); + const safeCurrent = validateCommitSha(currentCommit); + // Get commits between the two build commits (excluding the prev build commit) + // Use null byte delimiter (%x00) to avoid issues with pipe characters in commit messages + const log = execSync( + `git log ${safePrev}..${safeCurrent} --format="%H%x00%s%x00%an%x00%ad" --date=short`, + { + encoding: 'utf-8', + cwd: baseDir, + stdio: ['ignore', 'pipe', 'ignore'], + }, + ).trim(); + + if (!log) return []; + + const cherryPicks: CherryPickInfo[] = []; + const lines = log.split('\n').filter((l) => l); + + for (const line of lines) { + const [commit, message, author, date] = line.split('\x00'); + + // Skip the version bump commit itself + if (message.includes('Bump version number')) continue; + + // Extract PR number from message like "cherry-pick fix(card): ... (#25800)" + const prMatch = message.match(/#(\d+)/); + + cherryPicks.push({ + commit, + message, + prNumber: prMatch ? `#${prMatch[1]}` : null, + author, + date, + }); + } + + return cherryPicks; + } catch { + return []; + } +} + +/** + * Get cherry-picks between two commits directly + * Use this when you have commit SHAs from the release PR + */ +export function getCherryPicksBetweenCommits( + fromCommit: string, + toCommit: string, + baseDir: string, +): CherryPickInfo[] { + try { + const safeFrom = validateCommitSha(fromCommit); + const safeTo = validateCommitSha(toCommit); + // Get commits between the two commit SHAs + // Use null byte delimiter (%x00) to avoid issues with pipe characters in commit messages + const log = execSync( + `git log ${safeFrom}..${safeTo} --format="%H%x00%s%x00%an%x00%ad" --date=short`, + { + encoding: 'utf-8', + cwd: baseDir, + stdio: ['ignore', 'pipe', 'ignore'], + }, + ).trim(); + + if (!log) return []; + + const cherryPicks: CherryPickInfo[] = []; + const lines = log.split('\n').filter((l) => l); + + for (const line of lines) { + const [commit, message, author, date] = line.split('\x00'); + + // Skip version bump commits + if (message.includes('Bump version number')) continue; + + // Extract PR number from message like "cherry-pick fix(card): ... (#25800)" + const prMatch = message.match(/#(\d+)/); + + cherryPicks.push({ + commit, + message, + prNumber: prMatch ? `#${prMatch[1]}` : null, + author, + date, + }); + } + + return cherryPicks; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.warn( + ` Failed to get commits between ${fromCommit}..${toCommit}: ${msg}`, + ); + return []; + } +} + +/** + * Get cherry-picks from a release PR between two build numbers + * Uses gh CLI to fetch commits from the PR + */ +export function getCherryPicksFromPR( + prNumber: number, + repo: string, + prevBuildNumber: number, + currentBuildNumber: number, +): CherryPickInfo[] { + try { + // Get all commits from the PR + const commitsJson = execSync( + `gh pr view ${prNumber} --repo ${repo} --json commits`, + { + encoding: 'utf-8', + maxBuffer: 50 * 1024 * 1024, + }, + ); + + const data = JSON.parse(commitsJson); + const commits = data.commits || []; + + // Find commits between the two build numbers + // Build bumps look like: "[skip ci] Bump version number to 3685" + let inRange = false; + let foundPrevBuild = false; + const cherryPicks: CherryPickInfo[] = []; + + for (const commit of commits) { + const message = commit.messageHeadline || ''; + const buildMatch = message.match(/Bump version number to (\d+)/); + + if (buildMatch) { + const buildNum = parseInt(buildMatch[1], 10); + + // Start collecting after we pass the previous build + if (buildNum === prevBuildNumber) { + foundPrevBuild = true; + inRange = true; + continue; + } + + // Stop when we reach the current build + if (buildNum === currentBuildNumber) { + break; + } + } + + // Collect cherry-picks while in range + if (inRange && !message.includes('Bump version number')) { + const prMatch = message.match(/#(\d+)/); + cherryPicks.push({ + commit: (commit.oid || '').substring(0, 7), + message, + prNumber: prMatch ? `#${prMatch[1]}` : null, + author: commit.authors?.[0]?.name || 'Unknown', + date: commit.committedDate?.split('T')[0] || '', + }); + } + } + + if (!foundPrevBuild) { + console.warn(` Build ${prevBuildNumber} not found in PR commits`); + } + + return cherryPicks; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.warn(` Failed to get commits from PR: ${msg}`); + return []; + } +} diff --git a/tests/tools/e2e-ai-analyzer/utils/github-client.ts b/tests/tools/e2e-ai-analyzer/utils/github-client.ts new file mode 100644 index 00000000000..800c6638fe3 --- /dev/null +++ b/tests/tools/e2e-ai-analyzer/utils/github-client.ts @@ -0,0 +1,230 @@ +/** + * GitHub API client for retrieving PR information + * Uses `gh` CLI for simplicity and authentication + */ + +import { execSync } from 'child_process'; + +export interface PullRequestFile { + filename: string; + additions: number; + deletions: number; + status: 'added' | 'removed' | 'modified' | 'renamed'; +} + +export interface TeamSignOff { + team: string; + signedOff: boolean; +} + +export interface PullRequestInfo { + number: number; + title: string; + body: string; + author: string; + baseBranch: string; + headBranch: string; + files: PullRequestFile[]; + commitCount: number; + teamSignOffs: TeamSignOff[]; + /** Actual total file count (may be > files.length due to pagination limits) */ + actualFileCount?: number; +} + +/** + * Fetches PR information using gh CLI + * Note: gh pr view --json files has a 100 file limit, so we use the API for actual count + * and paginate to get all files for large PRs + */ +export function getPullRequestInfo( + prNumber: number, + repo: string = 'MetaMask/metamask-mobile', +): PullRequestInfo { + console.log(`📥 Fetching PR #${prNumber} from GitHub...`); + + // Get PR details including actual file count + const prJson = execSync(`gh api repos/${repo}/pulls/${prNumber}`, { + encoding: 'utf-8', + maxBuffer: 10 * 1024 * 1024, + }); + const prData = JSON.parse(prJson); + + // Get PR metadata (title, body, commits) + const prMetaJson = execSync( + `gh pr view ${prNumber} --repo ${repo} --json title,body,author,baseRefName,headRefName,commits`, + { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }, + ); + const pr = JSON.parse(prMetaJson); + + // Get changed files with pagination for large PRs + const actualFileCount = prData.changed_files || 0; + const files: PullRequestFile[] = []; + + // Use --paginate for automatic pagination (handles all pages) + // Cap at 1000 files to avoid memory issues + try { + const allFilesJson = execSync( + `gh api repos/${repo}/pulls/${prNumber}/files --paginate`, + { encoding: 'utf-8', maxBuffer: 100 * 1024 * 1024 }, + ); + + // --paginate returns newline-separated JSON arrays, need to parse each + const jsonArrays = allFilesJson + .trim() + .split('\n') + .filter((line) => line.startsWith('[')); + for (const jsonStr of jsonArrays) { + const pageFiles = JSON.parse(jsonStr); + for (const f of pageFiles) { + if (files.length >= 1000) break; // Cap at 1000 files + files.push({ + filename: f.filename, + additions: f.additions || 0, + deletions: f.deletions || 0, + status: f.status as 'added' | 'removed' | 'modified' | 'renamed', + }); + } + } + } catch { + // Fallback to simple fetch if pagination fails + try { + const filesJson = execSync( + `gh pr view ${prNumber} --repo ${repo} --json files`, + { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }, + ); + const filesData = JSON.parse(filesJson); + for (const f of filesData.files || []) { + files.push({ + filename: f.path, + additions: f.additions || 0, + deletions: f.deletions || 0, + status: 'modified' as const, + }); + } + } catch { + // Continue with empty files if all else fails + } + } + + // Parse team sign-offs from PR body + const teamSignOffs = parseTeamSignOffs(pr.body || ''); + + console.log(` ✓ Found: ${pr.title}`); + console.log( + ` ✓ ${actualFileCount} files changed (fetched ${files.length} for analysis)`, + ); + console.log(` ✓ ${prData.commits || pr.commits?.length || 0} commits`); + console.log( + ` ✓ ${teamSignOffs.filter((t) => t.signedOff).length}/${teamSignOffs.length} teams signed off`, + ); + + return { + number: prNumber, + title: pr.title, + body: pr.body || '', + author: pr.author?.login || 'unknown', + baseBranch: pr.baseRefName, + headBranch: pr.headRefName, + files, + commitCount: prData.commits || pr.commits?.length || 0, + teamSignOffs, + actualFileCount, // Add actual count for display + }; +} + +/** + * Parses team sign-off checklist from PR body + * + * Looks for pattern: + * - [x] Team Name (signed off) + * - [ ] Team Name (not signed off) + */ +export function parseTeamSignOffs(prBody: string): TeamSignOff[] { + const signOffs: TeamSignOff[] = []; + + // Find the "Team sign-off checklist" section + const checklistMatch = prBody.match( + /Team sign-off checklist[\s\S]*?(?=\n##|\n\*\*|$)/i, + ); + if (!checklistMatch) { + return signOffs; + } + + const checklistSection = checklistMatch[0]; + + // Match checkbox items: - [x] or - [ ] + const checkboxRegex = /- \[(x| )\] (.+)/gi; + let match; + + while ((match = checkboxRegex.exec(checklistSection)) !== null) { + const isChecked = match[1].toLowerCase() === 'x'; + const teamName = match[2].trim(); + + signOffs.push({ + team: teamName, + signedOff: isChecked, + }); + } + + return signOffs; +} + +/** + * Gets sign-off status summary + */ +export function getSignOffSummary(signOffs: TeamSignOff[]): { + signedOff: string[]; + needsAttention: string[]; +} { + return { + signedOff: signOffs.filter((t) => t.signedOff).map((t) => t.team), + needsAttention: signOffs.filter((t) => !t.signedOff).map((t) => t.team), + }; +} + +/** + * Fetches the latest build number from PR comments + * Looks for github-actions bot comments with "RC Builds Ready for Testing" + */ +export function getLatestBuildFromPRComments( + prNumber: number, + repo: string = 'MetaMask/metamask-mobile', +): number | undefined { + try { + // Fetch PR comments + const commentsJson = execSync( + `gh pr view ${prNumber} --repo ${repo} --json comments`, + { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }, + ); + const data = JSON.parse(commentsJson); + + // Look for github-actions bot comments with build info + // Pattern: "RC 7.65.0 (3701)" or "download build 3701" + const buildPattern = + /(?:RC\s+[\d.]+\s*\((\d+)\)|download\s+build\s+(\d+))/i; + + // Search comments in reverse order (newest first) + const comments = data.comments || []; + for (let i = comments.length - 1; i >= 0; i--) { + const comment = comments[i]; + // Check if it's from github-actions bot and has build info + if ( + comment.author?.login === 'github-actions[bot]' && + comment.body?.includes('RC Builds Ready for Testing') + ) { + const match = comment.body.match(buildPattern); + if (match) { + const buildNum = parseInt(match[1] || match[2], 10); + if (!isNaN(buildNum)) { + return buildNum; + } + } + } + } + + return undefined; + } catch (error) { + console.warn(' Could not fetch build number from PR comments'); + return undefined; + } +} From 432fbf5de8cd609ac51a777cb91abf344cfbdf19 Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Wed, 18 Mar 2026 22:13:39 +0100 Subject: [PATCH 109/206] feat: improve hardware wallet connection ui (#27649) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** No manual testing steps ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Low risk UI/i18n updates; primary risk is unintended UX regressions from replacing the loading button with an `ActivityIndicator` and removing the `tip_dnd_off` translation key. > > **Overview** > Updates the hardware wallet “connecting” bottom sheet to show connection tips above a themed `ActivityIndicator` spinner, removing the previous full-width loading `Button` footer. > > Refines the Ledger tip list by dropping the “Do Not Disturb off” tip, updates the connecting title/header copy in `en.json`, and extends tests to cover `getConnectionTipsForWalletType` behavior and the new spinner/tip rendering. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit a879a03bb38dd51905fb428a70ff9c1c2255a011. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../contents/ConnectingContent.test.tsx | 8 +- .../contents/ConnectingContent.tsx | 92 ++++++++++--------- app/core/HardwareWallet/helpers.test.ts | 25 +++++ app/core/HardwareWallet/helpers.ts | 1 - locales/languages/en.json | 5 +- 5 files changed, 77 insertions(+), 54 deletions(-) diff --git a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/ConnectingContent.test.tsx b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/ConnectingContent.test.tsx index 0a1d0c22ae3..176b6483393 100644 --- a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/ConnectingContent.test.tsx +++ b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/ConnectingContent.test.tsx @@ -57,19 +57,18 @@ describe('ConnectingContent', () => { expect(getByTestId(CONNECTING_CONTENT_TEST_ID)).toBeOnTheScreen(); }); - it('renders activity indicator', () => { + it('renders spinner', () => { const { getByTestId } = renderComponent(); expect(getByTestId(CONNECTING_CONTENT_SPINNER_TEST_ID)).toBeOnTheScreen(); }); - it('renders tips', () => { + it('renders connection tips', () => { const { getByText } = renderComponent(); expect( getByText('hardware_wallet.connecting.tips_header'), ).toBeOnTheScreen(); - // All tips are rendered with { device: deviceName } interpolation params expect( getByText(/hardware_wallet\.connecting\.tip_unlock/), ).toBeOnTheScreen(); @@ -79,8 +78,5 @@ describe('ConnectingContent', () => { expect( getByText(/hardware_wallet\.connecting\.tip_enable_bluetooth/), ).toBeOnTheScreen(); - expect( - getByText(/hardware_wallet\.connecting\.tip_dnd_off/), - ).toBeOnTheScreen(); }); }); diff --git a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/ConnectingContent.tsx b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/ConnectingContent.tsx index fd4ba6edb89..7a43ca3b923 100644 --- a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/ConnectingContent.tsx +++ b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/ConnectingContent.tsx @@ -1,15 +1,10 @@ import React from 'react'; -import { View, StyleSheet } from 'react-native'; +import { ActivityIndicator, StyleSheet, View } from 'react-native'; import Text, { TextVariant, TextColor, } from '../../../../../component-library/components/Texts/Text'; -import Button, { - ButtonVariants, - ButtonSize, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; import { strings } from '../../../../../../locales/i18n'; import { HardwareWalletType } from '@metamask/hw-wallet-sdk'; @@ -18,11 +13,15 @@ import { getConnectionTipsForWalletType, } from '../../../helpers'; import { ContentLayout } from './ContentLayout'; +import { useTheme } from '../../../../../util/theme'; export const CONNECTING_CONTENT_TEST_ID = 'connecting-content'; export const CONNECTING_CONTENT_SPINNER_TEST_ID = 'connecting-content-spinner'; const styles = StyleSheet.create({ + tipsHeader: { + marginBottom: 8, + }, tipItem: { flexDirection: 'row', marginBottom: 8, @@ -34,6 +33,10 @@ const styles = StyleSheet.create({ tipText: { flex: 1, }, + spinnerContainer: { + alignItems: 'center', + paddingVertical: 16, + }, }); export interface ConnectingContentProps { @@ -44,6 +47,7 @@ export interface ConnectingContentProps { export const ConnectingContent: React.FC = ({ deviceType, }) => { + const { colors } = useTheme(); const deviceName = getHardwareWalletTypeName(deviceType); const connectionTips = getConnectionTipsForWalletType(deviceType); @@ -54,46 +58,46 @@ export const ConnectingContent: React.FC = ({ device: deviceName, })} body={ - connectionTips.length > 0 ? ( - - - {strings('hardware_wallet.connecting.tips_header')} - + + {connectionTips.length > 0 && ( + + + {strings('hardware_wallet.connecting.tips_header')} + - {connectionTips.map((tipKey) => ( - - - • - - - {strings(tipKey, { device: deviceName })} - - - ))} + {connectionTips.map((tipKey) => ( + + + • + + + {strings(tipKey, { device: deviceName })} + + + ))} + + )} + + + - ) : undefined - } - footer={ - - + + - {SEEDLESS_ONBOARDING_ENABLED - ? strings('onboarding.import_using_srp_social_login') - : strings('onboarding.import_using_srp')} - + +
), - [ - state.startOnboardingAnimation, - setStartFoxAnimation, - handleCtaActions, - tw, - ], + [state.startOnboardingAnimation, setStartFoxAnimation, handleCtaActions], ); const handleSimpleNotification = From 7176f7103801f4189cfe185422734e12e53a7cb7 Mon Sep 17 00:00:00 2001 From: sophieqgu <37032128+sophieqgu@users.noreply.github.com> Date: Thu, 19 Mar 2026 05:00:31 -0400 Subject: [PATCH 116/206] feat: render rich text from contentful (#27658) ## **Description** Render contentful rich text ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** Simulator Screenshot - E2E Test -
2026-03-18 at 17 27 27 Simulator Screenshot - E2E Test -
2026-03-18 at 17 27 20 ## **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. ## **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** > Adds a new rich-text renderer and switches rewards screens to render Contentful-provided documents and open links via in-app browser navigation, which may affect UI rendering and link handling for live campaign content. > > **Overview** > Enables Rewards UI to render **Contentful rich text documents** (paragraphs, headings, lists, text marks, and hyperlinks) via a new `ContentfulRichText` component, with hyperlinks opening in the in-app browser. > > Updates `CampaignMechanicsView` to render `howItWorks.notes` only when it is a valid Contentful `document` (removing the previous structured-notes parsing and related testIDs), and updates `CampaignOptInSheet` to prefer `campaign.termsAndConditions` rich text with a static fallback. > > Adds comprehensive unit tests for the new rich-text renderer and adjusts existing tests for the new rendering/navigation behavior. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 70a77608938aef1711996540a0295c99ead35763. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../Views/CampaignMechanicsView.test.tsx | 126 +++++++-- .../Rewards/Views/CampaignMechanicsView.tsx | 85 +----- .../Campaigns/CampaignOptInSheet.test.tsx | 125 ++++++++- .../Campaigns/CampaignOptInSheet.tsx | 56 ++-- .../ContentfulRichText.test.tsx | 197 ++++++++++++++ .../ContentfulRichText/ContentfulRichText.tsx | 255 ++++++++++++++++++ 6 files changed, 715 insertions(+), 129 deletions(-) create mode 100644 app/components/UI/Rewards/components/ContentfulRichText/ContentfulRichText.test.tsx create mode 100644 app/components/UI/Rewards/components/ContentfulRichText/ContentfulRichText.tsx diff --git a/app/components/UI/Rewards/Views/CampaignMechanicsView.test.tsx b/app/components/UI/Rewards/Views/CampaignMechanicsView.test.tsx index 30b80c6b81d..6d631725025 100644 --- a/app/components/UI/Rewards/Views/CampaignMechanicsView.test.tsx +++ b/app/components/UI/Rewards/Views/CampaignMechanicsView.test.tsx @@ -88,6 +88,34 @@ jest.mock('../components/Campaigns/CampaignHowItWorks', () => { }; }); +jest.mock('../components/ContentfulRichText/ContentfulRichText', () => { + const ReactActual = jest.requireActual('react'); + const { View, Text: RNText } = jest.requireActual('react-native'); + const isDocumentFn = (value: unknown): boolean => + value !== null && + typeof value === 'object' && + 'nodeType' in (value as Record) && + (value as Record).nodeType === 'document' && + 'content' in (value as Record) && + Array.isArray((value as Record).content); + return { + __esModule: true, + isDocument: isDocumentFn, + default: ({ + document: doc, + testID, + }: { + document: unknown; + testID?: string; + }) => + ReactActual.createElement( + View, + { testID }, + ReactActual.createElement(RNText, null, JSON.stringify(doc)), + ), + }; +}); + jest.mock('../hooks/useRewardCampaigns'); const mockUseRewardCampaigns = useRewardCampaigns as jest.MockedFunction< typeof useRewardCampaigns @@ -196,7 +224,21 @@ describe('CampaignMechanicsView', () => { }); describe('notes section', () => { - it('renders notes section when notes has valid shape', () => { + const richTextNotes = { + nodeType: 'document', + data: {}, + content: [ + { + nodeType: 'paragraph', + data: {}, + content: [ + { nodeType: 'text', value: 'Important notes', marks: [], data: {} }, + ], + }, + ], + }; + + it('renders notes section with ContentfulRichText when notes is present', () => { mockUseRewardCampaigns.mockReturnValue({ ...hookDefaults, campaigns: [ @@ -210,39 +252,16 @@ describe('CampaignMechanicsView', () => { title: 'How it works', description: 'Earn rewards', phases: [], - notes: { - title: 'Important notes', - description: 'Please read carefully', - items: [ - { title: 'Note 1', description: 'Detail 1' }, - { title: 'Note 2', description: 'Detail 2' }, - ], - }, + notes: richTextNotes, }, }, }), ], }); - const { getByTestId, getByText } = render(); + const { getByTestId } = render(); expect( getByTestId(CAMPAIGN_MECHANICS_TEST_IDS.NOTES_SECTION), ).toBeDefined(); - expect( - getByTestId(CAMPAIGN_MECHANICS_TEST_IDS.NOTES_TITLE), - ).toHaveTextContent('Important notes'); - expect( - getByTestId(CAMPAIGN_MECHANICS_TEST_IDS.NOTES_DESCRIPTION), - ).toHaveTextContent('Please read carefully'); - expect( - getByTestId(`${CAMPAIGN_MECHANICS_TEST_IDS.NOTE_ITEM}-0`), - ).toBeDefined(); - expect( - getByTestId(`${CAMPAIGN_MECHANICS_TEST_IDS.NOTE_ITEM_TITLE}-0`), - ).toHaveTextContent('Note 1'); - expect( - getByTestId(`${CAMPAIGN_MECHANICS_TEST_IDS.NOTE_ITEM_DESCRIPTION}-0`), - ).toHaveTextContent('Detail 1'); - expect(getByText('Note 2')).toBeDefined(); }); it('does not render notes section when notes is null', () => { @@ -271,7 +290,58 @@ describe('CampaignMechanicsView', () => { ).toBeNull(); }); - it('does not render notes section when notes has invalid shape', () => { + it('does not render notes section when howItWorks has no notes field', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [ + createTestCampaign({ + details: { + image: { + lightModeUrl: 'https://example.com/light.png', + darkModeUrl: 'https://example.com/dark.png', + }, + howItWorks: { + title: 'How it works', + description: 'Earn rewards', + phases: [], + }, + }, + }), + ], + }); + const { queryByTestId } = render(); + expect( + queryByTestId(CAMPAIGN_MECHANICS_TEST_IDS.NOTES_SECTION), + ).toBeNull(); + }); + + it('does not render notes section when notes is a non-document object', () => { + mockUseRewardCampaigns.mockReturnValue({ + ...hookDefaults, + campaigns: [ + createTestCampaign({ + details: { + image: { + lightModeUrl: 'https://example.com/light.png', + darkModeUrl: 'https://example.com/dark.png', + }, + howItWorks: { + title: 'How it works', + description: 'Earn rewards', + phases: [], + notes: { title: 'Only title' }, + }, + }, + }), + ], + }); + const { queryByTestId } = render(); + expect( + queryByTestId(CAMPAIGN_MECHANICS_TEST_IDS.NOTES_SECTION), + ).toBeNull(); + }); + + it('does not render notes section when notes is a string', () => { mockUseRewardCampaigns.mockReturnValue({ ...hookDefaults, campaigns: [ @@ -285,7 +355,7 @@ describe('CampaignMechanicsView', () => { title: 'How it works', description: 'Earn rewards', phases: [], - notes: { title: 'Only title' }, // missing items + notes: 'just a string', }, }, }), diff --git a/app/components/UI/Rewards/Views/CampaignMechanicsView.tsx b/app/components/UI/Rewards/Views/CampaignMechanicsView.tsx index 63147bc3a90..3c6e66b75c5 100644 --- a/app/components/UI/Rewards/Views/CampaignMechanicsView.tsx +++ b/app/components/UI/Rewards/Views/CampaignMechanicsView.tsx @@ -1,18 +1,15 @@ import React, { useMemo } from 'react'; import { ScrollView } from 'react-native'; import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; -import { - Box, - Text, - TextVariant, - TextColor, - FontWeight, -} from '@metamask/design-system-react-native'; +import { Box } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { SafeAreaView } from 'react-native-safe-area-context'; import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard'; import ErrorBoundary from '../../../Views/ErrorBoundary'; import CampaignHowItWorks from '../components/Campaigns/CampaignHowItWorks'; +import ContentfulRichText, { + isDocument, +} from '../components/ContentfulRichText/ContentfulRichText'; import { useRewardCampaigns } from '../hooks/useRewardCampaigns'; import { strings } from '../../../../../locales/i18n'; @@ -22,41 +19,10 @@ type CampaignMechanicsRouteParams = { CampaignMechanics: { campaignId: string }; }; -interface CampaignNoteItem { - title: string; - description: string; -} - -interface CampaignNotes { - title: string; - description: string; - items: CampaignNoteItem[]; -} - -function parseCampaignNotes(notes: unknown): CampaignNotes | null { - if ( - notes !== null && - typeof notes === 'object' && - !Array.isArray(notes) && - 'title' in notes && - 'description' in notes && - 'items' in notes && - Array.isArray((notes as { items: unknown }).items) - ) { - return notes as CampaignNotes; - } - return null; -} - export const CAMPAIGN_MECHANICS_TEST_IDS = { CONTAINER: 'campaign-mechanics-container', HOW_IT_WORKS_SECTION: 'campaign-mechanics-how-it-works', NOTES_SECTION: 'campaign-mechanics-notes', - NOTES_TITLE: 'campaign-mechanics-notes-title', - NOTES_DESCRIPTION: 'campaign-mechanics-notes-description', - NOTE_ITEM: 'campaign-mechanics-note-item', - NOTE_ITEM_TITLE: 'campaign-mechanics-note-item-title', - NOTE_ITEM_DESCRIPTION: 'campaign-mechanics-note-item-description', } as const; const CampaignMechanicsView: React.FC = () => { @@ -73,7 +39,7 @@ const CampaignMechanicsView: React.FC = () => { ); const howItWorks = campaign?.details?.howItWorks ?? null; - const notes = parseCampaignNotes(howItWorks?.notes); + const notes = howItWorks?.notes ?? null; return ( @@ -101,47 +67,12 @@ const CampaignMechanicsView: React.FC = () => {
)} - {notes && ( + {isDocument(notes) && ( - - {notes.title} - - - {notes.description} - - {notes.items.map((item, index) => ( - - - {item.title} - - - {item.description} - - - ))} + )} diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignOptInSheet.test.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignOptInSheet.test.tsx index c07b18895a6..d2ecb939e59 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignOptInSheet.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/CampaignOptInSheet.test.tsx @@ -1,12 +1,18 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; -import { Linking } from 'react-native'; +import type { Json } from '@metamask/utils'; import CampaignOptInSheet from './CampaignOptInSheet'; import { type CampaignDto, CampaignType, } from '../../../../../core/Engine/controllers/rewards-controller/types'; import { useOptInToCampaign } from '../../hooks/useOptInToCampaign'; +import Routes from '../../../../../constants/navigation/Routes'; + +const mockNavigate = jest.fn(); +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ navigate: mockNavigate }), +})); jest.mock('@metamask/design-system-react-native', () => { const actual = jest.requireActual('@metamask/design-system-react-native'); @@ -63,6 +69,34 @@ jest.mock('../RewardsErrorBanner', () => { }; }); +jest.mock('../ContentfulRichText/ContentfulRichText', () => { + const ReactActual = jest.requireActual('react'); + const { View, Text: RNText } = jest.requireActual('react-native'); + const isDocumentFn = (value: unknown): boolean => + value !== null && + typeof value === 'object' && + 'nodeType' in (value as Record) && + (value as Record).nodeType === 'document' && + 'content' in (value as Record) && + Array.isArray((value as Record).content); + return { + __esModule: true, + isDocument: isDocumentFn, + default: ({ + document: doc, + testID, + }: { + document: unknown; + testID?: string; + }) => + ReactActual.createElement( + View, + { testID }, + ReactActual.createElement(RNText, null, JSON.stringify(doc)), + ), + }; +}); + jest.mock('../Onboarding/constants', () => ({ REWARDS_ONBOARD_TERMS_URL: 'https://go.metamask.io/rewards-terms', })); @@ -103,7 +137,6 @@ const mockOptInToCampaign = jest.fn(); describe('CampaignOptInSheet', () => { beforeEach(() => { jest.clearAllMocks(); - jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined); mockUseOptInToCampaign.mockReturnValue({ optInToCampaign: mockOptInToCampaign, isOptingIn: false, @@ -137,14 +170,17 @@ describe('CampaignOptInSheet', () => { ); }); - it('opens the terms URL when terms link is pressed', () => { + it('opens the terms URL in in-app browser when terms link is pressed', () => { const { getByTestId } = render( , ); fireEvent.press(getByTestId('campaign-opt-in-sheet-terms-link')); - expect(Linking.openURL).toHaveBeenCalledWith( - 'https://go.metamask.io/rewards-terms', - ); + expect(mockNavigate).toHaveBeenCalledWith(Routes.BROWSER.HOME, { + screen: Routes.BROWSER.VIEW, + params: expect.objectContaining({ + newTabUrl: 'https://go.metamask.io/rewards-terms', + }), + }); }); it('renders the CTA button', () => { @@ -234,4 +270,81 @@ describe('CampaignOptInSheet', () => { // Button still renders while loading expect(getByTestId('campaign-opt-in-cta')).toBeDefined(); }); + + describe('termsAndConditions rich text', () => { + const richTextDoc: Json = { + nodeType: 'document', + data: {}, + content: [ + { + nodeType: 'paragraph', + data: {}, + content: [ + { + nodeType: 'text', + value: 'By joining you agree to the ', + marks: [], + data: {}, + }, + { + nodeType: 'hyperlink', + data: { uri: 'https://example.com/terms' }, + content: [ + { nodeType: 'text', value: 'Terms', marks: [], data: {} }, + ], + }, + ], + }, + ], + }; + + it('renders ContentfulRichText when termsAndConditions is present', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId('campaign-opt-in-sheet-description')).toBeDefined(); + }); + + it('does not render the static terms link when termsAndConditions is present', () => { + const { queryByTestId } = render( + , + ); + expect(queryByTestId('campaign-opt-in-sheet-terms-link')).toBeNull(); + }); + + it('renders the static fallback when termsAndConditions is null', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId('campaign-opt-in-sheet-terms-link')).toBeOnTheScreen(); + }); + + it('renders the static fallback when termsAndConditions is malformed', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId('campaign-opt-in-sheet-terms-link')).toBeOnTheScreen(); + }); + + it('renders the static fallback when termsAndConditions is a non-document object', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId('campaign-opt-in-sheet-terms-link')).toBeOnTheScreen(); + }); + }); }); diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignOptInSheet.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignOptInSheet.tsx index 095b5392d80..45c1727f22d 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignOptInSheet.tsx +++ b/app/components/UI/Rewards/components/Campaigns/CampaignOptInSheet.tsx @@ -1,5 +1,4 @@ import React, { useCallback } from 'react'; -import { Linking } from 'react-native'; import { Box, BoxAlignItems, @@ -21,6 +20,11 @@ import { useOptInToCampaign } from '../../hooks/useOptInToCampaign'; import { strings } from '../../../../../../locales/i18n'; import { REWARDS_ONBOARD_TERMS_URL } from '../Onboarding/constants'; import RewardsErrorBanner from '../RewardsErrorBanner'; +import ContentfulRichText, { + isDocument, +} from '../ContentfulRichText/ContentfulRichText'; +import { useNavigation } from '@react-navigation/native'; +import Routes from '../../../../../constants/navigation/Routes'; interface CampaignOptInSheetProps { campaign: CampaignDto; @@ -35,6 +39,7 @@ const CampaignOptInSheet: React.FC = ({ campaign, onClose, }) => { + const navigation = useNavigation(); const { optInToCampaign, isOptingIn, optInError } = useOptInToCampaign(); const handleOptIn = useCallback(async () => { @@ -47,8 +52,14 @@ const CampaignOptInSheet: React.FC = ({ }, [optInToCampaign, campaign.id, onClose]); const handleTermsPress = useCallback(() => { - Linking.openURL(REWARDS_ONBOARD_TERMS_URL); - }, []); + navigation.navigate(Routes.BROWSER.HOME, { + screen: Routes.BROWSER.VIEW, + params: { + newTabUrl: REWARDS_ONBOARD_TERMS_URL, + timestamp: Date.now(), + }, + }); + }, [navigation]); return ( @@ -81,25 +92,34 @@ const CampaignOptInSheet: React.FC = ({ />
- {/* Legal disclaimer with tappable link */} + {/* Legal disclaimer – rich text from Contentful or static fallback */} - - {strings('rewards.campaign.opt_in_sheet_description_pre_link')}{' '} + {isDocument(campaign.termsAndConditions) ? ( + + ) : ( - {strings('rewards.campaign.opt_in_sheet_link_text')} + {strings('rewards.campaign.opt_in_sheet_description_pre_link')}{' '} + + {strings('rewards.campaign.opt_in_sheet_link_text')} + + {'. '} + {strings('rewards.campaign.opt_in_sheet_description_post_link')} - {'. '} - {strings('rewards.campaign.opt_in_sheet_description_post_link')} - + )} {optInError && ( diff --git a/app/components/UI/Rewards/components/ContentfulRichText/ContentfulRichText.test.tsx b/app/components/UI/Rewards/components/ContentfulRichText/ContentfulRichText.test.tsx new file mode 100644 index 00000000000..657d7f787c9 --- /dev/null +++ b/app/components/UI/Rewards/components/ContentfulRichText/ContentfulRichText.test.tsx @@ -0,0 +1,197 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import type { Json } from '@metamask/utils'; +import ContentfulRichText from './ContentfulRichText'; +import Routes from '../../../../../constants/navigation/Routes'; + +const mockNavigate = jest.fn(); +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ navigate: mockNavigate }), +})); + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + return { ...actual }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +type RichTextNode = Record; + +const makeDoc = (...content: RichTextNode[]): Json => ({ + nodeType: 'document', + data: {}, + content, +}); + +const paragraph = (...children: RichTextNode[]): RichTextNode => ({ + nodeType: 'paragraph', + data: {}, + content: children, +}); + +const text = (value: string, marks: { type: string }[] = []): RichTextNode => ({ + nodeType: 'text', + value, + marks: marks as Json[], + data: {}, +}); + +const hyperlink = (uri: string, linkText: string): RichTextNode => ({ + nodeType: 'hyperlink', + data: { uri }, + content: [text(linkText)], +}); + +describe('ContentfulRichText', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns null for invalid document', () => { + const { toJSON } = render( + , + ); + expect(toJSON()).toBeNull(); + }); + + it('returns null for a non-document object', () => { + const { toJSON } = render( + , + ); + expect(toJSON()).toBeNull(); + }); + + it('renders a simple paragraph with plain text', () => { + const doc = makeDoc(paragraph(text('Hello world'))); + const { getByText } = render( + , + ); + expect(getByText('Hello world')).toBeOnTheScreen(); + }); + + it('renders bold text', () => { + const doc = makeDoc(paragraph(text('bold text', [{ type: 'bold' }]))); + const { getByText } = render( + , + ); + expect(getByText('bold text')).toBeOnTheScreen(); + }); + + it('renders a hyperlink and opens in-app browser on press', () => { + const doc = makeDoc( + paragraph( + text('See '), + hyperlink('https://example.com', 'our terms'), + text(' for details.'), + ), + ); + const { getByText } = render( + , + ); + fireEvent.press(getByText('our terms')); + expect(mockNavigate).toHaveBeenCalledWith(Routes.BROWSER.HOME, { + screen: Routes.BROWSER.VIEW, + params: expect.objectContaining({ + newTabUrl: 'https://example.com', + }), + }); + }); + + it('renders multiple paragraphs', () => { + const doc = makeDoc( + paragraph(text('First paragraph')), + paragraph(text('Second paragraph')), + ); + const { getByText } = render( + , + ); + expect(getByText('First paragraph')).toBeOnTheScreen(); + expect(getByText('Second paragraph')).toBeOnTheScreen(); + }); + + it('renders an unordered list with bullets', () => { + const doc = makeDoc({ + nodeType: 'unordered-list', + data: {}, + content: [ + { + nodeType: 'list-item', + data: {}, + content: [paragraph(text('Item one'))], + }, + { + nodeType: 'list-item', + data: {}, + content: [paragraph(text('Item two'))], + }, + ], + }); + const { getByText, getAllByText } = render( + , + ); + expect(getByText('Item one')).toBeOnTheScreen(); + expect(getByText('Item two')).toBeOnTheScreen(); + expect(getAllByText('• ').length).toBe(2); + }); + + it('renders an ordered list with numbers', () => { + const doc = makeDoc({ + nodeType: 'ordered-list', + data: {}, + content: [ + { + nodeType: 'list-item', + data: {}, + content: [paragraph(text('First'))], + }, + { + nodeType: 'list-item', + data: {}, + content: [paragraph(text('Second'))], + }, + ], + }); + const { getByText } = render( + , + ); + expect(getByText('1. ')).toBeOnTheScreen(); + expect(getByText('2. ')).toBeOnTheScreen(); + }); + + it('renders a heading', () => { + const doc = makeDoc({ + nodeType: 'heading-2', + data: {}, + content: [text('Title')], + }); + const { getByText } = render( + , + ); + expect(getByText('Title')).toBeOnTheScreen(); + }); + + it('sets the testID on the container', () => { + const doc = makeDoc(paragraph(text('test'))); + const { getByTestId } = render( + , + ); + expect(getByTestId('my-rich-text')).toBeOnTheScreen(); + }); + + it('renders a text node that has no marks property', () => { + const doc = makeDoc( + paragraph({ + nodeType: 'text', + value: 'no marks', + data: {}, + }), + ); + const { getByText } = render( + , + ); + expect(getByText('no marks')).toBeOnTheScreen(); + }); +}); diff --git a/app/components/UI/Rewards/components/ContentfulRichText/ContentfulRichText.tsx b/app/components/UI/Rewards/components/ContentfulRichText/ContentfulRichText.tsx new file mode 100644 index 00000000000..99c2bc8609a --- /dev/null +++ b/app/components/UI/Rewards/components/ContentfulRichText/ContentfulRichText.tsx @@ -0,0 +1,255 @@ +import React, { Fragment, useCallback } from 'react'; +import { + useNavigation, + type NavigationProp, + type ParamListBase, +} from '@react-navigation/native'; +import { + Box, + Text, + TextVariant, + FontWeight, +} from '@metamask/design-system-react-native'; +import type { Json } from '@metamask/utils'; +import Routes from '../../../../../constants/navigation/Routes'; + +// Contentful rich text node-type constants (from @contentful/rich-text-types) +const BLOCK_TYPES = { + DOCUMENT: 'document', + PARAGRAPH: 'paragraph', + HEADING_1: 'heading-1', + HEADING_2: 'heading-2', + HEADING_3: 'heading-3', + HEADING_4: 'heading-4', + HEADING_5: 'heading-5', + HEADING_6: 'heading-6', + OL_LIST: 'ordered-list', + UL_LIST: 'unordered-list', + LIST_ITEM: 'list-item', + HR: 'hr', +} as const; + +const INLINE_TYPES = { + HYPERLINK: 'hyperlink', +} as const; + +const MARK_TYPES = { + BOLD: 'bold', + ITALIC: 'italic', + UNDERLINE: 'underline', +} as const; + +interface RichTextMark { + type: string; +} + +interface RichTextNode { + nodeType: string; + data: Record; + content?: RichTextNode[]; + value?: string; + marks?: RichTextMark[]; +} + +interface ContentfulRichTextProps { + document: Json; + textVariant?: TextVariant; + headingClassName?: string; + bodyClassName?: string; + testID?: string; +} + +function isDocument(value: unknown): value is { + nodeType: 'document'; + data: Record; + content: RichTextNode[]; +} { + return ( + value !== null && + typeof value === 'object' && + 'nodeType' in value && + (value as { nodeType: unknown }).nodeType === BLOCK_TYPES.DOCUMENT && + 'content' in value && + Array.isArray((value as { content: unknown }).content) + ); +} + +function isTextNode( + node: RichTextNode, +): node is RichTextNode & { value: string; marks: RichTextMark[] } { + return ( + node.nodeType === 'text' && + typeof node.value === 'string' && + Array.isArray(node.marks) + ); +} + +/** + * Renders a Contentful rich text Document as React Native components + * using the MetaMask design system primitives. + * + * Supports paragraphs, headings, lists, hyperlinks, and text marks + * (bold, italic, underline). + */ +const ContentfulRichText: React.FC = ({ + document: doc, + textVariant = TextVariant.BodyMd, + headingClassName = 'text-default', + bodyClassName = 'text-alternative', + testID, +}) => { + const navigation = useNavigation(); + + const handleLinkPress = useCallback( + (url: string) => { + navigation.navigate(Routes.BROWSER.HOME, { + screen: Routes.BROWSER.VIEW, + params: { + newTabUrl: url, + timestamp: Date.now(), + }, + }); + }, + [navigation], + ); + + if (!isDocument(doc)) { + return null; + } + + const renderMarkedText = ( + text: string, + marks: RichTextMark[], + key: string, + ): React.ReactElement => { + const isBold = marks.some((m) => m.type === MARK_TYPES.BOLD); + const isItalic = marks.some((m) => m.type === MARK_TYPES.ITALIC); + const isUnderline = marks.some((m) => m.type === MARK_TYPES.UNDERLINE); + + return ( + + {text} + + ); + }; + + const renderInlineChildren = ( + nodes: RichTextNode[], + keyPrefix: string, + ): React.ReactNode[] => + nodes.map((child, i) => { + const childKey = `${keyPrefix}-${i}`; + + if (isTextNode(child)) { + if (child.marks.length === 0) { + return {child.value}; + } + return renderMarkedText(child.value, child.marks, childKey); + } + + if (child.nodeType === 'text' && typeof child.value === 'string') { + return {child.value}; + } + + if (child.nodeType === INLINE_TYPES.HYPERLINK) { + const uri = (child.data as { uri?: string }).uri ?? ''; + return ( + handleLinkPress(uri)} + > + {renderInlineChildren(child.content ?? [], childKey)} + + ); + } + + return null; + }); + + const renderBlock = ( + node: RichTextNode, + key: string, + ): React.ReactElement | null => { + switch (node.nodeType) { + case BLOCK_TYPES.PARAGRAPH: + return ( + + {renderInlineChildren(node.content ?? [], key)} + + ); + + case BLOCK_TYPES.HEADING_1: + case BLOCK_TYPES.HEADING_2: + case BLOCK_TYPES.HEADING_3: + case BLOCK_TYPES.HEADING_4: + case BLOCK_TYPES.HEADING_5: + case BLOCK_TYPES.HEADING_6: { + const headingVariantMap: Record = { + [BLOCK_TYPES.HEADING_1]: TextVariant.HeadingLg, + [BLOCK_TYPES.HEADING_2]: TextVariant.HeadingLg, + [BLOCK_TYPES.HEADING_3]: TextVariant.HeadingMd, + [BLOCK_TYPES.HEADING_4]: TextVariant.HeadingMd, + [BLOCK_TYPES.HEADING_5]: TextVariant.HeadingSm, + [BLOCK_TYPES.HEADING_6]: TextVariant.HeadingSm, + }; + return ( + + {renderInlineChildren(node.content ?? [], key)} + + ); + } + + case BLOCK_TYPES.UL_LIST: + case BLOCK_TYPES.OL_LIST: + return ( + + {(node.content ?? []).map((item, i) => { + const bullet = + node.nodeType === BLOCK_TYPES.OL_LIST ? `${i + 1}. ` : '• '; + return ( + + + {bullet} + + + {(item.content ?? []).map((block, j) => + renderBlock(block, `${key}-li-${i}-${j}`), + )} + + + ); + })} + + ); + + case BLOCK_TYPES.HR: + return ( + + ); + + default: + return null; + } + }; + + return ( + + {doc.content.map((block, i) => renderBlock(block, `rt-${i}`))} + + ); +}; + +export { isDocument }; +export default ContentfulRichText; From e8d5375c86a3f0fcd58d2ebc1affb1daaf1c07c3 Mon Sep 17 00:00:00 2001 From: Ramon AC <36987446+racitores@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:24:15 +0100 Subject: [PATCH 117/206] fix: test multiple ios execution (#27635) ## **Description** - **Platform helpers** in `tests/component-view/platform.ts`: `describeForPlatforms`, `itForPlatforms`, `itOnlyForPlatforms`, `itEach`, `describeEach`, `getTargetPlatforms`. Tests run per OS (iOS/Android); optional `filter` and `TEST_OS` supported. - **Array-style tests**: `itEach(table)(name, fn)` and `describeEach(table)(name, define)` run each row for each OS (like Jest `it.each` / `describe.each` with platform dimension). - **View tests** now import from `tests/component-view/platform` instead of `app/util/test/platform` (BridgeView, Wallet, WalletActions, AssetDetails, Trending, Send, Earn, etc.). - **Docs**: `writing-tests.md` updated with a "describe / it and platform" section (helpers, filter, example). SKILL.md and AGENTS.md updated ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Medium risk because it changes the component-view test harness behavior (platform scoping and test generation), which can alter how many tests run and expose new CI failures/flakiness across iOS/Android. > > **Overview** > **Improves component-view test execution across iOS/Android.** The PR updates `tests/component-view/platform.ts` to better scope `describeForPlatforms` (avoiding duplicate nested platform runs) and adds table-driven helpers `itEach`/`describeEach` for running each case across platforms. > > It also migrates existing `*.view.test.tsx` files to import platform helpers from `tests/component-view/platform` instead of the older `app/util/test/platform`, and updates the testing docs/agent references to document the new helpers, filters, and `TEST_OS` environment targeting. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 090d516c2f15b856b837e7b1ea1ec85276fe4994. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .agents/skills/component-view-test/SKILL.md | 1 + .../references/writing-tests.md | 32 ++++++++ .../Views/BridgeView/BridgeView.view.test.tsx | 2 +- ...nMusdConversionEducationView.view.test.tsx | 2 +- .../MusdConversionAssetListCta.view.test.tsx | 2 +- ...sdConversionAssetOverviewCta.view.test.tsx | 2 +- .../AssetDetails/AssetDetails.view.test.tsx | 2 +- .../TrendingView/TrendingView.view.test.tsx | 2 +- .../Views/Wallet/Wallet.view.test.tsx | 2 +- .../WalletActions/WalletActions.view.test.tsx | 2 +- .../components/send/send.view.test.tsx | 2 +- docs/readme/component-view-testing.md | 5 +- tests/component-view/AGENTS.md | 2 + .../test => tests/component-view}/platform.ts | 80 +++++++++++++++++++ 14 files changed, 128 insertions(+), 10 deletions(-) rename {app/util/test => tests/component-view}/platform.ts (51%) diff --git a/.agents/skills/component-view-test/SKILL.md b/.agents/skills/component-view-test/SKILL.md index 3cd662235cb..7821d88a60c 100644 --- a/.agents/skills/component-view-test/SKILL.md +++ b/.agents/skills/component-view-test/SKILL.md @@ -56,6 +56,7 @@ tests/component-view/ ├── mocks.ts ← Engine + native mocks (import this first, always) ├── render.tsx ← renderComponentViewScreen, renderScreenWithRoutes ├── stateFixture.ts ← StateFixtureBuilder (createStateFixture) +├── platform.ts ← describeForPlatforms, itForPlatforms (run per iOS/Android) ├── api-mocking/ ← HTTP API mocks (nock) — extensible, one file per feature ├── presets/ ← initialState() builders — one file per feature area └── renderers/ ← renderView() functions — one file per feature area diff --git a/.agents/skills/component-view-test/references/writing-tests.md b/.agents/skills/component-view-test/references/writing-tests.md index a5d2130ca60..c003dfac022 100644 --- a/.agents/skills/component-view-test/references/writing-tests.md +++ b/.agents/skills/component-view-test/references/writing-tests.md @@ -195,6 +195,38 @@ const defaultBridgeWithTokens = (overrides?: Record) => { Then each test only specifies its delta from this baseline. +### describe / it and platform (iOS + Android) + +Import from `tests/component-view/platform`. All helpers accept an optional **filter** (3rd arg): `'ios'` | `'android'` | `['ios','android']` | `{ only: 'ios' }` | `{ skip: ['android'] }`. Env: `TEST_OS=ios` or `TEST_OS=android` to run only one OS. + +| Helper | Use | +| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `describeForPlatforms(name, define, filter?)` | One describe per OS. Inside, `define({ os })`; use `it()` or `itForPlatforms()` — each runs once per that OS. | +| `itForPlatforms(name, (ctx) => {}, filter?)` | One `it` per OS. Callback receives `{ os }`. | +| `itOnlyForPlatforms(name, fn, filter?)` | Same as `itForPlatforms` but registers `it.only`. | +| `itEach(table)(name, (row) => {}, filter?)` | One `it` per table row × per OS. Use `$key` in name to interpolate row fields. | +| `describeEach(table)(name, (row) => { it('...', () => {}); }, filter?)` | One describe per row × per OS. Use `$key` in name. | +| `getTargetPlatforms(filter?)` | Returns `['ios','android']` (or filtered list) for custom loops. | + +Example — `itEach` (each case runs on iOS and Android): + +```typescript +import { itEach } from '../../../../../../tests/component-view/platform'; + +const cases = [ + { name: 'renders empty', amount: '0' }, + { name: 'displays fiat', amount: '1' }, +]; +itEach(cases)('$name', ({ amount }) => { + const { findByDisplayValue } = renderDefault({ + bridge: { sourceAmount: amount }, + }); + expect(findByDisplayValue(amount)).toBeOnTheScreen(); +}); +``` + +Jest modifiers (`it.only`, `it.skip`, `describe.only`, `describe.skip`) work as usual inside these blocks. + ### Minimal template ```typescript diff --git a/app/components/UI/Bridge/Views/BridgeView/BridgeView.view.test.tsx b/app/components/UI/Bridge/Views/BridgeView/BridgeView.view.test.tsx index aeaff64ce20..039dc7f229c 100644 --- a/app/components/UI/Bridge/Views/BridgeView/BridgeView.view.test.tsx +++ b/app/components/UI/Bridge/Views/BridgeView/BridgeView.view.test.tsx @@ -9,7 +9,7 @@ import { renderScreenWithRoutes } from '../../../../../../tests/component-view/r import Routes from '../../../../../constants/navigation/Routes'; import { initialStateBridge } from '../../../../../../tests/component-view/presets/bridge'; import BridgeView from './index'; -import { describeForPlatforms } from '../../../../../util/test/platform'; +import { describeForPlatforms } from '../../../../../../tests/component-view/platform'; import { BridgeViewSelectorsIDs } from './BridgeView.testIds'; import { BuildQuoteSelectors } from '../../../Ramp/Aggregator/Views/BuildQuote/BuildQuote.testIds'; import { CommonSelectorsIDs } from '../../../../../util/Common.testIds'; diff --git a/app/components/UI/Earn/Views/EarnMusdConversionEducationView/EarnMusdConversionEducationView.view.test.tsx b/app/components/UI/Earn/Views/EarnMusdConversionEducationView/EarnMusdConversionEducationView.view.test.tsx index 34281237673..d8e7e8dde15 100644 --- a/app/components/UI/Earn/Views/EarnMusdConversionEducationView/EarnMusdConversionEducationView.view.test.tsx +++ b/app/components/UI/Earn/Views/EarnMusdConversionEducationView/EarnMusdConversionEducationView.view.test.tsx @@ -1,7 +1,7 @@ import '../../../../../../tests/component-view/mocks'; import { renderScreenWithRoutes } from '../../../../../../tests/component-view/render'; import { initialStateWallet } from '../../../../../../tests/component-view/presets/wallet'; -import { describeForPlatforms } from '../../../../../util/test/platform'; +import { describeForPlatforms } from '../../../../../../tests/component-view/platform'; import React from 'react'; import EarnMusdConversionEducationView from './index'; import { strings } from '../../../../../../locales/i18n'; diff --git a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.view.test.tsx b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.view.test.tsx index c2eb29cbffc..3b4a34b3a1a 100644 --- a/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.view.test.tsx +++ b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.view.test.tsx @@ -1,7 +1,7 @@ import '../../../../../../../tests/component-view/mocks'; import { renderComponentViewScreen } from '../../../../../../../tests/component-view/render'; import { initialStateWallet } from '../../../../../../../tests/component-view/presets/wallet'; -import { describeForPlatforms } from '../../../../../../util/test/platform'; +import { describeForPlatforms } from '../../../../../../../tests/component-view/platform'; import React from 'react'; import { View } from 'react-native'; import MusdConversionAssetListCta from './index'; diff --git a/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.view.test.tsx b/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.view.test.tsx index 3fa6a6945e6..d815be5d533 100644 --- a/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.view.test.tsx +++ b/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.view.test.tsx @@ -1,7 +1,7 @@ import '../../../../../../../tests/component-view/mocks'; import { renderComponentViewScreen } from '../../../../../../../tests/component-view/render'; import { initialStateWallet } from '../../../../../../../tests/component-view/presets/wallet'; -import { describeForPlatforms } from '../../../../../../util/test/platform'; +import { describeForPlatforms } from '../../../../../../../tests/component-view/platform'; import React from 'react'; import { View } from 'react-native'; import MusdConversionAssetOverviewCta from './index'; diff --git a/app/components/Views/AssetDetails/AssetDetails.view.test.tsx b/app/components/Views/AssetDetails/AssetDetails.view.test.tsx index e906f02aae7..e5d530ceba3 100644 --- a/app/components/Views/AssetDetails/AssetDetails.view.test.tsx +++ b/app/components/Views/AssetDetails/AssetDetails.view.test.tsx @@ -1,6 +1,6 @@ import '../../../../tests/component-view/mocks'; import { renderAssetDetailsView } from '../../../../tests/component-view/renderers/assetDetails'; -import { describeForPlatforms } from '../../../util/test/platform'; +import { describeForPlatforms } from '../../../../tests/component-view/platform'; // addresses Regression: #25100 – Token Details page shows wrong network diff --git a/app/components/Views/TrendingView/TrendingView.view.test.tsx b/app/components/Views/TrendingView/TrendingView.view.test.tsx index 9716f2faa82..4379531e969 100644 --- a/app/components/Views/TrendingView/TrendingView.view.test.tsx +++ b/app/components/Views/TrendingView/TrendingView.view.test.tsx @@ -1,5 +1,5 @@ import '../../../../tests/component-view/mocks'; -import { describeForPlatforms } from '../../../util/test/platform'; +import { describeForPlatforms } from '../../../../tests/component-view/platform'; import { renderTrendingViewWithRoutes } from '../../../../tests/component-view/renderers/trending'; import { TrendingViewSelectorsIDs } from './TrendingView.testIds'; import { diff --git a/app/components/Views/Wallet/Wallet.view.test.tsx b/app/components/Views/Wallet/Wallet.view.test.tsx index 79cd437ebf6..ba80fd21518 100644 --- a/app/components/Views/Wallet/Wallet.view.test.tsx +++ b/app/components/Views/Wallet/Wallet.view.test.tsx @@ -4,7 +4,7 @@ import { renderWalletViewWithRoutes, } from '../../../../tests/component-view/renderers/wallet'; import { WalletViewSelectorsIDs } from './WalletView.testIds'; -import { describeForPlatforms } from '../../../util/test/platform'; +import { describeForPlatforms } from '../../../../tests/component-view/platform'; import { fireEvent } from '@testing-library/react-native'; import Routes from '../../../constants/navigation/Routes'; diff --git a/app/components/Views/WalletActions/WalletActions.view.test.tsx b/app/components/Views/WalletActions/WalletActions.view.test.tsx index 5614b72e909..8e8a1cd2413 100644 --- a/app/components/Views/WalletActions/WalletActions.view.test.tsx +++ b/app/components/Views/WalletActions/WalletActions.view.test.tsx @@ -1,7 +1,7 @@ import '../../../../tests/component-view/mocks'; import { renderWalletActionsView } from '../../../../tests/component-view/renderers/walletActions'; import { WalletActionsBottomSheetSelectorsIDs } from './WalletActionsBottomSheet.testIds'; -import { describeForPlatforms } from '../../../util/test/platform'; +import { describeForPlatforms } from '../../../../tests/component-view/platform'; // Regression: #24972 – Perps missing from Trade menu when non-EVM network selected describeForPlatforms('WalletActions', () => { diff --git a/app/components/Views/confirmations/components/send/send.view.test.tsx b/app/components/Views/confirmations/components/send/send.view.test.tsx index b8fc54fab6b..238361f6545 100644 --- a/app/components/Views/confirmations/components/send/send.view.test.tsx +++ b/app/components/Views/confirmations/components/send/send.view.test.tsx @@ -8,7 +8,7 @@ import { sendViewOverrides, } from '../../../../../../tests/component-view/presets/send'; import { initialStateWallet } from '../../../../../../tests/component-view/presets/wallet'; -import { describeForPlatforms } from '../../../../../../app/util/test/platform'; +import { describeForPlatforms } from '../../../../../../tests/component-view/platform'; import Routes from '../../../../../constants/navigation/Routes'; import { TokenStandard } from '../../types/token'; import { diff --git a/docs/readme/component-view-testing.md b/docs/readme/component-view-testing.md index d183e804749..4e9be2150b7 100644 --- a/docs/readme/component-view-testing.md +++ b/docs/readme/component-view-testing.md @@ -142,7 +142,10 @@ By default, you can execute tests for both platforms using the platform helpers. Import helpers and define tests parameterized by platform: ```ts -import { itForPlatforms, describeForPlatforms } from '../../platform'; +import { + itForPlatforms, + describeForPlatforms, +} from '../../tests/component-view/platform'; import { renderBridgeView } from './renderers/bridge'; describeForPlatforms('BridgeView', ({ os }) => { diff --git a/tests/component-view/AGENTS.md b/tests/component-view/AGENTS.md index 157c6defba2..8a18522f3f1 100644 --- a/tests/component-view/AGENTS.md +++ b/tests/component-view/AGENTS.md @@ -29,6 +29,7 @@ tests/component-view/ ├── mocks.ts ← Engine + native mocks (import this first, always) ├── render.tsx ← renderComponentViewScreen, renderScreenWithRoutes ├── stateFixture.ts ← StateFixtureBuilder, createStateFixture, deepMerge +├── platform.ts ← describeForPlatforms, itForPlatforms, itEach, describeEach (per OS + array tables) ├── api-mocking/ ← HTTP API mocks (nock) — one file per feature, extensible ├── presets/ ← initialState() builders — one file per feature └── renderers/ ← renderView() functions — one file per feature @@ -109,6 +110,7 @@ For run-by-name, watch mode, or other options, see the skill’s [references/ref - Presets: [presets/](presets/) - Renderers: [renderers/](renderers/) - State fixture: [stateFixture.ts](stateFixture.ts) +- Platform + itEach/describeEach: [platform.ts](platform.ts) ## Before working diff --git a/app/util/test/platform.ts b/tests/component-view/platform.ts similarity index 51% rename from app/util/test/platform.ts rename to tests/component-view/platform.ts index ac779a6d8a9..5231483c1d5 100644 --- a/app/util/test/platform.ts +++ b/tests/component-view/platform.ts @@ -82,6 +82,7 @@ export function itOnlyForPlatforms( ) { const targets = resolveTargetPlatforms(filter); for (const os of targets) { + // eslint-disable-next-line jest/no-focused-tests -- itOnlyForPlatforms intentionally uses it.only it.only(`${name} [${os}]`, async () => { const originalOS = Platform.OS; Platform.OS = os; @@ -96,6 +97,8 @@ export function itOnlyForPlatforms( /** * Group tests under a describe for each targeted platform. + * When define() calls itForPlatforms/itOnlyForPlatforms, those register only one test + * for the current describe's platform (so we get exactly two runs total: ios + android). */ export function describeForPlatforms( name: string, @@ -114,7 +117,16 @@ export function describeForPlatforms( Platform.OS = originalOS; delete (globalThis as Record)[SCOPE_KEY]; }); + // Set scope before define() so itForPlatforms/itOnlyForPlatforms inside define + // see the current platform and register a single it per test (not duplicate per platform). + const previousScope = (globalThis as Record)[SCOPE_KEY]; + (globalThis as Record)[SCOPE_KEY] = os; define({ os }); + if (previousScope === undefined) { + delete (globalThis as Record)[SCOPE_KEY]; + } else { + (globalThis as Record)[SCOPE_KEY] = previousScope; + } }); } } @@ -125,3 +137,71 @@ export function describeForPlatforms( export function getTargetPlatforms(filter?: PlatformFilter): RNPlatform[] { return resolveTargetPlatforms(filter); } + +function interpolateName(name: string, row: Record): string { + return name.replace(/\$(\w+)/g, (_, key) => String(row[key] ?? '')); +} + +/** + * Like Jest's it.each, but each test runs for each targeted platform (iOS/Android). + * Use $key in the name to interpolate row properties. + */ +export function itEach>(table: readonly T[]) { + return ( + name: string, + testFn: (row: T) => void | Promise, + filter?: PlatformFilter, + ) => { + const targets = resolveTargetPlatforms(filter); + for (const row of table) { + for (const os of targets) { + it(`${interpolateName(name, row as Record)} [${os}]`, async () => { + const originalOS = Platform.OS; + Platform.OS = os; + try { + await testFn(row); + } finally { + Platform.OS = originalOS; + } + }); + } + } + }; +} + +/** + * Like Jest's describe.each, but each describe runs for each targeted platform (iOS/Android). + * Use $key in the name to interpolate row properties. Inside the callback, call it() to define tests. + */ +export function describeEach>( + table: readonly T[], +) { + return (name: string, define: (row: T) => void, filter?: PlatformFilter) => { + const targets = resolveTargetPlatforms(filter); + const originalOS = Platform.OS; + for (const row of table) { + for (const os of targets) { + describe(`${interpolateName(name, row as Record)} [${os}]`, () => { + beforeAll(() => { + Platform.OS = os; + (globalThis as Record)[SCOPE_KEY] = os; + }); + afterAll(() => { + Platform.OS = originalOS; + delete (globalThis as Record)[SCOPE_KEY]; + }); + const previousScope = (globalThis as Record)[ + SCOPE_KEY + ]; + (globalThis as Record)[SCOPE_KEY] = os; + define(row); + if (previousScope === undefined) { + delete (globalThis as Record)[SCOPE_KEY]; + } else { + (globalThis as Record)[SCOPE_KEY] = previousScope; + } + }); + } + } + }; +} From 43e231254ba27d0c4017d26937c7fe412c093acb Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Thu, 19 Mar 2026 10:29:12 +0100 Subject: [PATCH 118/206] feat(perps): add missing analytics properties and fix source propagation (#27493) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adds missing Segment/Mixpanel analytics data across Perps events and fixes source propagation issues to ensure accurate attribution in analytics dashboards. ### Motivation Several Perps analytics parameters were missing or inconsistent in Mixpanel: - `PERP SCREEN VIEWED` had many `(not set)` source values - Transaction events (`TRADE`, `CLOSE`) lacked source and order value tracking - `PERP RISK MANAGEMENT` didn't differentiate TP vs SL vs both - Reusable components navigated without passing `source`, leading to implicit/missing attribution - `source` was sometimes propagated from earlier screens in the navigation chain instead of reflecting the current screen ### Solution 1. **New analytics properties**: `open_order`, `order_value`, `market_category`, `error_type`, `action` (tp/sl/tpsl, flip variants), and `explore` source 2. **Source propagation fix**: Screens now always set themselves as the `source` rather than forwarding from previous screens (e.g., Market List → Market Details uses `perp_markets`, not `explore`) 3. **Explicit source passing**: Reusable components (`PerpsMarketTypeSection`, `PerpsWatchlistMarkets`, `PerpsCard`) now accept `source` as a prop from parent screens instead of navigating without it 4. **Tooltip context**: `button_location` for tooltip "Got it" clicks now includes screen-specific context 5. **Documentation**: Updated `docs/perps/perps-metametrics-reference.md` with all new properties and source ownership best practices ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TAT-2370 ## **Manual testing steps** ```gherkin Feature: Perps analytics source tracking Scenario: Source reflects current screen when navigating through multiple screens Given the user is on the Explore page When the user taps "View All" on the Perps section to open Market List And taps a market to open Market Details And taps Long to open the Trading screen Then PERPS_SCREEN_VIEWED for Market List has source = "explore" And PERPS_SCREEN_VIEWED for Market Details has source = "perp_markets" And PERPS_SCREEN_VIEWED for Trading has source = "perp_asset_screen" Scenario: Reusable components pass source from parent screen Given the user is on Perps Home When the user taps a market in the Crypto section Then PERPS_SCREEN_VIEWED for Market Details has source = "perps_home" Scenario: Trade transaction includes order value and source Given the user is on the Trading screen (from Market Details) When the user places a market order for 0.1 BTC at $60,000 Then PERPS_TRADE_TRANSACTION includes source = "perp_asset_screen" And PERPS_TRADE_TRANSACTION includes order_value = 6000 Scenario: Risk management event differentiates TP/SL Given the user has an open BTC position When the user sets both take profit and stop loss Then PERPS_RISK_MANAGEMENT includes action = "tpsl" Scenario: open_order parameter on screen viewed events Given the user has 3 open orders When the user views the Perps Home screen Then PERPS_SCREEN_VIEWED includes open_order = 3 ``` ## **Screenshots/Recordings** N/A — Analytics-only changes, no UI modifications. ### **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. ## **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** > Mostly analytics instrumentation changes, but they touch navigation params and `TradingService` tracking for trade/close events, so incorrect wiring could skew attribution or event schemas in production dashboards. > > **Overview** > Improves Perps analytics attribution by **standardizing `source` propagation** across navigation (market list/details/order/close flows), including passing `source` explicitly through reusable components (`PerpsMarketTypeSection`, `PerpsWatchlistMarkets`, tooltips, and close-position routing). > > Adds missing MetaMetrics properties across key screen views and transactions: `open_order`/`open_position` counts on more screens, `market_category` on market list, `order_value` for trade and close transactions, and more specific `action` values for TP/SL updates and flip-position direction; updates tests and the Perps MetaMetrics reference docs accordingly. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 36cf38e20c70b326981fa961d65d34d630696e17. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../PerpsClosePositionView.tsx | 6 +- .../PerpsHomeView/PerpsHomeView.test.tsx | 3 +- .../Views/PerpsHomeView/PerpsHomeView.tsx | 15 +++-- .../PerpsMarketDetailsView.test.tsx | 4 +- .../PerpsMarketDetailsView.tsx | 11 +++- .../PerpsMarketListView.tsx | 8 ++- .../PerpsOrderBookView.test.tsx | 1 + .../PerpsOrderBookView/PerpsOrderBookView.tsx | 7 ++- .../Views/PerpsOrderView/PerpsOrderView.tsx | 2 + .../PerpsSelectModifyActionView.test.tsx | 5 +- .../PerpsSelectModifyActionView.tsx | 5 +- .../Perps/Views/PerpsTabView/PerpsTabView.tsx | 1 + .../PerpsBottomSheetTooltip.tsx | 6 +- .../PerpsBottomSheetTooltip.types.ts | 7 +++ .../PerpsMarketTypeSection.test.tsx | 5 +- .../PerpsMarketTypeSection.tsx | 11 ++-- .../PerpsWatchlistMarkets.test.tsx | 18 ++++-- .../PerpsWatchlistMarkets.tsx | 7 ++- .../UI/Perps/hooks/usePerpsNavigation.ts | 6 +- .../UI/Perps/hooks/usePerpsOrderExecution.ts | 12 ++++ app/components/UI/Perps/types/navigation.ts | 1 + .../Views/TrendingView/sections.config.tsx | 7 ++- app/controllers/perps/constants/eventNames.ts | 16 +++++ .../perps/services/TradingService.ts | 59 +++++++++++++++++-- docs/perps/perps-metametrics-reference.md | 16 ++++- 25 files changed, 198 insertions(+), 41 deletions(-) diff --git a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx index b72dfae9857..7fa9bf72aea 100644 --- a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx +++ b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx @@ -86,7 +86,10 @@ const PerpsClosePositionView: React.FC = () => { const navigation = useNavigation(); const route = useRoute>(); - const { position } = route.params as { position: Position }; + const { position, source: routeSource } = route.params as { + position: Position; + source?: string; + }; const inputMethodRef = useRef('default'); const isAmountInitializedRef = useRef(false); @@ -392,6 +395,7 @@ const PerpsClosePositionView: React.FC = () => { metamaskFee: feeResults.metamaskFee, estimatedPoints: rewardsState.estimatedPoints, inputMethod: inputMethodRef.current, + source: routeSource, }, marketPrice: priceData[position.symbol]?.price, // Always pass slippage parameters for price context diff --git a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.test.tsx b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.test.tsx index e89f77d9c63..ad53a4c5e89 100644 --- a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.test.tsx @@ -230,6 +230,7 @@ jest.mock('@metamask/perps-controller', () => ({ SOURCE: { MAIN_ACTION_BUTTON: 'main_action_button', HOMESCREEN_TAB: 'homescreen_tab', + PERPS_HOME: 'perps_home', }, BUTTON_LOCATION: { PERPS_HOME: 'perps_home', @@ -558,7 +559,7 @@ describe('PerpsHomeView', () => { expect(mockNavigateToMarketList).toHaveBeenCalledWith({ defaultMarketTypeFilter: 'all', - source: PERPS_EVENT_VALUE.SOURCE.HOMESCREEN_TAB, + source: PERPS_EVENT_VALUE.SOURCE.PERPS_HOME, fromHome: true, button_clicked: 'magnifying_glass', button_location: 'perps_home', diff --git a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx index 2fb3cf8d900..47b45f0e55b 100644 --- a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx +++ b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx @@ -210,6 +210,8 @@ const PerpsHomeView = () => { PERPS_EVENT_VALUE.SCREEN_TYPE.PERPS_HOME, [PERPS_EVENT_PROPERTY.SOURCE]: source, [PERPS_EVENT_PROPERTY.HAS_PERP_BALANCE]: hasPerpBalance, + [PERPS_EVENT_PROPERTY.OPEN_POSITION]: livePositions.positions.length, + [PERPS_EVENT_PROPERTY.OPEN_ORDER]: orders?.length || 0, ...(buttonClicked && { [PERPS_EVENT_PROPERTY.BUTTON_CLICKED]: buttonClicked, }), @@ -235,7 +237,7 @@ const PerpsHomeView = () => { ); perpsNavigation.navigateToMarketList({ defaultMarketTypeFilter: 'all', - source: PERPS_EVENT_VALUE.SOURCE.HOMESCREEN_TAB, + source: PERPS_EVENT_VALUE.SOURCE.PERPS_HOME, fromHome: true, button_clicked: PERPS_EVENT_VALUE.BUTTON_CLICKED.MAGNIFYING_GLASS, button_location: PERPS_EVENT_VALUE.BUTTON_LOCATION.PERPS_HOME, @@ -257,7 +259,7 @@ const PerpsHomeView = () => { .build(), ); navigation.navigate(Routes.PERPS.TUTORIAL, { - source: PERPS_EVENT_VALUE.SOURCE.HOMESCREEN_TAB, + source: PERPS_EVENT_VALUE.SOURCE.PERPS_HOME, }); }, [navigation, trackEvent, createEventBuilder]); @@ -445,7 +447,7 @@ const PerpsHomeView = () => { ))} @@ -465,7 +467,7 @@ const PerpsHomeView = () => { ))} @@ -477,6 +479,7 @@ const PerpsHomeView = () => { isLoading={isLoading.markets} positions={positions} orders={orders} + source={PERPS_EVENT_VALUE.SOURCE.PERPS_HOME} /> {/* Crypto Markets List */} @@ -487,6 +490,7 @@ const PerpsHomeView = () => { marketType="crypto" sortBy={sortBy} isLoading={isLoading.markets} + source={PERPS_EVENT_VALUE.SOURCE.PERPS_HOME} /> @@ -497,6 +501,7 @@ const PerpsHomeView = () => { marketType="commodities" sortBy={sortBy} isLoading={isLoading.markets} + source={PERPS_EVENT_VALUE.SOURCE.PERPS_HOME} /> {/* Stocks Markets List */} @@ -507,6 +512,7 @@ const PerpsHomeView = () => { marketType="stocks" sortBy={sortBy} isLoading={isLoading.markets} + source={PERPS_EVENT_VALUE.SOURCE.PERPS_HOME} /> @@ -516,6 +522,7 @@ const PerpsHomeView = () => { markets={forexMarkets} marketType="forex" isLoading={isLoading.markets} + source={PERPS_EVENT_VALUE.SOURCE.PERPS_HOME} /> {/* Recent Activity List */} diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx index 5a189ea488c..0c4c30acf07 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx @@ -1423,7 +1423,7 @@ describe('PerpsMarketDetailsView', () => { expect(mockNavigateToOrder).toHaveBeenCalledWith({ direction: 'long', asset: 'BTC', - source: 'trade_action', + source: 'perp_asset_screen', }); }); @@ -1459,7 +1459,7 @@ describe('PerpsMarketDetailsView', () => { expect(mockNavigateToOrder).toHaveBeenCalledWith({ direction: 'short', asset: 'BTC', - source: 'trade_action', + source: 'perp_asset_screen', }); }); diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx index efbb8cc5b70..3c122c54ff1 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx @@ -557,6 +557,7 @@ const PerpsMarketDetailsView: React.FC = () => { [PERPS_EVENT_PROPERTY.SOURCE]: source || PERPS_EVENT_VALUE.SOURCE.PERP_MARKETS, [PERPS_EVENT_PROPERTY.OPEN_POSITION]: existingPosition ? 1 : 0, + [PERPS_EVENT_PROPERTY.OPEN_ORDER]: openOrders.length, // A/B Test context (TAT-1937) - for baseline exposure tracking ...(isButtonColorTestEnabled && { [PERPS_EVENT_PROPERTY.AB_TEST_BUTTON_COLOR]: buttonColorVariant, @@ -620,7 +621,7 @@ const PerpsMarketDetailsView: React.FC = () => { navigateBack(); } else { // Fallback to markets list if no previous screen - navigateToHome(source); + navigateToHome(PERPS_EVENT_VALUE.SOURCE.PERP_ASSET_SCREEN); } }; @@ -700,7 +701,7 @@ const PerpsMarketDetailsView: React.FC = () => { navigateToOrder({ direction, asset: market.symbol, - source: PERPS_EVENT_VALUE.SOURCE.TRADE_ACTION, + source: PERPS_EVENT_VALUE.SOURCE.PERP_ASSET_SCREEN, }); }, [ @@ -865,7 +866,10 @@ const PerpsMarketDetailsView: React.FC = () => { return; } - navigateToClosePosition(existingPosition); + navigateToClosePosition( + existingPosition, + PERPS_EVENT_VALUE.SOURCE.PERP_ASSET_SCREEN, + ); }, [existingPosition, navigateToClosePosition, isEligible, track]); // Modify position handler - opens the modify action sheet @@ -1526,6 +1530,7 @@ const PerpsMarketDetailsView: React.FC = () => { onClose={handleTooltipClose} contentKey={selectedTooltip} testID={PerpsMarketDetailsViewSelectorsIDs.BOTTOM_SHEET_TOOLTIP} + buttonLocation={PERPS_EVENT_VALUE.BUTTON_LOCATION.PERP_MARKET_DETAILS} /> )} diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx index c665775e0da..862a34e1d6c 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx @@ -104,10 +104,13 @@ const PerpsMarketListView = ({ if (onMarketSelect) { onMarketSelect(market); } else { - perpsNavigation.navigateToMarketDetails(market, route.params?.source); + perpsNavigation.navigateToMarketDetails( + market, + PERPS_EVENT_VALUE.SOURCE.PERP_MARKETS, + ); } }, - [onMarketSelect, perpsNavigation, route.params?.source], + [onMarketSelect, perpsNavigation], ); // Compute available categories based on market counts (hide empty categories) @@ -192,6 +195,7 @@ const PerpsMarketListView = ({ PERPS_EVENT_VALUE.SCREEN_TYPE.MARKET_LIST, [PERPS_EVENT_PROPERTY.SOURCE]: source, [PERPS_EVENT_PROPERTY.HAS_PERP_BALANCE]: hasPerpBalance, + [PERPS_EVENT_PROPERTY.MARKET_CATEGORY]: marketTypeFilter, ...(buttonClicked && { [PERPS_EVENT_PROPERTY.BUTTON_CLICKED]: buttonClicked, }), diff --git a/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.test.tsx b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.test.tsx index 3f00bd502cb..be8afdb0fce 100644 --- a/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.test.tsx @@ -775,6 +775,7 @@ describe('PerpsOrderBookView', () => { expect(mockNavigateToClosePosition).toHaveBeenCalledWith( mockLongPosition, + 'order_book', ); }); diff --git a/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx index a4a188cc6b6..b66a1a95b1d 100644 --- a/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx @@ -298,6 +298,7 @@ const PerpsOrderBookView: React.FC = ({ PERPS_EVENT_VALUE.SCREEN_TYPE.ORDER_BOOK, [PERPS_EVENT_PROPERTY.ASSET]: symbol || '', [PERPS_EVENT_PROPERTY.SOURCE]: PERPS_EVENT_VALUE.SOURCE.PERP_ASSET_SCREEN, + [PERPS_EVENT_PROPERTY.OPEN_POSITION]: existingPosition ? 1 : 0, }, }); @@ -465,7 +466,10 @@ const PerpsOrderBookView: React.FC = ({ return; } - navigateToClosePosition(existingPosition); + navigateToClosePosition( + existingPosition, + PERPS_EVENT_VALUE.SOURCE.ORDER_BOOK, + ); }, [existingPosition, navigateToClosePosition, isEligible, track]); // Handle Modify position button press @@ -779,6 +783,7 @@ const PerpsOrderBookView: React.FC = ({ onClose={handleTooltipClose} contentKey={selectedTooltip} testID={PerpsOrderBookViewSelectorsIDs.BOTTOM_SHEET_TOOLTIP} + buttonLocation={PERPS_EVENT_VALUE.BUTTON_LOCATION.ORDER_BOOK} /> diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx index 8b49ddc5879..1f66141f9a5 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -381,6 +381,7 @@ const PerpsOrderViewContentBase: React.FC = ({ : PERPS_EVENT_VALUE.DIRECTION.SHORT, [PERPS_EVENT_PROPERTY.SOURCE]: source ?? PERPS_EVENT_VALUE.SOURCE.PERP_ASSET_SCREEN, + [PERPS_EVENT_PROPERTY.OPEN_POSITION]: currentMarketPosition ? 1 : 0, ...(routeAbTestTokenDetailsLayout && { ab_tests: { assetsASSETS2493AbtestTokenDetailsLayout: routeAbTestTokenDetailsLayout, @@ -1788,6 +1789,7 @@ const PerpsOrderViewContentBase: React.FC = ({ contentKey={selectedTooltip} testID={PerpsOrderViewSelectorsIDs.BOTTOM_SHEET_TOOLTIP} key={selectedTooltip} + buttonLocation={PERPS_EVENT_VALUE.BUTTON_LOCATION.PERPS_ASSET_SCREEN} data={ selectedTooltip === 'fees' ? { diff --git a/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.test.tsx b/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.test.tsx index 66c3db5936f..91610ef6c99 100644 --- a/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.test.tsx @@ -184,7 +184,10 @@ describe('PerpsSelectModifyActionView', () => { fireEvent.press(screen.getByTestId('reduce-position')); - expect(mockNavigateToClosePosition).toHaveBeenCalledWith(mockLongPosition); + expect(mockNavigateToClosePosition).toHaveBeenCalledWith( + mockLongPosition, + 'position_screen', + ); }); it('calls onReversePosition when flip_position is selected with callback', () => { diff --git a/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.tsx b/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.tsx index b296e8039d0..8fc576fb60f 100644 --- a/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.tsx +++ b/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.tsx @@ -98,7 +98,10 @@ const PerpsSelectModifyActionView: React.FC< case 'reduce_position': // Open close position screen - navigateToClosePosition(position); + navigateToClosePosition( + position, + PERPS_EVENT_VALUE.SOURCE.POSITION_SCREEN, + ); break; case 'flip_position': diff --git a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx index c117442a59a..4cd8d910dd3 100644 --- a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx +++ b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx @@ -126,6 +126,7 @@ const PerpsTabView = () => { [PERPS_EVENT_PROPERTY.SCREEN_TYPE]: PERPS_EVENT_VALUE.SCREEN_TYPE.WALLET_HOME_PERPS_TAB, [PERPS_EVENT_PROPERTY.OPEN_POSITION]: positions?.length || 0, + [PERPS_EVENT_PROPERTY.OPEN_ORDER]: orders?.length || 0, [PERPS_EVENT_PROPERTY.SOURCE]: PERPS_EVENT_VALUE.SOURCE.HOMESCREEN_TAB, }, }); diff --git a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.tsx b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.tsx index 4b969c10048..1e84531fc34 100644 --- a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.tsx +++ b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.tsx @@ -54,6 +54,7 @@ const PerpsBottomSheetTooltip = React.memo( testID = PerpsBottomSheetTooltipSelectorsIDs.TOOLTIP, buttonConfig: buttonConfigProps, data, + buttonLocation, }) => { const { styles } = useStyles(createStyles, {}); const bottomSheetRef = useRef(null); @@ -96,17 +97,16 @@ const PerpsBottomSheetTooltip = React.memo( // Memoize the button handler to prevent recreation const handleGotItPress = useCallback(() => { - // Track tooltip button click track(MetaMetricsEvents.PERPS_UI_INTERACTION, { [PERPS_EVENT_PROPERTY.INTERACTION_TYPE]: PERPS_EVENT_VALUE.INTERACTION_TYPE.BUTTON_CLICKED, [PERPS_EVENT_PROPERTY.BUTTON_CLICKED]: PERPS_EVENT_VALUE.BUTTON_CLICKED.TOOLTIP, [PERPS_EVENT_PROPERTY.BUTTON_LOCATION]: - PERPS_EVENT_VALUE.BUTTON_LOCATION.TOOLTIP, + buttonLocation ?? PERPS_EVENT_VALUE.BUTTON_LOCATION.TOOLTIP, }); handleClose(); - }, [track, handleClose]); + }, [track, handleClose, buttonLocation]); // Memoize button label and footer buttons const buttonLabel = useMemo( diff --git a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types.ts b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types.ts index a45cf5433e1..feda16042c4 100644 --- a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types.ts +++ b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types.ts @@ -31,6 +31,13 @@ export interface PerpsBottomSheetTooltipProps { * Optional button config to pass to custom content renderers */ buttonConfig?: ButtonProps[]; + + /** + * Analytics: screen context for button_location tracking. + * When provided, overrides the default 'tooltip' button_location + * to indicate which screen the tooltip was opened from. + */ + buttonLocation?: string; } export type PerpsTooltipContentKey = diff --git a/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.test.tsx b/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.test.tsx index 6c4901aac30..f1b2472cb70 100644 --- a/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.test.tsx +++ b/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.test.tsx @@ -129,6 +129,7 @@ describe('PerpsMarketTypeSection', () => { title="Crypto" markets={mockMarkets} marketType="crypto" + source="perps_home" />, { state: initialState }, ); @@ -142,6 +143,7 @@ describe('PerpsMarketTypeSection', () => { screen: Routes.PERPS.MARKET_LIST, params: { defaultMarketTypeFilter: 'crypto', + source: 'perps_home', }, }); }); @@ -153,6 +155,7 @@ describe('PerpsMarketTypeSection', () => { title="Crypto" markets={mockMarkets} marketType="crypto" + source="perps_home" />, { state: initialState }, ); @@ -164,7 +167,7 @@ describe('PerpsMarketTypeSection', () => { // Assert expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_DETAILS, - params: { market: mockMarkets[0] }, + params: { market: mockMarkets[0], source: 'perps_home' }, }); }); diff --git a/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.tsx b/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.tsx index b474f14ee67..f1a75092538 100644 --- a/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.tsx +++ b/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.tsx @@ -24,6 +24,8 @@ export interface PerpsMarketTypeSectionProps { sortBy?: SortField; /** Whether markets are loading */ isLoading?: boolean; + /** Analytics source identifying the parent screen (e.g., 'perps_home') */ + source?: string; /** Test ID for component */ testID?: string; /** Optional style override for the section container */ @@ -63,6 +65,7 @@ const PerpsMarketTypeSection: React.FC = ({ marketType, sortBy = 'volume', isLoading, + source, testID, style, headerStyle, @@ -72,23 +75,23 @@ const PerpsMarketTypeSection: React.FC = ({ const navigation = useNavigation(); const handleViewAll = useCallback(() => { - // Navigate to the specific market type tab when "See all" is pressed navigation.navigate(Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_LIST, params: { defaultMarketTypeFilter: marketType, + source, }, }); - }, [navigation, marketType]); + }, [navigation, marketType, source]); const handleMarketPress = useCallback( (market: PerpsMarketData) => { navigation.navigate(Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_DETAILS, - params: { market }, + params: { market, source }, }); }, - [navigation], + [navigation, source], ); // Show skeleton during initial load diff --git a/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.test.tsx b/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.test.tsx index a631c7466d8..45906c7ad77 100644 --- a/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.test.tsx +++ b/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.test.tsx @@ -150,7 +150,9 @@ describe('PerpsWatchlistMarkets', () => { // Navigation to watchlist is now handled elsewhere it('navigates to market details when market row is pressed', () => { - render(); + render( + , + ); const btcRow = screen.getByTestId('perps-market-row-BTC'); fireEvent.press(btcRow); @@ -161,12 +163,15 @@ describe('PerpsWatchlistMarkets', () => { params: { market: mockMarkets[0], initialTab: undefined, + source: 'perps_home', }, }); }); it('passes correct market data to navigation for different markets', () => { - render(); + render( + , + ); const ethRow = screen.getByTestId('perps-market-row-ETH'); fireEvent.press(ethRow); @@ -177,6 +182,7 @@ describe('PerpsWatchlistMarkets', () => { params: { market: mockMarkets[1], initialTab: undefined, + source: 'perps_home', }, }); }); @@ -374,7 +380,9 @@ describe('PerpsWatchlistMarkets', () => { }); it('handles pressing different market rows in sequence', () => { - render(); + render( + , + ); const btcRow = screen.getByTestId('perps-market-row-BTC'); const ethRow = screen.getByTestId('perps-market-row-ETH'); @@ -385,11 +393,11 @@ describe('PerpsWatchlistMarkets', () => { expect(mockNavigate).toHaveBeenCalledTimes(2); expect(mockNavigate).toHaveBeenNthCalledWith(1, Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_DETAILS, - params: { market: mockMarkets[0] }, + params: { market: mockMarkets[0], source: 'perps_home' }, }); expect(mockNavigate).toHaveBeenNthCalledWith(2, Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_DETAILS, - params: { market: mockMarkets[1] }, + params: { market: mockMarkets[1], source: 'perps_home' }, }); }); }); diff --git a/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.tsx b/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.tsx index e8e213ec6c8..5d3cbebbeb4 100644 --- a/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.tsx +++ b/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.tsx @@ -24,6 +24,8 @@ interface PerpsWatchlistMarketsProps { positions?: Position[]; /** Orders from parent - avoids duplicate WebSocket subscriptions */ orders?: Order[]; + /** Analytics source identifying the parent screen (e.g., 'perps_home') */ + source?: string; /** Override section styles (e.g., to adjust margins) */ sectionStyle?: StyleProp; /** Override header styles (e.g., to remove horizontal padding) */ @@ -37,6 +39,7 @@ const PerpsWatchlistMarkets: React.FC = ({ isLoading, positions = [], orders = [], + source, sectionStyle, headerStyle, contentContainerStyle, @@ -57,17 +60,17 @@ const PerpsWatchlistMarkets: React.FC = ({ } else if (hasOrder) { initialTab = 'orders'; } - // If no position or order, initialTab remains undefined and defaults to Overview navigation.navigate(Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_DETAILS, params: { market, initialTab, + source, }, }); }, - [navigation, positions, orders], + [navigation, positions, orders, source], ); const renderMarket = useCallback( diff --git a/app/components/UI/Perps/hooks/usePerpsNavigation.ts b/app/components/UI/Perps/hooks/usePerpsNavigation.ts index 812a8d7b03c..74a6a30f371 100644 --- a/app/components/UI/Perps/hooks/usePerpsNavigation.ts +++ b/app/components/UI/Perps/hooks/usePerpsNavigation.ts @@ -40,7 +40,7 @@ export interface PerpsNavigationHandlers { params?: PerpsNavigationParamList['PerpsTutorial'], ) => void; navigateToAdjustMargin: (position: Position, mode: 'add' | 'remove') => void; - navigateToClosePosition: (position: Position) => void; + navigateToClosePosition: (position: Position, source?: string) => void; navigateToOrderDetails: (order: Order) => void; // Utility navigation @@ -199,8 +199,8 @@ export const usePerpsNavigation = (): PerpsNavigationHandlers => { ); const navigateToClosePosition = useCallback( - (position: Position) => { - navigation.navigate(Routes.PERPS.CLOSE_POSITION, { position }); + (position: Position, source?: string) => { + navigation.navigate(Routes.PERPS.CLOSE_POSITION, { position, source }); }, [navigation], ); diff --git a/app/components/UI/Perps/hooks/usePerpsOrderExecution.ts b/app/components/UI/Perps/hooks/usePerpsOrderExecution.ts index c9c4b580ae5..7239d7af3d3 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderExecution.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderExecution.ts @@ -102,6 +102,10 @@ export function usePerpsOrderExecution( [PERPS_EVENT_PROPERTY.TRADE_WITH_TOKEN]: orderParams.trackingData?.tradeWithToken === true, }; + if (orderParams.trackingData?.source) { + partialProps[PERPS_EVENT_PROPERTY.SOURCE] = + orderParams.trackingData.source; + } if (orderParams.trackingData?.tradeWithToken === true) { if (orderParams.trackingData.mmPayTokenSelected != null) { partialProps[PERPS_EVENT_PROPERTY.MM_PAY_TOKEN_SELECTED] = @@ -168,6 +172,10 @@ export function usePerpsOrderExecution( [PERPS_EVENT_PROPERTY.TRADE_WITH_TOKEN]: orderParams.trackingData?.tradeWithToken === true, }; + if (orderParams.trackingData?.source) { + failedProps[PERPS_EVENT_PROPERTY.SOURCE] = + orderParams.trackingData.source; + } if (orderParams.trackingData?.tradeWithToken === true) { if (orderParams.trackingData.mmPayTokenSelected != null) { failedProps[PERPS_EVENT_PROPERTY.MM_PAY_TOKEN_SELECTED] = @@ -232,6 +240,10 @@ export function usePerpsOrderExecution( [PERPS_EVENT_PROPERTY.TRADE_WITH_TOKEN]: orderParams.trackingData?.tradeWithToken === true, }; + if (orderParams.trackingData?.source) { + exceptionProps[PERPS_EVENT_PROPERTY.SOURCE] = + orderParams.trackingData.source; + } if (orderParams.trackingData?.tradeWithToken === true) { if (orderParams.trackingData.mmPayTokenSelected != null) { exceptionProps[PERPS_EVENT_PROPERTY.MM_PAY_TOKEN_SELECTED] = diff --git a/app/components/UI/Perps/types/navigation.ts b/app/components/UI/Perps/types/navigation.ts index 876596ad122..a24af66c397 100644 --- a/app/components/UI/Perps/types/navigation.ts +++ b/app/components/UI/Perps/types/navigation.ts @@ -110,6 +110,7 @@ export interface PerpsNavigationParamList extends ParamListBase { PerpsClosePosition: { position: Position; + source?: string; }; PerpsAdjustMargin: { diff --git a/app/components/Views/TrendingView/sections.config.tsx b/app/components/Views/TrendingView/sections.config.tsx index 36154309c6c..be3b525baa7 100644 --- a/app/components/Views/TrendingView/sections.config.tsx +++ b/app/components/Views/TrendingView/sections.config.tsx @@ -10,6 +10,7 @@ import TrendingTokensSkeleton from '../../UI/Trending/components/TrendingTokenSk import PerpsMarketRowItem from '../../UI/Perps/components/PerpsMarketRowItem'; import { filterMarketsByQuery, + PERPS_EVENT_VALUE, type PerpsMarketData, } from '@metamask/perps-controller'; import type { PredictMarket as PredictMarketType } from '../../UI/Predict/types'; @@ -214,6 +215,7 @@ export const SECTIONS_CONFIG: Record = { screen: Routes.PERPS.MARKET_LIST, params: { defaultMarketTypeFilter: 'all', + source: PERPS_EVENT_VALUE.SOURCE.EXPLORE, }, }); }, @@ -225,7 +227,10 @@ export const SECTIONS_CONFIG: Record = { Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_DETAILS, - params: { market: item as PerpsMarketData }, + params: { + market: item as PerpsMarketData, + source: PERPS_EVENT_VALUE.SOURCE.EXPLORE, + }, }, ); }} diff --git a/app/controllers/perps/constants/eventNames.ts b/app/controllers/perps/constants/eventNames.ts index 2fe7d4e156a..5740e8a26bc 100644 --- a/app/controllers/perps/constants/eventNames.ts +++ b/app/controllers/perps/constants/eventNames.ts @@ -34,6 +34,7 @@ export const PERPS_EVENT_PROPERTY = { // Position properties OPEN_POSITION: 'open_position', + OPEN_ORDER: 'open_order', OPEN_POSITION_SIZE: 'open_position_size', UNREALIZED_PNL_DOLLAR: 'unrealized_dollar_pnl', UNREALIZED_PNL_PERCENT: 'unrealized_percent_pnl', @@ -136,6 +137,12 @@ export const PERPS_EVENT_PROPERTY = { // Scroll tracking properties SECTION_VIEWED: 'section_viewed', + // Order value (USD $ value of the order) + ORDER_VALUE: 'order_value', + + // Market category filter (for market list screen) + MARKET_CATEGORY: 'market_category', + // Pay with any token (PERPS_TRADE_TRANSACTION) TRADE_WITH_TOKEN: 'trade_with_token', MM_PAY_TOKEN_SELECTED: 'mm_pay_token_selected', @@ -203,6 +210,8 @@ export const PERPS_EVENT_VALUE = { PERPS_HOME_EXPLORE_CRYPTO: 'perps_home_explore_crypto', PERPS_HOME_EXPLORE_STOCKS: 'perps_home_explore_stocks', PERPS_HOME_ACTIVITY: 'perps_home_activity', + // Explore/Trending page source + EXPLORE: 'explore', // Market list tab sources PERPS_MARKET_LIST_ALL: 'perps_market_list_all', PERPS_MARKET_LIST_CRYPTO: 'perps_market_list_crypto', @@ -402,9 +411,16 @@ export const PERPS_EVENT_VALUE = { REMOVE_MARGIN: 'remove_margin', EDIT_TP_SL: 'edit_tp_sl', CREATE_TP_SL: 'create_tp_sl', + // TP/SL specific actions for risk management events + TP: 'tp', + SL: 'sl', + TPSL: 'tpsl', // Trade transaction actions - differentiates new position from adding to existing CREATE_POSITION: 'create_position', INCREASE_EXPOSURE: 'increase_exposure', + // Flip position actions with direction specificity + FLIP_LONG_TO_SHORT: 'flip_long_to_short', + FLIP_SHORT_TO_LONG: 'flip_short_to_long', }, // Risk management sources RISK_MANAGEMENT_SOURCE: { diff --git a/app/controllers/perps/services/TradingService.ts b/app/controllers/perps/services/TradingService.ts index f865ee51cb2..4f3530372fa 100644 --- a/app/controllers/perps/services/TradingService.ts +++ b/app/controllers/perps/services/TradingService.ts @@ -181,6 +181,15 @@ export class TradingService { PERPS_EVENT_VALUE.MM_PAY_TOKEN.PERPS_BALANCE; } + // Calculate order value in USD (size * price) + const orderSize = parseFloat(result?.filledSize ?? params.size); + const assetPrice = result?.averagePrice + ? parseFloat(result.averagePrice) + : params.trackingData?.marketPrice; + if (assetPrice && orderSize) { + properties[PERPS_EVENT_PROPERTY.ORDER_VALUE] = orderSize * assetPrice; + } + // Add success-specific properties if (status === PERPS_EVENT_VALUE.STATUS.EXECUTED) { if (params.trackingData?.metamaskFee !== undefined) { @@ -674,13 +683,28 @@ export class TradingService { [PERPS_EVENT_PROPERTY.RECEIVED_AMOUNT]: params.trackingData.receivedAmount, }), + ...(params.trackingData?.source && { + [PERPS_EVENT_PROPERTY.SOURCE]: params.trackingData.source, + }), }; + // Calculate and add order value in USD (size * price) + const closeAssetPrice = result?.averagePrice + ? parseFloat(result.averagePrice) + : params.trackingData?.marketPrice; + const orderValue = + closeAssetPrice && metrics.requestedSize + ? metrics.requestedSize * closeAssetPrice + : undefined; + // Add success-specific properties if (status === PERPS_EVENT_VALUE.STATUS.EXECUTED) { return { ...baseProperties, [PERPS_EVENT_PROPERTY.CLOSE_TYPE]: metrics.closeType, + ...(orderValue !== undefined && { + [PERPS_EVENT_PROPERTY.ORDER_VALUE]: orderValue, + }), }; } @@ -688,6 +712,9 @@ export class TradingService { return { ...baseProperties, ...(error && { [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: error }), + ...(orderValue !== undefined && { + [PERPS_EVENT_PROPERTY.ORDER_VALUE]: orderValue, + }), }; } @@ -1710,6 +1737,16 @@ export class TradingService { const hasTakeProfit = Boolean(params.takeProfitPrice); const hasStopLoss = Boolean(params.stopLossPrice); + // Determine TP/SL action type + let tpslAction: string | undefined; + if (hasTakeProfit && hasStopLoss) { + tpslAction = PERPS_EVENT_VALUE.ACTION.TPSL; + } else if (hasTakeProfit) { + tpslAction = PERPS_EVENT_VALUE.ACTION.TP; + } else if (hasStopLoss) { + tpslAction = PERPS_EVENT_VALUE.ACTION.SL; + } + // Build comprehensive event properties const eventProperties = { [PERPS_EVENT_PROPERTY.STATUS]: result?.success @@ -1721,6 +1758,9 @@ export class TradingService { [PERPS_EVENT_PROPERTY.SCREEN_TYPE]: screenType, [PERPS_EVENT_PROPERTY.HAS_TAKE_PROFIT]: hasTakeProfit, [PERPS_EVENT_PROPERTY.HAS_STOP_LOSS]: hasStopLoss, + ...(tpslAction && { + [PERPS_EVENT_PROPERTY.ACTION]: tpslAction, + }), ...(direction && { [PERPS_EVENT_PROPERTY.DIRECTION]: direction === 'long' @@ -1948,7 +1988,11 @@ export class TradingService { }); } - // Track success analytics + // Track success analytics with direction-specific flip action + const flipAction = isCurrentlyLong + ? PERPS_EVENT_VALUE.ACTION.FLIP_LONG_TO_SHORT + : PERPS_EVENT_VALUE.ACTION.FLIP_SHORT_TO_LONG; + this.#deps.metrics.trackPerpsEvent( PerpsAnalyticsEvent.TradeTransaction, { @@ -1961,7 +2005,9 @@ export class TradingService { [PERPS_EVENT_PROPERTY.LEVERAGE]: position.leverage?.value || 1, [PERPS_EVENT_PROPERTY.ORDER_SIZE]: positionSize, [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: completionDuration, - [PERPS_EVENT_PROPERTY.ACTION]: 'flip_position', + [PERPS_EVENT_PROPERTY.ACTION]: flipAction, + [PERPS_EVENT_PROPERTY.ORDER_VALUE]: + positionSize * parseFloat(position.entryPrice), }, ); @@ -1987,11 +2033,16 @@ export class TradingService { this.#getErrorContext('flipPosition', { symbol: position.symbol }), ); - // Track failure analytics + // Track failure analytics with direction-specific flip action + const wasLong = parseFloat(position.size) > 0; + const failFlipAction = wasLong + ? PERPS_EVENT_VALUE.ACTION.FLIP_LONG_TO_SHORT + : PERPS_EVENT_VALUE.ACTION.FLIP_SHORT_TO_LONG; + this.#deps.metrics.trackPerpsEvent(PerpsAnalyticsEvent.TradeTransaction, { [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.FAILED, [PERPS_EVENT_PROPERTY.ASSET]: position.symbol, - [PERPS_EVENT_PROPERTY.ACTION]: 'flip_position', + [PERPS_EVENT_PROPERTY.ACTION]: failFlipAction, [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: completionDuration, [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: errorMessage, }); diff --git a/docs/perps/perps-metametrics-reference.md b/docs/perps/perps-metametrics-reference.md index 7fa1ba1c1bc..10f2faa7bfd 100644 --- a/docs/perps/perps-metametrics-reference.md +++ b/docs/perps/perps-metametrics-reference.md @@ -83,9 +83,13 @@ this.#getMetrics().trackPerpsEvent(PerpsAnalyticsEvent.TradeTransaction, { - **Home section sources:** `'perps_home'` | `'perps_home_position'` | `'perps_home_orders'` | `'perps_home_watchlist'` | `'perps_home_explore_crypto'` | `'perps_home_explore_stocks'` | `'perps_home_activity'` | `'perps_home_empty_state'` - **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'` -- `open_position` (optional): Number of open positions (used for close_all_positions screen, number) +- `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) +- `error_type` (optional): Type of error for error screen views (e.g., `'network'`, `'backend'`; used when screen_type is `'error'`) - `has_perp_balance` (optional): Whether user has a perps balance or positions (boolean) - `has_take_profit` (optional): Whether take profit is set (boolean, used for TP/SL screens) - `has_stop_loss` (optional): Whether stop loss is set (boolean, used for TP/SL screens) @@ -110,7 +114,7 @@ this.#getMetrics().trackPerpsEvent(PerpsAnalyticsEvent.TradeTransaction, { - **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'` +- `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'` - `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'`) @@ -152,6 +156,9 @@ this.#getMetrics().trackPerpsEvent(PerpsAnalyticsEvent.TradeTransaction, { - `order_size` (required for executed): Size of the order in tokens (number) - `asset_price` (required for executed): Price of the asset (number) - `completion_duration` (required): Duration in milliseconds (number) +- `source` (optional): Screen where trade was initiated (e.g., `'perp_asset_screen'`, `'order_book_long_button'`, `'position_screen'`, `'explore'`) +- `action` (optional): Specific trade action: `'create_position'` | `'increase_exposure'` | `'flip_long_to_short'` | `'flip_short_to_long'` +- `order_value` (optional): USD value of the order (order_size × asset_price; number) - `margin_used` (optional): Margin required/used in USDC (number) - `metamask_fee` (optional): MetaMask fee amount in USDC (number) - `metamask_fee_rate` (optional): MetaMask fee rate as decimal (number) @@ -177,6 +184,8 @@ this.#getMetrics().trackPerpsEvent(PerpsAnalyticsEvent.TradeTransaction, { - `open_position_size` (required): Size of the open position (number) - `order_size` (required): Size being closed (number) - `completion_duration` (required): Duration in milliseconds (number) +- `source` (optional): Screen where close was initiated (e.g., `'perp_asset_screen'`, `'order_book'`, `'position_screen'`) +- `order_value` (optional): USD value of the close order (requested_size × asset_price; number) - `close_type` (optional): `'full' | 'partial'` - `percentage_closed` (optional): Percentage of position closed (number) - `dollar_pnl` (optional): Profit/loss in dollars (number) @@ -218,6 +227,7 @@ this.#getMetrics().trackPerpsEvent(PerpsAnalyticsEvent.TradeTransaction, { - `stop_loss_price` (at least one required): Stop loss trigger price (number) - `direction` (optional): `'long' | 'short'` - `source` (optional): Where TP/SL update originated (e.g., `'tp_sl_view'`, `'position_screen'`) +- `action` (optional): Which TP/SL fields were set: `'tp'` (take profit only) | `'sl'` (stop loss only) | `'tpsl'` (both) - `position_size` (optional): Size of the position (number) - `screen_type` (optional): `'create_tpsl' | 'edit_tpsl'` - Whether creating TP/SL for new order or editing existing position - `has_take_profit` (optional): Whether take profit is set (boolean) @@ -556,6 +566,8 @@ usePerpsEventTracking({ 5. **Auto timestamp** - `usePerpsEventTracking` adds it automatically 6. **AB test tracking** - Only in screen view events, not every interaction 7. **Entry point tracking** - Include `button_clicked` and `button_location` to track user navigation flows +8. **Source = current screen** - The `source` property must always identify the screen the user is currently on, never a screen from earlier in the navigation chain. If the user navigates A → B → action C, the source for C must be B, not A. +9. **Explicit source passing** - Reusable components (e.g., `PerpsMarketTypeSection`, `PerpsWatchlistMarkets`, `PerpsCard`) must receive `source` as a prop from the parent screen rather than setting it implicitly. The screen component is the single owner of the `source` value. ## Sentry vs MetaMetrics From 0a623842d7eb51d5a9c282d9ccda5f93053db53e Mon Sep 17 00:00:00 2001 From: TylerC Date: Thu, 19 Mar 2026 17:42:26 +0800 Subject: [PATCH 119/206] refactor(AccountBackupStep1): migrate to design system components (#27580) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Migrate the `AccountBackupStep1` component from legacy styling patterns to the design system: - Replace `View` with `Box` and `StyleSheet.create()` with `twClassName` Tailwind utilities - Replace component-library `Button` and `Text` with `@metamask/design-system-react-native` equivalents - Remove redundant default props (`flexDirection Column`, `justifyContent FlexStart`) - Clean up unused imports (`PropTypes`, `fontStyles`, `scaling`, `StorageWrapper`) This is a pure refactor with no behavioral changes — functional logic, navigation, and analytics are untouched. ## **Changelog** CHANGELOG entry: null ## **Related issues** N/A ## **Manual testing steps** ```gherkin Feature: AccountBackupStep1 screen Scenario: View secure wallet prompt after wallet creation Given the user has just created a new wallet When the user reaches the "Secure your wallet" screen Then they see the SRP illustration, explanation text, and two buttons Scenario: Proceed to manual backup Given the user is on the "Secure your wallet" screen When the user taps "Start" Then they navigate to ManualBackupStep1 Scenario: Skip backup for later Given the user has no funds and is on the "Secure your wallet" screen When the user taps "Remind me later" Then the skip confirmation modal appears Scenario: Theme switching Given the user is on the "Secure your wallet" screen When the device is in dark mode Then the dark SRP illustration is displayed ``` ## **Screenshots/Recordings** | | Before | After | |-----------|--------|-------| | **Light** | | Simulator Screenshot
- iPhone 17 - 2026-03-19 at 15 55 40 | | **Dark** | | Simulator Screenshot
- iPhone 17 - 2026-03-19 at 15 55 48 | ## **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. ## **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** > Low risk refactor limited to the `AccountBackupStep1` UI layer, but it could cause minor layout/spacing regressions due to the styling and component swap. > > **Overview** > Migrates `AccountBackupStep1` from legacy `StyleSheet`/`View` and component-library `Button`/`Text` to the MetaMask design system (`Box`, `Button`, `Text`) with Tailwind (`useTailwind`) styling. > > Keeps the existing navigation/metrics/back-handler flows, while updating padding/spacing rules and refreshing Jest snapshots to match the new rendered component tree and style output. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 2ba0f3dcc6dbccd51d58c8f2af77a4927b6595b0. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../__snapshots__/index.test.tsx.snap | 1785 +++++++++++------ .../Views/AccountBackupStep1/index.js | 236 +-- 2 files changed, 1318 insertions(+), 703 deletions(-) diff --git a/app/components/Views/AccountBackupStep1/__snapshots__/index.test.tsx.snap b/app/components/Views/AccountBackupStep1/__snapshots__/index.test.tsx.snap index c9ef7235e42..aee7cdab906 100644 --- a/app/components/Views/AccountBackupStep1/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/AccountBackupStep1/__snapshots__/index.test.tsx.snap @@ -13,7 +13,9 @@ exports[`AccountBackupStep1 Snapshots android render matches snapshot 1`] = ` style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, "paddingTop": 24, } } @@ -27,7 +29,9 @@ exports[`AccountBackupStep1 Snapshots android render matches snapshot 1`] = ` style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, "paddingTop": 24, } } @@ -36,49 +40,69 @@ exports[`AccountBackupStep1 Snapshots android render matches snapshot 1`] = ` Step 2 of 3 Secure your wallet @@ -88,32 +112,39 @@ exports[`AccountBackupStep1 Snapshots android render matches snapshot 1`] = ` style={ { "height": 250, - "marginHorizontal": "auto", + "marginLeft": "auto", + "marginRight": "auto", "width": 250, } } /> Don’t risk losing your funds. Protect your wallet by saving your @@ -122,13 +153,17 @@ exports[`AccountBackupStep1 Snapshots android render matches snapshot 1`] = ` accessibilityRole="text" onPress={[Function]} style={ - { - "color": "#4459ff", - "fontFamily": "Geist-Regular", - "fontSize": 16, - "letterSpacing": 0, - "lineHeight": 24, - } + [ + { + "color": "#4459ff", + "fontFamily": "Geist-Regular", + "fontSize": 16, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 24, + }, + undefined, + ] } testID="seedphrase-link" > @@ -141,13 +176,17 @@ exports[`AccountBackupStep1 Snapshots android render matches snapshot 1`] = ` It’s the only way to recover your wallet if you get locked out of the app or get a new device. @@ -156,93 +195,187 @@ exports[`AccountBackupStep1 Snapshots android render matches snapshot 1`] = ` - - - + - Get started - - + "textAlign": "center", + }, + undefined, + ] + } + > + Get started + - - - + - Remind me later - - + "textAlign": "center", + }, + undefined, + ] + } + > + Remind me later + @@ -264,7 +397,9 @@ exports[`AccountBackupStep1 Snapshots android render matches snapshot with statu style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, "paddingTop": 24, } } @@ -278,7 +413,9 @@ exports[`AccountBackupStep1 Snapshots android render matches snapshot with statu style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, "paddingTop": 24, } } @@ -287,49 +424,69 @@ exports[`AccountBackupStep1 Snapshots android render matches snapshot with statu Step 2 of 3 Secure your wallet @@ -339,32 +496,39 @@ exports[`AccountBackupStep1 Snapshots android render matches snapshot with statu style={ { "height": 250, - "marginHorizontal": "auto", + "marginLeft": "auto", + "marginRight": "auto", "width": 250, } } /> Don’t risk losing your funds. Protect your wallet by saving your @@ -373,13 +537,17 @@ exports[`AccountBackupStep1 Snapshots android render matches snapshot with statu accessibilityRole="text" onPress={[Function]} style={ - { - "color": "#4459ff", - "fontFamily": "Geist-Regular", - "fontSize": 16, - "letterSpacing": 0, - "lineHeight": 24, - } + [ + { + "color": "#4459ff", + "fontFamily": "Geist-Regular", + "fontSize": 16, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 24, + }, + undefined, + ] } testID="seedphrase-link" > @@ -392,13 +560,17 @@ exports[`AccountBackupStep1 Snapshots android render matches snapshot with statu It’s the only way to recover your wallet if you get locked out of the app or get a new device. @@ -407,93 +579,187 @@ exports[`AccountBackupStep1 Snapshots android render matches snapshot with statu - - - + - Get started - - + "textAlign": "center", + }, + undefined, + ] + } + > + Get started + - - - + - Remind me later - - + "textAlign": "center", + }, + undefined, + ] + } + > + Remind me later + @@ -515,7 +781,9 @@ exports[`AccountBackupStep1 Snapshots iOS render matches snapshot 1`] = ` style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, "paddingTop": 8, } } @@ -529,7 +797,9 @@ exports[`AccountBackupStep1 Snapshots iOS render matches snapshot 1`] = ` style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, "paddingTop": 8, } } @@ -538,49 +808,69 @@ exports[`AccountBackupStep1 Snapshots iOS render matches snapshot 1`] = ` Step 2 of 3 Secure your wallet @@ -590,32 +880,39 @@ exports[`AccountBackupStep1 Snapshots iOS render matches snapshot 1`] = ` style={ { "height": 250, - "marginHorizontal": "auto", + "marginLeft": "auto", + "marginRight": "auto", "width": 250, } } /> Don’t risk losing your funds. Protect your wallet by saving your @@ -624,13 +921,17 @@ exports[`AccountBackupStep1 Snapshots iOS render matches snapshot 1`] = ` accessibilityRole="text" onPress={[Function]} style={ - { - "color": "#4459ff", - "fontFamily": "Geist-Regular", - "fontSize": 16, - "letterSpacing": 0, - "lineHeight": 24, - } + [ + { + "color": "#4459ff", + "fontFamily": "Geist-Regular", + "fontSize": 16, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 24, + }, + undefined, + ] } testID="seedphrase-link" > @@ -643,13 +944,17 @@ exports[`AccountBackupStep1 Snapshots iOS render matches snapshot 1`] = ` It’s the only way to recover your wallet if you get locked out of the app or get a new device. @@ -658,93 +963,187 @@ exports[`AccountBackupStep1 Snapshots iOS render matches snapshot 1`] = ` - - - + - Get started - - + "textAlign": "center", + }, + undefined, + ] + } + > + Get started + - - - + - Remind me later - - + "textAlign": "center", + }, + undefined, + ] + } + > + Remind me later + @@ -766,7 +1165,9 @@ exports[`AccountBackupStep1 Theme appearance renders dark SRP design image by de style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, "paddingTop": 24, } } @@ -780,7 +1181,9 @@ exports[`AccountBackupStep1 Theme appearance renders dark SRP design image by de style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, "paddingTop": 24, } } @@ -789,49 +1192,69 @@ exports[`AccountBackupStep1 Theme appearance renders dark SRP design image by de Step 2 of 3 Secure your wallet @@ -841,32 +1264,39 @@ exports[`AccountBackupStep1 Theme appearance renders dark SRP design image by de style={ { "height": 250, - "marginHorizontal": "auto", + "marginLeft": "auto", + "marginRight": "auto", "width": 250, } } /> Don’t risk losing your funds. Protect your wallet by saving your @@ -875,13 +1305,17 @@ exports[`AccountBackupStep1 Theme appearance renders dark SRP design image by de accessibilityRole="text" onPress={[Function]} style={ - { - "color": "#4459ff", - "fontFamily": "Geist-Regular", - "fontSize": 16, - "letterSpacing": 0, - "lineHeight": 24, - } + [ + { + "color": "#4459ff", + "fontFamily": "Geist-Regular", + "fontSize": 16, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 24, + }, + undefined, + ] } testID="seedphrase-link" > @@ -894,13 +1328,17 @@ exports[`AccountBackupStep1 Theme appearance renders dark SRP design image by de It’s the only way to recover your wallet if you get locked out of the app or get a new device. @@ -909,93 +1347,187 @@ exports[`AccountBackupStep1 Theme appearance renders dark SRP design image by de - - - + - Get started - - + "textAlign": "center", + }, + undefined, + ] + } + > + Get started + - - - + - Remind me later - - + "textAlign": "center", + }, + undefined, + ] + } + > + Remind me later + @@ -1017,7 +1549,9 @@ exports[`AccountBackupStep1 Theme appearance renders light SRP design image for style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, "paddingTop": 24, } } @@ -1031,7 +1565,9 @@ exports[`AccountBackupStep1 Theme appearance renders light SRP design image for style={ { "backgroundColor": "#ffffff", - "flex": 1, + "flexBasis": "0%", + "flexGrow": 1, + "flexShrink": 1, "paddingTop": 24, } } @@ -1040,49 +1576,69 @@ exports[`AccountBackupStep1 Theme appearance renders light SRP design image for Step 2 of 3 Secure your wallet @@ -1092,32 +1648,39 @@ exports[`AccountBackupStep1 Theme appearance renders light SRP design image for style={ { "height": 250, - "marginHorizontal": "auto", + "marginLeft": "auto", + "marginRight": "auto", "width": 250, } } /> Don’t risk losing your funds. Protect your wallet by saving your @@ -1126,13 +1689,17 @@ exports[`AccountBackupStep1 Theme appearance renders light SRP design image for accessibilityRole="text" onPress={[Function]} style={ - { - "color": "#4459ff", - "fontFamily": "Geist-Regular", - "fontSize": 16, - "letterSpacing": 0, - "lineHeight": 24, - } + [ + { + "color": "#4459ff", + "fontFamily": "Geist-Regular", + "fontSize": 16, + "fontWeight": 400, + "letterSpacing": 0, + "lineHeight": 24, + }, + undefined, + ] } testID="seedphrase-link" > @@ -1145,13 +1712,17 @@ exports[`AccountBackupStep1 Theme appearance renders light SRP design image for It’s the only way to recover your wallet if you get locked out of the app or get a new device. @@ -1160,93 +1731,187 @@ exports[`AccountBackupStep1 Theme appearance renders light SRP design image for - - - + - Get started - - + "textAlign": "center", + }, + undefined, + ] + } + > + Get started + - - - + - Remind me later - - + "textAlign": "center", + }, + undefined, + ] + } + > + Remind me later + diff --git a/app/components/Views/AccountBackupStep1/index.js b/app/components/Views/AccountBackupStep1/index.js index 98a34d49d6b..4aec4fa6333 100644 --- a/app/components/Views/AccountBackupStep1/index.js +++ b/app/components/Views/AccountBackupStep1/index.js @@ -1,8 +1,6 @@ import React, { useState, useEffect } from 'react'; import { ScrollView, - View, - StyleSheet, BackHandler, Image, Platform, @@ -10,32 +8,32 @@ import { } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import PropTypes from 'prop-types'; -import { fontStyles } from '../../../styles/common'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + BoxAlignItems, + BoxJustifyContent, + Button, + ButtonVariant, + ButtonSize, + Text, + TextVariant, + TextColor, +} from '@metamask/design-system-react-native'; import { strings } from '../../../../locales/i18n'; import AndroidBackHandler from '../AndroidBackHandler'; import Device from '../../../util/device'; -import scaling from '../../../util/scaling'; import Engine from '../../../core/Engine'; import { connect } from 'react-redux'; import { saveOnboardingEvent as saveEvent } from '../../../actions/onboarding'; import { MetaMetricsEvents } from '../../../core/Analytics'; -import StorageWrapper from '../../../store/storage-wrapper'; import { useTheme } from '../../../util/theme'; import { ManualBackUpStepsSelectorsIDs } from '../ManualBackupStep1/ManualBackUpSteps.testIds'; import trackOnboarding from '../../../util/metrics/TrackOnboarding/trackOnboarding'; -import Routes from '../../../../app/constants/navigation/Routes'; +import Routes from '../../../constants/navigation/Routes'; import { MetricsEventBuilder } from '../../../core/Analytics/MetricsEventBuilder'; import SRPDesignLight from '../../../images/secure_wallet_light.png'; import SRPDesignDark from '../../../images/secure_wallet_dark.png'; -import Button, { - ButtonVariants, - ButtonWidthTypes, - ButtonSize, -} from '../../../component-library/components/Buttons/Button'; -import Text, { - TextVariant, - TextColor, -} from '../../../component-library/components/Texts/Text'; import { CommonActions, useNavigation } from '@react-navigation/native'; import { useMetrics } from '../../hooks/useMetrics'; import { @@ -45,75 +43,11 @@ import { import { TraceName, endTrace } from '../../../util/trace'; import { AppThemeKey } from '../../../util/theme/models'; -const createStyles = (colors) => - StyleSheet.create({ - mainWrapper: { - backgroundColor: colors.background.default, - flex: 1, - paddingTop: Platform.OS === 'android' ? StatusBar.currentHeight || 24 : 8, - }, - scrollviewWrapper: { - flexGrow: 1, - }, - wrapper: { - flex: 1, - paddingHorizontal: 16, - }, - content: { - alignItems: 'center', - justifyContent: 'flex-start', - flex: 1, - marginBottom: 10, - }, - title: { - textAlign: 'left', - alignSelf: 'flex-start', - marginBottom: 16, - }, - text: { - marginTop: 32, - justifyContent: 'center', - alignSelf: 'flex-start', - flexDirection: 'column', - rowGap: 16, - }, - label: { - lineHeight: scaling.scale(20), - fontSize: scaling.scale(14), - color: colors.text.default, - textAlign: 'left', - ...fontStyles.normal, - }, - buttonWrapper: { - flex: 1, - justifyContent: 'flex-end', - flexDirection: 'column', - rowGap: 16, - marginBottom: Platform.select({ - ios: 16, - android: 24, - default: 16, - }), - }, - srpDesign: { - width: 250, - height: 250, - marginHorizontal: 'auto', - }, - headerLeft: { - marginLeft: 16, - }, - }); - -/** - * View that's shown during the first step of - * the backup seed phrase flow - */ const AccountBackupStep1 = (props) => { const [hasFunds, setHasFunds] = useState(false); - const { colors, themeAppearance } = useTheme(); - const styles = createStyles(colors); + const { themeAppearance } = useTheme(); const { isEnabled: isMetricsEnabled } = useMetrics(); + const tw = useTailwind(); const track = (event, properties) => { const eventBuilder = MetricsEventBuilder.createEventBuilder(event); @@ -123,27 +57,21 @@ const AccountBackupStep1 = (props) => { const navigation = useNavigation(); - useEffect( - () => { - // Check if user has funds - if (Engine.hasFunds()) setHasFunds(true); - - // Disable back press - const hardwareBackPress = () => true; + useEffect(() => { + if (Engine.hasFunds()) setHasFunds(true); - // Add event listener - BackHandler.addEventListener('hardwareBackPress', hardwareBackPress); + const hardwareBackPress = () => true; + BackHandler.addEventListener('hardwareBackPress', hardwareBackPress); - // Remove event listener on cleanup - return () => { - BackHandler.removeEventListener('hardwareBackPress', hardwareBackPress); - }; - }, - [], // Run only when component mounts - ); + return () => { + BackHandler.removeEventListener('hardwareBackPress', hardwareBackPress); + }; + }, []); const goNext = () => { - navigation.navigate('ManualBackupStep1', { ...props.route.params }); + navigation.navigate('ManualBackupStep1', { + ...props.route.params, + }); track(MetaMetricsEvents.WALLET_SECURITY_STARTED); }; @@ -205,24 +133,37 @@ const AccountBackupStep1 = (props) => { }; return ( - + - - + + {strings('manual_backup_step_1.steps', { currentStep: 2, totalSteps: 3, })} - + {strings('account_backup_step_1.title')} @@ -232,14 +173,17 @@ const AccountBackupStep1 = (props) => { ? SRPDesignDark : SRPDesignLight } - style={styles.srpDesign} + style={tw.style('w-[250px] h-[250px] mx-auto')} /> - - + + {strings('account_backup_step_1.info_text_1_1')}{' '} @@ -248,36 +192,42 @@ const AccountBackupStep1 = (props) => { {strings('account_backup_step_1.info_text_1_3')}{' '} - + {strings('account_backup_step_1.info_text_1_4')} - - - - - + + + + + + {!hasFunds && ( )} - - + +
{Device.isAndroid() && ( @@ -286,19 +236,19 @@ const AccountBackupStep1 = (props) => { ); }; +const mapDispatchToProps = (dispatch) => ({ + saveOnboardingEvent: (...eventArgs) => dispatch(saveEvent(eventArgs)), +}); + AccountBackupStep1.propTypes = { /** - * Object that represents the current route info like params passed to it + * Object that represents the current route info like params passed to it. */ route: PropTypes.object, /** - * Action to save onboarding event + * Action to save onboarding event. */ saveOnboardingEvent: PropTypes.func, }; -const mapDispatchToProps = (dispatch) => ({ - saveOnboardingEvent: (...eventArgs) => dispatch(saveEvent(eventArgs)), -}); - export default connect(null, mapDispatchToProps)(AccountBackupStep1); From 543e1386be919bd2df21bcb64b076d486f3781d3 Mon Sep 17 00:00:00 2001 From: Gaurav Goel Date: Thu, 19 Mar 2026 15:30:34 +0530 Subject: [PATCH 120/206] feat: migrate WalletCreationError screens to design system (#27613) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Migrates the WalletCreationError screens (SocialLoginErrorSheet and SRPErrorScreen) from legacy StyleSheet.create() styling to the MetaMask design system with Tailwind CSS. Changes: * Replaced View with Box from @metamask/design-system-react-native * Replaced component-library Text with design system Text using corrected variants (HeadingMd, BodyMd, BodySm) and semantic colors (PrimaryDefault, TextAlternative, ErrorDefault) * Replaced component-library Icon with design system Icon using corrected colors (ErrorDefault, InfoDefault) * Replaced TextVariant.BodyMDMedium with TextVariant.BodyMd + FontWeight.Medium * Replaced useStyles(styleSheet) with useTailwind() and twClassName props * Styled SafeAreaView and ScrollView via tw.style() for non-Box components Jira: https://consensyssoftware.atlassian.net/browse/TO-599 ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Wallet Creation Error Screens Scenario: user encounters social login wallet creation error Given the user is on the onboarding flow with metrics enabled When wallet creation fails during social login Then the SocialLoginErrorSheet displays with fox logo, error title, description with MetaMask Support link, and a Try again button And tapping Try again deletes the wallet and resets to onboarding And tapping MetaMask Support opens the support URL Scenario: user encounters SRP wallet creation error Given the user is on the onboarding flow with metrics disabled When wallet creation fails during SRP import or password setup Then the SRPErrorScreen displays with error title, info banner with support link, error report details, Copy button, Send error report button, and Try again button And tapping Copy copies the error report to clipboard and shows "Copied" feedback And tapping Send error report sends the error to Sentry and navigates back with a toast And tapping Try again deletes the wallet and resets to onboarding ``` ## **Screenshots/Recordings** ### **Before** Screenshot 2026-03-18 at 7 22 48 PM Screenshot 2026-03-18 at 7 23 22 PM ### **After** Screenshot 2026-03-18 at 7 17 43 PM Screenshot 2026-03-18 at 7 18 49 PM ## **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. ## **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** > Primarily a UI/styling refactor to design-system components and Tailwind classes, with minimal behavioral change. Risk is limited to potential layout/visual regressions or minor prop/variant mismatches on these error screens. > > **Overview** > Migrates the `WalletCreationError` UI (`SRPErrorScreen` and `SocialLoginErrorSheet`) from legacy `StyleSheet`/`useStyles` and component-library `Text`/`Icon` to the MetaMask design system (`Box`, design-system `Text`/`Icon`) with Tailwind-based styling via `useTailwind()`. > > Deletes the dedicated `.styles.ts` files and updates typography/color usage to design-system variants/semantic tokens (including `FontWeight.Medium` for the SRP error report header), while keeping existing analytics, support linking, wallet deletion, and error-report/copy flows intact. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit ececdf95655b3fe484fb976cf0d77f5d99463e2c. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../SRPErrorScreen.styles.ts | 71 --------------- .../WalletCreationError/SRPErrorScreen.tsx | 86 +++++++++---------- .../SocialLoginErrorSheet.styles.ts | 57 ------------ .../SocialLoginErrorSheet.tsx | 60 ++++++------- 4 files changed, 74 insertions(+), 200 deletions(-) delete mode 100644 app/components/Views/WalletCreationError/SRPErrorScreen.styles.ts delete mode 100644 app/components/Views/WalletCreationError/SocialLoginErrorSheet.styles.ts diff --git a/app/components/Views/WalletCreationError/SRPErrorScreen.styles.ts b/app/components/Views/WalletCreationError/SRPErrorScreen.styles.ts deleted file mode 100644 index 896cadc91e9..00000000000 --- a/app/components/Views/WalletCreationError/SRPErrorScreen.styles.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { StyleSheet } from 'react-native'; -import { Theme } from '../../../util/theme/models'; - -const styleSheet = (params: { theme: Theme }) => { - const { theme } = params; - const { colors } = theme; - - return StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.background.default, - }, - scrollContent: { - flexGrow: 1, - padding: 16, - }, - content: { - flex: 1, - alignItems: 'center', - paddingTop: 48, - }, - warningIcon: { - marginBottom: 16, - }, - title: { - textAlign: 'center', - marginBottom: 16, - }, - infoBanner: { - flexDirection: 'row', - backgroundColor: colors.info.muted, - borderRadius: 8, - padding: 12, - marginBottom: 24, - width: '100%', - }, - infoBannerIcon: { - marginRight: 8, - marginTop: 2, - }, - infoBannerText: { - flex: 1, - }, - errorReportContainer: { - width: '100%', - marginBottom: 24, - }, - errorReportHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 8, - }, - errorReportContent: { - backgroundColor: colors.background.alternative, - borderRadius: 8, - padding: 12, - maxHeight: 200, - }, - buttonContainer: { - width: '100%', - paddingTop: 16, - paddingBottom: 24, - }, - button: { - marginBottom: 16, - }, - }); -}; - -export default styleSheet; diff --git a/app/components/Views/WalletCreationError/SRPErrorScreen.tsx b/app/components/Views/WalletCreationError/SRPErrorScreen.tsx index 66e22942225..7f114a90d44 100644 --- a/app/components/Views/WalletCreationError/SRPErrorScreen.tsx +++ b/app/components/Views/WalletCreationError/SRPErrorScreen.tsx @@ -1,11 +1,24 @@ import React, { useCallback, useState, useRef, useEffect } from 'react'; -import { View, SafeAreaView, ScrollView, Linking } from 'react-native'; +import { SafeAreaView, ScrollView, Linking } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import Clipboard from '@react-native-clipboard/clipboard'; import { captureException } from '@sentry/react-native'; import { connect } from 'react-redux'; import { Dispatch } from 'redux'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + Text, + TextVariant, + TextColor, + FontWeight, + Icon, + IconName, + IconSize, + IconColor, +} from '@metamask/design-system-react-native'; + import { OnboardingActionTypes, saveOnboardingEvent as saveEvent, @@ -14,27 +27,17 @@ import { MetaMetricsEvents } from '../../../core/Analytics'; import trackOnboarding from '../../../util/metrics/TrackOnboarding/trackOnboarding'; import { ITrackingEvent } from '../../../core/Analytics/MetaMetrics.types'; import { MetricsEventBuilder } from '../../../core/Analytics/MetricsEventBuilder'; -import Text, { - TextVariant, - TextColor, -} from '../../../component-library/components/Texts/Text'; import Button, { ButtonVariants, ButtonSize, ButtonWidthTypes, } from '../../../component-library/components/Buttons/Button'; -import Icon, { - IconName, - IconSize, - IconColor, -} from '../../../component-library/components/Icons/Icon'; +import { IconName as CLibIconName } from '../../../component-library/components/Icons/Icon'; import { strings } from '../../../../locales/i18n'; import Routes from '../../../constants/navigation/Routes'; -import { useStyles } from '../../../component-library/hooks/useStyles'; import AppConstants from '../../../core/AppConstants'; import { Authentication } from '../../../core'; -import styleSheet from './SRPErrorScreen.styles'; interface SRPErrorScreenProps { error: Error; @@ -46,11 +49,10 @@ const SRPErrorScreen = ({ saveOnboardingEvent, }: SRPErrorScreenProps) => { const navigation = useNavigation(); - const { styles } = useStyles(styleSheet, {}); + const tw = useTailwind(); const [copied, setCopied] = useState(false); const copyTimeoutRef = useRef | null>(null); - // Cleanup timeout on unmount useEffect( () => () => { if (copyTimeoutRef.current) { @@ -60,7 +62,6 @@ const SRPErrorScreen = ({ [], ); - // Track screen viewed event useEffect(() => { trackOnboarding( MetricsEventBuilder.createEventBuilder( @@ -90,7 +91,6 @@ const SRPErrorScreen = ({ saveOnboardingEvent, ); - // Delete wallet await Authentication.deleteWallet(); navigation.reset({ routes: [{ name: Routes.ONBOARDING.ROOT_NAV }], @@ -146,50 +146,50 @@ const SRPErrorScreen = ({ }, []); return ( - + - + - + {strings('wallet_creation_error.title')} - + {strings('wallet_creation_error.srp_description_part1')}{' '} {strings('wallet_creation_error.metamask_support')} {'.'} - + - - - + + + {strings('wallet_creation_error.error_report')} + - {strings('login.forgot_password')} - - } - isDisabled={loading} - size={ButtonSize.Lg} - /> - - + onPress={toggleWarningModal} + disabled={loading} + style={tw.style('my-0 self-center pt-4')} + > + + {strings('login.forgot_password')} + + + + {!isE2E && ( ; - -const mockGetAuthType = jest.fn(); -const mockComponentAuthenticationType = jest.fn(); -const mockUnlockWallet = jest.fn(); -const mockLockApp = jest.fn(); -const mockReauthenticate = jest.fn(); -const mockRevealSRP = jest.fn(); -const mockRevealPrivateKey = jest.fn(); -const mockCheckIsSeedlessPasswordOutdated = jest.fn(); -const mockUpdateAuthPreference = jest.fn(); - -jest.mock('../../../core/Authentication/hooks/useAuthentication', () => ({ - __esModule: true, - default: () => ({ - getAuthType: mockGetAuthType, - componentAuthenticationType: mockComponentAuthenticationType, - unlockWallet: mockUnlockWallet, - lockApp: mockLockApp, - reauthenticate: mockReauthenticate, - revealSRP: mockRevealSRP, - revealPrivateKey: mockRevealPrivateKey, - checkIsSeedlessPasswordOutdated: mockCheckIsSeedlessPasswordOutdated, - updateAuthPreference: mockUpdateAuthPreference, - }), -})); - -const defaultCapabilities: AuthCapabilities = { - authType: AUTHENTICATION_TYPE.DEVICE_AUTHENTICATION, - isBiometricsAvailable: true, - passcodeAvailable: true, - authLabel: 'Face ID', - authDescription: - 'Use your device’s biometrics or passcode to unlock MetaMask.', - authIcon: IconName.FaceId, - osAuthEnabled: false, - allowLoginWithRememberMe: false, - deviceAuthRequiresSettings: false, -}; - -const mockUseAuthCapabilities = jest.fn(() => ({ - capabilities: defaultCapabilities, - isLoading: false, -})); - -jest.mock('../../../core/Authentication/hooks/useAuthCapabilities', () => ({ - __esModule: true, - default: () => mockUseAuthCapabilities(), -})); - -const mockNavigate = jest.fn(); -const mockReplace = jest.fn(); -const mockReset = jest.fn(); -const mockGoBack = jest.fn(); - -const mockRoute = jest.fn(); - -jest.mock('../../../core/Engine', () => ({ - context: { - KeyringController: { - verifyPassword: jest.fn(), - submitPassword: jest.fn(), - }, - MultichainAccountService: { - init: jest.fn().mockResolvedValue(undefined), - }, - }, -})); - -jest.mock('../../../util/mnemonic', () => ({ - uint8ArrayToMnemonic: jest.fn(), -})); - -jest.mock('@react-navigation/native', () => { - const actualNav = jest.requireActual('@react-navigation/native'); - return { - ...actualNav, - useNavigation: () => ({ - navigate: mockNavigate, - replace: mockReplace, - reset: mockReset, - goBack: mockGoBack, - dispatch: jest.fn((action) => { - if (action.type === 'REPLACE') { - mockReplace(action.payload.name, action.payload.params); - } - }), - }), - useRoute: () => mockRoute(), - }; -}); -jest.mock('../../../util/authentication', () => ({ - updateAuthTypeStorageFlags: jest.fn(), - passcodeType: jest.fn().mockReturnValue('passcode_ios'), -})); - -jest.mock('../../../util/validators', () => ({ - parseVaultValue: jest.fn(), -})); - -jest.mock('../../../core/BackupVault', () => ({ - getVaultFromBackup: jest.fn(), -})); - -const mockGetVaultFromBackup = getVaultFromBackup as jest.Mock; - -const mockParseVaultValue = parseVaultValue as jest.Mock; - -const mockEndTrace = jest.fn(); - -jest.mock('../../../util/trace', () => { - const actualTrace = jest.requireActual('../../../util/trace'); - return { - ...actualTrace, - endTrace: (request: EndTraceRequest) => mockEndTrace(request), - }; -}); - -jest.mock('../../../multichain-accounts/AccountTreeInitService', () => ({ - initializeAccountTree: jest.fn().mockResolvedValue(undefined), -})); - -// Mock useNetInfo hook -jest.mock('@react-native-community/netinfo', () => ({ - useNetInfo: jest.fn(() => ({ - isConnected: true, - isInternetReachable: true, - type: 'wifi', - details: { - isConnectionExpensive: false, - }, - })), -})); - -jest.mock('../../UI/ScreenshotDeterrent', () => ({ - ScreenshotDeterrent: () => null, -})); - -describe('Login test suite 2', () => { - const createMockReduxStore = ( - stateOverrides?: RecursivePartial, - ) => { - const defaultState = { - user: { - existingUser: false, - }, - security: { - allowLoginWithRememberMe: false, - }, - settings: { - lockTime: -1, - }, - ...(stateOverrides || {}), - } as RecursivePartial; - - return { - dispatch: jest.fn(), - getState: jest.fn(() => defaultState), - subscribe: jest.fn(), - replaceReducer: jest.fn(), - [Symbol.observable]: jest.fn(), - } as unknown as ReduxStore; - }; - - beforeAll(() => { - jest.useFakeTimers(); - }); - - beforeEach(() => { - // Mock Redux store for all tests - const mockStore = createMockReduxStore(); - jest.spyOn(ReduxService, 'store', 'get').mockReturnValue(mockStore); - // Default mock for checkIsSeedlessPasswordOutdated - returns false (password not outdated) - mockCheckIsSeedlessPasswordOutdated.mockResolvedValue(false); - mockRoute.mockReturnValue({ - params: { locked: false, oauthLoginSuccess: false }, - }); - mockUseAuthCapabilities.mockReturnValue({ - capabilities: defaultCapabilities, - isLoading: false, - }); - }); - - afterEach(() => { - act(() => { - jest.runOnlyPendingTimers(); - }); - jest.clearAllTimers(); - jest.clearAllMocks(); - // Restore Redux store mock after clearing mocks - const mockStore = createMockReduxStore(); - jest.spyOn(ReduxService, 'store', 'get').mockReturnValue(mockStore); - }); - - afterAll(() => { - jest.clearAllTimers(); - jest.useRealTimers(); - }); - - describe('handleVaultCorruption', () => { - beforeEach(() => { - mockRoute.mockReturnValue({ - params: { - locked: false, - oauthLoginSuccess: false, - }, - }); - mockGetAuthType.mockResolvedValueOnce({ - currentAuthType: AUTHENTICATION_TYPE.PASSCODE, - }); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('navigates to restore wallet screen when vault is corrupted and password is valid', async () => { - mockGetVaultFromBackup.mockResolvedValueOnce({ - success: true, - vault: 'mock-vault', - }); - mockParseVaultValue.mockResolvedValueOnce('mock-seed'); - - mockUnlockWallet.mockRejectedValue(new Error(VAULT_ERROR)); - - mockComponentAuthenticationType.mockResolvedValueOnce({ - currentAuthType: AUTHENTICATION_TYPE.PASSCODE, - }); - - const { getByTestId } = renderWithProvider(); - const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); - - await act(async () => { - fireEvent.changeText(passwordInput, 'valid-password123'); - }); - await act(async () => { - fireEvent(passwordInput, 'submitEditing'); - }); - - expect(mockReplace).toHaveBeenCalledWith( - Routes.VAULT_RECOVERY.RESTORE_WALLET, - expect.objectContaining({ - params: { - previousScreen: Routes.ONBOARDING.LOGIN, - }, - screen: Routes.VAULT_RECOVERY.RESTORE_WALLET, - }), - ); - }); - - it('show error for invalid password during vault corruption', async () => { - mockGetVaultFromBackup.mockResolvedValueOnce({ - success: true, - vault: 'mock-vault', - }); - mockParseVaultValue.mockResolvedValueOnce(undefined); - - mockUnlockWallet.mockRejectedValue(new Error(VAULT_ERROR)); - - const { getByTestId } = renderWithProvider(); - const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); - - await act(async () => { - fireEvent.changeText(passwordInput, 'invalid-password'); - }); - await act(async () => { - fireEvent(passwordInput, 'submitEditing'); - }); - - expect(getByTestId(LoginViewSelectors.PASSWORD_ERROR)).toBeTruthy(); - }); - - it('handle vault corruption when password requirements are not met', async () => { - mockUnlockWallet.mockRejectedValue(new Error(VAULT_ERROR)); - - const { getByTestId } = renderWithProvider(); - const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); - - await act(async () => { - fireEvent.changeText(passwordInput, '123'); // Too short password - }); - await act(async () => { - fireEvent(passwordInput, 'submitEditing'); - }); - - expect(getByTestId(LoginViewSelectors.PASSWORD_ERROR)).toBeTruthy(); - }); - - it('handle vault corruption when backup has error', async () => { - mockGetVaultFromBackup.mockResolvedValueOnce({ - success: false, - error: 'Backup error', - }); - mockUnlockWallet.mockRejectedValue(new Error(VAULT_ERROR)); - - const { getByTestId } = renderWithProvider(); - const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); - - await act(async () => { - fireEvent.changeText(passwordInput, 'valid-password123'); - }); - await act(async () => { - fireEvent(passwordInput, 'submitEditing'); - }); - - expect(getByTestId(LoginViewSelectors.PASSWORD_ERROR)).toBeTruthy(); - }); - - it('handle vault corruption when storePassword fails', async () => { - mockUnlockWallet.mockRejectedValue(new Error(VAULT_ERROR)); - - // Mock getVaultFromBackup to return an error to trigger error handling - mockGetVaultFromBackup.mockResolvedValueOnce({ - success: false, - error: 'Store password failed', - }); - - const { getByTestId } = renderWithProvider(); - const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); - - await act(async () => { - fireEvent.changeText(passwordInput, 'valid-password123'); - }); - await act(async () => { - fireEvent(passwordInput, 'submitEditing'); - }); - - await waitFor(() => { - expect(getByTestId(LoginViewSelectors.PASSWORD_ERROR)).toBeTruthy(); - }); - }); - - it('handle vault corruption when vault seed cannot be parsed', async () => { - mockGetVaultFromBackup.mockResolvedValueOnce({ - success: true, - vault: 'mock-vault', - }); - mockParseVaultValue.mockResolvedValueOnce(undefined); - - mockUnlockWallet.mockRejectedValue(new Error(VAULT_ERROR)); - - const { getByTestId } = renderWithProvider(); - const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); - - await act(async () => { - fireEvent.changeText(passwordInput, 'valid-password123'); - }); - await act(async () => { - fireEvent(passwordInput, 'submitEditing'); - }); - - expect(getByTestId(LoginViewSelectors.PASSWORD_ERROR)).toBeTruthy(); - }); - }); - - describe('Global Password changed', () => { - afterEach(() => { - jest.clearAllTimers(); - }); - - it('shows device authentication button when capabilities allow device auth', async () => { - mockRoute.mockReturnValue({ - params: { - locked: false, - oauthLoginSuccess: false, - }, - }); - mockUseAuthCapabilities.mockReturnValue({ - capabilities: { - ...defaultCapabilities, - authType: AUTHENTICATION_TYPE.BIOMETRIC, - }, - isLoading: false, - }); - - const { getByTestId } = renderWithProvider(); - - await waitFor(() => { - expect( - getByTestId(LoginViewSelectors.DEVICE_AUTHENTICATION_ICON), - ).toBeOnTheScreen(); - }); - }); - }); - - describe('biometric cancellation', () => { - it('does not log error when Android biometric auth is cancelled', async () => { - // Arrange - mockRoute.mockReturnValue({ - params: { - locked: false, - oauthLoginSuccess: false, - }, - }); - mockUnlockWallet.mockRejectedValue(new Error('Cancel')); - - const { getByTestId } = renderWithProvider(); - const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); - - // Act - await act(async () => { - fireEvent.changeText(passwordInput, 'valid-password123'); - }); - await act(async () => { - fireEvent(passwordInput, 'submitEditing'); - }); - - // Assert - expect(mockLogger.error).not.toHaveBeenCalled(); - }); - - it('does not log error when iOS biometric auth is cancelled', async () => { - // Arrange - mockRoute.mockReturnValue({ - params: { - locked: false, - oauthLoginSuccess: false, - }, - }); - mockUnlockWallet.mockRejectedValue( - new Error(UNLOCK_WALLET_ERROR_MESSAGES.IOS_USER_CANCELLED_BIOMETRICS), - ); - - const { getByTestId } = renderWithProvider(); - const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); - - // Act - await act(async () => { - fireEvent.changeText(passwordInput, 'valid-password123'); - }); - await act(async () => { - fireEvent(passwordInput, 'submitEditing'); - }); - - // Assert - expect(mockLogger.error).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/app/components/Views/Login/styles.ts b/app/components/Views/Login/styles.ts deleted file mode 100644 index 50f1b1f3d1a..00000000000 --- a/app/components/Views/Login/styles.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { Theme } from '../../../util/theme/models'; -import { Platform, StatusBar, StyleSheet } from 'react-native'; -import Device from '../../../util/device'; -import { fontStyles } from '../../../styles/common'; -const deviceHeight = Device.getDeviceHeight(); -const breakPoint = deviceHeight < 700; - -const styleSheet = (params: { theme: Theme }) => { - const { - theme: { colors }, - } = params; - - return StyleSheet.create({ - mainWrapper: { - paddingTop: Platform.select({ - android: StatusBar.currentHeight ?? 0, - default: 0, - }), - flex: 1, - }, - wrapper: { - flex: 1, - }, - container: { - flex: 1, - justifyContent: 'flex-start', - alignItems: 'center', - flexDirection: 'column', - width: '100%', - paddingHorizontal: 24, - paddingTop: 80, - }, - scrollContentContainer: { - flex: 1, - }, - foxAnimationWrapper: { - position: 'absolute', - bottom: 0, - left: 0, - right: 0, - height: 200, - }, - foxWrapper: { - justifyContent: 'center', - alignSelf: 'center', - width: Device.isIos() ? 175 : 150, - height: Device.isIos() ? 175 : 150, - marginTop: 48, - }, - image: { - alignSelf: 'center', - width: Device.isIos() ? 175 : 150, - height: Device.isIos() ? 175 : 150, - }, - title: { - textAlign: 'center', - marginVertical: 24, - }, - field: { - flexDirection: 'column', - width: '100%', - rowGap: 8, - marginTop: 80, - justifyContent: 'flex-start', - marginBottom: 8, - }, - ctaWrapper: { - width: '100%', - flexDirection: 'column', - alignItems: 'center', - }, - - footer: { - marginTop: 32, - alignItems: 'center', - }, - unlockButton: { - marginTop: 4, - }, - metamaskName: { - width: 160, - height: 80, - alignSelf: 'center', - marginBottom: 60, - marginTop: 60, - tintColor: colors.icon.default, - }, - goBack: { - marginVertical: 0, - alignSelf: 'center', - paddingTop: 16, - }, - biometrics: { - flexDirection: 'row', - alignItems: 'center', - marginTop: 20, - marginBottom: 30, - }, - biometryLabel: { - flex: 1, - fontSize: 16, - color: colors.text.default, - ...fontStyles.normal, - }, - biometrySwitch: { - flex: 0, - }, - cant: { - width: 280, - alignSelf: 'center', - justifyContent: 'center', - textAlign: 'center', - }, - areYouSure: { - width: '100%', - padding: breakPoint ? 16 : 24, - justifyContent: 'center', - alignSelf: 'center', - }, - heading: { - marginHorizontal: 6, - color: colors.text.default, - ...fontStyles.bold, - fontSize: 20, - textAlign: 'center', - lineHeight: breakPoint ? 24 : 26, - }, - red: { - marginHorizontal: 24, - color: colors.error.default, - }, - warningText: { - ...fontStyles.normal, - textAlign: 'center', - fontSize: 14, - lineHeight: breakPoint ? 18 : 22, - color: colors.text.default, - marginTop: 20, - }, - warningIcon: { - alignSelf: 'center', - color: colors.error.default, - marginVertical: 10, - }, - bold: { - ...fontStyles.bold, - }, - delete: { - marginBottom: 20, - }, - deleteWarningMsg: { - ...fontStyles.normal, - fontSize: 16, - lineHeight: 20, - marginTop: 10, - color: colors.error.default, - }, - oauthContentWrapper: { - width: '100%', - alignItems: 'center', - marginTop: Platform.select({ - ios: -200, - android: -180, - }), - }, - input: { - width: '100%', - }, - labelContainer: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - hintText: { - textAlign: 'left', - }, - helperTextContainer: { - flexDirection: 'row', - alignItems: 'flex-start', - justifyContent: 'flex-start', - rowGap: 2, - alignSelf: 'flex-start', - }, - }); -}; - -export default styleSheet; diff --git a/tests/page-objects/wallet/LoginView.ts b/tests/page-objects/wallet/LoginView.ts index 3047819ecda..26565d50ea6 100644 --- a/tests/page-objects/wallet/LoginView.ts +++ b/tests/page-objects/wallet/LoginView.ts @@ -18,7 +18,11 @@ class LoginView { get passwordInput(): EncapsulatedElementType { return encapsulated({ - detox: () => Matchers.getElementByID(LoginViewSelectors.PASSWORD_INPUT), + // Use getElementByLabel so Detox targets the inner TextInput (EditText on + // Android) rather than the outer Pressable container which carries the + // testID but has no input connection and therefore rejects typeText. + detox: () => + Matchers.getElementByLabel(LoginViewSelectors.PASSWORD_INPUT), appium: { android: () => PlaywrightMatchers.getElementById(LoginViewSelectors.PASSWORD_INPUT, { diff --git a/yarn.lock b/yarn.lock index af957f0c897..321844ea8e6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -45264,11 +45264,11 @@ __metadata: linkType: hard "ts-api-utils@npm:^1.3.0": - version: 1.3.0 - resolution: "ts-api-utils@npm:1.3.0" + version: 1.4.3 + resolution: "ts-api-utils@npm:1.4.3" peerDependencies: typescript: ">=4.2.0" - checksum: 10/3ee44faa24410cd649b5c864e068d438aa437ef64e9e4a66a41646a6d3024d3097a695eeb3fb26ee364705d3cb9653a65756d009e6a53badb6066a5f447bf7ed + checksum: 10/713c51e7392323305bd4867422ba130fbf70873ef6edbf80ea6d7e9c8f41eeeb13e40e8e7fe7cd321d74e4864777329797077268c9f570464303a1723f1eed39 languageName: node linkType: hard From 37673545475ddcc2c949caa2616e63a30e9c0cea Mon Sep 17 00:00:00 2001 From: Kevin Le Jeune Date: Thu, 19 Mar 2026 12:15:49 +0100 Subject: [PATCH 125/206] feat: authenticate sentinel and transaction api transaction submissions (#27410) ## **Description** Authenticate calls to Sentinel and Transaction API, with a focus on calls submitting transactions. Authenticating simulations require a transaction controller update, it is out of the scope of this PR. **Unauthenticated calls still succeed, the back-end is not requiring authentication.** A related PR for extension is at https://github.com/MetaMask/metamask-extension/pull/40667 ## **Changelog** CHANGELOG entry: authenticate transaction submission to sentinel and transaction API ## **Related issues** Fixes: ## **Manual testing steps** For each of these: - Perform an action involving Sentinel or Transaction API - In the network logs, check the call includes a "Authorization" header with a bearer token 1. Click on "Swap" in the main screen: - https://tx-sentinel-XXX-mainnet.api.cx.metamask.io/network - https://tx-sentinel-ethereum-mainnet.api.cx.metamask.io/networks (check multiple calls, they don't all come from the same part of the code) - https://transaction.api.cx.metamask.io/networks/XXX/submitTransactions 2. Perform a smart transaction send (on Ethereum mainnet or BSC for example) - https://transaction.api.cx.metamask.io/networks/XXX/submitTransactions 3. Perform a gasless swap with EIP-7702 (on Polygon or Base) - https://tx-sentinel-XXX-mainnet.api.cx.metamask.io/ (with RPC method `eth_sendRelayTransaction`) ## **Screenshots/Recordings** ### **Before** ### **After** Screenshot 2026-03-12 at 11 06 07 ## **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. ## **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** > Adds bearer-token authentication to Sentinel network flag fetches and transaction relay submissions/polling, which can affect transaction flows if headers are misapplied or token retrieval fails. Token retrieval is best-effort (falls back to unauthenticated requests), reducing but not eliminating integration risk. > > **Overview** > **Adds bearer-token auth plumbed from `AuthenticationController` into smart transactions, Sentinel, and relay calls.** `smartTransactionsControllerInit` now passes a `getBearerToken` callback to `SmartTransactionsController` and also registers it via `setSentinelApiAuth`. > > Sentinel utilities now build request headers via new `getSentinelApiHeadersAsync` and attach them to `/networks` fetches and transaction relay polling; `jsonRpcRequest` also accepts optional extra headers so relay JSON-RPC submissions can include `Authorization`. Tests are expanded to cover token getter behavior and header injection, and `@metamask/smart-transactions-controller` is bumped to `^22.7.0`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit d9cfc9018f928f5ca04eb93ace15c9c6089172ec. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- ...smart-transactions-controller-init.test.ts | 64 +++++++++++++++++++ .../smart-transactions-controller-init.ts | 20 ++++++ ...smart-transactions-controller-messenger.ts | 9 ++- app/util/jsonRpcRequest.ts | 13 +++- app/util/transactions/sentinel-api.test.ts | 40 ++++++++++++ app/util/transactions/sentinel-api.ts | 48 +++++++++++++- .../transactions/transaction-relay.test.ts | 12 +++- app/util/transactions/transaction-relay.ts | 27 ++++++-- package.json | 2 +- yarn.lock | 10 +-- 10 files changed, 226 insertions(+), 19 deletions(-) diff --git a/app/core/Engine/controllers/smart-transactions-controller-init.test.ts b/app/core/Engine/controllers/smart-transactions-controller-init.test.ts index 7d5c4e05c37..c689e052011 100644 --- a/app/core/Engine/controllers/smart-transactions-controller-init.test.ts +++ b/app/core/Engine/controllers/smart-transactions-controller-init.test.ts @@ -51,7 +51,71 @@ describe('SmartTransactionsControllerInit', () => { clientId: 'mobile', getMetaMetricsProps: expect.any(Function), trackMetaMetricsEvent: expect.any(Function), + getBearerToken: expect.any(Function), trace: expect.any(Function), }); }); + + describe('getBearerToken', () => { + it('passes getter that returns token when AuthenticationController returns one', async () => { + const bearerToken = 'test-bearer-token'; + const request = getInitRequestMock(); + const mockCall = jest.fn().mockResolvedValue(bearerToken); + jest.spyOn(request.initMessenger, 'call').mockImplementation(mockCall); + + smartTransactionsControllerInit(request); + + const controllerMock = jest.mocked(SmartTransactionsController); + const constructorCall = + controllerMock.mock.calls[controllerMock.mock.calls.length - 1][0]; + const getBearerToken = constructorCall.getBearerToken as () => Promise< + string | undefined + >; + + const result = await getBearerToken(); + + expect(result).toBe(bearerToken); + expect(mockCall).toHaveBeenCalledWith( + 'AuthenticationController:getBearerToken', + ); + }); + + it('passes getter that returns undefined when AuthenticationController returns undefined', async () => { + const request = getInitRequestMock(); + const mockCall = jest.fn().mockResolvedValue(undefined); + jest.spyOn(request.initMessenger, 'call').mockImplementation(mockCall); + + smartTransactionsControllerInit(request); + + const controllerMock = jest.mocked(SmartTransactionsController); + const constructorCall = + controllerMock.mock.calls[controllerMock.mock.calls.length - 1][0]; + const getBearerToken = constructorCall.getBearerToken as () => Promise< + string | undefined + >; + + const result = await getBearerToken(); + + expect(result).toBeUndefined(); + }); + + it('passes getter that returns undefined when AuthenticationController throws', async () => { + const request = getInitRequestMock(); + const mockCall = jest.fn().mockRejectedValue(new Error('auth error')); + jest.spyOn(request.initMessenger, 'call').mockImplementation(mockCall); + + smartTransactionsControllerInit(request); + + const controllerMock = jest.mocked(SmartTransactionsController); + const constructorCall = + controllerMock.mock.calls[controllerMock.mock.calls.length - 1][0]; + const getBearerToken = constructorCall.getBearerToken as () => Promise< + string | undefined + >; + + const result = await getBearerToken(); + + expect(result).toBeUndefined(); + }); + }); }); diff --git a/app/core/Engine/controllers/smart-transactions-controller-init.ts b/app/core/Engine/controllers/smart-transactions-controller-init.ts index 86ec551eae0..2d7186d7a45 100644 --- a/app/core/Engine/controllers/smart-transactions-controller-init.ts +++ b/app/core/Engine/controllers/smart-transactions-controller-init.ts @@ -12,6 +12,7 @@ import type { SmartTransactionsControllerInitMessenger } from '../messengers/sma import { AnalyticsEventBuilder } from '../../../util/analytics/AnalyticsEventBuilder'; import { trace } from '../../../util/trace'; import { getAllowedSmartTransactionsChainIds } from '../../../constants/smartTransactions'; +import { setSentinelApiAuth } from '../../../util/transactions/sentinel-api'; /** * Initialize the smart transactions controller. @@ -46,6 +47,24 @@ export const smartTransactionsControllerInit: ControllerInitFunction< } }; + /** + * Bearer token for Transaction API (and Sentinel) authentication. Only present when + * the user is signed in (AuthenticationController has a valid session). If getBearerToken + * returns undefined, no Authorization header is sent on smart transaction API calls. + */ + const getBearerToken = async (): Promise => { + try { + return await Promise.resolve( + initMessenger.call('AuthenticationController:getBearerToken'), + ); + } catch { + return undefined; + } + }; + + // Use same bearer token for Sentinel API (networks, relay) as for Transaction API + setSentinelApiAuth(getBearerToken); + const controller = new SmartTransactionsController({ messenger: controllerMessenger, state: persistedState.SmartTransactionsController, @@ -55,6 +74,7 @@ export const smartTransactionsControllerInit: ControllerInitFunction< // transactions. getMetaMetricsProps: () => Promise.resolve({}), trackMetaMetricsEvent, + getBearerToken, // @ts-expect-error: Type of `TraceRequest` is different. trace, diff --git a/app/core/Engine/messengers/smart-transactions-controller-messenger.ts b/app/core/Engine/messengers/smart-transactions-controller-messenger.ts index fc7e6654e0f..89631def64e 100644 --- a/app/core/Engine/messengers/smart-transactions-controller-messenger.ts +++ b/app/core/Engine/messengers/smart-transactions-controller-messenger.ts @@ -6,6 +6,7 @@ import { import { RootMessenger } from '../types'; import { SmartTransactionsControllerMessenger } from '@metamask/smart-transactions-controller'; import { AnalyticsControllerActions } from '@metamask/analytics-controller'; +import { AuthenticationController } from '@metamask/profile-sync-controller'; /** * Get the messenger for the smart transactions controller. This is scoped to the @@ -46,7 +47,8 @@ export function getSmartTransactionsControllerMessenger( } type SmartTransactionsControllerInitMessengerActions = - AnalyticsControllerActions; + | AnalyticsControllerActions + | AuthenticationController.AuthenticationControllerGetBearerTokenAction; /** * Get the SmartTransactionsControllerInitMessenger for the SmartTransactionsController. @@ -78,7 +80,10 @@ export function getSmartTransactionsControllerInitMessenger( }); rootMessenger.delegate({ - actions: ['AnalyticsController:trackEvent'], + actions: [ + 'AnalyticsController:trackEvent', + 'AuthenticationController:getBearerToken', + ], events: [], messenger, }); diff --git a/app/util/jsonRpcRequest.ts b/app/util/jsonRpcRequest.ts index e31439ab3fd..783cfdd0725 100644 --- a/app/util/jsonRpcRequest.ts +++ b/app/util/jsonRpcRequest.ts @@ -3,23 +3,32 @@ import ParsedURL from 'url-parse'; import { Buffer } from 'buffer'; import { JsonRpcParams } from '@metamask/utils'; +export interface JsonRpcRequestOptions { + /** + * Additional headers to send with the request (merged with Content-Type and any Basic auth). + */ + headers?: Record; +} + /** * Makes a JSON RPC request to the given URL, with the given RPC method and params. * * @param {string} rpcUrl - The RPC endpoint URL to target. * @param {string} rpcMethod - The RPC method to request. * @param {Array} [rpcParams] - The RPC method params. - * @returns {Promise} Returns the result of the RPC method call, - * or throws an error in case of failure. + * @param {JsonRpcRequestOptions} [options] - Optional settings (e.g. extra headers for Sentinel/Transaction API auth). + * @returns {Promise} Returns the result of the RPC method call, or throws an error in case of failure. */ export async function jsonRpcRequest( rpcUrl: string, rpcMethod: string, rpcParams: JsonRpcParams = [], + options?: JsonRpcRequestOptions, ) { let fetchUrl = rpcUrl; const headers: Record = { 'Content-Type': 'application/json', + ...options?.headers, }; // Convert basic auth URL component to Authorization header diff --git a/app/util/transactions/sentinel-api.test.ts b/app/util/transactions/sentinel-api.test.ts index 09ca47dc055..53008f257ea 100644 --- a/app/util/transactions/sentinel-api.test.ts +++ b/app/util/transactions/sentinel-api.test.ts @@ -6,6 +6,8 @@ import { getSendBundleSupportedChains, isSendBundleSupported, clearSentinelNetworkCache, + setSentinelApiAuth, + getSentinelApiHeadersAsync, } from './sentinel-api'; const fetchMock = jest.fn(); @@ -64,12 +66,50 @@ describe('sentinel-api', () => { beforeEach(() => { jest.clearAllMocks(); clearSentinelNetworkCache(); + setSentinelApiAuth(undefined); }); afterEach(() => { jest.restoreAllMocks(); }); + describe('setSentinelApiAuth and getSentinelApiHeadersAsync', () => { + it('returns empty headers when no auth setter is configured', async () => { + const headers = await getSentinelApiHeadersAsync(); + expect(headers).toEqual({}); + }); + + it('includes Authorization when getter returns a token', async () => { + setSentinelApiAuth(async () => 'test-token'); + const headers = await getSentinelApiHeadersAsync(); + expect(headers).toMatchObject({ Authorization: 'Bearer test-token' }); + }); + + it('adds Bearer prefix when getter returns raw token', async () => { + setSentinelApiAuth(async () => 'raw-token'); + const headers = await getSentinelApiHeadersAsync(); + expect((headers as Record).Authorization).toBe( + 'Bearer raw-token', + ); + }); + + it('omits Authorization when getter returns undefined', async () => { + setSentinelApiAuth(async () => undefined); + const headers = await getSentinelApiHeadersAsync(); + expect(headers).toEqual({}); + expect((headers as Record).Authorization).toBeUndefined(); + }); + + it('omits Authorization when getter throws', async () => { + setSentinelApiAuth(async () => { + throw new Error('token error'); + }); + const headers = await getSentinelApiHeadersAsync(); + expect(headers).toEqual({}); + expect((headers as Record).Authorization).toBeUndefined(); + }); + }); + describe('buildUrl', () => { it('builds the correct sentinel API URL for a subdomain', () => { expect(buildUrl('my-chain')).toBe( diff --git a/app/util/transactions/sentinel-api.ts b/app/util/transactions/sentinel-api.ts index c65972f93de..8c1d26301ac 100644 --- a/app/util/transactions/sentinel-api.ts +++ b/app/util/transactions/sentinel-api.ts @@ -5,6 +5,51 @@ import { Hex } from '@metamask/utils'; const BASE_URL = 'https://tx-sentinel-{0}.api.cx.metamask.io/'; const ENDPOINT_NETWORKS = 'networks'; +/** + * Optional bearer token getter, set at Engine init to authenticate + * Sentinel and Transaction API calls via core-backend (AuthenticationController). + */ +let getBearerTokenForSentinel: (() => Promise) | undefined; + +/** + * Sets the bearer token getter for authenticating Sentinel and Transaction API calls. + * Called once at Engine init (e.g. from smart-transactions-controller-init) with + * AuthenticationController.getBearerToken. + * + * @param getter - Async function that returns the current bearer token, or undefined to clear. + */ +export function setSentinelApiAuth( + getter: (() => Promise) | undefined, +): void { + getBearerTokenForSentinel = getter; +} + +/** + * Returns headers for Sentinel/Transaction API requests, including Authorization + * when the app has set a bearer token getter and it returns a token. + * Use this for all outbound Sentinel and relay requests. + * + * @returns Promise resolving to headers (optional Bearer only when authenticated). + */ +export async function getSentinelApiHeadersAsync(): Promise< + Record +> { + const headers: Record = {}; + + if (getBearerTokenForSentinel) { + try { + const token = await getBearerTokenForSentinel(); + if (token) { + headers.Authorization = `Bearer ${token}`; + } + } catch { + // Proceed without auth if token retrieval fails + } + } + + return headers; +} + // In-memory cache for network flags (matches server's cache-control: max-age=300) const CACHE_TTL_MS = 300_000; // 5 minutes @@ -90,7 +135,8 @@ function getAllSentinelNetworkFlags(): Promise { async function fetchNetworkFlags(): Promise { try { const url = `${buildUrl('ethereum-mainnet')}${ENDPOINT_NETWORKS}`; - const response = await fetch(url); + const headers = await getSentinelApiHeadersAsync(); + const response = await fetch(url, { headers }); if (!response.ok) { const errorBody = await response.text(); diff --git a/app/util/transactions/transaction-relay.test.ts b/app/util/transactions/transaction-relay.test.ts index faf689487fb..be7617de349 100644 --- a/app/util/transactions/transaction-relay.test.ts +++ b/app/util/transactions/transaction-relay.test.ts @@ -12,7 +12,10 @@ import { } from './transaction-relay'; import jsonRpcRequest from '../../util/jsonRpcRequest'; -import { getSentinelNetworkFlags } from './sentinel-api'; +import { + getSentinelApiHeadersAsync, + getSentinelNetworkFlags, +} from './sentinel-api'; jest.useFakeTimers(); @@ -20,10 +23,14 @@ jest.mock('../../util/jsonRpcRequest'); jest.mock('./sentinel-api', () => ({ ...jest.requireActual('./sentinel-api'), getSentinelNetworkFlags: jest.fn(), + getSentinelApiHeadersAsync: jest.fn().mockResolvedValue({}), })); describe('Transaction Relay (mobile)', () => { const jsonRpcRequestMock = jest.mocked(jsonRpcRequest); + const getSentinelApiHeadersAsyncMock = jest.mocked( + getSentinelApiHeadersAsync, + ); const getSentinelNetworkFlagsMock = jest.mocked(getSentinelNetworkFlags); const TRANSACTION_HASH_MOCK = '0x123'; @@ -82,6 +89,7 @@ describe('Transaction Relay (mobile)', () => { jest.resetAllMocks(); jest.clearAllTimers(); global.fetch = jest.fn() as unknown as typeof fetch; + getSentinelApiHeadersAsyncMock.mockResolvedValue({}); mockRelaySupported(); jsonRpcRequestMock.mockResolvedValue({ uuid: UUID_MOCK, @@ -103,6 +111,7 @@ describe('Transaction Relay (mobile)', () => { expect.any(String), RELAY_RPC_METHOD, [expect.objectContaining(SUBMIT_REQUEST_MOCK)], + expect.objectContaining({ headers: expect.any(Object) }), ); }); @@ -143,6 +152,7 @@ describe('Transaction Relay (mobile)', () => { expect(global.fetch).toHaveBeenCalledWith( 'https://tx-sentinel-mainnet.api.cx.metamask.io/smart-transactions/uuid-123', + expect.objectContaining({ headers: expect.any(Object) }), ); }); diff --git a/app/util/transactions/transaction-relay.ts b/app/util/transactions/transaction-relay.ts index cd769ed424c..7c20ba04edc 100644 --- a/app/util/transactions/transaction-relay.ts +++ b/app/util/transactions/transaction-relay.ts @@ -1,8 +1,12 @@ import { AuthorizationList } from '@metamask/transaction-controller'; import { SentinelMeta } from '@metamask/smart-transactions-controller'; import { Hex, Json, createProjectLogger } from '@metamask/utils'; -import { buildUrl, getSentinelNetworkFlags } from './sentinel-api'; import jsonRpcRequest from '../../util/jsonRpcRequest'; +import { + buildUrl, + getSentinelApiHeadersAsync, + getSentinelNetworkFlags, +} from './sentinel-api'; const log = createProjectLogger('transaction-relay'); @@ -49,9 +53,14 @@ export async function submitRelayTransaction( log('Request', url, request); - const response = (await jsonRpcRequest(url, RELAY_RPC_METHOD, [ - request as unknown as Json, - ])) as RelaySubmitResponse; + const headers = await getSentinelApiHeadersAsync(); + + const response = (await jsonRpcRequest( + url, + RELAY_RPC_METHOD, + [request as unknown as Json], + { headers }, + )) as RelaySubmitResponse; log('Response', response); @@ -74,7 +83,8 @@ export async function waitForRelayResult( return new Promise((resolve, reject) => { const intervalId = setInterval(async () => { try { - const result = await pollResult(url); + const headers = await getSentinelApiHeadersAsync(); + const result = await pollResult(url, headers); if (result.status !== RelayStatus.Pending) { clearInterval(intervalId); resolve(result); @@ -91,10 +101,13 @@ export async function isRelaySupported(chainId: Hex): Promise { return Boolean(await getRelayUrl(chainId)); } -async function pollResult(url: string): Promise { +async function pollResult( + url: string, + headers: HeadersInit = {}, +): Promise { log('Polling request', url); - const response = await fetch(url); + const response = await fetch(url, { headers }); log('Polling response', response); diff --git a/package.json b/package.json index a521c0f16c1..e2b8ecf282a 100644 --- a/package.json +++ b/package.json @@ -295,7 +295,7 @@ "@metamask/selected-network-controller": "^25.0.0", "@metamask/signature-controller": "^35.0.0", "@metamask/slip44": "^4.2.0", - "@metamask/smart-transactions-controller": "^22.6.0", + "@metamask/smart-transactions-controller": "^22.7.0", "@metamask/snaps-controllers": "^18.0.4", "@metamask/snaps-execution-environments": "^11.0.1", "@metamask/snaps-rpc-methods": "^15.0.0", diff --git a/yarn.lock b/yarn.lock index 321844ea8e6..c3541c76c96 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9611,9 +9611,9 @@ __metadata: languageName: node linkType: hard -"@metamask/smart-transactions-controller@npm:^22.6.0": - version: 22.6.0 - resolution: "@metamask/smart-transactions-controller@npm:22.6.0" +"@metamask/smart-transactions-controller@npm:^22.7.0": + version: 22.7.0 + resolution: "@metamask/smart-transactions-controller@npm:22.7.0" dependencies: "@babel/runtime": "npm:^7.24.1" "@ethereumjs/tx": "npm:^5.2.1" @@ -9647,7 +9647,7 @@ __metadata: optional: true "@metamask/gas-fee-controller": optional: true - checksum: 10/f8fc9b7b63f343d9e806cec8da14f5419af075aac0d751bff727f6c9380ee28710406a4deae7753bf55155cf0469089b6e1058d02f0e47a2bfbfa3128abb7cba + checksum: 10/49acf33fc852c109d2ce821a1a3a702068e8b9cd16b7a457838bb2ae0fce958ea1cc1eaf5395d876f77630939b0c8569b0032754554274bbc88e20d5d0f74de2 languageName: node linkType: hard @@ -35616,7 +35616,7 @@ __metadata: "@metamask/selected-network-controller": "npm:^25.0.0" "@metamask/signature-controller": "npm:^35.0.0" "@metamask/slip44": "npm:^4.2.0" - "@metamask/smart-transactions-controller": "npm:^22.6.0" + "@metamask/smart-transactions-controller": "npm:^22.7.0" "@metamask/snaps-controllers": "npm:^18.0.4" "@metamask/snaps-execution-environments": "npm:^11.0.1" "@metamask/snaps-rpc-methods": "npm:^15.0.0" From f7a914bfe7a44b29c0133280c4fc1104a9f4c250 Mon Sep 17 00:00:00 2001 From: Kirill Zyusko Date: Thu, 19 Mar 2026 12:39:51 +0100 Subject: [PATCH 126/206] feat: migrate Skeleton component (perps scope) (#27366) ## **Description** Migrated Skeleton to DSRN usage. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/DSYS-274 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/b9a5e1ba-8e1e-4ce3-a15a-ee320c3d3f59 ### **After** https://github.com/user-attachments/assets/6dc17b3b-b9a6-4b11-9cb9-f766b1157f64 ## **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. ## **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** > Low risk import/mocking change that swaps the Perps UI from the legacy `Skeleton` component to the `components-temp/Skeleton` export; primary risk is minor rendering or test breakage if the new Skeleton API/styles differ. > > **Overview** > Updates Perps screens/components to use the new design-system `Skeleton` implementation by switching imports from the legacy `component-library/components/Skeleton` paths to `component-library/components-temp/Skeleton`. > > Adjusts related unit tests to mock the new named `Skeleton` export (instead of the previous default export), keeping existing skeleton-based loading UI and assertions working. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 8e4db5b9a6249b41198b7e691a8ba050dca2e68a. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../PerpsMarketDetailsView.tsx | 2 +- .../PerpsMarketRowSkeleton.test.tsx | 31 ++++++++++--------- .../PerpsMarketRowSkeleton.tsx | 2 +- .../Views/PerpsOrderView/PerpsOrderView.tsx | 2 +- .../Perps/Views/PerpsTabView/PerpsTabView.tsx | 2 +- .../PerpsMarketBalanceActions.test.tsx | 2 +- .../PerpsMarketBalanceActions.tsx | 2 +- .../PerpsRowSkeleton.test.tsx | 29 ++++++++--------- .../PerpsRowSkeleton/PerpsRowSkeleton.tsx | 2 +- .../PerpsTransactionsSkeleton.test.tsx | 24 +++++++------- .../PerpsTransactionsSkeleton.tsx | 2 +- .../TradingViewChart/TradingViewChart.tsx | 2 +- 12 files changed, 50 insertions(+), 52 deletions(-) diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx index 3c122c54ff1..8f879e8bfc9 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx @@ -40,7 +40,7 @@ import Button, { ButtonVariants, ButtonWidthTypes, } from '../../../../../component-library/components/Buttons/Button'; -import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import Text, { TextColor, TextVariant, diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketRowSkeleton/PerpsMarketRowSkeleton.test.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketRowSkeleton/PerpsMarketRowSkeleton.test.tsx index 85f90d8fdb2..aba6e315287 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketRowSkeleton/PerpsMarketRowSkeleton.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketRowSkeleton/PerpsMarketRowSkeleton.test.tsx @@ -2,20 +2,23 @@ import React from 'react'; import { render } from '@testing-library/react-native'; import PerpsMarketRowSkeleton from './PerpsMarketRowSkeleton'; -jest.mock('../../../../../../../component-library/components/Skeleton', () => { - const { View } = jest.requireActual('react-native'); - return { - Skeleton: ({ - testID, - ...props - }: { - testID?: string; - width?: number; - height?: number; - style?: object; - }) => , - }; -}); +jest.mock( + '../../../../../../../component-library/components-temp/Skeleton', + () => { + const { View } = jest.requireActual('react-native'); + return { + Skeleton: ({ + testID, + ...props + }: { + testID?: string; + width?: number; + height?: number; + style?: object; + }) => , + }; + }, +); describe('PerpsMarketRowSkeleton', () => { it('renders without crashing with testID', () => { diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketRowSkeleton/PerpsMarketRowSkeleton.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketRowSkeleton/PerpsMarketRowSkeleton.tsx index 555343d84df..455b42b05ae 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketRowSkeleton/PerpsMarketRowSkeleton.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketRowSkeleton/PerpsMarketRowSkeleton.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { View } from 'react-native'; -import { Skeleton } from '../../../../../../../component-library/components/Skeleton'; +import { Skeleton } from '../../../../../../../component-library/components-temp/Skeleton'; import { useStyles } from '../../../../../../../component-library/hooks'; import type { PerpsMarketRowSkeletonProps } from './PerpsMarketRowSkeleton.types'; import styleSheet from './PerpsMarketRowSkeleton.styles'; diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx index 1f66141f9a5..a57a9f81cd8 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -40,7 +40,7 @@ import ListItem from '../../../../../component-library/components/List/ListItem' import ListItemColumn, { WidthType, } from '../../../../../component-library/components/List/ListItemColumn'; -import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import Text, { TextColor, TextVariant, diff --git a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx index 4cd8d910dd3..d47e484debd 100644 --- a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx +++ b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx @@ -45,7 +45,7 @@ import styleSheet from './PerpsTabView.styles'; import PerpsRowSkeleton from '../../components/PerpsRowSkeleton'; import PerpsMarketRowItem from '../../components/PerpsMarketRowItem'; -import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import ConditionalScrollView from '../../../../../component-library/components-temp/ConditionalScrollView'; const PerpsTabView = () => { diff --git a/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.test.tsx b/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.test.tsx index 94f4f4b6691..ff319add57d 100644 --- a/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.test.tsx +++ b/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.test.tsx @@ -259,7 +259,7 @@ jest.mock('../../../../../component-library/components/Badges/Badge', () => { }; }); -jest.mock('../../../../../component-library/components/Skeleton', () => { +jest.mock('../../../../../component-library/components-temp/Skeleton', () => { const { View } = jest.requireActual('react-native'); return { Skeleton: jest.fn(({ testID, width, height }) => ( diff --git a/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.tsx b/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.tsx index e286922579d..1b726707208 100644 --- a/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.tsx +++ b/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.tsx @@ -35,7 +35,7 @@ import { import { usePerpsDepositProgress } from '../../hooks/usePerpsDepositProgress'; import { usePerpsTransactionState } from '../../hooks/usePerpsTransactionState'; import { convertPerpsAmountToUSD } from '../../utils/amountConversion'; -import { Skeleton } from '../../../../../component-library/components/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import PerpsEmptyBalance from '../PerpsEmptyBalance'; import DevLogger from '../../../../../core/SDKConnect/utils/DevLogger'; import { PerpsProgressBar } from '../PerpsProgressBar'; diff --git a/app/components/UI/Perps/components/PerpsRowSkeleton/PerpsRowSkeleton.test.tsx b/app/components/UI/Perps/components/PerpsRowSkeleton/PerpsRowSkeleton.test.tsx index 46c7f500beb..fcc00c5f237 100644 --- a/app/components/UI/Perps/components/PerpsRowSkeleton/PerpsRowSkeleton.test.tsx +++ b/app/components/UI/Perps/components/PerpsRowSkeleton/PerpsRowSkeleton.test.tsx @@ -4,22 +4,19 @@ import PerpsRowSkeleton from './PerpsRowSkeleton'; import { HOME_SCREEN_CONFIG } from '../../constants/perpsConfig'; // Mock Skeleton component -jest.mock( - '../../../../../component-library/components/Skeleton/Skeleton', - () => { - const ReactNative = jest.requireActual('react-native'); - return { - __esModule: true, - default: jest.fn(({ height, width, style, testID }) => ( - - )), - }; - }, -); +jest.mock('../../../../../component-library/components-temp/Skeleton', () => { + const ReactNative = jest.requireActual('react-native'); + return { + __esModule: true, + Skeleton: jest.fn(({ height, width, style, testID }) => ( + + )), + }; +}); describe('PerpsRowSkeleton', () => { describe('rendering', () => { diff --git a/app/components/UI/Perps/components/PerpsRowSkeleton/PerpsRowSkeleton.tsx b/app/components/UI/Perps/components/PerpsRowSkeleton/PerpsRowSkeleton.tsx index 1ecaa2c276e..169422cfd5e 100644 --- a/app/components/UI/Perps/components/PerpsRowSkeleton/PerpsRowSkeleton.tsx +++ b/app/components/UI/Perps/components/PerpsRowSkeleton/PerpsRowSkeleton.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { View, StyleSheet, type ViewStyle } from 'react-native'; -import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import { HOME_SCREEN_CONFIG } from '../../constants/perpsConfig'; export interface PerpsRowSkeletonProps { diff --git a/app/components/UI/Perps/components/PerpsTransactionsSkeleton/PerpsTransactionsSkeleton.test.tsx b/app/components/UI/Perps/components/PerpsTransactionsSkeleton/PerpsTransactionsSkeleton.test.tsx index 1733e6d75d8..2310fbdeca8 100644 --- a/app/components/UI/Perps/components/PerpsTransactionsSkeleton/PerpsTransactionsSkeleton.test.tsx +++ b/app/components/UI/Perps/components/PerpsTransactionsSkeleton/PerpsTransactionsSkeleton.test.tsx @@ -22,19 +22,17 @@ jest.mock('../../../../../component-library/hooks', () => ({ })); // Mock the Skeleton component -jest.mock( - '../../../../../component-library/components/Skeleton/Skeleton', - () => - function MockSkeleton({ - testID, - ...props - }: { - testID?: string; - [key: string]: unknown; - }) { - return
; - }, -); +jest.mock('../../../../../component-library/components-temp/Skeleton', () => ({ + Skeleton: function MockSkeleton({ + testID, + ...props + }: { + testID?: string; + [key: string]: unknown; + }) { + return
; + }, +})); describe('PerpsTransactionsSkeleton', () => { it('renders loading skeleton with correct structure', () => { diff --git a/app/components/UI/Perps/components/PerpsTransactionsSkeleton/PerpsTransactionsSkeleton.tsx b/app/components/UI/Perps/components/PerpsTransactionsSkeleton/PerpsTransactionsSkeleton.tsx index 2824574fc29..927ebc2e655 100644 --- a/app/components/UI/Perps/components/PerpsTransactionsSkeleton/PerpsTransactionsSkeleton.tsx +++ b/app/components/UI/Perps/components/PerpsTransactionsSkeleton/PerpsTransactionsSkeleton.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { View } from 'react-native'; -import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import { useStyles } from '../../../../../component-library/hooks'; import styleSheet from './PerpsTransactionsSkeleton.styles'; diff --git a/app/components/UI/Perps/components/TradingViewChart/TradingViewChart.tsx b/app/components/UI/Perps/components/TradingViewChart/TradingViewChart.tsx index b48284cf7d2..a92865adb1f 100644 --- a/app/components/UI/Perps/components/TradingViewChart/TradingViewChart.tsx +++ b/app/components/UI/Perps/components/TradingViewChart/TradingViewChart.tsx @@ -8,7 +8,7 @@ import React, { } from 'react'; import { WebView, WebViewMessageEvent } from '@metamask/react-native-webview'; import { Box, Text, TextVariant } from '@metamask/design-system-react-native'; -import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import { useStyles } from '../../../../../component-library/hooks'; import { styleSheet } from './TradingViewChart.styles'; import { type CandleData } from '@metamask/perps-controller'; From 6cbb16961d22402293f9faaf175b84e31009988c Mon Sep 17 00:00:00 2001 From: Kirill Zyusko Date: Thu, 19 Mar 2026 12:40:29 +0100 Subject: [PATCH 127/206] feat: migrate Button (acc-eng scope) (#27549) ## **Description** Replaced `Button` in `acc-eng` scope. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/DSYS-445 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/84260d66-993a-4172-8a1f-22cd5517399c ### **After** https://github.com/user-attachments/assets/64b16cd0-bc2e-40cd-98df-00fac4ec52f2 ## **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. ## **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** > Swaps several Multichain Accounts/permissions screens to the design-system `Button` API (prop/variant/disabled semantics), which could subtly affect CTA styling or enabled/disabled behavior in connection and account-management flows. > > **Overview** > Migrates Multichain Accounts screens from the legacy component-library `Button` to `@metamask/design-system-react-native` `Button`, updating usage from `label` props to children, mapping variants/sizes (`ButtonVariant`, `ButtonBaseSize`), and switching width/disabled props to `isFullWidth`/`isDisabled`. > > Updates affected CTAs across remove account, intro/learn-more modals, connect multi-selector, permissions summary (including `startIconName` to design-system icon enum), private key list, and edit account name. Test assertions were adjusted to use `toBeDisabled()`/`toBeEnabled()` where button disabled state is checked. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit f5177ddfedf4379ddf2f47754e1420fccbcaeee1. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../RemoveAccount/RemoveAccount.tsx | 13 +++---- .../IntroModal/LearnMoreBottomSheet.test.tsx | 4 +- .../IntroModal/LearnMoreBottomSheet.tsx | 19 +++++---- .../MultichainAccountsIntroModal.tsx | 30 +++++++------- ...ichainAccountConnectMultiSelector.test.tsx | 2 +- .../MultichainAccountConnectMultiSelector.tsx | 29 +++++++------- .../MultichainPermissionsSummary.tsx | 39 +++++++++++-------- .../PrivateKeyList/PrivateKeyList.tsx | 27 +++++++------ .../EditMultichainAccountName.tsx | 23 ++++++----- 9 files changed, 96 insertions(+), 90 deletions(-) diff --git a/app/components/Views/MultichainAccounts/AccountDetails/components/RemoveAccount/RemoveAccount.tsx b/app/components/Views/MultichainAccounts/AccountDetails/components/RemoveAccount/RemoveAccount.tsx index 4434cf909d1..0362fd848f8 100644 --- a/app/components/Views/MultichainAccounts/AccountDetails/components/RemoveAccount/RemoveAccount.tsx +++ b/app/components/Views/MultichainAccounts/AccountDetails/components/RemoveAccount/RemoveAccount.tsx @@ -1,14 +1,11 @@ import React, { useCallback } from 'react'; -import { TextVariant } from '../../../../../../component-library/components/Texts/Text'; import { useNavigation } from '@react-navigation/native'; import Routes from '../../../../../../constants/navigation/Routes'; import { InternalAccount } from '@metamask/keyring-internal-api'; import { strings } from '../../../../../../../locales/i18n'; import { useStyles } from '../../../../../hooks/useStyles'; import styleSheet from './RemoveAccount.styles'; -import Button, { - ButtonVariants, -} from '../../../../../../component-library/components/Buttons/Button'; +import { Button, ButtonVariant } from '@metamask/design-system-react-native'; import { AccountDetailsIds } from '../../../AccountDetails.testIds'; interface RemoveAccountProps { @@ -31,10 +28,10 @@ export const RemoveAccount = ({ account }: RemoveAccountProps) => { testID={AccountDetailsIds.REMOVE_ACCOUNT_BUTTON} style={styles.button} isDanger - variant={ButtonVariants.Secondary} - labelTextVariant={TextVariant.BodyMDMedium} + variant={ButtonVariant.Secondary} onPress={handleRemoveAccountClick} - label={strings('multichain_accounts.delete_account.title')} - /> + > + {strings('multichain_accounts.delete_account.title')} + ); }; diff --git a/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.test.tsx b/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.test.tsx index a08101dae38..c33a8dab3c3 100644 --- a/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.test.tsx +++ b/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.test.tsx @@ -221,13 +221,13 @@ describe('LearnMoreBottomSheet', () => { ); // Initially checkbox should be unchecked and confirm button disabled - expect(confirmButton).toHaveProp('disabled', true); + expect(confirmButton).toBeDisabled(); // Press checkbox to check it fireEvent.press(checkbox); // Confirm button should now be enabled - expect(confirmButton).toHaveProp('disabled', false); + expect(confirmButton).toBeEnabled(); }); it('handles confirm button press when checkbox is checked', () => { diff --git a/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.tsx b/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.tsx index f32b90a325a..0c33027f5d5 100644 --- a/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.tsx +++ b/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.tsx @@ -6,12 +6,10 @@ import { TextVariant, IconName, TextColor, + Button, + ButtonVariant, + ButtonBaseSize, } from '@metamask/design-system-react-native'; -import Button, { - ButtonVariants, - ButtonWidthTypes, - ButtonSize, -} from '../../../../component-library/components/Buttons/Button'; import Checkbox from '../../../../component-library/components/Checkbox'; import BottomSheet, { BottomSheetRef, @@ -111,14 +109,15 @@ const LearnMoreBottomSheet: React.FC = ({ diff --git a/app/components/Views/MultichainAccounts/IntroModal/MultichainAccountsIntroModal.tsx b/app/components/Views/MultichainAccounts/IntroModal/MultichainAccountsIntroModal.tsx index 3cfbd55aa4a..d910d35cfdf 100644 --- a/app/components/Views/MultichainAccounts/IntroModal/MultichainAccountsIntroModal.tsx +++ b/app/components/Views/MultichainAccounts/IntroModal/MultichainAccountsIntroModal.tsx @@ -6,12 +6,10 @@ import { TextVariant, IconName, TextColor, + Button, + ButtonVariant, + ButtonBaseSize, } from '@metamask/design-system-react-native'; -import Button, { - ButtonVariants, - ButtonWidthTypes, - ButtonSize, -} from '../../../../component-library/components/Buttons/Button'; import { useNavigation, useTheme } from '@react-navigation/native'; import { useDispatch } from 'react-redux'; import { createAccountSelectorNavDetails } from '../../AccountSelector'; @@ -201,24 +199,26 @@ const MultichainAccountsIntroModal = () => { diff --git a/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnectMultiSelector/MultichainAccountConnectMultiSelector.test.tsx b/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnectMultiSelector/MultichainAccountConnectMultiSelector.test.tsx index dc0555c8f25..4bad6e15d0f 100644 --- a/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnectMultiSelector/MultichainAccountConnectMultiSelector.test.tsx +++ b/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnectMultiSelector/MultichainAccountConnectMultiSelector.test.tsx @@ -306,7 +306,7 @@ describe('MultichainAccountConnectMultiSelector', () => { const updateButton = getByTestId( ConnectAccountBottomSheetSelectorsIDs.SELECT_MULTI_BUTTON, ); - expect(updateButton.props.disabled).toBe(true); + expect(updateButton).toBeDisabled(); }); }); diff --git a/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnectMultiSelector/MultichainAccountConnectMultiSelector.tsx b/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnectMultiSelector/MultichainAccountConnectMultiSelector.tsx index f2761721abe..7101315a946 100644 --- a/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnectMultiSelector/MultichainAccountConnectMultiSelector.tsx +++ b/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnectMultiSelector/MultichainAccountConnectMultiSelector.tsx @@ -5,10 +5,11 @@ import { useSelector } from 'react-redux'; // External dependencies. import { strings } from '../../../../../../locales/i18n'; -import Button, { - ButtonSize, - ButtonVariants, -} from '../../../../../component-library/components/Buttons/Button'; +import { + Button, + ButtonVariant, + ButtonBaseSize, +} from '@metamask/design-system-react-native'; import SheetHeader from '../../../../../component-library/components/Sheet/SheetHeader'; import Text, { TextColor, @@ -109,17 +110,18 @@ const MultichainAccountConnectMultiSelector = ({ {areAnyAccountsSelected && ( )} {areNoAccountsSelected && showDisconnectAllButton && ( @@ -133,16 +135,17 @@ const MultichainAccountConnectMultiSelector = ({ )} diff --git a/app/components/Views/MultichainAccounts/MultichainPermissionsSummary/MultichainPermissionsSummary.tsx b/app/components/Views/MultichainAccounts/MultichainPermissionsSummary/MultichainPermissionsSummary.tsx index 78fdc36f24f..e42361a5d11 100644 --- a/app/components/Views/MultichainAccounts/MultichainPermissionsSummary/MultichainPermissionsSummary.tsx +++ b/app/components/Views/MultichainAccounts/MultichainPermissionsSummary/MultichainPermissionsSummary.tsx @@ -25,10 +25,12 @@ import TextComponent, { TextVariant, } from '../../../../component-library/components/Texts/Text'; import AvatarGroup from '../../../../component-library/components/Avatars/AvatarGroup'; -import Button, { - ButtonSize, - ButtonVariants, -} from '../../../../component-library/components/Buttons/Button'; +import { + Button, + ButtonVariant, + ButtonBaseSize, + IconName as DesignSystemIconName, +} from '@metamask/design-system-react-native'; import { getHost } from '../../../../util/browser'; import WebsiteIcon from '../../../UI/WebsiteIcon'; import styleSheet from './MultichainPermissionsSummary.styles'; @@ -623,19 +625,20 @@ const MultichainPermissionsSummary = ({ {isAlreadyConnected && isDisconnectAllShown && ( )} {showActionButtons && !isNonDappNetworkSwitch && ( @@ -670,31 +673,33 @@ const MultichainPermissionsSummary = ({ )} diff --git a/app/components/Views/MultichainAccounts/PrivateKeyList/PrivateKeyList.tsx b/app/components/Views/MultichainAccounts/PrivateKeyList/PrivateKeyList.tsx index 383617b5387..bdf63fb4847 100644 --- a/app/components/Views/MultichainAccounts/PrivateKeyList/PrivateKeyList.tsx +++ b/app/components/Views/MultichainAccounts/PrivateKeyList/PrivateKeyList.tsx @@ -38,10 +38,11 @@ import Text, { TextVariant, TextColor, } from '../../../../component-library/components/Texts/Text'; -import Button, { - ButtonSize, - ButtonVariants, -} from '../../../../component-library/components/Buttons/Button'; +import { + Button, + ButtonVariant, + ButtonBaseSize, +} from '@metamask/design-system-react-native'; import { useParams, createNavigationDetails, @@ -233,21 +234,23 @@ export const PrivateKeyList = () => { ), diff --git a/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.tsx b/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.tsx index c816975e1b7..c00ac07efc5 100644 --- a/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.tsx +++ b/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.tsx @@ -13,11 +13,11 @@ import Text, { TextVariant, } from '../../../../../component-library/components/Texts/Text'; import { Box } from '../../../../UI/Box/Box'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; +import { + Button, + ButtonVariant, + ButtonBaseSize, +} from '@metamask/design-system-react-native'; import styleSheet from './EditMultichainAccountName.styles'; import { useStyles } from '../../../../hooks/useStyles'; import { useTheme } from '../../../../../util/theme'; @@ -144,15 +144,14 @@ export const EditMultichainAccountName = () => { From 290655d275ace61606218c0febc8db1cceff1147 Mon Sep 17 00:00:00 2001 From: Kirill Zyusko Date: Thu, 19 Mar 2026 12:51:45 +0100 Subject: [PATCH 128/206] feat: migrate Skeleton component (predict scope) (#27370) ## **Description** Migrated `Skeleton` component to DSRN usage. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/DSYS-274 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/33054167-dc21-41da-bc31-4849d69bfbb2 ### **After** https://github.com/user-attachments/assets/3b569e47-3c46-4113-8627-093f275098ee ## **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. ## **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** > Low risk UI-only change that swaps Skeleton loader implementations in Predict screens; main risk is minor visual/regression differences and snapshot updates. > > **Overview** > Migrates Predict feature loading placeholders to the new DSRN-based `Skeleton` by updating imports across multiple Predict components (balance, headers, market cards, picks, buy/sell previews). > > Updates associated tests/mocks and refreshes Jest snapshots to match the new `Skeleton` render output (notably style prop shape/ordering). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit e6c2d888adcb805f5889e887b9adf21a0b042252. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../PredictBalance/PredictBalance.tsx | 2 +- .../PredictDetailsButtonsSkeleton.tsx | 2 +- ...redictDetailsButtonsSkeleton.test.tsx.snap | 42 ++++++--- .../PredictDetailsContentSkeleton.tsx | 2 +- ...redictDetailsContentSkeleton.test.tsx.snap | 84 ++++++++++++------ .../PredictDetailsHeaderSkeleton.tsx | 2 +- ...PredictDetailsHeaderSkeleton.test.tsx.snap | 44 +++++++--- .../PredictHomeFeaturedSkeleton.test.tsx | 21 ++--- .../PredictHomeFeaturedSkeleton.tsx | 2 +- .../PredictHome/PredictHomeSkeleton.tsx | 2 +- .../PredictMarketSkeleton.tsx | 2 +- .../PredictMarketSkeleton.test.tsx.snap | 86 +++++++++++++------ .../PredictPicks/PredictPickItem.tsx | 2 +- .../PredictPicks/PredictPicksForCardItem.tsx | 2 +- .../PredictPosition/PredictPosition.tsx | 2 +- .../PredictPositionDetail.tsx | 2 +- .../PredictPositionsHeader.tsx | 2 +- .../PredictSportCardFooter.tsx | 2 +- .../PredictBuyPreview/PredictBuyPreview.tsx | 2 +- .../PredictSellPreview/PredictSellPreview.tsx | 2 +- 20 files changed, 206 insertions(+), 101 deletions(-) diff --git a/app/components/UI/Predict/components/PredictBalance/PredictBalance.tsx b/app/components/UI/Predict/components/PredictBalance/PredictBalance.tsx index 9b332047e7f..8b2bdd9bb4f 100644 --- a/app/components/UI/Predict/components/PredictBalance/PredictBalance.tsx +++ b/app/components/UI/Predict/components/PredictBalance/PredictBalance.tsx @@ -31,7 +31,7 @@ import BadgeWrapper, { import Button, { ButtonVariants, } from '../../../../../component-library/components/Buttons/Button'; -import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import { USDC_SYMBOL, USDC_TOKEN_ICON_URL } from '@metamask/perps-controller'; import { usePredictBalance } from '../../hooks/usePredictBalance'; import { usePredictDeposit } from '../../hooks/usePredictDeposit'; diff --git a/app/components/UI/Predict/components/PredictDetailsButtonsSkeleton/PredictDetailsButtonsSkeleton.tsx b/app/components/UI/Predict/components/PredictDetailsButtonsSkeleton/PredictDetailsButtonsSkeleton.tsx index f177ea9873c..a8ed53ba5d0 100644 --- a/app/components/UI/Predict/components/PredictDetailsButtonsSkeleton/PredictDetailsButtonsSkeleton.tsx +++ b/app/components/UI/Predict/components/PredictDetailsButtonsSkeleton/PredictDetailsButtonsSkeleton.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Box, BoxFlexDirection } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import { PREDICT_DETAILS_BUTTONS_SKELETON, PREDICT_DETAILS_BUTTONS_SKELETON_TEST_IDS, diff --git a/app/components/UI/Predict/components/PredictDetailsButtonsSkeleton/__snapshots__/PredictDetailsButtonsSkeleton.test.tsx.snap b/app/components/UI/Predict/components/PredictDetailsButtonsSkeleton/__snapshots__/PredictDetailsButtonsSkeleton.test.tsx.snap index 501b2e36d19..cdb1b28375d 100644 --- a/app/components/UI/Predict/components/PredictDetailsButtonsSkeleton/__snapshots__/PredictDetailsButtonsSkeleton.test.tsx.snap +++ b/app/components/UI/Predict/components/PredictDetailsButtonsSkeleton/__snapshots__/PredictDetailsButtonsSkeleton.test.tsx.snap @@ -30,12 +30,21 @@ exports[`PredictDetailsButtonsSkeleton matches snapshot 1`] = ` > @@ -72,12 +81,21 @@ exports[`PredictDetailsButtonsSkeleton matches snapshot 1`] = ` > diff --git a/app/components/UI/Predict/components/PredictDetailsContentSkeleton/PredictDetailsContentSkeleton.tsx b/app/components/UI/Predict/components/PredictDetailsContentSkeleton/PredictDetailsContentSkeleton.tsx index 7b719e1e6bc..da4527a4620 100644 --- a/app/components/UI/Predict/components/PredictDetailsContentSkeleton/PredictDetailsContentSkeleton.tsx +++ b/app/components/UI/Predict/components/PredictDetailsContentSkeleton/PredictDetailsContentSkeleton.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Box } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import { PREDICT_DETAILS_CONTENT_SKELETON, PREDICT_DETAILS_CONTENT_SKELETON_TEST_IDS, diff --git a/app/components/UI/Predict/components/PredictDetailsContentSkeleton/__snapshots__/PredictDetailsContentSkeleton.test.tsx.snap b/app/components/UI/Predict/components/PredictDetailsContentSkeleton/__snapshots__/PredictDetailsContentSkeleton.test.tsx.snap index 2576050fbab..a6feef74a46 100644 --- a/app/components/UI/Predict/components/PredictDetailsContentSkeleton/__snapshots__/PredictDetailsContentSkeleton.test.tsx.snap +++ b/app/components/UI/Predict/components/PredictDetailsContentSkeleton/__snapshots__/PredictDetailsContentSkeleton.test.tsx.snap @@ -27,12 +27,21 @@ exports[`PredictDetailsContentSkeleton matches snapshot 1`] = ` > @@ -55,12 +64,21 @@ exports[`PredictDetailsContentSkeleton matches snapshot 1`] = ` @@ -95,12 +113,21 @@ exports[`PredictDetailsContentSkeleton matches snapshot 1`] = ` > @@ -123,12 +150,21 @@ exports[`PredictDetailsContentSkeleton matches snapshot 1`] = ` diff --git a/app/components/UI/Predict/components/PredictDetailsHeaderSkeleton/PredictDetailsHeaderSkeleton.tsx b/app/components/UI/Predict/components/PredictDetailsHeaderSkeleton/PredictDetailsHeaderSkeleton.tsx index c6a0d191e78..967c03198db 100644 --- a/app/components/UI/Predict/components/PredictDetailsHeaderSkeleton/PredictDetailsHeaderSkeleton.tsx +++ b/app/components/UI/Predict/components/PredictDetailsHeaderSkeleton/PredictDetailsHeaderSkeleton.tsx @@ -13,7 +13,7 @@ import Icon, { IconName, IconSize, } from '../../../../../component-library/components/Icons/Icon'; -import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import { PREDICT_DETAILS_HEADER_SKELETON, PREDICT_DETAILS_HEADER_SKELETON_TEST_IDS, diff --git a/app/components/UI/Predict/components/PredictDetailsHeaderSkeleton/__snapshots__/PredictDetailsHeaderSkeleton.test.tsx.snap b/app/components/UI/Predict/components/PredictDetailsHeaderSkeleton/__snapshots__/PredictDetailsHeaderSkeleton.test.tsx.snap index 2fc292ede41..3e8cbf6ed49 100644 --- a/app/components/UI/Predict/components/PredictDetailsHeaderSkeleton/__snapshots__/PredictDetailsHeaderSkeleton.test.tsx.snap +++ b/app/components/UI/Predict/components/PredictDetailsHeaderSkeleton/__snapshots__/PredictDetailsHeaderSkeleton.test.tsx.snap @@ -88,13 +88,22 @@ exports[`PredictDetailsHeaderSkeleton matches snapshot 1`] = ` > @@ -118,12 +127,21 @@ exports[`PredictDetailsHeaderSkeleton matches snapshot 1`] = ` diff --git a/app/components/UI/Predict/components/PredictHome/PredictHomeFeaturedSkeleton.test.tsx b/app/components/UI/Predict/components/PredictHome/PredictHomeFeaturedSkeleton.test.tsx index 8134a7f46f9..e4b41673384 100644 --- a/app/components/UI/Predict/components/PredictHome/PredictHomeFeaturedSkeleton.test.tsx +++ b/app/components/UI/Predict/components/PredictHome/PredictHomeFeaturedSkeleton.test.tsx @@ -30,18 +30,15 @@ jest.mock('@metamask/design-system-react-native', () => { }; }); -jest.mock( - '../../../../../component-library/components/Skeleton/Skeleton', - () => { - const ReactNative = jest.requireActual('react-native'); - return { - __esModule: true, - default: ({ testID }: { testID?: string }) => ( - - ), - }; - }, -); +jest.mock('../../../../../component-library/components-temp/Skeleton', () => { + const ReactNative = jest.requireActual('react-native'); + return { + __esModule: true, + Skeleton: ({ testID }: { testID?: string }) => ( + + ), + }; +}); jest.mock('./PredictHomeSkeleton', () => { const ReactNative = jest.requireActual('react-native'); diff --git a/app/components/UI/Predict/components/PredictHome/PredictHomeFeaturedSkeleton.tsx b/app/components/UI/Predict/components/PredictHome/PredictHomeFeaturedSkeleton.tsx index 8fe65c2cf5d..de061c7be46 100644 --- a/app/components/UI/Predict/components/PredictHome/PredictHomeFeaturedSkeleton.tsx +++ b/app/components/UI/Predict/components/PredictHome/PredictHomeFeaturedSkeleton.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Dimensions, ScrollView } from 'react-native'; import { Box, BoxFlexDirection } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import { PredictHomeFeaturedVariant } from '../../selectors/featureFlags'; import PredictHomeSkeleton from './PredictHomeSkeleton'; import { diff --git a/app/components/UI/Predict/components/PredictHome/PredictHomeSkeleton.tsx b/app/components/UI/Predict/components/PredictHome/PredictHomeSkeleton.tsx index ad7b519c65c..dd15b92d93f 100644 --- a/app/components/UI/Predict/components/PredictHome/PredictHomeSkeleton.tsx +++ b/app/components/UI/Predict/components/PredictHome/PredictHomeSkeleton.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Box, BoxFlexDirection } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import { PREDICT_HOME_SKELETON, PREDICT_HOME_SKELETON_TEST_IDS, diff --git a/app/components/UI/Predict/components/PredictMarketSkeleton/PredictMarketSkeleton.tsx b/app/components/UI/Predict/components/PredictMarketSkeleton/PredictMarketSkeleton.tsx index 3545423ea98..3143f9f2aa6 100644 --- a/app/components/UI/Predict/components/PredictMarketSkeleton/PredictMarketSkeleton.tsx +++ b/app/components/UI/Predict/components/PredictMarketSkeleton/PredictMarketSkeleton.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Box, BoxFlexDirection } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import { PREDICT_MARKET_SKELETON, PREDICT_MARKET_SKELETON_TEST_IDS, diff --git a/app/components/UI/Predict/components/PredictMarketSkeleton/__snapshots__/PredictMarketSkeleton.test.tsx.snap b/app/components/UI/Predict/components/PredictMarketSkeleton/__snapshots__/PredictMarketSkeleton.test.tsx.snap index 67b45c75034..918dc09d373 100644 --- a/app/components/UI/Predict/components/PredictMarketSkeleton/__snapshots__/PredictMarketSkeleton.test.tsx.snap +++ b/app/components/UI/Predict/components/PredictMarketSkeleton/__snapshots__/PredictMarketSkeleton.test.tsx.snap @@ -36,12 +36,21 @@ exports[`PredictMarketSkeleton matches snapshot 1`] = ` > @@ -77,12 +86,21 @@ exports[`PredictMarketSkeleton matches snapshot 1`] = ` > @@ -107,13 +125,22 @@ exports[`PredictMarketSkeleton matches snapshot 1`] = ` @@ -136,12 +163,21 @@ exports[`PredictMarketSkeleton matches snapshot 1`] = ` diff --git a/app/components/UI/Predict/components/PredictPicks/PredictPickItem.tsx b/app/components/UI/Predict/components/PredictPicks/PredictPickItem.tsx index 4c0b7c0a7ec..b1fad2244a3 100644 --- a/app/components/UI/Predict/components/PredictPicks/PredictPickItem.tsx +++ b/app/components/UI/Predict/components/PredictPicks/PredictPickItem.tsx @@ -10,7 +10,7 @@ import React from 'react'; import { PredictPosition } from '../../types'; import { formatPrice } from '../../utils/format'; import { strings } from '../../../../../../locales/i18n'; -import { Skeleton } from '../../../../../component-library/components/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import { PREDICT_PICK_ITEM_TEST_IDS } from './PredictPickItem.testIds'; interface PredictPickItemProps { diff --git a/app/components/UI/Predict/components/PredictPicks/PredictPicksForCardItem.tsx b/app/components/UI/Predict/components/PredictPicks/PredictPicksForCardItem.tsx index 65d5619668f..a93662ce37a 100644 --- a/app/components/UI/Predict/components/PredictPicks/PredictPicksForCardItem.tsx +++ b/app/components/UI/Predict/components/PredictPicks/PredictPicksForCardItem.tsx @@ -7,7 +7,7 @@ import { } from '@metamask/design-system-react-native'; import { formatPrice } from '../../utils/format'; import { strings } from '../../../../../../locales/i18n'; -import { Skeleton } from '../../../../../component-library/components/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import type { PredictPosition } from '../../types'; import { PREDICT_PICKS_FOR_CARD_ITEM_TEST_IDS } from './PredictPicksForCardItem.testIds'; diff --git a/app/components/UI/Predict/components/PredictPosition/PredictPosition.tsx b/app/components/UI/Predict/components/PredictPosition/PredictPosition.tsx index 4249f8cb3c4..6936eb1c59f 100644 --- a/app/components/UI/Predict/components/PredictPosition/PredictPosition.tsx +++ b/app/components/UI/Predict/components/PredictPosition/PredictPosition.tsx @@ -13,7 +13,7 @@ import { formatPercentage, formatPrice } from '../../utils/format'; import styleSheet from './PredictPosition.styles'; import { PredictPositionSelectorsIDs } from '../../Predict.testIds'; import { strings } from '../../../../../../locales/i18n'; -import { Skeleton } from '../../../../../component-library/components/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import { Text, TextColor, diff --git a/app/components/UI/Predict/components/PredictPositionDetail/PredictPositionDetail.tsx b/app/components/UI/Predict/components/PredictPositionDetail/PredictPositionDetail.tsx index 01bd728814f..c01ea289b99 100644 --- a/app/components/UI/Predict/components/PredictPositionDetail/PredictPositionDetail.tsx +++ b/app/components/UI/Predict/components/PredictPositionDetail/PredictPositionDetail.tsx @@ -28,7 +28,7 @@ import Button, { ButtonVariants, ButtonWidthTypes, } from '../../../../../component-library/components/Buttons/Button'; -import { Skeleton } from '../../../../../component-library/components/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import Routes from '../../../../../constants/navigation/Routes'; import { PredictEventValues } from '../../constants/eventNames'; import { usePredictActionGuard } from '../../hooks/usePredictActionGuard'; diff --git a/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx b/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx index 9bdec7faa39..259adb6888a 100644 --- a/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx +++ b/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx @@ -50,7 +50,7 @@ import { selectPredictWonPositions } from '../../selectors/predictController'; import { PredictPosition } from '../../types'; import { PredictNavigationParamList } from '../../types/navigation'; import { formatPercentage, formatPrice } from '../../utils/format'; -import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import PredictClaimButton from '../PredictActionButtons/PredictClaimButton'; import { PredictEventValues } from '../../constants/eventNames'; import { getEvmAccountFromSelectedAccountGroup } from '../../utils/accounts'; diff --git a/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.tsx b/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.tsx index e65848f9e4e..6760ea7daeb 100644 --- a/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.tsx +++ b/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.tsx @@ -14,7 +14,7 @@ import { import { PredictEventValues } from '../../constants/eventNames'; import { usePredictEntryPoint } from '../../contexts'; import TrendingFeedSessionManager from '../../../Trending/services/TrendingFeedSessionManager'; -import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import { PredictActionButtons } from '../PredictActionButtons'; import { PredictPicksForCard } from '../PredictPicks'; import { usePredictPositions } from '../../hooks/usePredictPositions'; diff --git a/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.tsx b/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.tsx index fc78e1b5e08..4b0a24b7bc3 100644 --- a/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.tsx +++ b/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.tsx @@ -56,7 +56,7 @@ import PredictKeypad, { } from '../../components/PredictKeypad'; import { SafeAreaView } from 'react-native-safe-area-context'; import { usePredictBalance } from '../../hooks/usePredictBalance'; -import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import { strings } from '../../../../../../locales/i18n'; import ButtonHero from '../../../../../component-library/components-temp/Buttons/ButtonHero'; diff --git a/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.tsx b/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.tsx index 1587b8b1b6c..099afa0912f 100644 --- a/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.tsx +++ b/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.tsx @@ -24,7 +24,7 @@ import Button, { ButtonVariants, ButtonWidthTypes, } from '../../../../../component-library/components/Buttons/Button'; -import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import { useStyles } from '../../../../../component-library/hooks/useStyles'; import Engine from '../../../../../core/Engine'; import { TraceName } from '../../../../../util/trace'; From 2c92ecc625e1595ddc863d98ef8cfc9aa88864dd Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 19 Mar 2026 13:10:52 +0100 Subject: [PATCH 129/206] fix(snaps): Improve WebView loading detection (#27626) ## **Description** Apparently `onLoadEnd` may trigger even if loading the WebView fails. Therefore, we delay resolving the WebView promise for a Snap until the first message is received from the WebView. We strictly use `onLoadEnd` to detect errors instead. ## **Changelog** CHANGELOG entry: null ## **Related issues** https://consensyssoftware.atlassian.net/browse/WPC-517 --- > [!NOTE] > **Medium Risk** > Changes the readiness/creation flow for snaps execution WebViews to resolve only after the first `onMessage`, and uses `onLoadEnd` solely for error rejection; this could affect snap startup timing or lead to hangs if a WebView never posts its initial message. > > **Overview** > Adjusts `SnapsExecutionWebView` so `createWebView()` no longer resolves when `onLoadEnd` fires; it now resolves on the **first `onMessage` received** from the WebView. > > `onLoadEnd` is repurposed to **only detect load failures**, rejecting the promise when the event contains an error `code`, and the explicit `onError` handler is removed along with updated WebView event typings. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit c54c9703732a5adc5f53dcf6d20d2a0fe2fe9ab1. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/lib/snaps/SnapsExecutionWebView.tsx | 80 ++++++++++++++----------- 1 file changed, 44 insertions(+), 36 deletions(-) diff --git a/app/lib/snaps/SnapsExecutionWebView.tsx b/app/lib/snaps/SnapsExecutionWebView.tsx index 5642d0aa920..3df41e997a9 100644 --- a/app/lib/snaps/SnapsExecutionWebView.tsx +++ b/app/lib/snaps/SnapsExecutionWebView.tsx @@ -1,15 +1,18 @@ ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) import React, { Component } from 'react'; -import { View, NativeSyntheticEvent } from 'react-native'; +import { View } from 'react-native'; import { WebViewMessageEvent, WebView } from '@metamask/react-native-webview'; import { createStyles } from './styles'; import { WebViewInterface } from '@metamask/snaps-controllers/react-native'; -import { WebViewError } from '@metamask/react-native-webview/src/WebViewTypes'; +import { + WebViewNavigationEvent, + WebViewErrorEvent, +} from '@metamask/react-native-webview/src/WebViewTypes'; import { PostMessageEvent } from '@metamask/post-message-stream'; // @ts-expect-error Types are currently broken for this. import WebViewHTML from '@metamask/snaps-execution-environments/dist/webpack/webview/index.html'; import { EmptyObject } from '@metamask/snaps-sdk'; -import { assert } from '@metamask/utils'; +import { assert, hasProperty } from '@metamask/utils'; import Logger from '../../util/Logger'; const styles = createStyles(); @@ -25,8 +28,7 @@ interface WebViewState { listener?: (event: PostMessageEvent) => void; props: { onWebViewMessage: (data: WebViewMessageEvent) => void; - onWebViewLoad: () => void; - onWebViewError: (error: NativeSyntheticEvent) => void; + onWebViewLoad: (event: WebViewNavigationEvent | WebViewErrorEvent) => void; ref: (ref: WebView) => void; }; } @@ -44,34 +46,46 @@ export class SnapsExecutionWebView extends Component { createWebView(jobId: string) { const promise = new Promise((resolve, reject) => { - const onWebViewLoad = () => { - const api = { - injectJavaScript: (js: string) => { - assert( - this.webViews[jobId]?.ref, - 'Snaps execution webview reference not found.', - ); - this.webViews[jobId].ref?.injectJavaScript(js); - }, - registerMessageListener: ( - listener: (event: PostMessageEvent) => void, - ) => { - if (this.webViews[jobId]) { - this.webViews[jobId].listener = listener; - } - }, - unregisterMessageListener: ( - _listener: (event: PostMessageEvent) => void, - ) => { - if (this.webViews[jobId]) { - this.webViews[jobId].listener = undefined; - } - }, - }; - resolve(api); + const api = { + injectJavaScript: (js: string) => { + assert( + this.webViews[jobId]?.ref, + 'Snaps execution webview reference not found.', + ); + this.webViews[jobId].ref?.injectJavaScript(js); + }, + registerMessageListener: ( + listener: (event: PostMessageEvent) => void, + ) => { + if (this.webViews[jobId]) { + this.webViews[jobId].listener = listener; + } + }, + unregisterMessageListener: ( + _listener: (event: PostMessageEvent) => void, + ) => { + if (this.webViews[jobId]) { + this.webViews[jobId].listener = undefined; + } + }, + }; + + const onWebViewLoad = ( + event: WebViewNavigationEvent | WebViewErrorEvent, + ) => { + if (hasProperty(event.nativeEvent, 'code')) { + reject( + new Error( + `Snaps execution webview failed to load with error code: ${event.nativeEvent.code}`, + ), + ); + } }; const onWebViewMessage = (data: WebViewMessageEvent) => { + // We resolve the promise on the first message received + resolve(api); + if (this.webViews[jobId]?.listener) { try { this.webViews[jobId].listener?.( @@ -83,10 +97,6 @@ export class SnapsExecutionWebView extends Component { } }; - const onWebViewError = (error: NativeSyntheticEvent) => { - reject(error); - }; - const setWebViewRef = (ref: WebView) => { if (this.webViews[jobId]) { this.webViews[jobId].ref = ref; @@ -96,7 +106,6 @@ export class SnapsExecutionWebView extends Component { this.webViews[jobId] = { props: { onWebViewLoad, - onWebViewError, onWebViewMessage, ref: setWebViewRef, }, @@ -126,7 +135,6 @@ export class SnapsExecutionWebView extends Component { ref={props.ref} source={{ html: WebViewHTML, baseUrl: 'https://localhost' }} onMessage={props.onWebViewMessage} - onError={props.onWebViewError} onLoadEnd={props.onWebViewLoad} originWhitelist={['*']} javaScriptEnabled From a9111b3dc39fa84fc85939ad2b21fe682c2e2527 Mon Sep 17 00:00:00 2001 From: Yu-Gi-Oh! <54774811+imyugioh@users.noreply.github.com> Date: Thu, 19 Mar 2026 21:11:10 +0900 Subject: [PATCH 130/206] =?UTF-8?q?feat(ramp):=20Converts=20payment=20meth?= =?UTF-8?q?ods=20and=20quotes=20to=20use=20react-query=20for=20requ?= =?UTF-8?q?=E2=80=A6=20(#27448)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Fixes the "Token Not Available" modal and payment methods loading behavior in the Buy flow. Three issues are addressed: ### 1. React-query migration for request status tracking Previously, the controller only exposed `isLoading` (boolean) and `data` (array). This made it impossible to distinguish between: - **Never fetched yet** (idle) — `isLoading: false`, `data: []` - **Fetched successfully with no results** (token genuinely unavailable) — `isLoading: false`, `data: []` This ambiguity caused the app to flash the "Token Not Available" modal immediately when switching providers, before payment methods had even been fetched. By using react-query, we now have explicit status tracking (`idle` | `loading` | `success` | `error`) plus `isFetching` for background refetches. The "Token Not Available" modal only shows when `paymentMethodsStatus === 'success' && !paymentMethodsFetching && paymentMethods.length === 0`. ### 2. Fix token unavailability detection across all Buy entry points The `isTokenUnavailable` check in `BuildQuote` previously relied solely on `params?.assetId` (route navigation params). This worked for the **token buy** flow (`Home → Tokens → Token Info → Buy`) which passes `assetId` via `createBuildQuoteNavDetails`, but failed for the **home buy** flow (`Home → Buy → Token Selection → BuildQuote`) where `params.assetId` could be missing or stale. The fix: treat `route.params.assetId` as bootstrapping input only. Once on the BuildQuote screen, derive the effective asset ID from the controller state (`selectedToken?.assetId`) as the source of truth, falling back to `params?.assetId`. ### 3. Fix navigation behavior for three distinct Buy flow origins The "Token Not Available" modal previously had a single dismiss/change-token behavior regardless of how the user entered the Buy flow. This caused a crash (`cannot read property 'address' of undefined`) when navigating back via `navigation.navigate('Asset')` without params from the token info flow. Introduced `BuyFlowOrigin` type (`'tokenInfo' | 'homeTokenList' | undefined`) to distinguish the three entry points and handle navigation correctly: - **Token info flow** (`tokenInfo`): "Change token" → Tokens Full View; dismiss → `goBack()` twice (exits ramp flow, preserves Asset screen params) - **Home token list flow** (`homeTokenList`): "Change token" → Home; dismiss → Home - **Default/home buy flow** (`undefined`): "Change token" → Token Selection; dismiss → Token Selection Additional fixes: - 600ms debounce on modal navigation to prevent flashing when `isTokenUnavailable` is briefly true due to stale cached data - `lastShownUnavailableKeyRef` dedup pattern (`providerId:assetId`) to prevent duplicate modal navigations - `focusTrigger` counter via `useFocusEffect` to force modal re-evaluation on screen re-focus (e.g., after returning from provider picker) - `isOnBuildQuoteScreen` guard to prevent effects firing when screen is in background - Payment pill disabled when token is unavailable (`onPress={isTokenUnavailable ? undefined : handlePaymentPillPress}`) ### Changes - **New `queries/paymentMethods.ts`** — react-query `queryOptions` for fetching payment methods via `RampsController.getPaymentMethods`, keyed by region/fiat/assetId/providerId - **New `queries/quotes.ts`** — react-query `queryOptions` for fetching quotes via `RampsController.getQuotes`, keyed by assetId/amount/wallet/paymentMethod/provider - **New `queries/index.ts`** — barrel export combining both query definitions - **Updated `useRampsPaymentMethods.ts`** — rewired to use `useQuery` instead of reading from Redux store; exposes `status`, `isFetching`, `isSuccess`, `isLoading`, and `error` - **Updated `useRampsQuotes.ts`** — rewired to use `useQuery` with proper `enabled` gating; exposes `status`, `isSuccess`, `loading`, and `error` - **Updated `useRampsController.ts`** — passes through `paymentMethodsStatus` and `paymentMethodsFetching` from the payment methods hook - **Updated `BuildQuote.tsx`** — derives `effectiveAssetId` from controller state (source of truth) with route params as fallback; uses `paymentMethodsStatus === 'success' && !paymentMethodsFetching` check before determining token unavailability; 600ms debounced modal navigation with dedup; `BuyFlowOrigin` passthrough; disabled payment pill when unavailable - **Updated `TokenNotAvailableModal.tsx`** — handles three flows via `buyFlowOrigin` param with correct navigation for each - **Updated `useRampNavigation.ts`** — passes `buyFlowOrigin` through to `createBuildQuoteNavDetails` - **Updated `useTokenActions.ts`** — passes `{ buyFlowOrigin: 'tokenInfo' }` to `goToBuy` - **Updated `PopularTokenRow.tsx`** — passes `{ buyFlowOrigin: 'homeTokenList' }` to `goToBuy` - **Unit tests** — added/updated tests for `BuildQuote`, `TokenNotAvailableModal`, `useRampNavigation`, `useRampsPaymentMethods`, `useRampsQuotes`, `useRampsController`, and query definitions ## **Changelog** CHANGELOG entry: Fixed false "Token Not Available" errors during Buy flow when payment methods are still loading after provider change; fixed missing "Token Not Available" modal in home buy flow; fixed crash when navigating back from "Token Not Available" modal in token info buy flow ## **Related issues** Refs: [TRAM-3314](https://consensyssoftware.atlassian.net/browse/TRAM-3314) ## **Manual testing steps** ### Step-by-step walkthrough for testing **Test 1 — Home buy flow (the fixed path):** 1. Launch the app in the iOS 2. Tap **Buy** on the home screen 3. In the Token Selection screen, pick a niche token (e.g. **TRX on Tron**) 4. On the BuildQuote screen, verify the **"Token Not Available" modal** appears after loading 5. Press "Change token" — should go back to token selection 6. Press "Change provider" — should open provider picker **Test 2 — Token buy flow (existing path, verify no regression + crash fix):** 1. From the Home screen, tap on a token in the Tokens list (e.g. **TRX**) 2. On the Token Info screen (with graph), tap **Buy** 3. Verify the **"Token Not Available" modal** appears after loading 4. Press "Change token" — should go to Tokens Full View 5. Dismiss the modal — should go back to Token Info (NOT crash) **Test 3 — Home token list buy flow:** 1. From the Home screen, tap the **Buy** button next to a token in the token list 2. If the token is unavailable, verify the modal appears 3. Press "Change token" — should go to Home screen 4. Dismiss — should go to Home screen **Test 4 — Re-selecting tokens:** 1. Enter via token buy flow (Token Info → Buy) with a supported token 2. Go back to token selection 3. Select a token that is unavailable with the current provider 4. Verify the "Token Not Available" modal appears (previously it did NOT in this path) **Test 5 — Modal re-appears after provider change:** 1. Enter Buy flow with an unavailable token 2. After modal appears, tap "Change provider" 3. Select another provider that also doesn't support the token 4. Verify the modal appears again (previously it only showed once due to dedup) ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/efff2d42-698c-4084-8e64-8857852bd34a ### **After** https://github.com/user-attachments/assets/e8803901-9d83-4803-a324-1787c0a630ea https://github.com/user-attachments/assets/e2f8b51a-4e12-44c6-8d75-d83d8083929e https://github.com/user-attachments/assets/37a56f6c-af24-4cf4-85a6-00929312b41f ### **After rebasing 7.71.0** https://github.com/user-attachments/assets/db1da8ea-3641-4302-95cf-39735b6d9260 https://github.com/user-attachments/assets/d9598eda-2f5a-4788-b523-6e3b0738a189 https://github.com/user-attachments/assets/930866b3-0a75-454c-b5cc-96e132a9df7e ## **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 - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Touches the core Buy flow by changing how payment methods/quotes are fetched and how the Token Unavailable modal is triggered/navigated, which can affect ramp availability and navigation behavior across entry points. > > **Overview** > **Improves Buy-flow reliability by migrating `paymentMethods` and `quotes` fetching to `@tanstack/react-query`** (new `rampsQueries` with stable keys/options) and exposing richer request state (`status`, `isFetching`, `isSuccess`) through `useRampsPaymentMethods`, `useRampsQuotes`, and `useRampsController`. > > **Fixes Token Unavailable handling in `BuildQuote`** by deriving the effective token from controller state (not just route params), gating the modal on settled payment-methods state, and adding focus-aware, de-duped, 600ms-debounced modal navigation; it also disables the payment-method pill when the token is unavailable. > > **Adds `BuyFlowOrigin` plumbing (`tokenInfo` | `homeTokenList`)** through `useRampNavigation`/callers and updates `TokenNotAvailableModal` to return the user to the correct screen on change-token/dismiss for each entry path. Tests are updated/added to cover the new query state and navigation behaviors, and the Ramp routes are wrapped in a `QueryClientProvider`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 45f4c0af8dd0173c6773ff1ac9e4c25d62fb92d3. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: George Weiler --- .../Views/Settings/Settings.test.tsx | 2 + .../Ramp/Views/BuildQuote/BuildQuote.test.tsx | 190 ++++++++++++ .../UI/Ramp/Views/BuildQuote/BuildQuote.tsx | 135 ++++++-- .../ProviderSelection.test.tsx | 2 + .../ProviderSelectionModal.test.tsx | 58 ++-- .../TokenNotAvailableModal.test.tsx | 53 ++++ .../TokenNotAvailableModal.tsx | 46 ++- .../RegionSelector/RegionSelector.test.tsx | 2 + .../TokenSelection/TokenSelection.test.tsx | 16 + .../UI/Ramp/hooks/useRampNavigation.test.ts | 28 ++ .../UI/Ramp/hooks/useRampNavigation.ts | 7 +- .../UI/Ramp/hooks/useRampsController.test.ts | 5 + .../UI/Ramp/hooks/useRampsController.ts | 6 + .../Ramp/hooks/useRampsPaymentMethods.test.ts | 289 ++++++++++++------ .../UI/Ramp/hooks/useRampsPaymentMethods.ts | 90 +++++- .../UI/Ramp/hooks/useRampsQuotes.test.ts | 239 ++++++--------- .../UI/Ramp/hooks/useRampsQuotes.ts | 120 +++----- app/components/UI/Ramp/queries/index.ts | 16 + .../UI/Ramp/queries/paymentMethods.test.ts | 44 +++ .../UI/Ramp/queries/paymentMethods.ts | 49 +++ app/components/UI/Ramp/queries/quotes.test.ts | 46 +++ app/components/UI/Ramp/queries/quotes.ts | 46 +++ app/components/UI/Ramp/routes.tsx | 38 +-- .../hooks/useTokenActions.test.ts | 49 +-- .../UI/TokenDetails/hooks/useTokenActions.ts | 4 +- .../components/PopularTokenRow.test.tsx | 9 +- .../Tokens/components/PopularTokenRow.tsx | 2 +- .../custom-amount-info.test.tsx | 13 + 28 files changed, 1178 insertions(+), 426 deletions(-) create mode 100644 app/components/UI/Ramp/queries/index.ts create mode 100644 app/components/UI/Ramp/queries/paymentMethods.test.ts create mode 100644 app/components/UI/Ramp/queries/paymentMethods.ts create mode 100644 app/components/UI/Ramp/queries/quotes.test.ts create mode 100644 app/components/UI/Ramp/queries/quotes.ts diff --git a/app/components/UI/Ramp/Aggregator/Views/Settings/Settings.test.tsx b/app/components/UI/Ramp/Aggregator/Views/Settings/Settings.test.tsx index f81eca09c76..b79e5d55bd6 100644 --- a/app/components/UI/Ramp/Aggregator/Views/Settings/Settings.test.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/Settings/Settings.test.tsx @@ -136,6 +136,8 @@ const mockUseRampsControllerInitialValues: ReturnType< setSelectedPaymentMethod: jest.fn(), paymentMethodsLoading: false, paymentMethodsError: null, + paymentMethodsFetching: false, + paymentMethodsStatus: 'idle' as const, getQuotes: jest.fn(), getBuyWidgetData: jest.fn(), orders: [], diff --git a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx index 316d911c40c..db775cb0ccd 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx +++ b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx @@ -83,10 +83,18 @@ jest.mock('../../components/QuickAmounts', () => { }); jest.mock('@react-navigation/native', () => { + const ReactActual = jest.requireActual('react'); const actual = jest.requireActual('@react-navigation/native'); return { ...actual, useNavigation: jest.fn(), + useIsFocused: jest.fn(() => true), + useFocusEffect: (callback: () => void | (() => void)) => { + ReactActual.useEffect(() => { + const cleanup = callback(); + return typeof cleanup === 'function' ? cleanup : undefined; + }, [callback]); + }, }; }); @@ -354,11 +362,14 @@ describe('BuildQuote', () => { userRegion: USER_REGION, selectedProvider: WIDGET_PROVIDER, selectedToken: SELECTED_TOKEN, + paymentMethods: [SELECTED_PAYMENT_METHOD], getBuyWidgetData: mockGetBuyWidgetData, addPrecreatedOrder: mockAddPrecreatedOrder, addOrder: mockAddOrder, getOrderFromCallback: mockGetOrderFromCallback, paymentMethodsLoading: false, + paymentMethodsFetching: false, + paymentMethodsStatus: 'success', selectedPaymentMethod: SELECTED_PAYMENT_METHOD, }); mockUseRampsQuotes.mockReturnValue({ @@ -676,9 +687,14 @@ describe('BuildQuote', () => { userRegion: USER_REGION, selectedProvider: NATIVE_PROVIDER, selectedToken: SELECTED_TOKEN, + paymentMethods: [SELECTED_PAYMENT_METHOD], getBuyWidgetData: mockGetBuyWidgetData, addPrecreatedOrder: mockAddPrecreatedOrder, + addOrder: mockAddOrder, + getOrderFromCallback: mockGetOrderFromCallback, paymentMethodsLoading: false, + paymentMethodsFetching: false, + paymentMethodsStatus: 'success', selectedPaymentMethod: SELECTED_PAYMENT_METHOD, }); mockUseRampsQuotes.mockReturnValue({ @@ -843,4 +859,178 @@ describe('BuildQuote', () => { expect(toJSON()).toMatchSnapshot(); }); }); + + describe('Token unavailable for provider', () => { + const TOKEN_ASSET = 'eip155:1/slip44:60'; + + const transakProvider = { + id: '/providers/transak', + name: 'Transak', + supportedCryptoCurrencies: { [TOKEN_ASSET]: true }, + links: [], + }; + + const mockUnavailableController = (overrides: Record) => { + mockUseRampsController.mockReturnValue({ + userRegion: USER_REGION, + selectedProvider: transakProvider, + selectedToken: SELECTED_TOKEN, + paymentMethods: [], + getBuyWidgetData: mockGetBuyWidgetData, + addPrecreatedOrder: mockAddPrecreatedOrder, + addOrder: mockAddOrder, + getOrderFromCallback: mockGetOrderFromCallback, + paymentMethodsLoading: false, + paymentMethodsFetching: false, + paymentMethodsStatus: 'success', + selectedPaymentMethod: null, + ...overrides, + }); + }; + + beforeEach(() => { + jest.useFakeTimers(); + mockUseParams.mockReturnValue({ assetId: TOKEN_ASSET }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('navigates to token unavailable modal after debounce when payment methods are empty', () => { + mockUnavailableController({}); + renderWithProvider(, { state: initialRootState }); + act(() => { + jest.advanceTimersByTime(650); + }); + expect(mockNavigate).toHaveBeenCalledWith( + 'RampModals', + expect.objectContaining({ + screen: 'RampTokenNotAvailableModal', + params: expect.objectContaining({ assetId: TOKEN_ASSET }), + }), + ); + }); + + it('does not navigate while payment methods are still fetching', () => { + mockUnavailableController({ paymentMethodsFetching: true }); + renderWithProvider(, { state: initialRootState }); + act(() => { + jest.advanceTimersByTime(650); + }); + expect(mockNavigate).not.toHaveBeenCalledWith( + 'RampModals', + expect.objectContaining({ + screen: 'RampTokenNotAvailableModal', + }), + ); + }); + + it('does not navigate before payment methods status is success', () => { + mockUnavailableController({ paymentMethodsStatus: 'loading' }); + renderWithProvider(, { state: initialRootState }); + act(() => { + jest.advanceTimersByTime(650); + }); + expect(mockNavigate).not.toHaveBeenCalledWith( + 'RampModals', + expect.objectContaining({ + screen: 'RampTokenNotAvailableModal', + }), + ); + }); + + it('does not navigate when payment methods returned', () => { + mockUnavailableController({ + paymentMethods: [SELECTED_PAYMENT_METHOD], + }); + renderWithProvider(, { state: initialRootState }); + act(() => { + jest.advanceTimersByTime(650); + }); + expect(mockNavigate).not.toHaveBeenCalledWith( + 'RampModals', + expect.objectContaining({ + screen: 'RampTokenNotAvailableModal', + }), + ); + }); + + it('passes buyFlowOrigin to token unavailable modal params', () => { + mockUseParams.mockReturnValue({ + assetId: TOKEN_ASSET, + buyFlowOrigin: 'tokenInfo' as const, + }); + mockUnavailableController({}); + renderWithProvider(, { state: initialRootState }); + act(() => { + jest.advanceTimersByTime(650); + }); + expect(mockNavigate).toHaveBeenCalledWith( + 'RampModals', + expect.objectContaining({ + screen: 'RampTokenNotAvailableModal', + params: expect.objectContaining({ + assetId: TOKEN_ASSET, + buyFlowOrigin: 'tokenInfo', + }), + }), + ); + }); + + it('does not open payment selection when token unavailable disables pill', () => { + mockUnavailableController({}); + const { getByTestId } = renderWithProvider(, { + state: initialRootState, + }); + act(() => { + jest.advanceTimersByTime(650); + }); + mockNavigate.mockClear(); + fireEvent.press(getByTestId('build-quote-payment-pill')); + expect(mockNavigate).not.toHaveBeenCalledWith( + 'RampModals', + expect.objectContaining({ + screen: 'RampPaymentSelectionModal', + }), + ); + }); + + it('re-navigates when provider id changes', () => { + mockUnavailableController({ + selectedProvider: { + id: '/providers/a', + name: 'A', + supportedCryptoCurrencies: { [TOKEN_ASSET]: true }, + links: [], + }, + }); + const { rerender } = renderWithProvider(, { + state: initialRootState, + }); + act(() => { + jest.advanceTimersByTime(650); + }); + expect(mockNavigate).toHaveBeenCalled(); + mockNavigate.mockClear(); + mockUnavailableController({ + selectedProvider: { + id: '/providers/b', + name: 'B', + supportedCryptoCurrencies: { [TOKEN_ASSET]: true }, + links: [], + }, + }); + rerender(); + act(() => { + jest.advanceTimersByTime(650); + }); + expect(mockNavigate).toHaveBeenCalledWith( + 'RampModals', + expect.objectContaining({ + screen: 'RampTokenNotAvailableModal', + }), + ); + }); + }); }); diff --git a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx index b665528a3af..88c6ed6d94f 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx +++ b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx @@ -6,7 +6,11 @@ import React, { useState, } from 'react'; import { Linking, Animated, View } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; +import { + useNavigation, + useFocusEffect, + useIsFocused, +} from '@react-navigation/native'; import type { CaipChainId } from '@metamask/utils'; import InAppBrowser from 'react-native-inappbrowser-reborn'; import ScreenLayout from '../../Aggregator/components/ScreenLayout'; @@ -92,9 +96,19 @@ export function isBailedOrderStatus( return status != null && BAILED_ORDER_STATUSES.has(status); } +/** + * Identifies which flow the user used to enter the Buy screen. + * - 'tokenInfo': Home → Tokens → Token Info → Buy + * - 'homeTokenList': Home → (token list with Buy buttons) → Buy + * - undefined: Home → Buy → Token Selection → BuildQuote (standard flow) + */ +export type BuyFlowOrigin = 'tokenInfo' | 'homeTokenList'; + export interface BuildQuoteParams { assetId?: string; nativeFlowError?: string; + /** Which flow the user used to enter the Buy screen. */ + buyFlowOrigin?: BuyFlowOrigin; } /** @@ -129,6 +143,7 @@ const DEFAULT_AMOUNT = 100; function BuildQuote() { const navigation = useNavigation(); + const isOnBuildQuoteScreen = useIsFocused(); const { styles } = useStyles(styleSheet, {}); const { formatCurrency } = useFormatters(); const cursorOpacity = useBlinkingCursor(); @@ -152,11 +167,14 @@ function BuildQuote() { userRegion, selectedProvider, selectedToken, + paymentMethods, getBuyWidgetData, addPrecreatedOrder, addOrder, getOrderFromCallback, paymentMethodsLoading, + paymentMethodsFetching, + paymentMethodsStatus, selectedPaymentMethod, } = useRampsController(); @@ -174,33 +192,103 @@ function BuildQuote() { } }, [selectedProvider]); - const isTokenUnavailable = useMemo( - () => - !!( - selectedProvider && - params?.assetId && - selectedProvider.supportedCryptoCurrencies && - !selectedProvider.supportedCryptoCurrencies[params.assetId] - ), - [selectedProvider, params?.assetId], + const tokenStateIsSettled = + !params?.assetId || selectedToken?.assetId === params.assetId; + + // Controller state is the source of truth for the active token; + // route params are only used as bootstrapping input. + const effectiveAssetId = selectedToken?.assetId ?? params?.assetId; + + const isTokenUnavailable = useMemo(() => { + if (!selectedProvider || !effectiveAssetId) { + return false; + } + + if ( + selectedProvider.supportedCryptoCurrencies && + !selectedProvider.supportedCryptoCurrencies[effectiveAssetId] + ) { + return true; + } + + // Only determine unavailability after payment methods have fully settled. + // This prevents the modal from flashing during loading/idle/error states + // (e.g. after a provider change while the new query is still in flight). + // Also wait for background refetches to complete — react-query may return + // stale cached data (status='success') while refetching for a new provider. + if (paymentMethodsStatus !== 'success' || paymentMethodsFetching) { + return false; + } + + // If payment methods returned results, token IS available + // (payment methods API is more authoritative than supportedCryptoCurrencies) + if (paymentMethods.length > 0) { + return false; + } + + // Payment methods loaded but empty + if (tokenStateIsSettled) { + return true; + } + + return false; + }, [ + selectedProvider, + effectiveAssetId, + paymentMethodsFetching, + tokenStateIsSettled, + paymentMethodsStatus, + paymentMethods.length, + ]); + + // Tracks which provider:token combination was last shown the modal, + // so we don't duplicate-navigate within the same visit but DO re-show + // when the combination changes. + const lastShownUnavailableKeyRef = useRef(''); + + // Bump a counter on screen focus so the modal effect re-evaluates + // when the user navigates away (e.g. token selection) and comes back. + const [focusTrigger, setFocusTrigger] = useState(0); + useFocusEffect( + useCallback(() => { + lastShownUnavailableKeyRef.current = ''; + setFocusTrigger((c) => c + 1); + }, []), ); - /* - * Shows the "token not available modal" if the token is not available for the selected provider. - */ - const hasShownTokenUnavailableRef = useRef(false); + // Show "Token Not Available" modal when the selected token is unavailable + // for the current provider. Debounced to let the query settle — prevents + // the modal from flashing when isTokenUnavailable is briefly true due to + // stale cached data before the fresh response arrives. useEffect(() => { - if (isTokenUnavailable && !hasShownTokenUnavailableRef.current) { - hasShownTokenUnavailableRef.current = true; + if (!isOnBuildQuoteScreen || !isTokenUnavailable) { + lastShownUnavailableKeyRef.current = ''; + return; + } + + const key = `${selectedProvider?.id}:${effectiveAssetId}`; + if (lastShownUnavailableKeyRef.current === key) return; + + const timer = setTimeout(() => { + lastShownUnavailableKeyRef.current = key; navigation.navigate( ...createTokenNotAvailableModalNavigationDetails({ - assetId: params?.assetId ?? '', + assetId: effectiveAssetId ?? '', + buyFlowOrigin: params?.buyFlowOrigin, }), ); - } else if (!isTokenUnavailable) { - hasShownTokenUnavailableRef.current = false; - } - }, [isTokenUnavailable, params?.assetId, navigation]); + }, 600); + + return () => clearTimeout(timer); + }, [ + isOnBuildQuoteScreen, + params?.buyFlowOrigin, + isTokenUnavailable, + effectiveAssetId, + navigation, + selectedProvider?.id, + focusTrigger, + ]); const { checkExistingToken: transakCheckExistingToken, @@ -270,6 +358,7 @@ function BuildQuote() { selectedPaymentMethod && selectedProvider && selectedToken?.assetId && + tokenStateIsSettled && debouncedPollingAmount > 0 ); @@ -797,7 +886,9 @@ function BuildQuote() { strings('fiat_on_ramp.select_payment_method') } isLoading={paymentMethodsLoading} - onPress={handlePaymentPillPress} + onPress={ + isTokenUnavailable ? undefined : handlePaymentPillPress + } testID="build-quote-payment-pill" /> diff --git a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelection.test.tsx b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelection.test.tsx index 685faaec938..96b0de78623 100644 --- a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelection.test.tsx +++ b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelection.test.tsx @@ -61,6 +61,8 @@ const defaultMockController: UseRampsControllerResult = { setSelectedPaymentMethod: jest.fn(), paymentMethodsLoading: false, paymentMethodsError: null, + paymentMethodsFetching: false, + paymentMethodsStatus: 'idle' as const, getQuotes: jest.fn(), getBuyWidgetData: jest.fn(), orders: [], diff --git a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelectionModal.test.tsx b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelectionModal.test.tsx index e970e514222..b476f23d4a5 100644 --- a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelectionModal.test.tsx +++ b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelectionModal.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { fireEvent, waitFor } from '@testing-library/react-native'; +import { fireEvent } from '@testing-library/react-native'; import ProviderSelectionModal, { type ProviderSelectionModalParams, } from './ProviderSelectionModal'; @@ -127,6 +127,20 @@ jest.mock('../../../hooks/useRampAccountAddress', () => ({ default: () => '0x123', })); +const mockUseRampsQuotes = jest.fn((_opts?: unknown) => ({ + data: null, + loading: false, + status: 'idle' as const, + isSuccess: false, + error: null, + getQuotes: mockGetQuotes, + getBuyWidgetData: jest.fn(), +})); + +jest.mock('../../../hooks/useRampsQuotes', () => ({ + useRampsQuotes: (opts: unknown) => mockUseRampsQuotes(opts), +})); + let capturedOnClose: ((hasPendingAction?: boolean) => void) | undefined; jest.mock( @@ -170,46 +184,42 @@ function renderWithProvider(component: React.ComponentType) { describe('ProviderSelectionModal', () => { beforeEach(() => { jest.clearAllMocks(); - mockGetQuotes.mockResolvedValue({ - success: [], - error: [], - sorted: [], - customActions: [], + mockUseRampsQuotes.mockReturnValue({ + data: null, + loading: false, + status: 'idle' as const, + isSuccess: false, + error: null, + getQuotes: mockGetQuotes, + getBuyWidgetData: jest.fn(), }); mockUseRampsController.mockImplementation(() => defaultControllerReturn); mockUseParams.mockReturnValue({ amount: 100 }); }); - it('matches snapshot', async () => { + it('matches snapshot', () => { const { toJSON } = renderWithProvider(ProviderSelectionModal); - await waitFor(() => { - expect(mockGetQuotes).toHaveBeenCalled(); - }); expect(toJSON()).toMatchSnapshot(); }); - it('calls getQuotes with provider params on mount', async () => { + it('calls useRampsQuotes with provider params on mount', () => { renderWithProvider(ProviderSelectionModal); - await waitFor(() => { - expect(mockGetQuotes).toHaveBeenCalledWith({ + expect(mockUseRampsQuotes).toHaveBeenCalledWith( + expect.objectContaining({ amount: 100, walletAddress: '0x123', assetId: 'eip155:1/slip44:60', providers: ['/providers/transak', '/providers/moonpay'], paymentMethods: ['/payments/debit-credit-card-1'], forceRefresh: true, - }); - }); + }), + ); }); - it('calls setSelectedProvider and goBack when provider is selected', async () => { + it('calls setSelectedProvider and goBack when provider is selected', () => { const { getByText } = renderWithProvider(ProviderSelectionModal); - await waitFor(() => { - expect(mockGetQuotes).toHaveBeenCalled(); - }); - fireEvent.press(getByText('Transak')); expect(mockSetSelectedProvider).toHaveBeenCalledWith( @@ -218,13 +228,9 @@ describe('ProviderSelectionModal', () => { expect(mockGoBack).toHaveBeenCalled(); }); - it('calls goBack when back button is pressed', async () => { + it('calls goBack when back button is pressed', () => { const { getByTestId } = renderWithProvider(ProviderSelectionModal); - await waitFor(() => { - expect(mockGetQuotes).toHaveBeenCalled(); - }); - fireEvent.press(getByTestId('button-icon')); expect(mockGoBack).toHaveBeenCalled(); @@ -237,7 +243,7 @@ describe('ProviderSelectionModal', () => { }); renderWithProvider(ProviderSelectionModal); - expect(mockGetQuotes).not.toHaveBeenCalled(); + expect(mockUseRampsQuotes).toHaveBeenCalledWith(null); }); it('filters providers by assetId when provided', () => { diff --git a/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.test.tsx b/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.test.tsx index 8196300ba7f..bd898802e9d 100644 --- a/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.test.tsx +++ b/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.test.tsx @@ -244,4 +244,57 @@ describe('TokenNotAvailableModal', () => { }); expect(mockTrackEvent).toHaveBeenCalledTimes(1); }); + + describe('buyFlowOrigin: tokenInfo', () => { + beforeEach(() => { + mockUseParams.mockReturnValue({ + assetId: MOCK_ASSET_ID, + buyFlowOrigin: 'tokenInfo', + }); + }); + + it('navigates to Tokens Full View when Change token is pressed', () => { + const { getByText } = render(TokenNotAvailableModal); + + fireEvent.press(getByText('Change token')); + + expect(mockOnCloseBottomSheet).toHaveBeenCalledWith(expect.any(Function)); + expect(mockNavigate).toHaveBeenCalledWith(Routes.WALLET.TOKENS_FULL_VIEW); + }); + + it('calls goBack once when modal is dismissed without a pending action', () => { + render(TokenNotAvailableModal); + + capturedOnClose?.(false); + + expect(mockGoBack).toHaveBeenCalledTimes(1); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + }); + + describe('buyFlowOrigin: homeTokenList', () => { + beforeEach(() => { + mockUseParams.mockReturnValue({ + assetId: MOCK_ASSET_ID, + buyFlowOrigin: 'homeTokenList', + }); + }); + + it('navigates to Home when Change token is pressed', () => { + const { getByText } = render(TokenNotAvailableModal); + + fireEvent.press(getByText('Change token')); + + expect(mockOnCloseBottomSheet).toHaveBeenCalledWith(expect.any(Function)); + expect(mockNavigate).toHaveBeenCalledWith(Routes.WALLET.HOME); + }); + + it('navigates to Home when modal is dismissed without a pending action', () => { + render(TokenNotAvailableModal); + + capturedOnClose?.(false); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.WALLET.HOME); + }); + }); }); diff --git a/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.tsx b/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.tsx index 93f0d50603b..e8fd544c92e 100644 --- a/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.tsx +++ b/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.tsx @@ -27,8 +27,12 @@ import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../../../core/Analytics'; import { TOKEN_NOT_AVAILABLE_MODAL_TEST_IDS } from './TokenNotAvailableModal.testIds'; +import type { BuyFlowOrigin } from '../../BuildQuote/BuildQuote'; + export interface TokenNotAvailableModalParams { assetId: string; + /** Which flow the user used to enter the Buy screen. */ + buyFlowOrigin?: BuyFlowOrigin; } export const createTokenNotAvailableModalNavigationDetails = @@ -39,7 +43,7 @@ export const createTokenNotAvailableModalNavigationDetails = function TokenNotAvailableModal() { const { trackEvent, createEventBuilder } = useAnalytics(); - const { assetId } = useParams(); + const { assetId, buyFlowOrigin } = useParams(); const navigation = useNavigation(); const sheetRef = useRef(null); const { styles } = useStyles(styleSheet, {}); @@ -71,11 +75,25 @@ function TokenNotAvailableModal() { .build(), ); sheetRef.current?.onCloseBottomSheet(() => { - navigation.navigate(Routes.RAMP.TOKEN_SELECTION, { - screen: Routes.RAMP.TOKEN_SELECTION, - }); + if (buyFlowOrigin === 'tokenInfo') { + // Token Info buy flow: return to the Tokens Full View screen + navigation.navigate(Routes.WALLET.TOKENS_FULL_VIEW as never); + } else if (buyFlowOrigin === 'homeTokenList') { + // Home token list buy flow: return to home screen + navigation.navigate(Routes.WALLET.HOME as never); + } else { + navigation.navigate(Routes.RAMP.TOKEN_SELECTION, { + screen: Routes.RAMP.TOKEN_SELECTION, + }); + } }); - }, [navigation, selectedProvider?.name, trackEvent, createEventBuilder]); + }, [ + navigation, + buyFlowOrigin, + selectedProvider?.name, + trackEvent, + createEventBuilder, + ]); const handleChangeProvider = useCallback(() => { trackEvent( @@ -118,12 +136,22 @@ function TokenNotAvailableModal() { const handleDismiss = useCallback( (hasPendingAction?: boolean) => { if (!hasPendingAction) { - navigation.navigate(Routes.RAMP.TOKEN_SELECTION, { - screen: Routes.RAMP.TOKEN_SELECTION, - }); + if (buyFlowOrigin === 'tokenInfo') { + // Token Info buy flow: pop back through the ramp flow to the + // existing Asset screen. BottomSheet already performs one goBack + // when shouldNavigateBack is true; we need one more to exit ramp. + navigation.goBack(); + } else if (buyFlowOrigin === 'homeTokenList') { + // Home token list buy flow: return to home screen + navigation.navigate(Routes.WALLET.HOME as never); + } else { + navigation.navigate(Routes.RAMP.TOKEN_SELECTION, { + screen: Routes.RAMP.TOKEN_SELECTION, + }); + } } }, - [navigation], + [navigation, buyFlowOrigin], ); return ( diff --git a/app/components/UI/Ramp/Views/Settings/RegionSelector/RegionSelector.test.tsx b/app/components/UI/Ramp/Views/Settings/RegionSelector/RegionSelector.test.tsx index 09624e1f7c1..e33b578df58 100644 --- a/app/components/UI/Ramp/Views/Settings/RegionSelector/RegionSelector.test.tsx +++ b/app/components/UI/Ramp/Views/Settings/RegionSelector/RegionSelector.test.tsx @@ -112,6 +112,8 @@ const mockUseRampsControllerInitialValues: ReturnType< setSelectedPaymentMethod: jest.fn(), paymentMethodsLoading: false, paymentMethodsError: null, + paymentMethodsFetching: false, + paymentMethodsStatus: 'idle' as const, getQuotes: jest.fn(), getBuyWidgetData: jest.fn(), orders: [], diff --git a/app/components/UI/Ramp/Views/TokenSelection/TokenSelection.test.tsx b/app/components/UI/Ramp/Views/TokenSelection/TokenSelection.test.tsx index 20a92a64d0d..93a37ed51be 100644 --- a/app/components/UI/Ramp/Views/TokenSelection/TokenSelection.test.tsx +++ b/app/components/UI/Ramp/Views/TokenSelection/TokenSelection.test.tsx @@ -172,6 +172,8 @@ describe('TokenSelection Component', () => { setSelectedPaymentMethod: jest.fn(), paymentMethodsLoading: false, paymentMethodsError: null, + paymentMethodsFetching: false, + paymentMethodsStatus: 'idle' as const, getQuotes: jest.fn(), getBuyWidgetData: jest.fn(), orders: [], @@ -314,6 +316,8 @@ describe('TokenSelection Component', () => { setSelectedPaymentMethod: jest.fn(), paymentMethodsLoading: false, paymentMethodsError: null, + paymentMethodsFetching: false, + paymentMethodsStatus: 'idle' as const, getQuotes: jest.fn(), getBuyWidgetData: jest.fn(), orders: [], @@ -355,6 +359,8 @@ describe('TokenSelection Component', () => { setSelectedPaymentMethod: jest.fn(), paymentMethodsLoading: false, paymentMethodsError: null, + paymentMethodsFetching: false, + paymentMethodsStatus: 'idle' as const, getQuotes: jest.fn(), getBuyWidgetData: jest.fn(), orders: [], @@ -410,6 +416,8 @@ describe('TokenSelection Component', () => { setSelectedPaymentMethod: jest.fn(), paymentMethodsLoading: false, paymentMethodsError: null, + paymentMethodsFetching: false, + paymentMethodsStatus: 'idle' as const, getQuotes: jest.fn(), getBuyWidgetData: jest.fn(), orders: [], @@ -476,6 +484,8 @@ describe('TokenSelection Component', () => { setSelectedPaymentMethod: jest.fn(), paymentMethodsLoading: false, paymentMethodsError: null, + paymentMethodsFetching: false, + paymentMethodsStatus: 'idle' as const, getQuotes: jest.fn(), getBuyWidgetData: jest.fn(), orders: [], @@ -552,6 +562,8 @@ describe('TokenSelection Component', () => { setSelectedPaymentMethod: jest.fn(), paymentMethodsLoading: false, paymentMethodsError: null, + paymentMethodsFetching: false, + paymentMethodsStatus: 'idle' as const, getQuotes: jest.fn(), getBuyWidgetData: jest.fn(), orders: [], @@ -632,6 +644,8 @@ describe('TokenSelection Component', () => { setSelectedPaymentMethod: jest.fn(), paymentMethodsLoading: false, paymentMethodsError: null, + paymentMethodsFetching: false, + paymentMethodsStatus: 'idle' as const, getQuotes: jest.fn(), getBuyWidgetData: jest.fn(), orders: [], @@ -693,6 +707,8 @@ describe('TokenSelection Component', () => { setSelectedPaymentMethod: jest.fn(), paymentMethodsLoading: false, paymentMethodsError: null, + paymentMethodsFetching: false, + paymentMethodsStatus: 'idle' as const, getQuotes: jest.fn(), getBuyWidgetData: jest.fn(), orders: [], diff --git a/app/components/UI/Ramp/hooks/useRampNavigation.test.ts b/app/components/UI/Ramp/hooks/useRampNavigation.test.ts index e5280f0a6f8..a8ebb919bbb 100644 --- a/app/components/UI/Ramp/hooks/useRampNavigation.test.ts +++ b/app/components/UI/Ramp/hooks/useRampNavigation.test.ts @@ -146,12 +146,39 @@ describe('useRampNavigation', () => { expect(mockSetSelectedToken).toHaveBeenCalledWith(intent.assetId); expect(mockCreateBuildQuoteNavDetails).toHaveBeenCalledWith({ assetId: intent.assetId, + buyFlowOrigin: undefined, }); expect(mockNavigate).toHaveBeenCalledWith(...mockNavDetails); expect(mockCreateRampNavigationDetails).not.toHaveBeenCalled(); expect(mockCreateDepositNavigationDetails).not.toHaveBeenCalled(); }); + it('passes buyFlowOrigin through to BuildQuote params', () => { + const intent = { assetId: 'eip155:1/erc20:0x123' }; + + const { result } = renderHookWithProvider(() => useRampNavigation()); + + result.current.goToBuy(intent, { buyFlowOrigin: 'tokenInfo' }); + + expect(mockCreateBuildQuoteNavDetails).toHaveBeenCalledWith({ + assetId: intent.assetId, + buyFlowOrigin: 'tokenInfo', + }); + }); + + it('passes homeTokenList buyFlowOrigin through to BuildQuote params', () => { + const intent = { assetId: 'eip155:1/erc20:0x123' }; + + const { result } = renderHookWithProvider(() => useRampNavigation()); + + result.current.goToBuy(intent, { buyFlowOrigin: 'homeTokenList' }); + + expect(mockCreateBuildQuoteNavDetails).toHaveBeenCalledWith({ + assetId: intent.assetId, + buyFlowOrigin: 'homeTokenList', + }); + }); + it('navigates to TokenSelection when no assetId and V1 is disabled (matches handleRampUrl deeplink)', () => { // V2 on, V1 off (default in this describe): must go to TokenSelection like handleRampUrl, not legacy const mockNavDetails = [ @@ -236,6 +263,7 @@ describe('useRampNavigation', () => { expect(mockSetSelectedToken).toHaveBeenCalledWith(intent.assetId); expect(mockCreateBuildQuoteNavDetails).toHaveBeenCalledWith({ assetId: intent.assetId, + buyFlowOrigin: undefined, }); expect(mockNavigate).toHaveBeenCalledWith(...mockNavDetails); expect(mockCreateDepositNavigationDetails).not.toHaveBeenCalled(); diff --git a/app/components/UI/Ramp/hooks/useRampNavigation.ts b/app/components/UI/Ramp/hooks/useRampNavigation.ts index 8d31a1b6a5b..e8386e716d7 100644 --- a/app/components/UI/Ramp/hooks/useRampNavigation.ts +++ b/app/components/UI/Ramp/hooks/useRampNavigation.ts @@ -9,6 +9,7 @@ import { createRampNavigationDetails } from '../Aggregator/routes/utils'; import { createDepositNavigationDetails } from '../Deposit/routes/utils'; import { createTokenSelectionNavDetails } from '../Views/TokenSelection/TokenSelection'; import { createBuildQuoteNavDetails } from '../Views/BuildQuote'; +import type { BuyFlowOrigin } from '../Views/BuildQuote/BuildQuote'; import useRampsUnifiedV1Enabled from './useRampsUnifiedV1Enabled'; import useRampsUnifiedV2Enabled from './useRampsUnifiedV2Enabled'; import { @@ -47,6 +48,7 @@ export const useRampNavigation = () => { options?: { mode?: RampMode; overrideUnifiedRouting?: boolean; + buyFlowOrigin?: BuyFlowOrigin; }, ) => { const { mode = RampMode.AGGREGATOR, overrideUnifiedRouting = false } = @@ -90,7 +92,10 @@ export const useRampNavigation = () => { // Navigate anyway — BuildQuote will handle the missing token. } navigation.navigate( - ...createBuildQuoteNavDetails({ assetId: controllerAssetId }), + ...createBuildQuoteNavDetails({ + assetId: controllerAssetId, + buyFlowOrigin: options?.buyFlowOrigin, + }), ); return; } diff --git a/app/components/UI/Ramp/hooks/useRampsController.test.ts b/app/components/UI/Ramp/hooks/useRampsController.test.ts index d99640d55fd..cb839a82c11 100644 --- a/app/components/UI/Ramp/hooks/useRampsController.test.ts +++ b/app/components/UI/Ramp/hooks/useRampsController.test.ts @@ -59,6 +59,9 @@ jest.mock('./useRampsPaymentMethods', () => ({ selectedPaymentMethod: null, setSelectedPaymentMethod: jest.fn(), isLoading: false, + isFetching: false, + status: 'idle', + isSuccess: false, error: null, })), })); @@ -67,6 +70,8 @@ jest.mock('./useRampsQuotes', () => ({ useRampsQuotes: jest.fn(() => ({ getQuotes: jest.fn(), getBuyWidgetData: jest.fn(), + status: 'idle', + isSuccess: false, })), })); diff --git a/app/components/UI/Ramp/hooks/useRampsController.ts b/app/components/UI/Ramp/hooks/useRampsController.ts index 2b6315e5859..a70e4bc1afd 100644 --- a/app/components/UI/Ramp/hooks/useRampsController.ts +++ b/app/components/UI/Ramp/hooks/useRampsController.ts @@ -53,6 +53,8 @@ export interface UseRampsControllerResult { selectedPaymentMethod: UseRampsPaymentMethodsResult['selectedPaymentMethod']; setSelectedPaymentMethod: UseRampsPaymentMethodsResult['setSelectedPaymentMethod']; paymentMethodsLoading: UseRampsPaymentMethodsResult['isLoading']; + paymentMethodsFetching: UseRampsPaymentMethodsResult['isFetching']; + paymentMethodsStatus: UseRampsPaymentMethodsResult['status']; paymentMethodsError: UseRampsPaymentMethodsResult['error']; // Quotes @@ -145,6 +147,8 @@ export function useRampsController(): UseRampsControllerResult { selectedPaymentMethod, setSelectedPaymentMethod, isLoading: paymentMethodsLoading, + isFetching: paymentMethodsFetching, + status: paymentMethodsStatus, error: paymentMethodsError, } = useRampsPaymentMethods(); @@ -185,6 +189,8 @@ export function useRampsController(): UseRampsControllerResult { selectedPaymentMethod, setSelectedPaymentMethod, paymentMethodsLoading, + paymentMethodsFetching, + paymentMethodsStatus, paymentMethodsError, getQuotes, diff --git a/app/components/UI/Ramp/hooks/useRampsPaymentMethods.test.ts b/app/components/UI/Ramp/hooks/useRampsPaymentMethods.test.ts index bc9a1b9436f..9f717066d84 100644 --- a/app/components/UI/Ramp/hooks/useRampsPaymentMethods.test.ts +++ b/app/components/UI/Ramp/hooks/useRampsPaymentMethods.test.ts @@ -1,4 +1,5 @@ -import { renderHook, act } from '@testing-library/react-native'; +import { renderHook, act, waitFor } from '@testing-library/react-native'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { Provider } from 'react-redux'; import { configureStore } from '@reduxjs/toolkit'; import React from 'react'; @@ -9,6 +10,7 @@ import Engine from '../../../../core/Engine'; jest.mock('../../../../core/Engine', () => ({ context: { RampsController: { + getPaymentMethods: jest.fn(), setSelectedPaymentMethod: jest.fn(), }, }, @@ -31,144 +33,233 @@ const mockPaymentMethods: PaymentMethod[] = [ }, ]; -const createMockStore = (paymentMethodsState = {}) => +const baseRampsState = { + userRegion: { + country: { + currency: 'USD', + quickAmounts: [50, 100, 200], + }, + state: null, + regionCode: 'us', + }, + providers: { + data: [], + selected: { + id: '/providers/transak', + name: 'Transak', + }, + isLoading: false, + error: null, + }, + tokens: { + data: null, + selected: { + assetId: 'eip155:1/slip44:60', + chainId: 'eip155:1', + name: 'Ether', + symbol: 'ETH', + decimals: 18, + iconUrl: '', + tokenSupported: true, + }, + isLoading: false, + error: null, + }, + paymentMethods: { + data: [], + selected: null, + isLoading: false, + error: null, + }, +}; + +const createMockStore = (rampsControllerOverrides = {}) => configureStore({ reducer: { engine: () => ({ backgroundState: { RampsController: { - paymentMethods: { - data: [], - selected: null, - isLoading: false, - error: null, - ...paymentMethodsState, - }, + ...baseRampsState, + ...rampsControllerOverrides, }, }, }), }, }); -const wrapper = (store: ReturnType) => - function Wrapper({ children }: { children: React.ReactNode }) { - return React.createElement(Provider, { store } as never, children); - }; +const createWrapper = (store: ReturnType) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + const Wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement( + Provider, + { store } as never, + React.createElement( + QueryClientProvider, + { client: queryClient }, + children, + ), + ); + + return { Wrapper, queryClient }; +}; describe('useRampsPaymentMethods', () => { beforeEach(() => { jest.clearAllMocks(); }); - describe('return value structure', () => { - it('returns paymentMethods, selectedPaymentMethod, setSelectedPaymentMethod, isLoading, and error', () => { - const store = createMockStore(); - const { result } = renderHook(() => useRampsPaymentMethods(), { - wrapper: wrapper(store), - }); - expect(result.current).toMatchObject({ - paymentMethods: [], - selectedPaymentMethod: null, - isLoading: false, - error: null, - }); - expect(typeof result.current.setSelectedPaymentMethod).toBe('function'); + it('returns idle before an active request exists', () => { + const store = createMockStore({ + providers: { ...baseRampsState.providers, selected: null }, }); - }); + const { Wrapper } = createWrapper(store); - describe('paymentMethods state', () => { - it('returns paymentMethods from state', () => { - const store = createMockStore({ data: mockPaymentMethods }); - const { result } = renderHook(() => useRampsPaymentMethods(), { - wrapper: wrapper(store), - }); - expect(result.current.paymentMethods).toEqual(mockPaymentMethods); + const { result } = renderHook(() => useRampsPaymentMethods(), { + wrapper: Wrapper, }); - it('returns empty array when paymentMethods are not available', () => { - const store = createMockStore(); - const { result } = renderHook(() => useRampsPaymentMethods(), { - wrapper: wrapper(store), - }); - expect(result.current.paymentMethods).toEqual([]); + expect(result.current).toMatchObject({ + paymentMethods: [], + selectedPaymentMethod: null, + isLoading: false, + status: 'idle', + isSuccess: false, + error: null, }); + expect( + Engine.context.RampsController.getPaymentMethods, + ).not.toHaveBeenCalled(); }); - describe('selectedPaymentMethod state', () => { - it('returns selectedPaymentMethod from state', () => { - const store = createMockStore({ + it('returns loading while the active query is in flight', () => { + const store = createMockStore(); + const { Wrapper } = createWrapper(store); + + ( + Engine.context.RampsController.getPaymentMethods as jest.Mock + ).mockImplementation(() => new Promise(() => undefined)); + + const { result } = renderHook(() => useRampsPaymentMethods(), { + wrapper: Wrapper, + }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.status).toBe('loading'); + }); + + it('returns success with data and preserves controller-backed selection', async () => { + const store = createMockStore({ + paymentMethods: { + ...baseRampsState.paymentMethods, selected: mockPaymentMethods[0], - }); - const { result } = renderHook(() => useRampsPaymentMethods(), { - wrapper: wrapper(store), - }); - expect(result.current.selectedPaymentMethod).toEqual( - mockPaymentMethods[0], - ); - }); - - it('returns null when selectedPaymentMethod is not available', () => { - const store = createMockStore(); - const { result } = renderHook(() => useRampsPaymentMethods(), { - wrapper: wrapper(store), - }); - expect(result.current.selectedPaymentMethod).toBeNull(); + }, + }); + const { Wrapper } = createWrapper(store); + + ( + Engine.context.RampsController.getPaymentMethods as jest.Mock + ).mockResolvedValue({ + payments: mockPaymentMethods, + }); + + const { result } = renderHook(() => useRampsPaymentMethods(), { + wrapper: Wrapper, + }); + + await waitFor(() => { + expect(result.current.status).toBe('success'); }); + + expect(result.current.paymentMethods).toEqual(mockPaymentMethods); + expect(result.current.selectedPaymentMethod).toEqual(mockPaymentMethods[0]); + expect(result.current.isSuccess).toBe(true); }); - describe('loading state', () => { - it('returns isLoading true when isLoading is true', () => { - const store = createMockStore({ - isLoading: true, - }); - const { result } = renderHook(() => useRampsPaymentMethods(), { - wrapper: wrapper(store), - }); - expect(result.current.isLoading).toBe(true); + it('returns success with an empty array when the request completes empty', async () => { + const store = createMockStore(); + const { Wrapper } = createWrapper(store); + + ( + Engine.context.RampsController.getPaymentMethods as jest.Mock + ).mockResolvedValue({ + payments: [], }); + + const { result } = renderHook(() => useRampsPaymentMethods(), { + wrapper: Wrapper, + }); + + await waitFor(() => { + expect(result.current.status).toBe('success'); + }); + + expect(result.current.paymentMethods).toEqual([]); + expect(result.current.error).toBeNull(); }); - describe('error state', () => { - it('returns error from state', () => { - const store = createMockStore({ - error: 'Network error', - }); - const { result } = renderHook(() => useRampsPaymentMethods(), { - wrapper: wrapper(store), - }); - expect(result.current.error).toBe('Network error'); + it('returns error when the request rejects', async () => { + const store = createMockStore(); + const { Wrapper } = createWrapper(store); + + ( + Engine.context.RampsController.getPaymentMethods as jest.Mock + ).mockRejectedValue(new Error('Network error')); + + const { result } = renderHook(() => useRampsPaymentMethods(), { + wrapper: Wrapper, + }); + + await waitFor(() => { + expect(result.current.status).toBe('error'); }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe('Network error'); + expect(result.current.paymentMethods).toEqual([]); }); - describe('setSelectedPaymentMethod', () => { - it('calls Engine.context.RampsController.setSelectedPaymentMethod with payment method id', () => { - const store = createMockStore(); - const { result } = renderHook(() => useRampsPaymentMethods(), { - wrapper: wrapper(store), - }); + it('calls Engine.context.RampsController.setSelectedPaymentMethod with payment method id', () => { + const store = createMockStore({ + providers: { ...baseRampsState.providers, selected: null }, + }); + const { Wrapper } = createWrapper(store); - act(() => { - result.current.setSelectedPaymentMethod(mockPaymentMethods[0]); - }); + const { result } = renderHook(() => useRampsPaymentMethods(), { + wrapper: Wrapper, + }); - expect( - Engine.context.RampsController.setSelectedPaymentMethod, - ).toHaveBeenCalledWith(mockPaymentMethods[0].id); + act(() => { + result.current.setSelectedPaymentMethod(mockPaymentMethods[0]); }); - it('calls Engine.context.RampsController.setSelectedPaymentMethod with undefined when payment method is null', () => { - const store = createMockStore(); - const { result } = renderHook(() => useRampsPaymentMethods(), { - wrapper: wrapper(store), - }); + expect( + Engine.context.RampsController.setSelectedPaymentMethod, + ).toHaveBeenCalledWith(mockPaymentMethods[0].id); + }); - act(() => { - result.current.setSelectedPaymentMethod(null); - }); + it('calls Engine.context.RampsController.setSelectedPaymentMethod with undefined when payment method is null', () => { + const store = createMockStore({ + providers: { ...baseRampsState.providers, selected: null }, + }); + const { Wrapper } = createWrapper(store); - expect( - Engine.context.RampsController.setSelectedPaymentMethod, - ).toHaveBeenCalledWith(undefined); + const { result } = renderHook(() => useRampsPaymentMethods(), { + wrapper: Wrapper, }); + + act(() => { + result.current.setSelectedPaymentMethod(null); + }); + + expect( + Engine.context.RampsController.setSelectedPaymentMethod, + ).toHaveBeenCalledWith(undefined); }); }); diff --git a/app/components/UI/Ramp/hooks/useRampsPaymentMethods.ts b/app/components/UI/Ramp/hooks/useRampsPaymentMethods.ts index 8bf26ec9407..432b76a11c0 100644 --- a/app/components/UI/Ramp/hooks/useRampsPaymentMethods.ts +++ b/app/components/UI/Ramp/hooks/useRampsPaymentMethods.ts @@ -1,8 +1,17 @@ -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; -import { selectPaymentMethods } from '../../../../selectors/rampsController'; +import { + selectPaymentMethods, + selectProviders, + selectTokens, + selectUserRegion, +} from '../../../../selectors/rampsController'; import { type PaymentMethod } from '@metamask/ramps-controller'; import Engine from '../../../../core/Engine'; +import { rampsQueries } from '../queries'; + +export type RampsQueryStatus = 'idle' | 'loading' | 'success' | 'error'; /** * Result returned by the useRampsPaymentMethods hook. @@ -22,9 +31,21 @@ export interface UseRampsPaymentMethodsResult { */ setSelectedPaymentMethod: (paymentMethod: PaymentMethod | null) => void; /** - * Whether the payment methods request is currently loading. + * Whether the payment methods request is currently loading (no cached data). */ isLoading: boolean; + /** + * Whether a fetch is in-flight (includes background refetches with cached data). + */ + isFetching: boolean; + /** + * Query lifecycle status for the active payment methods request. + */ + status: RampsQueryStatus; + /** + * Whether the active payment methods request completed successfully. + */ + isSuccess: boolean; /** * The error message if the request failed, or null. */ @@ -38,12 +59,34 @@ export interface UseRampsPaymentMethodsResult { * @returns Payment methods state. */ export function useRampsPaymentMethods(): UseRampsPaymentMethodsResult { - const { - data: paymentMethods, - selected: selectedPaymentMethod, - isLoading, - error, - } = useSelector(selectPaymentMethods); + const { selected: selectedPaymentMethod } = useSelector(selectPaymentMethods); + const { selected: selectedProvider } = useSelector(selectProviders); + const { selected: selectedToken } = useSelector(selectTokens); + const userRegion = useSelector(selectUserRegion); + + const tokenSupportedByProvider = selectedProvider?.supportedCryptoCurrencies + ? selectedProvider.supportedCryptoCurrencies[ + selectedToken?.assetId ?? '' + ] === true + : true; + + const queryEnabled = Boolean( + userRegion?.regionCode && + userRegion?.country?.currency && + selectedToken?.assetId && + selectedProvider?.id && + tokenSupportedByProvider, + ); + + const paymentMethodsQuery = useQuery({ + ...rampsQueries.paymentMethods.options({ + regionCode: userRegion?.regionCode ?? '', + fiat: userRegion?.country?.currency ?? '', + assetId: selectedToken?.assetId ?? '', + providerId: selectedProvider?.id ?? '', + }), + enabled: queryEnabled, + }); const setSelectedPaymentMethod = useCallback( (paymentMethod: PaymentMethod | null) => @@ -53,12 +96,35 @@ export function useRampsPaymentMethods(): UseRampsPaymentMethodsResult { [], ); + const status = useMemo(() => { + if (!queryEnabled) { + return 'idle'; + } + if (paymentMethodsQuery.isPending) { + return 'loading'; + } + if (paymentMethodsQuery.isError) { + return 'error'; + } + return 'success'; + }, [ + paymentMethodsQuery.isError, + paymentMethodsQuery.isPending, + queryEnabled, + ]); + return { - paymentMethods, + paymentMethods: paymentMethodsQuery.data ?? [], selectedPaymentMethod, setSelectedPaymentMethod, - isLoading, - error, + isLoading: status === 'loading', + isFetching: paymentMethodsQuery.isFetching, + status, + isSuccess: status === 'success', + error: + paymentMethodsQuery.error instanceof Error + ? paymentMethodsQuery.error.message + : null, }; } diff --git a/app/components/UI/Ramp/hooks/useRampsQuotes.test.ts b/app/components/UI/Ramp/hooks/useRampsQuotes.test.ts index adc2e7c1feb..09e4f07d78e 100644 --- a/app/components/UI/Ramp/hooks/useRampsQuotes.test.ts +++ b/app/components/UI/Ramp/hooks/useRampsQuotes.test.ts @@ -1,4 +1,5 @@ import { renderHook, act, waitFor } from '@testing-library/react-native'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { Provider } from 'react-redux'; import { configureStore } from '@reduxjs/toolkit'; import React from 'react'; @@ -6,11 +7,12 @@ import { useRampsQuotes, type GetQuotesOptions } from './useRampsQuotes'; import type { Quote } from '../types'; import Engine from '../../../../core/Engine'; +const mockGetBuyWidgetData = jest.fn(); jest.mock('../../../../core/Engine', () => ({ context: { RampsController: { getQuotes: jest.fn(), - getBuyWidgetData: jest.fn(), + getBuyWidgetData: (...args: unknown[]) => mockGetBuyWidgetData(...args), }, }, })); @@ -26,10 +28,28 @@ const createMockStore = () => }, }); -const wrapper = (store: ReturnType) => - function Wrapper({ children }: { children: React.ReactNode }) { - return React.createElement(Provider, { store } as never, children); - }; +const createWrapper = (store: ReturnType) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + const Wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement( + Provider, + { store } as never, + React.createElement( + QueryClientProvider, + { client: queryClient }, + children, + ), + ); + + return { Wrapper, queryClient }; +}; const mockQuotesResponse = { success: [{ provider: 'test', quote: { amountIn: 100 } }], @@ -46,58 +66,67 @@ describe('useRampsQuotes', () => { describe('return value structure', () => { it('returns getQuotes and getBuyWidgetData functions', () => { const store = createMockStore(); + const { Wrapper } = createWrapper(store); + const { result } = renderHook(() => useRampsQuotes(), { - wrapper: wrapper(store), + wrapper: Wrapper, }); expect(typeof result.current.getQuotes).toBe('function'); expect(typeof result.current.getBuyWidgetData).toBe('function'); }); + }); - it('returns data, loading, error with default values when no options', () => { - const store = createMockStore(); - const { result } = renderHook(() => useRampsQuotes(), { - wrapper: wrapper(store), - }); + it('returns idle state when no options are provided', () => { + const store = createMockStore(); + const { Wrapper } = createWrapper(store); - expect(result.current.data).toBeNull(); - expect(result.current.loading).toBe(false); - expect(result.current.error).toBeNull(); + const { result } = renderHook(() => useRampsQuotes(), { + wrapper: Wrapper, }); - }); - describe('getQuotes', () => { - it('calls Engine.context.RampsController.getQuotes with options', async () => { - const store = createMockStore(); - const { result } = renderHook(() => useRampsQuotes(), { - wrapper: wrapper(store), - }); + expect(result.current.data).toBeNull(); + expect(result.current.loading).toBe(false); + expect(result.current.status).toBe('idle'); + expect(result.current.isSuccess).toBe(false); + expect(result.current.error).toBeNull(); + }); - (Engine.context.RampsController.getQuotes as jest.Mock).mockResolvedValue( - { success: [], sorted: [], error: [], customActions: [] }, - ); + it('calls Engine.context.RampsController.getQuotes with options', async () => { + const store = createMockStore(); + const { Wrapper } = createWrapper(store); + const { result } = renderHook(() => useRampsQuotes(), { + wrapper: Wrapper, + }); - const options = { - amount: 100, - walletAddress: '0x123', - assetId: 'eip155:1/slip44:60', - }; + (Engine.context.RampsController.getQuotes as jest.Mock).mockResolvedValue({ + success: [], + sorted: [], + error: [], + customActions: [], + }); - await act(async () => { - await result.current.getQuotes(options); - }); + const options = { + amount: 100, + walletAddress: '0x123', + assetId: 'eip155:1/slip44:60', + }; - expect(Engine.context.RampsController.getQuotes).toHaveBeenCalledWith( - options, - ); + await act(async () => { + await result.current.getQuotes(options); }); + + expect(Engine.context.RampsController.getQuotes).toHaveBeenCalledWith( + options, + ); }); describe('getBuyWidgetData', () => { it('calls Engine.context.RampsController.getBuyWidgetData with quote', async () => { const store = createMockStore(); + const { Wrapper } = createWrapper(store); const { result } = renderHook(() => useRampsQuotes(), { - wrapper: wrapper(store), + wrapper: Wrapper, }); const testQuote: Quote = { @@ -114,9 +143,7 @@ describe('useRampsQuotes', () => { url: 'https://global.transak.com/?apiKey=test', orderId: null, }; - ( - Engine.context.RampsController.getBuyWidgetData as jest.Mock - ).mockResolvedValue(mockBuyWidget); + mockGetBuyWidgetData.mockResolvedValue(mockBuyWidget); let resolvedValue: Awaited< ReturnType @@ -125,163 +152,97 @@ describe('useRampsQuotes', () => { resolvedValue = await result.current.getBuyWidgetData(testQuote); }); - expect( - Engine.context.RampsController.getBuyWidgetData, - ).toHaveBeenCalledWith(testQuote); + expect(mockGetBuyWidgetData).toHaveBeenCalledWith(testQuote); expect(resolvedValue).toEqual(mockBuyWidget); }); }); describe('fetch mode', () => { - const options = { + const options: GetQuotesOptions = { amount: 100, walletAddress: '0x123', assetId: 'eip155:1/slip44:60', + paymentMethods: ['/payments/card'], + providers: ['/providers/transak'], }; - it('fetches and updates data/loading when options is provided', async () => { + it('fetches and updates data/loading when options are provided', async () => { const store = createMockStore(); + const { Wrapper } = createWrapper(store); (Engine.context.RampsController.getQuotes as jest.Mock).mockResolvedValue( mockQuotesResponse, ); const { result } = renderHook(() => useRampsQuotes(options), { - wrapper: wrapper(store), + wrapper: Wrapper, }); expect(result.current.loading).toBe(true); + expect(result.current.status).toBe('loading'); expect(result.current.data).toBeNull(); await waitFor(() => { - expect(result.current.loading).toBe(false); + expect(result.current.status).toBe('success'); }); + expect(result.current.loading).toBe(false); expect(result.current.data).toEqual(mockQuotesResponse); + expect(result.current.isSuccess).toBe(true); expect(Engine.context.RampsController.getQuotes).toHaveBeenCalledWith( - options, + expect.objectContaining({ + amount: 100, + walletAddress: '0x123', + assetId: 'eip155:1/slip44:60', + paymentMethods: ['/payments/card'], + providers: ['/providers/transak'], + }), ); }); - it('skips fetch when options is null', () => { - const store = createMockStore(); - const { result } = renderHook(() => useRampsQuotes(null), { - wrapper: wrapper(store), - }); - - expect(result.current.data).toBeNull(); - expect(result.current.loading).toBe(false); - expect(result.current.error).toBeNull(); - expect(Engine.context.RampsController.getQuotes).not.toHaveBeenCalled(); - }); - - it('always sets loading to false in finally when effect cleanup runs', async () => { - const store = createMockStore(); - let resolve: ((value: typeof mockQuotesResponse) => void) | undefined; - const fetchPromise = new Promise((r) => { - resolve = r; - }); - (Engine.context.RampsController.getQuotes as jest.Mock).mockReturnValue( - fetchPromise, - ); - - const { result, rerender } = renderHook( - ({ params }: { params: GetQuotesOptions | null }) => - useRampsQuotes(params), - { - wrapper: wrapper(store), - initialProps: { params: options } as { - params: GetQuotesOptions | null; - }, - }, - ); - - expect(result.current.loading).toBe(true); - - rerender({ params: null }); - - await act(async () => { - if (resolve) resolve(mockQuotesResponse); - }); - - expect(result.current.loading).toBe(false); - expect(result.current.data).toBeNull(); - }); - - it('does not apply stale data when cancelled', async () => { - const store = createMockStore(); - let resolveFirst: - | ((value: typeof mockQuotesResponse) => void) - | undefined; - const firstPromise = new Promise((r) => { - resolveFirst = r; - }); - (Engine.context.RampsController.getQuotes as jest.Mock).mockReturnValue( - firstPromise, - ); - - const { result, rerender } = renderHook( - ({ params }: { params: GetQuotesOptions | null }) => - useRampsQuotes(params), - { - wrapper: wrapper(store), - initialProps: { params: options } as { - params: GetQuotesOptions | null; - }, - }, - ); - - rerender({ params: null }); - - await act(async () => { - if (resolveFirst) resolveFirst(mockQuotesResponse); - }); - - expect(result.current.data).toBeNull(); - }); - - it('populates error when fetch rejects', async () => { + it('returns error when the request rejects', async () => { const store = createMockStore(); + const { Wrapper } = createWrapper(store); (Engine.context.RampsController.getQuotes as jest.Mock).mockRejectedValue( new Error('Network error'), ); const { result } = renderHook(() => useRampsQuotes(options), { - wrapper: wrapper(store), + wrapper: Wrapper, }); await waitFor(() => { - expect(result.current.loading).toBe(false); + expect(result.current.status).toBe('error'); }); + expect(result.current.loading).toBe(false); expect(result.current.error).toBe('Network error'); expect(result.current.data).toBeNull(); }); - it('clears data when options becomes null', async () => { + it('returns idle and clears data when options become null', async () => { const store = createMockStore(); + const { Wrapper } = createWrapper(store); (Engine.context.RampsController.getQuotes as jest.Mock).mockResolvedValue( mockQuotesResponse, ); - const { result, rerender } = renderHook( - ({ params }: { params: GetQuotesOptions | null }) => - useRampsQuotes(params), - { - wrapper: wrapper(store), - initialProps: { params: options } as { - params: GetQuotesOptions | null; - }, - }, - ); + const { result, rerender } = renderHook< + ReturnType, + { params: GetQuotesOptions | null } + >(({ params }) => useRampsQuotes(params), { + wrapper: Wrapper, + initialProps: { params: options }, + }); await waitFor(() => { - expect(result.current.data).toEqual(mockQuotesResponse); + expect(result.current.status).toBe('success'); }); rerender({ params: null }); expect(result.current.data).toBeNull(); expect(result.current.loading).toBe(false); + expect(result.current.status).toBe('idle'); expect(result.current.error).toBeNull(); }); }); diff --git a/app/components/UI/Ramp/hooks/useRampsQuotes.ts b/app/components/UI/Ramp/hooks/useRampsQuotes.ts index 63cb622d72e..dfed6f18d40 100644 --- a/app/components/UI/Ramp/hooks/useRampsQuotes.ts +++ b/app/components/UI/Ramp/hooks/useRampsQuotes.ts @@ -1,11 +1,11 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; import type { BuyWidget, QuotesResponse } from '@metamask/ramps-controller'; import type { Quote } from '../types'; import Engine from '../../../../core/Engine'; +import { rampsQueries } from '../queries'; +import type { RampsQueryStatus } from './useRampsPaymentMethods'; -/** - * Options for fetching quotes (matches RampsController.getQuotes). - */ export interface GetQuotesOptions { region?: string; fiat?: string; @@ -19,39 +19,16 @@ export interface GetQuotesOptions { ttl?: number; } -/** - * Result returned by the useRampsQuotes hook. - */ export interface UseRampsQuotesResult { - /** - * Fetches quotes and returns the response. Uses controller cache; callers manage response in local state. - */ getQuotes: (options: GetQuotesOptions) => Promise; - /** - * Fetches the widget data from a quote for redirect providers. - * Makes a request to the buyURL endpoint to get the actual provider widget URL and order ID. - * @param quote - The quote to fetch the widget URL from. - * @returns Promise resolving to the full BuyWidget (url, browser, orderId), or null if not available. - */ getBuyWidgetData: (quote: Quote) => Promise; - /** Fetched quotes response when options is used. Null when not fetching or fetch skipped. */ data: QuotesResponse | null; - /** True while a fetch is in progress. Reset when fetch settles, unless the effect was cancelled (component unmounted). */ loading: boolean; - /** Error message when fetch rejects. */ + status: RampsQueryStatus; + isSuccess: boolean; error: string | null; } -/** - * Hook to get quote-related functions from RampsController. - * Components call getQuotes() and manage quotes/selection locally. - * - * When options is provided, runs an effect to fetch quotes and returns data, loading, and error. - * Loading is reset when the fetch settles unless the effect was cancelled (avoids setState on unmounted component). - * - * @param options - GetQuotesOptions to fetch, or null/undefined to skip fetch. - * @returns getQuotes, getBuyWidgetData, and when options used: data, loading, error. - */ export function useRampsQuotes( options?: GetQuotesOptions | null, ): UseRampsQuotesResult { @@ -60,59 +37,54 @@ export function useRampsQuotes( [], ); - const getBuyWidgetData = useCallback( - (quote: Quote) => Engine.context.RampsController.getBuyWidgetData(quote), - [], + const getBuyWidgetData = useCallback((quote: Quote) => { + const ramps = Engine.context + .RampsController as typeof Engine.context.RampsController & { + getBuyWidgetData: (q: Quote) => Promise; + }; + return ramps.getBuyWidgetData(quote); + }, []); + + const queryEnabled = Boolean( + options?.assetId && options.walletAddress && options.amount > 0, ); - const [data, setData] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + const quotesQuery = useQuery({ + ...rampsQueries.quotes.options({ + assetId: options?.assetId, + amount: options?.amount ?? 0, + walletAddress: options?.walletAddress ?? '', + redirectUrl: options?.redirectUrl, + paymentMethods: options?.paymentMethods, + providers: options?.providers, + forceRefresh: options?.forceRefresh, + ttl: options?.ttl, + }), + enabled: queryEnabled, + }); - useEffect(() => { - if (options == null) { - setData(null); - setLoading(false); - setError(null); - return; + const status = useMemo(() => { + if (!queryEnabled) { + return 'idle'; } - - let cancelled = false; - setLoading(true); - setData(null); - setError(null); - - getQuotes(options) - .then((response) => { - if (!cancelled) { - setData(response); - } - }) - .catch((err) => { - if (!cancelled) { - setData(null); - setError( - err instanceof Error ? err.message : 'Failed to fetch quotes', - ); - } - }) - .finally(() => { - if (!cancelled) { - setLoading(false); - } - }); - - return () => { - cancelled = true; - }; - }, [options, getQuotes]); + if (quotesQuery.isPending) { + return 'loading'; + } + if (quotesQuery.isError) { + return 'error'; + } + return 'success'; + }, [queryEnabled, quotesQuery.isError, quotesQuery.isPending]); return { getQuotes, getBuyWidgetData, - data, - loading, - error, + data: quotesQuery.data ?? null, + loading: status === 'loading', + status, + isSuccess: status === 'success', + error: + quotesQuery.error instanceof Error ? quotesQuery.error.message : null, }; } diff --git a/app/components/UI/Ramp/queries/index.ts b/app/components/UI/Ramp/queries/index.ts new file mode 100644 index 00000000000..93c0650976f --- /dev/null +++ b/app/components/UI/Ramp/queries/index.ts @@ -0,0 +1,16 @@ +import { + rampsPaymentMethodsKeys, + rampsPaymentMethodsOptions, +} from './paymentMethods'; +import { rampsQuotesKeys, rampsQuotesOptions } from './quotes'; + +export const rampsQueries = { + paymentMethods: { + keys: rampsPaymentMethodsKeys, + options: rampsPaymentMethodsOptions, + }, + quotes: { + keys: rampsQuotesKeys, + options: rampsQuotesOptions, + }, +}; diff --git a/app/components/UI/Ramp/queries/paymentMethods.test.ts b/app/components/UI/Ramp/queries/paymentMethods.test.ts new file mode 100644 index 00000000000..1fcd15be431 --- /dev/null +++ b/app/components/UI/Ramp/queries/paymentMethods.test.ts @@ -0,0 +1,44 @@ +import { + rampsPaymentMethodsKeys, + rampsPaymentMethodsOptions, +} from './paymentMethods'; + +describe('rampsPaymentMethodsOptions', () => { + it('creates a stable normalized query key', () => { + expect( + rampsPaymentMethodsKeys.detail({ + regionCode: 'US ', + fiat: ' USD', + assetId: 'eip155:1/slip44:60', + providerId: '/providers/transak', + }), + ).toEqual([ + 'ramps', + 'paymentMethods', + 'us', + 'usd', + 'eip155:1/slip44:60', + '/providers/transak', + ]); + }); + + it('builds query options for payment methods', () => { + const opts = rampsPaymentMethodsOptions({ + regionCode: 'us', + fiat: 'usd', + assetId: 'eip155:1/slip44:60', + providerId: '/providers/transak', + }); + + expect(opts.queryKey).toEqual([ + 'ramps', + 'paymentMethods', + 'us', + 'usd', + 'eip155:1/slip44:60', + '/providers/transak', + ]); + expect(typeof opts.queryFn).toBe('function'); + expect(opts.staleTime).toBe(0); + }); +}); diff --git a/app/components/UI/Ramp/queries/paymentMethods.ts b/app/components/UI/Ramp/queries/paymentMethods.ts new file mode 100644 index 00000000000..33dc6122a46 --- /dev/null +++ b/app/components/UI/Ramp/queries/paymentMethods.ts @@ -0,0 +1,49 @@ +import { queryOptions } from '@tanstack/react-query'; +import type { + PaymentMethod, + PaymentMethodsResponse, +} from '@metamask/ramps-controller'; +import Engine from '../../../../core/Engine'; + +interface PaymentMethodsQueryParams { + regionCode: string; + fiat: string; + assetId: string; + providerId: string; +} + +export const rampsPaymentMethodsKeys = { + all: () => ['ramps', 'paymentMethods'] as const, + detail: ({ + regionCode, + fiat, + assetId, + providerId, + }: PaymentMethodsQueryParams) => + [ + ...rampsPaymentMethodsKeys.all(), + regionCode.trim().toLowerCase(), + fiat.trim().toLowerCase(), + assetId, + providerId, + ] as const, +}; + +export const rampsPaymentMethodsOptions = (params: PaymentMethodsQueryParams) => + queryOptions({ + queryKey: rampsPaymentMethodsKeys.detail(params), + queryFn: async (): Promise => { + const response: PaymentMethodsResponse = + await Engine.context.RampsController.getPaymentMethods( + params.regionCode, + { + fiat: params.fiat, + assetId: params.assetId, + provider: params.providerId, + }, + ); + + return response.payments; + }, + staleTime: 0, + }); diff --git a/app/components/UI/Ramp/queries/quotes.test.ts b/app/components/UI/Ramp/queries/quotes.test.ts new file mode 100644 index 00000000000..fe6d6c7a742 --- /dev/null +++ b/app/components/UI/Ramp/queries/quotes.test.ts @@ -0,0 +1,46 @@ +import { rampsQuotesKeys, rampsQuotesOptions } from './quotes'; + +describe('rampsQuotesOptions', () => { + it('creates a stable query key for quotes', () => { + expect( + rampsQuotesKeys.detail({ + assetId: 'eip155:1/slip44:60', + amount: 100, + walletAddress: '0x123', + paymentMethods: ['/payments/card'], + providers: ['/providers/transak'], + }), + ).toEqual([ + 'ramps', + 'quotes', + 'eip155:1/slip44:60', + 100, + '0x123', + '/payments/card', + '/providers/transak', + ]); + }); + + it('builds query options for quotes', () => { + const opts = rampsQuotesOptions({ + assetId: 'eip155:1/slip44:60', + amount: 100, + walletAddress: '0x123', + paymentMethods: ['/payments/card'], + providers: ['/providers/transak'], + forceRefresh: true, + }); + + expect(opts.queryKey).toEqual([ + 'ramps', + 'quotes', + 'eip155:1/slip44:60', + 100, + '0x123', + '/payments/card', + '/providers/transak', + ]); + expect(typeof opts.queryFn).toBe('function'); + expect(opts.staleTime).toBe(0); + }); +}); diff --git a/app/components/UI/Ramp/queries/quotes.ts b/app/components/UI/Ramp/queries/quotes.ts new file mode 100644 index 00000000000..470547c856c --- /dev/null +++ b/app/components/UI/Ramp/queries/quotes.ts @@ -0,0 +1,46 @@ +import { queryOptions } from '@tanstack/react-query'; +import type { QuotesResponse } from '@metamask/ramps-controller'; +import type { GetQuotesOptions } from '../hooks/useRampsQuotes'; +import Engine from '../../../../core/Engine'; + +type RampsQuotesQueryParams = Pick< + GetQuotesOptions, + | 'assetId' + | 'amount' + | 'walletAddress' + | 'redirectUrl' + | 'forceRefresh' + | 'ttl' + | 'paymentMethods' + | 'providers' +>; + +export const rampsQuotesKeys = { + all: () => ['ramps', 'quotes'] as const, + detail: (params: RampsQuotesQueryParams) => + [ + ...rampsQuotesKeys.all(), + params.assetId ?? '', + params.amount, + params.walletAddress, + (params.paymentMethods ?? []).join(','), + (params.providers ?? []).join(','), + ] as const, +}; + +export const rampsQuotesOptions = (params: RampsQuotesQueryParams) => + queryOptions({ + queryKey: rampsQuotesKeys.detail(params), + queryFn: async (): Promise => + Engine.context.RampsController.getQuotes({ + assetId: params.assetId, + amount: params.amount, + walletAddress: params.walletAddress, + redirectUrl: params.redirectUrl, + paymentMethods: params.paymentMethods, + providers: params.providers, + forceRefresh: params.forceRefresh, + ttl: params.ttl, + }), + staleTime: 0, + }); diff --git a/app/components/UI/Ramp/routes.tsx b/app/components/UI/Ramp/routes.tsx index 19e56f3e73c..915d408bd6e 100644 --- a/app/components/UI/Ramp/routes.tsx +++ b/app/components/UI/Ramp/routes.tsx @@ -1,5 +1,7 @@ import React, { useEffect } from 'react'; +import { QueryClientProvider } from '@tanstack/react-query'; import { createStackNavigator } from '@react-navigation/stack'; +import reactQueryService from '../../../core/ReactQueryService/ReactQueryService'; import Routes from '../../../constants/navigation/Routes'; import TokenSelection from './Views/TokenSelection'; import BuildQuote from './Views/BuildQuote'; @@ -141,23 +143,25 @@ const TokenListRoutes = () => { }, []); return ( - - - - + + + + + + ); }; diff --git a/app/components/UI/TokenDetails/hooks/useTokenActions.test.ts b/app/components/UI/TokenDetails/hooks/useTokenActions.test.ts index f005da54278..d974d2b6f52 100644 --- a/app/components/UI/TokenDetails/hooks/useTokenActions.test.ts +++ b/app/components/UI/TokenDetails/hooks/useTokenActions.test.ts @@ -301,9 +301,10 @@ describe('useTokenActions', () => { const expectedAssetId = 'eip155:1/erc20:0x6B175474E89094C44Da98b954EedeAC495271d0F'; expect(mockGoToBuy).toHaveBeenCalledTimes(1); - expect(mockGoToBuy).toHaveBeenCalledWith({ - assetId: expectedAssetId, - }); + expect(mockGoToBuy).toHaveBeenCalledWith( + { assetId: expectedAssetId }, + { buyFlowOrigin: 'tokenInfo' }, + ); expect(mockCreateEventBuilder).toHaveBeenCalledWith( MetaMetricsEvents.ACTION_BUTTON_CLICKED, @@ -453,9 +454,10 @@ describe('useTokenActions', () => { expect(mockIsCaipAssetType).toHaveBeenCalledWith(solanaToken.address); expect(mockFormatAddressToAssetId).not.toHaveBeenCalled(); - expect(mockGoToBuy).toHaveBeenCalledWith({ - assetId: solanaToken.address, - }); + expect(mockGoToBuy).toHaveBeenCalledWith( + { assetId: solanaToken.address }, + { buyFlowOrigin: 'tokenInfo' }, + ); }); it('uses token.address directly for trending non-EVM tokens with CAIP address', () => { @@ -488,9 +490,10 @@ describe('useTokenActions', () => { trendingSolanaToken.address, ); expect(mockFormatAddressToAssetId).not.toHaveBeenCalled(); - expect(mockGoToBuy).toHaveBeenCalledWith({ - assetId: trendingSolanaToken.address, - }); + expect(mockGoToBuy).toHaveBeenCalledWith( + { assetId: trendingSolanaToken.address }, + { buyFlowOrigin: 'tokenInfo' }, + ); }); it('uses formatAddressToAssetId for EVM tokens with hex address', () => { @@ -532,9 +535,10 @@ describe('useTokenActions', () => { evmToken.address, evmToken.chainId, ); - expect(mockGoToBuy).toHaveBeenCalledWith({ - assetId: expectedAssetId, - }); + expect(mockGoToBuy).toHaveBeenCalledWith( + { assetId: expectedAssetId }, + { buyFlowOrigin: 'tokenInfo' }, + ); }); it('uses formatAddressToAssetId for trending EVM tokens', () => { @@ -574,9 +578,10 @@ describe('useTokenActions', () => { trendingEvmToken.address, trendingEvmToken.chainId, ); - expect(mockGoToBuy).toHaveBeenCalledWith({ - assetId: expectedAssetId, - }); + expect(mockGoToBuy).toHaveBeenCalledWith( + { assetId: expectedAssetId }, + { buyFlowOrigin: 'tokenInfo' }, + ); }); it('passes undefined assetId when formatAddressToAssetId throws an error', () => { @@ -594,9 +599,10 @@ describe('useTokenActions', () => { result.current.handleBuyPress(); - expect(mockGoToBuy).toHaveBeenCalledWith({ - assetId: undefined, - }); + expect(mockGoToBuy).toHaveBeenCalledWith( + { assetId: undefined }, + { buyFlowOrigin: 'tokenInfo' }, + ); }); it('passes undefined assetId when formatAddressToAssetId returns null', () => { @@ -612,9 +618,10 @@ describe('useTokenActions', () => { result.current.handleBuyPress(); - expect(mockGoToBuy).toHaveBeenCalledWith({ - assetId: undefined, - }); + expect(mockGoToBuy).toHaveBeenCalledWith( + { assetId: undefined }, + { buyFlowOrigin: 'tokenInfo' }, + ); }); }); diff --git a/app/components/UI/TokenDetails/hooks/useTokenActions.ts b/app/components/UI/TokenDetails/hooks/useTokenActions.ts index 45fc936fa1d..78feb0d4f30 100644 --- a/app/components/UI/TokenDetails/hooks/useTokenActions.ts +++ b/app/components/UI/TokenDetails/hooks/useTokenActions.ts @@ -325,7 +325,7 @@ export const useTokenActions = ({ .build(), ); - goToBuy({ assetId }); + goToBuy({ assetId }, { buyFlowOrigin: 'tokenInfo' }); }, [ trackEvent, createEventBuilder, @@ -434,7 +434,7 @@ export const useTokenActions = ({ assetId = undefined; } - goToBuy({ assetId }); + goToBuy({ assetId }, { buyFlowOrigin: 'tokenInfo' }); return; } diff --git a/app/components/Views/Homepage/Sections/Tokens/components/PopularTokenRow.test.tsx b/app/components/Views/Homepage/Sections/Tokens/components/PopularTokenRow.test.tsx index 89bf812d7d3..f5d182a12d3 100644 --- a/app/components/Views/Homepage/Sections/Tokens/components/PopularTokenRow.test.tsx +++ b/app/components/Views/Homepage/Sections/Tokens/components/PopularTokenRow.test.tsx @@ -268,9 +268,12 @@ describe('PopularTokenRow', () => { fireEvent.press(screen.getByText('Buy')); - expect(mockGoToBuy).toHaveBeenCalledWith({ - assetId: 'eip155:1/erc20:0x1234567890abcdef1234567890abcdef12345678', - }); + expect(mockGoToBuy).toHaveBeenCalledWith( + { + assetId: 'eip155:1/erc20:0x1234567890abcdef1234567890abcdef12345678', + }, + { buyFlowOrigin: 'homeTokenList' }, + ); }); it('fires Ramps Button Clicked analytics event when Buy is pressed', () => { diff --git a/app/components/Views/Homepage/Sections/Tokens/components/PopularTokenRow.tsx b/app/components/Views/Homepage/Sections/Tokens/components/PopularTokenRow.tsx index 59ba410a025..e06443b290a 100644 --- a/app/components/Views/Homepage/Sections/Tokens/components/PopularTokenRow.tsx +++ b/app/components/Views/Homepage/Sections/Tokens/components/PopularTokenRow.tsx @@ -168,7 +168,7 @@ const PopularTokenRow: React.FC = ({ token }) => { const handleBuy = useCallback(() => { trackBuyButtonClicked(); - goToBuy({ assetId: token.assetId }); + goToBuy({ assetId: token.assetId }, { buyFlowOrigin: 'homeTokenList' }); }, [trackBuyButtonClicked, goToBuy, token.assetId]); const priceDisplay = useMemo(() => { diff --git a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx index 7878a6d99e7..c4f532eec9d 100644 --- a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx +++ b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx @@ -88,6 +88,19 @@ jest.mock('../../../../../UI/Ramp/hooks/useRampNavigation', () => ({ }), })); +jest.mock('../../../../../UI/Ramp/hooks/useRampsPaymentMethods', () => ({ + useRampsPaymentMethods: () => ({ + paymentMethods: [], + selectedPaymentMethod: null, + setSelectedPaymentMethod: jest.fn(), + isFetching: false, + isLoading: false, + status: 'idle', + isSuccess: false, + error: null, + }), +})); + const TOKEN_ADDRESS_MOCK = '0x123' as Hex; const CHAIN_ID_MOCK = '0x1' as Hex; From e0486caecf2ca17c3951d18ebefe0db9614cebbc Mon Sep 17 00:00:00 2001 From: jeremy-consensys Date: Thu, 19 Mar 2026 19:43:46 +0700 Subject: [PATCH 131/206] fix: update MegaETH explorer display name to Megaeth Explorer (#27592) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Updates the MegaETH block explorer button text from "View on Megaeth" to "View on Megaeth Explorer" in the Receive flow. Adds a `BLOCK_EXPLORER_NAME_OVERRIDES` map in `app/util/networks/index.js` that overrides the auto-derived explorer name for `megaeth.blockscout.com`. Only MegaETH is affected; all other networks continue using the existing hostname-based auto-derivation logic. ## **Changelog** CHANGELOG entry: Fixed MegaETH explorer button to display "View on Megaeth Explorer" instead of "View on Megaeth" ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: MegaETH explorer button wording Scenario: user views receive address for ETH on MegaETH Given user has MegaETH network added When user selects ETH on MegaETH and taps Receive Then the button at the bottom reads "View on Megaeth Explorer" Scenario: other networks are unaffected Given user has other networks added When user selects a token on another network and taps Receive Then the explorer button text remains unchanged ``` Screenshots/Recordings Before image "View on Megaeth" After image "View on Megaeth Explorer" Pre-merge author checklist - [x] I've followed https://github.com/MetaMask/contributor-docs and 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 https://jsdoc.app/ format if applicable - [x] I've applied the right labels on the PR (see https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md). Not equired for external contributors. 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** > Low risk string-only change: adds a hostname-specific override for one block explorer name, leaving existing hostname-derived naming for all other explorers unchanged. > > **Overview** > Updates `getBlockExplorerName` to support hostname-specific display-name overrides via a new `BLOCK_EXPLORER_NAME_OVERRIDES` map. > > This ensures `megaeth.blockscout.com` is shown as **"MegaETH Explorer"** (e.g., in “View on …” buttons) instead of relying on the default subdomain-based capitalization. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit dad1e7eeaf19e40c44a2200c94bfac655bdaf068. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/util/networks/index.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/app/util/networks/index.js b/app/util/networks/index.js index 62df848cc7f..7ad53247a1e 100644 --- a/app/util/networks/index.js +++ b/app/util/networks/index.js @@ -496,6 +496,14 @@ export function compareRpcUrls(rpcOne, rpcTwo) { return false; } +/** + * Hostname-to-display-name overrides for block explorers whose + * auto-derived name (first subdomain, capitalised) is not ideal. + */ +const BLOCK_EXPLORER_NAME_OVERRIDES = { + 'megaeth.blockscout.com': 'MegaETH Explorer', +}; + /** * From block explorer url, get rendereable name or undefined * @@ -505,6 +513,14 @@ export function getBlockExplorerName(blockExplorerUrl) { if (!blockExplorerUrl) return undefined; const hostname = new URL(blockExplorerUrl).hostname; if (!hostname) return undefined; + if ( + Object.prototype.hasOwnProperty.call( + BLOCK_EXPLORER_NAME_OVERRIDES, + hostname, + ) + ) { + return BLOCK_EXPLORER_NAME_OVERRIDES[hostname]; + } const tempBlockExplorerName = fastSplit(hostname); if (!tempBlockExplorerName || !tempBlockExplorerName[0]) return undefined; return ( From 9f52a9cb20716ecc9153354cfc9c77f7ae8e4fb0 Mon Sep 17 00:00:00 2001 From: "Matt D." <85914066+geositta@users.noreply.github.com> Date: Thu, 19 Mar 2026 07:56:28 -0500 Subject: [PATCH 132/206] fix(perps): resolve blank activity tabs from perps home (#27509) ## **Description** This PR fixes intermittent Activity tab rendering issues and tab-switch latency when entering Activity from Perps Home (Perps Home -> Activity -> back -> Activity, and subsequent tab switches). Reason for the change - Users could see a selected Activity tab with no rows rendered, especially on repeated entry from Perps. - This was caused by timing issues between initial tab selection and deferred tab content loading. Improvement / solution - Updated ActivityView to set the initial tab index from route params on mount (instead of relying on post-mount tab switching), then clear redirect params on focus. - Hardened shared TabsList tab loading with InteractionManager scheduling plus a fallback timeout, so tab content still loads when interaction callbacks are delayed. - Added a stale callback safeguard in TabsList so an older interaction callback cannot cancel a newer tab's pending load. - Added/updated regression tests for async tab-loading behavior, stale-callback handling, and repeated Perps entry navigation paths. Out of scope / follow-up - This PR intentionally does not fix the Predictions tab infinite spinner. - The Predictions behavior appears to be a separate feature-specific loading lifecycle issue and is deferred to a follow-up with the Predictions owners. ## **Changelog** CHANGELOG entry: Fixed a bug where Perps activity could appear blank after reopening the Activity screen from Perps home. ## **Related issues** https://consensyssoftware.atlassian.net/browse/TAT-2614 Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ``` Feature: Activity tab loading and switching from Perps entry Scenario: user re-enters Activity from Perps and sees data Given the user is on Perps Home with account activity available When user opens Activity, goes back to Perps Home, and opens Activity again Then the selected Activity tab loads its rows instead of showing a blank body Scenario: user switches tabs in Activity from Perps entry Given the user opened Activity from Perps Home When user taps Transactions, Transfers, and Perps tabs Then each tab becomes selected promptly and shows loading/content without a multi-second delay Scenario: user opens Perps tab repeatedly without blank state Given the user is on Activity with Perps tab enabled When user switches away from Perps and then back to Perps multiple times Then Perps activity content continues to render reliably on each return Scenario: user does not hit tab-switch crash Given the user is on Activity opened from Perps Home When user taps between Activity tabs Then the app does not crash and no red-screen error is shown ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/b425e3dd-37c1-45f8-bf06-62e176bc4046 ### **After** https://github.com/user-attachments/assets/6a92a910-4fad-44d4-9646-dfef510cc138 ## **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. ## **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** > Touches shared `TabsList` loading/scheduling and Activity/Perps screen focus behavior; mistakes could reintroduce blank tabs or regress tab switching/refresh timing. > > **Overview** > Fixes intermittent **blank Activity tab content** when entering Activity from Perps by selecting the correct tab *at mount time* (`ActivityView` now derives `initialActiveIndex` from route params and clears redirect params on focus, instead of imperatively switching tabs post-mount). > > Hardens `TabsList` on-demand rendering by scheduling active-tab loading via `InteractionManager` **with a 250ms fallback timeout** and centralized cancellation, improving reliability when `runAfterInteractions` callbacks are delayed/missed. > > Updates Perps Activity content behavior: `PerpsTransactionsView` now **refetches on screen focus** and shows a skeleton when disconnected/focus-refreshing with no cached rows; tests were expanded/adjusted across tabs and Perps views to cover the new timing and loading guarantees. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 36e8a2a3ee18424d207b937791c820e34be9d508. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../Tabs/TabsList/TabsList.test.tsx | 259 ++++++++++++++---- .../Tabs/TabsList/TabsList.tsx | 120 ++++---- .../Views/PerpsActiveTraderFlow.view.test.tsx | 2 +- .../PerpsTransactionsView.test.tsx | 69 ++++- .../PerpsTransactionsView.tsx | 71 +++-- .../PerpsMarketTabs/PerpsMarketTabs.test.tsx | 22 +- .../UI/Perps/types/transactionHistory.ts | 2 - app/components/Views/ActivityView/index.js | 51 ++-- .../Views/ActivityView/index.test.tsx | 59 +++- 9 files changed, 495 insertions(+), 160 deletions(-) diff --git a/app/component-library/components-temp/Tabs/TabsList/TabsList.test.tsx b/app/component-library/components-temp/Tabs/TabsList/TabsList.test.tsx index 35efd2dba34..70283d2d1ce 100644 --- a/app/component-library/components-temp/Tabs/TabsList/TabsList.test.tsx +++ b/app/component-library/components-temp/Tabs/TabsList/TabsList.test.tsx @@ -1,6 +1,6 @@ // Third party dependencies. import React from 'react'; -import { render, fireEvent, act, waitFor } from '@testing-library/react-native'; +import { render, fireEvent, act } from '@testing-library/react-native'; import { View, InteractionManager } from 'react-native'; // External dependencies. @@ -71,7 +71,7 @@ describe('TabsList', () => { , ); - // Assert - Active tab loads via InteractionManager + // Assert - active tab is loaded immediately expect(getByText('Tokens Content')).toBeOnTheScreen(); // Other tabs should not be loaded yet (on-demand loading) @@ -82,10 +82,7 @@ describe('TabsList', () => { fireEvent.press(getAllByText('NFTs')[0]); }); - // Wait for the deferred loading to complete - await waitFor(() => { - expect(getByText('NFTs Content')).toBeOnTheScreen(); - }); + expect(getByText('NFTs Content')).toBeOnTheScreen(); }); it('switches tab content when tab is pressed', () => { @@ -198,6 +195,68 @@ describe('TabsList', () => { expect(getByText('Tab 2 Content')).toBeOnTheScreen(); }); + it('goToTabIndex loads target tab immediately', async () => { + const ref = React.createRef(); + const tabs = ['Tab 1', 'Tab 2']; + + const { getByText } = render( + + {tabs.map((label, index) => ( + + {label} Content + + ))} + , + ); + + // Act + await act(async () => { + ref.current?.goToTabIndex(1); + }); + + // Assert + expect(getByText('Tab 2 Content')).toBeOnTheScreen(); + }); + + it('renders initial active tab immediately', () => { + // Act + const { getByText } = render( + + + Content 1 + + + Content 2 + + , + ); + + // Assert + expect(getByText('Content 1')).toBeOnTheScreen(); + }); + + it('loads target tab on user press immediately', async () => { + // Arrange + const { getAllByText, getByText } = render( + + + Content 1 + + + Content 2 + + , + ); + + // Act + await act(async () => { + fireEvent.press(getAllByText('Tab 2')[0]); + }); + + // Assert + expect(getByText('Content 2')).toBeOnTheScreen(); + }); + it('exposes getCurrentIndex method via ref', () => { // Arrange const ref = React.createRef(); @@ -454,17 +513,8 @@ describe('TabsList', () => { // even when the tab was temporarily removed and re-added }); - describe('Deferred Content Loading', () => { - it('loads active tab content via InteractionManager', () => { - // Arrange - const mockRunAfterInteractions = jest.fn((callback) => { - callback(); - return { cancel: jest.fn() }; - }); - (InteractionManager.runAfterInteractions as jest.Mock).mockImplementation( - mockRunAfterInteractions, - ); - + describe('Content Loading', () => { + it('loads active tab content via InteractionManager scheduling', () => { // Act const { getByText } = render( @@ -477,9 +527,9 @@ describe('TabsList', () => { , ); - // Assert - InteractionManager used for initial tab load - expect(mockRunAfterInteractions).toHaveBeenCalled(); + // Assert expect(getByText('Content 1')).toBeOnTheScreen(); + expect(InteractionManager.runAfterInteractions).toHaveBeenCalled(); }); it('defers loading of inactive tabs until switched to', () => { @@ -499,17 +549,8 @@ describe('TabsList', () => { expect(queryByText('Content 2')).toBeNull(); }); - it('cancels pending content load when switching tabs quickly', async () => { + it('switches quickly while keeping loads scheduled and stable', async () => { // Arrange - const mockCancel = jest.fn(); - let capturedCallback: (() => void) | null = null; - (InteractionManager.runAfterInteractions as jest.Mock).mockImplementation( - (callback: () => void) => { - capturedCallback = callback; - return { cancel: mockCancel }; - }, - ); - const { getAllByText } = render( @@ -524,29 +565,20 @@ describe('TabsList', () => { , ); - // Act - Switch tabs quickly before interaction completes + // Act await act(async () => { fireEvent.press(getAllByText('Tab 2')[0]); fireEvent.press(getAllByText('Tab 3')[0]); - if (capturedCallback) { - capturedCallback(); - } }); - // Assert - Previous interaction was cancelled - expect(mockCancel).toHaveBeenCalled(); + // Assert + expect(InteractionManager.runAfterInteractions).toHaveBeenCalled(); }); - it('loads already-loaded tabs immediately without InteractionManager delay', async () => { + it('does not re-schedule loading for already-loaded tabs', async () => { // Arrange - const mockRunAfterInteractions = jest.fn((callback) => { - callback(); - return { cancel: jest.fn() }; - }); - (InteractionManager.runAfterInteractions as jest.Mock).mockImplementation( - mockRunAfterInteractions, - ); - + const mockRunAfterInteractions = + InteractionManager.runAfterInteractions as jest.Mock; const { getAllByText, getByText } = render( @@ -563,7 +595,7 @@ describe('TabsList', () => { fireEvent.press(getAllByText('Tab 2')[0]); }); - const callCountAfterFirstSwitch = + const callCountAfterFirstLoad = mockRunAfterInteractions.mock.calls.length; // Act - Switch back to Tab 1 (already loaded) @@ -571,11 +603,144 @@ describe('TabsList', () => { fireEvent.press(getAllByText('Tab 1')[0]); }); - // Assert - Already loaded tab displays immediately without new InteractionManager call + // Assert expect(getByText('Content 1')).toBeOnTheScreen(); expect(mockRunAfterInteractions).toHaveBeenCalledTimes( - callCountAfterFirstSwitch, + callCountAfterFirstLoad, + ); + }); + + it('stale callback from previous tab does not cancel current tab load', async () => { + jest.useFakeTimers(); + + // Capture callbacks so we can fire them manually + let capturedCallbackA: (() => void) | null = null; + + (InteractionManager.runAfterInteractions as jest.Mock).mockImplementation( + (cb: () => void) => { + // Capture only the first callback (Tab 1); don't auto-invoke any + if (!capturedCallbackA) capturedCallbackA = cb; + return { cancel: jest.fn() }; + }, ); + + try { + const { getAllByText, getByText, queryByText } = render( + + + Content 1 + + + Content 2 + + , + ); + + // Tab 1 scheduled but not yet loaded (InteractionManager not fired) + expect(queryByText('Content 1')).toBeNull(); + + // Switch to Tab 2 before Tab 1's callback fires + await act(async () => { + fireEvent.press(getAllByText('Tab 2')[0]); + }); + + // Now fire Tab 1's stale callback — this must NOT cancel Tab 2's fallback + await act(async () => { + capturedCallbackA?.(); + }); + + // Tab 2 must still load via its fallback timeout + await act(async () => { + jest.advanceTimersByTime(250); + }); + expect(getByText('Content 2')).toBeOnTheScreen(); + } finally { + jest.useRealTimers(); + } + }); + + it('uses fallback timeout if InteractionManager callback does not run', async () => { + jest.useFakeTimers(); + (InteractionManager.runAfterInteractions as jest.Mock).mockImplementation( + () => ({ cancel: jest.fn() }), + ); + + try { + const { getAllByText, getByText, queryByText } = render( + + + Content 1 + + + Content 2 + + , + ); + + expect(queryByText('Content 1')).toBeNull(); + expect(queryByText('Content 2')).toBeNull(); + + await act(async () => { + jest.advanceTimersByTime(250); + }); + expect(getByText('Content 1')).toBeOnTheScreen(); + + await act(async () => { + fireEvent.press(getAllByText('Tab 2')[0]); + }); + expect(queryByText('Content 2')).toBeNull(); + + await act(async () => { + jest.advanceTimersByTime(250); + }); + expect(getByText('Content 2')).toBeOnTheScreen(); + } finally { + jest.useRealTimers(); + } + }); + + it('does not lose scheduled load across a rerender', async () => { + jest.useFakeTimers(); + (InteractionManager.runAfterInteractions as jest.Mock).mockImplementation( + () => ({ cancel: jest.fn() }), + ); + + try { + const renderTabs = () => ( + + + Content 1 + + + Content 2 + + + ); + + const { getAllByText, getByText, queryByText, rerender } = + render(renderTabs()); + + expect(queryByText('Content 1')).toBeNull(); + + await act(async () => { + jest.advanceTimersByTime(250); + }); + expect(getByText('Content 1')).toBeOnTheScreen(); + + await act(async () => { + fireEvent.press(getAllByText('Tab 2')[0]); + }); + expect(queryByText('Content 2')).toBeNull(); + + rerender(renderTabs()); + + await act(async () => { + jest.advanceTimersByTime(250); + }); + expect(getByText('Content 2')).toBeOnTheScreen(); + } finally { + jest.useRealTimers(); + } }); }); diff --git a/app/component-library/components-temp/Tabs/TabsList/TabsList.tsx b/app/component-library/components-temp/Tabs/TabsList/TabsList.tsx index 737c3f21783..63de753e4c7 100644 --- a/app/component-library/components-temp/Tabs/TabsList/TabsList.tsx +++ b/app/component-library/components-temp/Tabs/TabsList/TabsList.tsx @@ -16,6 +16,8 @@ import { InteractionManager } from 'react-native'; import TabsBar from '../TabsBar'; import { TabsListProps, TabsListRef, TabItem } from './TabsList.types'; +const TAB_LOAD_FALLBACK_TIMEOUT_MS = 250; + const TabsList = forwardRef( ( { @@ -29,10 +31,6 @@ const TabsList = forwardRef( }, ref, ) => { - const [activeIndex, setActiveIndex] = useState(initialActiveIndex); - const [loadedTabs, setLoadedTabs] = useState>(new Set()); - const interactionHandleRef = useRef<{ cancel: () => void } | null>(null); - const tabs: TabItem[] = useMemo( () => React.Children.toArray(children) @@ -56,40 +54,83 @@ const TabsList = forwardRef( [children], ); - // Cache only the actively viewed tab (no preloading of adjacent tabs) - // Use InteractionManager to defer content loading until after animations complete - useEffect(() => { - if (activeIndex >= 0 && activeIndex < tabs.length) { - if (interactionHandleRef.current) { - interactionHandleRef.current.cancel(); + const normalizeTabIndex = useCallback( + (tabIndex: number) => { + if ( + tabIndex >= 0 && + tabIndex < tabs.length && + !tabs[tabIndex]?.isDisabled + ) { + return tabIndex; } + const firstEnabled = tabs.findIndex((tab) => !tab.isDisabled); + return firstEnabled >= 0 ? firstEnabled : -1; + }, + [tabs], + ); + + const [activeIndex, setActiveIndex] = useState(() => + normalizeTabIndex(initialActiveIndex), + ); + const [loadedTabs, setLoadedTabs] = useState>(new Set()); + const interactionHandleRef = useRef<{ cancel?: () => void } | null>(null); + const fallbackTimeoutRef = useRef | null>( + null, + ); - const isAlreadyLoaded = loadedTabs.has(activeIndex); + // Cancel any pending InteractionManager + fallback timeout + const cancelPendingLoad = useCallback(() => { + if (interactionHandleRef.current) { + interactionHandleRef.current.cancel?.(); + interactionHandleRef.current = null; + } + if (fallbackTimeoutRef.current) { + clearTimeout(fallbackTimeoutRef.current); + fallbackTimeoutRef.current = null; + } + }, []); - if (isAlreadyLoaded) { + // Schedule tab content loading via InteractionManager with a fallback timeout + // in case the InteractionManager callback never fires (observed in repeated + // Perps Home -> Activity navigation) + useEffect(() => { + if (activeIndex >= 0 && activeIndex < tabs.length) { + cancelPendingLoad(); + + if (loadedTabs.has(activeIndex)) { return; } - const handle = InteractionManager.runAfterInteractions(() => { + const markLoaded = () => { setLoadedTabs((prev) => { - const newLoadedTabs = new Set(prev); - newLoadedTabs.add(activeIndex); - return newLoadedTabs.size !== prev.size ? newLoadedTabs : prev; + if (prev.has(activeIndex)) return prev; + const next = new Set(prev); + next.add(activeIndex); + return next; }); - }); + }; + + interactionHandleRef.current = + InteractionManager.runAfterInteractions(markLoaded); - interactionHandleRef.current = handle; + fallbackTimeoutRef.current = setTimeout( + markLoaded, + TAB_LOAD_FALLBACK_TIMEOUT_MS, + ); } return () => { - if (interactionHandleRef.current) { - interactionHandleRef.current.cancel(); - } + cancelPendingLoad(); }; - }, [activeIndex, tabs.length, loadedTabs]); + }, [activeIndex, tabs.length, loadedTabs, cancelPendingLoad]); useEffect(() => { - const currentActiveTabKey = tabs[activeIndex]?.key; + const currentActiveTabKey = + activeIndex >= 0 && activeIndex < tabs.length + ? tabs[activeIndex]?.key + : undefined; + + let nextIndex = -1; if (currentActiveTabKey && tabs.length > 0) { const newIndexForCurrentTab = tabs.findIndex( @@ -97,30 +138,20 @@ const TabsList = forwardRef( ); if ( newIndexForCurrentTab >= 0 && - !tabs[newIndexForCurrentTab].isDisabled && - newIndexForCurrentTab !== activeIndex + !tabs[newIndexForCurrentTab].isDisabled ) { - setActiveIndex(newIndexForCurrentTab); - return; + nextIndex = newIndexForCurrentTab; } } - if ( - activeIndex >= 0 && - activeIndex < tabs.length && - !tabs[activeIndex]?.isDisabled - ) { - return; + if (nextIndex === -1) { + nextIndex = normalizeTabIndex(initialActiveIndex); } - const targetTab = tabs[initialActiveIndex]; - if (targetTab && !targetTab.isDisabled) { - setActiveIndex(initialActiveIndex); - } else { - const firstEnabledIndex = tabs.findIndex((tab) => !tab.isDisabled); - setActiveIndex(firstEnabledIndex >= 0 ? firstEnabledIndex : -1); + if (nextIndex !== activeIndex) { + setActiveIndex(nextIndex); } - }, [initialActiveIndex, tabs, activeIndex]); + }, [activeIndex, initialActiveIndex, normalizeTabIndex, tabs]); const handleTabPress = useCallback( (tabIndex: number) => { @@ -136,13 +167,6 @@ const TabsList = forwardRef( setActiveIndex(tabIndex); - if ( - (process.env.JEST_WORKER_ID || process.env.E2E) && - !loadedTabs.has(tabIndex) - ) { - setLoadedTabs((prev) => new Set(prev).add(tabIndex)); - } - if (onChangeTab && tabChanged) { onChangeTab({ i: tabIndex, @@ -150,7 +174,7 @@ const TabsList = forwardRef( }); } }, - [activeIndex, tabs, onChangeTab, loadedTabs], + [activeIndex, tabs, onChangeTab], ); const goToPreviousTab = useCallback(() => { diff --git a/app/components/UI/Perps/Views/PerpsActiveTraderFlow.view.test.tsx b/app/components/UI/Perps/Views/PerpsActiveTraderFlow.view.test.tsx index cbdc19b6f32..b58a8cccdde 100644 --- a/app/components/UI/Perps/Views/PerpsActiveTraderFlow.view.test.tsx +++ b/app/components/UI/Perps/Views/PerpsActiveTraderFlow.view.test.tsx @@ -277,7 +277,7 @@ describe('Active Trader Flow', () => { ).toBeOnTheScreen(); expect(screen.queryAllByText(MARKET_ORDERS)).toHaveLength(0); expect( - screen.getByTestId(PerpsMarketTabsSelectorsIDs.STATISTICS_CONTENT), + await screen.findByTestId(PerpsMarketTabsSelectorsIDs.STATISTICS_CONTENT), ).toBeOnTheScreen(); // ── PHASE 2: Review individual order rows ──────────────────────────── diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.test.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.test.tsx index 1d11e1a0ffd..e718f342707 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.test.tsx @@ -22,6 +22,7 @@ import { createMockAccountsControllerState } from '../../../../../util/test/acco import { mockNetworkState } from '../../../../../util/test/network'; import { TRANSACTION_DETAIL_EVENTS } from '../../../../../core/Analytics/events/transactions'; import { MonetizedPrimitive } from '../../../../../core/Analytics/MetaMetrics.types'; +import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement'; const mockTrackEvent = jest.fn(); const mockAddProperties = jest.fn(); @@ -29,11 +30,16 @@ const mockBuild = jest.fn(() => ({ name: 'test-event' })); const mockCreateEventBuilder = jest.fn(); const mockNavigate = jest.fn(); +const mockRefetchTransactions = jest.fn(); jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), useNavigation: () => ({ navigate: mockNavigate, }), + useFocusEffect: (callback: () => void | (() => void)) => { + const ReactActual = jest.requireActual('react'); + ReactActual.useEffect(() => callback(), [callback]); + }, })); jest.mock('../../hooks', () => ({ @@ -50,6 +56,10 @@ jest.mock('../../hooks/usePerpsAssetsMetadata', () => ({ }), })); +jest.mock('../../hooks/usePerpsMeasurement', () => ({ + usePerpsMeasurement: jest.fn(), +})); + const mockInitialState: DeepPartial = { engine: { backgroundState: { @@ -126,10 +136,14 @@ describe('PerpsTransactionsView', () => { >; const mockUsePerpsEventTracking = usePerpsEventTracking as jest.MockedFunction; + const mockUsePerpsMeasurement = usePerpsMeasurement as jest.MockedFunction< + typeof usePerpsMeasurement + >; beforeEach(() => { jest.clearAllMocks(); mockNavigate.mockClear(); + mockRefetchTransactions.mockClear(); // Set up analytics mock const { useAnalytics } = jest.requireMock( @@ -160,12 +174,14 @@ describe('PerpsTransactionsView', () => { transactions: mockTransactions, isLoading: false, error: null, - refetch: jest.fn(), + refetch: mockRefetchTransactions, }); mockUsePerpsEventTracking.mockReturnValue({ track: jest.fn(), }); + + mockUsePerpsMeasurement.mockImplementation(() => undefined); }); it('should render with filter tabs', () => { @@ -191,6 +207,29 @@ describe('PerpsTransactionsView', () => { }); }); + it('refreshes transaction history when screen becomes focused', async () => { + renderWithProvider(, { + state: mockInitialState, + }); + + await waitFor(() => { + expect(mockRefetchTransactions).toHaveBeenCalled(); + }); + }); + + it('tracks measurement as ready when initial loading is complete', () => { + renderWithProvider(, { + state: mockInitialState, + }); + + expect(mockUsePerpsMeasurement).toHaveBeenCalledWith( + expect.objectContaining({ + conditions: [true], + resetConditions: [], + }), + ); + }); + it('should not load transactions when not connected', () => { mockUsePerpsConnection.mockReturnValue({ isConnected: false, @@ -294,6 +333,34 @@ describe('PerpsTransactionsView', () => { }); }); + it('shows loading skeleton instead of blank list when disconnected with no data', () => { + mockUsePerpsConnection.mockReturnValue({ + isConnected: false, + isConnecting: false, + isInitialized: true, + error: null, + connect: jest.fn(), + disconnect: jest.fn(), + resetError: jest.fn(), + reconnectWithNewContext: jest.fn(), + }); + + mockUsePerpsTransactionHistory.mockReturnValue({ + transactions: [], + isLoading: false, + error: null, + refetch: mockRefetchTransactions, + }); + + const component = renderWithProvider(, { + state: mockInitialState, + }); + + expect( + component.getByTestId('perps-transactions-loading-skeleton'), + ).toBeOnTheScreen(); + }); + it('should handle API errors gracefully', async () => { // Mock hook to return error state mockUsePerpsTransactionHistory.mockReturnValue({ diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.tsx index 36c4ea19ad9..3e2474e4397 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.tsx @@ -1,12 +1,6 @@ -import { useNavigation } from '@react-navigation/native'; +import { useFocusEffect, useNavigation } from '@react-navigation/native'; import { FlashList } from '@shopify/flash-list'; -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; import { RefreshControl, ScrollView, View } from 'react-native'; import { useSelector } from 'react-redux'; import { strings } from '../../../../../../locales/i18n'; @@ -42,7 +36,6 @@ import { FilterTab, ListItem, PerpsTransaction, - PerpsTransactionsViewProps, TransactionSection, } from '../../types/transactionHistory'; import { formatDateSection } from '../../utils/formatUtils'; @@ -51,15 +44,14 @@ import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement'; import { TraceName } from '../../../../../util/trace'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -const PerpsTransactionsView: React.FC = () => { +const PerpsTransactionsView: React.FC = () => { const { styles } = useStyles(styleSheet, {}); const tw = useTailwind(); const navigation = useNavigation(); - // Transaction data is now computed from hooks instead of stored in state - const [flatListData, setFlatListData] = useState([]); const [activeFilter, setActiveFilter] = useState('Trades'); const [refreshing, setRefreshing] = useState(false); + const [isFocusRefreshing, setIsFocusRefreshing] = useState(false); // Ref for FlashList to control scrolling const flashListRef = useRef(null); @@ -179,16 +171,10 @@ const PerpsTransactionsView: React.FC = () => { groupTransactionsByDate, ]); - // Memoized flat data for current filter - prevents re-flattening on every change - const currentFlatListData = useMemo(() => { + const flatListData = useMemo(() => { const currentGrouped = allGroupedTransactions[activeFilter] || []; return flattenGroupedTransactions(currentGrouped, activeFilter); - }, [allGroupedTransactions, activeFilter]); - - // Update state only when needed - much faster tab switching - useEffect(() => { - setFlatListData(currentFlatListData); - }, [allGroupedTransactions, activeFilter, currentFlatListData]); + }, [activeFilter, allGroupedTransactions]); // Note: Removed automatic scroll to top on tab change to allow switching tabs while scrolling @@ -207,7 +193,35 @@ const PerpsTransactionsView: React.FC = () => { } }, [isConnected, refreshTransactions]); - // Initial loading is handled by the hooks themselves + useFocusEffect( + useCallback(() => { + if (!isConnected) { + setIsFocusRefreshing(false); + return; + } + + let isMounted = true; + + const refreshOnFocus = async () => { + setIsFocusRefreshing(true); + try { + await refreshTransactions(); + } catch (error) { + console.warn('Failed to refresh perps transactions on focus:', error); + } finally { + if (isMounted) { + setIsFocusRefreshing(false); + } + } + }; + + refreshOnFocus(); + + return () => { + isMounted = false; + }; + }, [isConnected, refreshTransactions]), + ); const renderFilterTab = useCallback( (tab: FilterTab, index: number) => { @@ -380,9 +394,18 @@ const PerpsTransactionsView: React.FC = () => { // Determine if we should show loading skeleton const isInitialLoading = useMemo( () => - // Show loading if we're connecting or if transaction data is loading - isConnecting || transactionsLoading, - [isConnecting, transactionsLoading], + // Show loading for connection/data fetch states and focus-refresh with no cached rows. + isConnecting || + transactionsLoading || + (!isConnected && flatListData.length === 0) || + (isFocusRefreshing && flatListData.length === 0), + [ + isConnecting, + transactionsLoading, + isConnected, + isFocusRefreshing, + flatListData.length, + ], ); // Track screen load performance - measures time until all data is loaded and UI is interactive diff --git a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.test.tsx b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.test.tsx index c575f805004..0b17436e2c7 100644 --- a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.test.tsx +++ b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.test.tsx @@ -509,7 +509,7 @@ describe('PerpsMarketTabs', () => { expect(onActiveTabChange).toHaveBeenLastCalledWith('position'); }); - it('handles initialTab when data loads after component mount', () => { + it('handles initialTab when data loads after component mount', async () => { const onActiveTabChange = jest.fn(); mockUsePerpsLivePositions.mockReturnValue({ positions: [{ ...mockPosition, symbol: 'BTC' }], @@ -525,9 +525,11 @@ describe('PerpsMarketTabs', () => { />, ); - expect( - getByTestId(PerpsMarketTabsSelectorsIDs.POSITION_CONTENT), - ).toBeDefined(); + await waitFor(() => { + expect( + getByTestId(PerpsMarketTabsSelectorsIDs.POSITION_CONTENT), + ).toBeDefined(); + }); expect(onActiveTabChange).toHaveBeenCalledWith('position'); }); }); @@ -646,7 +648,7 @@ describe('PerpsMarketTabs', () => { }); describe('Order Sorting', () => { - it('sorts orders by detailedOrderType then by orderId', () => { + it('sorts orders by detailedOrderType then by orderId', async () => { const limitOrder = { ...mockOrder, symbol: 'BTC', @@ -682,10 +684,12 @@ describe('PerpsMarketTabs', () => { />, ); - expect(getAllByTestId('mock-perps-open-order-card')).toHaveLength(3); + await waitFor(() => { + expect(getAllByTestId('mock-perps-open-order-card')).toHaveLength(3); + }); }); - it('falls back to orderType when detailedOrderType is missing', () => { + it('falls back to orderType when detailedOrderType is missing', async () => { const orderWithoutDetailedType = { ...mockOrder, symbol: 'BTC', @@ -707,7 +711,9 @@ describe('PerpsMarketTabs', () => { />, ); - expect(getByTestId('mock-perps-open-order-card')).toBeDefined(); + await waitFor(() => { + expect(getByTestId('mock-perps-open-order-card')).toBeDefined(); + }); }); }); diff --git a/app/components/UI/Perps/types/transactionHistory.ts b/app/components/UI/Perps/types/transactionHistory.ts index 7ff2dc9a1f4..fd5851e25e4 100644 --- a/app/components/UI/Perps/types/transactionHistory.ts +++ b/app/components/UI/Perps/types/transactionHistory.ts @@ -99,8 +99,6 @@ export type ListItem = export type FilterTab = 'Trades' | 'Orders' | 'Funding' | 'Deposits'; -export interface PerpsTransactionsViewProps {} - export type PerpsPositionTransactionRouteProp = RouteProp< PerpsNavigationParamList, 'PerpsPositionTransaction' diff --git a/app/components/Views/ActivityView/index.js b/app/components/Views/ActivityView/index.js index 9e163a2a9d8..02d27e59d6e 100644 --- a/app/components/Views/ActivityView/index.js +++ b/app/components/Views/ActivityView/index.js @@ -1,5 +1,5 @@ import { useFocusEffect, useNavigation } from '@react-navigation/native'; -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { StyleSheet, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useSelector } from 'react-redux'; @@ -98,14 +98,12 @@ const ActivityView = () => { const currentNetworkName = getNetworkInfo(0)?.networkName; - const tabViewRef = useRef(); const params = useParams(); const perpsEnabledFlag = useSelector(selectPerpsEnabledFlag); const isPerpsEnabled = useMemo( () => perpsEnabledFlag && isEvmSelected, [perpsEnabledFlag, isEvmSelected], ); - const [activeTabIndex, setActiveTabIndex] = useState(0); const predictEnabledFlag = useSelector(selectPredictEnabledFlag); const isPredictEnabled = useMemo( () => predictEnabledFlag, @@ -129,6 +127,17 @@ const ActivityView = () => { // Perps comes after Transactions (0) and Orders (1) const perpsTabIndex = useMemo(() => 2, []); + const [initialTabIndex] = useState(() => { + if (params.redirectToOrders) { + return 1; + } + if (isPerpsEnabled && params.redirectToPerpsTransactions) { + return perpsTabIndex; + } + return 0; + }); + const [activeTabIndex, setActiveTabIndex] = useState(initialTabIndex); + // Predict comes after Transactions (0), Orders (1), and optionally Perps const predictTabIndex = useMemo( () => (isPerpsEnabled ? 3 : 2), @@ -139,22 +148,26 @@ const ActivityView = () => { const isPredictTabActive = isPredictEnabled && activeTabIndex === predictTabIndex; + const handleChangeTab = useCallback(({ i }) => { + setActiveTabIndex(i); + }, []); + useFocusEffect( useCallback(() => { + const nextParams = {}; if (params.redirectToOrders) { - const orderTabNumber = 1; - navigation.setParams({ redirectToOrders: false }); - tabViewRef.current?.goToTabIndex(orderTabNumber); - } else if (isPerpsEnabled && params.redirectToPerpsTransactions) { - navigation.setParams({ redirectToPerpsTransactions: false }); - tabViewRef.current?.goToTabIndex(perpsTabIndex); + nextParams.redirectToOrders = false; + } + if (params.redirectToPerpsTransactions) { + nextParams.redirectToPerpsTransactions = false; + } + if (Object.keys(nextParams).length > 0) { + navigation.setParams(nextParams); } }, [ navigation, params.redirectToOrders, - isPerpsEnabled, params.redirectToPerpsTransactions, - perpsTabIndex, ]), ); @@ -190,8 +203,8 @@ const ActivityView = () => { setActiveTabIndex(i)} + initialActiveIndex={initialTabIndex} + onChangeTab={handleChangeTab} tabsListContentTwClassName="px-0 pb-3" testID={ActivitiesViewSelectorsIDs.TABS_CONTAINER} > @@ -249,13 +262,11 @@ const ActivityView = () => { tabLabel={strings('perps.transactions.title')} style={styles.tabWrapper} > - {isPerpsTabActive ? ( - - - - - - ) : null} + + + {isPerpsTabActive ? : null} + + )} diff --git a/app/components/Views/ActivityView/index.test.tsx b/app/components/Views/ActivityView/index.test.tsx index 77d99e001e5..c6ae66b3ab9 100644 --- a/app/components/Views/ActivityView/index.test.tsx +++ b/app/components/Views/ActivityView/index.test.tsx @@ -27,11 +27,14 @@ jest.mock('../../UI/Predict/selectors/featureFlags', () => ({ // Track which tabs are rendered - populated by mock let renderedTabs: string[] = []; +let lastInitialActiveIndex: number | undefined; // Helper to get rendered tabs for assertions const getRenderedTabs = () => renderedTabs; +const getLastInitialActiveIndex = () => lastInitialActiveIndex; const clearRenderedTabs = () => { renderedTabs = []; + lastInitialActiveIndex = undefined; }; jest.mock('../../../component-library/components-temp/Tabs', () => { @@ -42,10 +45,11 @@ jest.mock('../../../component-library/components-temp/Tabs', () => { ( props: { children?: React.ReactElement[]; + initialActiveIndex?: number; onChangeTab?: (params: { i: number }) => void; [key: string]: unknown; }, - ref: React.Ref<{ goToTabIndex: (index: number) => void }>, + _ref: React.Ref<{ goToTabIndex: (index: number) => void }>, ) => { const children = Array.isArray(props.children) ? props.children : []; @@ -62,13 +66,8 @@ jest.mock('../../../component-library/components-temp/Tabs', () => { }); // Update module-level variable for test assertions renderedTabs = tabKeys; - }, [children]); - - ReactActual.useImperativeHandle(ref, () => ({ - goToTabIndex: (index: number) => { - props.onChangeTab?.({ i: index }); - }, - })); + lastInitialActiveIndex = props.initialActiveIndex; + }, [children, props.initialActiveIndex]); return ReactActual.createElement( View, @@ -100,6 +99,7 @@ const Stack = createStackNavigator(); const mockNavigation = { navigate: jest.fn(), + setParams: jest.fn(), setOptions: jest.fn(), goBack: jest.fn(), canGoBack: jest.fn(() => true), @@ -264,6 +264,7 @@ describe('ActivityView', () => { mockPerpsEnabled = false; mockPredictEnabled = false; clearRenderedTabs(); + mockRoute.params = {}; }); it('matches snapshot', () => { @@ -472,9 +473,10 @@ describe('ActivityView', () => { mockPerpsEnabled = true; mockIsEvmSelected = true; - const { getByTestId } = renderComponent(mockInitialState); + const { getByTestId, queryByTestId } = renderComponent(mockInitialState); expect(getByTestId('tab-perps')).toBeTruthy(); + expect(queryByTestId('perps-transactions-view')).toBeNull(); expect(getRenderedTabs()).toContain('perps'); }); @@ -495,6 +497,45 @@ describe('ActivityView', () => { expect(getRenderedTabs()).not.toContain('perps'); }); + + it('uses Perps as initial tab when redirected to Perps transactions', () => { + mockPerpsEnabled = true; + mockIsEvmSelected = true; + mockRoute.params = { + redirectToPerpsTransactions: true, + showBackButton: true, + }; + + const { getByTestId } = renderComponent(mockInitialState); + + expect(getByTestId('perps-transactions-view')).toBeTruthy(); + expect(getLastInitialActiveIndex()).toBe(2); + expect(mockNavigation.setParams).toHaveBeenCalledWith({ + redirectToPerpsTransactions: false, + }); + }); + }); + + describe('Orders tab', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockRoute.params = {}; + }); + + it('renders orders list and clears redirect param when redirected to orders', () => { + mockRoute.params = { + redirectToOrders: true, + showBackButton: true, + }; + + const { getByTestId } = renderComponent(mockInitialState); + + expect(getByTestId('ramp-orders-list')).toBeTruthy(); + expect(getLastInitialActiveIndex()).toBe(1); + expect(mockNavigation.setParams).toHaveBeenCalledWith({ + redirectToOrders: false, + }); + }); }); describe('Predict tab', () => { From 36b460290c40ef3f8e521f465964c750ba0af66e Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Thu, 19 Mar 2026 18:50:46 +0530 Subject: [PATCH 133/206] feat: add metrics property mm_pay_time_to_complete_s (#27476) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Add new property for MM Pay transaction metrics `mm_pay_time_to_complete_s`. Which is time from when transaction is submitted to what it is finalized. ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/CONF-721 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** 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. ## **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** > Low risk analytics-only change that adds a derived timing property on `TRANSACTION_FINALIZED` events; main risk is incorrect/undefined `submittedTime` leading to missing or skewed metrics. > > **Overview** > Adds a new MetaMetrics property, `mm_pay_time_to_complete_s`, to MetaMask Pay transaction metrics, computed as seconds from `submittedTime` to `TRANSACTION_FINALIZED`. > > The metric is emitted for both parent pay transactions and child bridge/swap steps (using the parent transaction’s `submittedTime`), with unit tests covering finalized vs non-finalized events and missing timestamps. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit b3f5da6f00f9f9a307e91acdbd546c3a5fba5e87. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../metrics_properties/metamask-pay.test.ts | 71 +++++++++++++++++++ .../metrics_properties/metamask-pay.ts | 24 +++++++ 2 files changed, 95 insertions(+) diff --git a/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.test.ts b/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.test.ts index 81a6c3c06cd..e3237c380ce 100644 --- a/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.test.ts +++ b/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.test.ts @@ -484,4 +484,75 @@ describe('Metamask Pay Metrics', () => { sensitiveProperties: {}, }); }); + + describe('mm_pay_time_to_complete_s', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('adds mm_pay_time_to_complete_s for finalized parent MM Pay transaction', () => { + jest.spyOn(Date, 'now').mockReturnValue(1060500); + + request.transactionMeta.type = TransactionType.perpsDeposit; + request.transactionMeta.submittedTime = 1000000; + + const result = getMetaMaskPayProperties(request) as TransactionMetrics; + + expect(result.properties).toStrictEqual( + expect.objectContaining({ + mm_pay_time_to_complete_s: 60.5, + }), + ); + }); + + it('adds mm_pay_time_to_complete_s for finalized child transaction using parent submittedTime', () => { + jest.spyOn(Date, 'now').mockReturnValue(2045123); + + request.allTransactions = [ + { + id: 'parent-1', + type: TransactionType.perpsDeposit, + requiredTransactionIds: ['child-1'], + submittedTime: 2000000, + } as TransactionMeta, + ]; + + const result = getMetaMaskPayProperties(request) as TransactionMetrics; + + expect(result.properties).toStrictEqual( + expect.objectContaining({ + mm_pay_time_to_complete_s: 45.123, + }), + ); + }); + + it('does not add mm_pay_time_to_complete_s for non-finalized events', () => { + request.eventType = TRANSACTION_EVENTS.TRANSACTION_SUBMITTED; + request.transactionMeta.type = TransactionType.perpsDeposit; + request.transactionMeta.submittedTime = 1000000; + + const result = getMetaMaskPayProperties(request) as TransactionMetrics; + + expect(result.properties).not.toHaveProperty('mm_pay_time_to_complete_s'); + }); + + it('does not add mm_pay_time_to_complete_s when submittedTime is undefined', () => { + request.transactionMeta.type = TransactionType.perpsDeposit; + + const result = getMetaMaskPayProperties(request) as TransactionMetrics; + + expect(result.properties).not.toHaveProperty('mm_pay_time_to_complete_s'); + }); + + it('does not add mm_pay_time_to_complete_s for non-MM-Pay transactions', () => { + jest.spyOn(Date, 'now').mockReturnValue(1060000); + + request.transactionMeta.type = TransactionType.contractInteraction; + request.transactionMeta.submittedTime = 1000000; + + const result = getMetaMaskPayProperties(request) as TransactionMetrics; + + expect(result.properties).not.toHaveProperty('mm_pay_time_to_complete_s'); + }); + }); }); diff --git a/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.ts b/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.ts index b537086fee0..b5a55ce1bae 100644 --- a/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.ts +++ b/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.ts @@ -15,6 +15,7 @@ import { import { RootState } from '../../../../../reducers'; import { selectSingleTokenByAddressAndChainId } from '../../../../../selectors/tokensController'; import { Hex } from '@metamask/utils'; +import { TRANSACTION_EVENTS } from '../../../../Analytics/events/confirmations'; const FOUR_BYTE_SAFE_PROXY_CREATE = '0xa1884d2c'; @@ -34,6 +35,7 @@ const PAY_TYPES = [ ]; export const getMetaMaskPayProperties: TransactionMetricsBuilder = ({ + eventType, transactionMeta, allTransactions, getUIMetrics, @@ -58,6 +60,10 @@ export const getMetaMaskPayProperties: TransactionMetricsBuilder = ({ if (hasTransactionType(transactionMeta, PAY_TYPES) || !parentTransaction) { addFallbackProperties(properties, transactionMeta, getState()); + if (hasTransactionType(transactionMeta, PAY_TYPES) || properties.mm_pay) { + addTimeToComplete(properties, eventType, transactionMeta.submittedTime); + } + return { properties, sensitiveProperties, @@ -127,12 +133,30 @@ export const getMetaMaskPayProperties: TransactionMetricsBuilder = ({ } } + addTimeToComplete(properties, eventType, parentTransaction.submittedTime); + return { properties, sensitiveProperties, }; }; +function addTimeToComplete( + properties: JsonMap, + eventType: Parameters[0]['eventType'], + submittedTime: number | undefined, +) { + if ( + eventType !== TRANSACTION_EVENTS.TRANSACTION_FINALIZED || + typeof submittedTime !== 'number' + ) { + return; + } + + properties.mm_pay_time_to_complete_s = + Math.round(Date.now() - submittedTime) / 1000; +} + function addFallbackProperties( properties: JsonMap, transaction: TransactionMeta, From 2ba87de9cf695de8c0069ac5cea1f891dbe763d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20=C5=81ucka?= <5708018+PatrykLucka@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:45:39 +0100 Subject: [PATCH 134/206] fix(network-details): require network name in NetworkDetails form (#27541) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This fixes the NetworkDetails form so **Network name is no longer optional**. ### What changed - Made the `Network name` field required in validation logic. - Added `disabledByName` validation and wired it into: - save-button disabled state - save operation guard (prevents save if name is empty) - Updated the name field placeholder text to remove optional wording (`Network name` instead of `Network name (optional)`). - Updated tests for validation, screen behavior, and save operation guards. - Applied Prettier formatting fix for `NetworkFormFields.tsx` to satisfy `format:check` CI. ## **Changelog** CHANGELOG entry: Fixed Network Details so network name is required and no longer labeled optional. ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TMCU-537 ## **Manual testing steps** ```gherkin Feature: Required network name in NetworkDetails Scenario: user cannot save a network without a name Given user is on Add custom network screen When user leaves Network name empty Then the Save button is disabled Scenario: optional verbiage removed from network name input Given user is on Add custom network screen When user views the Network name input placeholder Then it shows "Network name" and does not include "optional" Scenario: user can save when required fields are filled Given user is on Add custom network screen And user enters Network name, RPC URL, Chain ID, and Symbol When user taps Save Then the network is saved successfully ``` ## **Screenshots/Recordings** ### **Before** Screenshot 2026-03-17 at 17 12 38 ### **After** Screenshot 2026-03-17 at 17 10 05 ## **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. ## **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.
Open in Web Open in Cursor 
--------- Co-authored-by: Cursor Agent Co-authored-by: Patryk Łucka --- .../NetworkDetailsView.test.tsx | 15 +++++++ .../NetworkDetailsView/NetworkDetailsView.tsx | 2 + .../components/NetworkFormFields.tsx | 40 +++++++++++++------ .../hooks/useNetworkOperations.test.ts | 21 ++++++++-- .../hooks/useNetworkOperations.ts | 14 ++++++- .../hooks/useNetworkValidation.test.ts | 24 +++++++++++ .../hooks/useNetworkValidation.ts | 19 ++++++++- locales/languages/en.json | 1 + 8 files changed, 117 insertions(+), 19 deletions(-) diff --git a/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.test.tsx b/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.test.tsx index a6bc031900f..515769c6b31 100644 --- a/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.test.tsx +++ b/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.test.tsx @@ -175,6 +175,7 @@ const createMockValidation = () => ({ validateName: jest.fn(), validateRpcAndChainId: jest.fn(), disabledByChainId: jest.fn(() => false), + disabledByName: jest.fn(() => false), disabledBySymbol: jest.fn(() => false), checkIfChainIdExists: jest.fn(() => false), checkIfNetworkExists: jest.fn().mockResolvedValue([]), @@ -453,6 +454,20 @@ describe('NetworkDetailsView', () => { expect(saveButton.props.disabled).toBe(true); }); + it('disables save button when validation disables network name', () => { + mockValidation.mockReturnValue({ + ...createMockValidation(), + disabledByName: jest.fn(() => true), + }); + + const { getByTestId } = render(); + + const saveButton = getByTestId( + NetworkDetailsViewSelectorsIDs.ADD_CUSTOM_NETWORK_BUTTON, + ); + expect(saveButton.props.disabled).toBe(true); + }); + it('shows warning modal when showWarningModal is true', () => { mockFormHook.mockReturnValue({ ...createMockFormHook(), diff --git a/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.tsx b/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.tsx index e9849a75d45..b321f9d2b18 100644 --- a/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.tsx +++ b/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.tsx @@ -110,12 +110,14 @@ const NetworkDetailsView = () => { !formHook.enableAction || formHook.form.editable === false || validation.disabledByChainId(formHook.form) || + validation.disabledByName(formHook.form) || validation.disabledBySymbol(formHook.form); const handleSave = useCallback(async () => { await operations.saveNetwork(formHook.form, { enableAction: formHook.enableAction, disabledByChainId: validation.disabledByChainId(formHook.form), + disabledByName: validation.disabledByName(formHook.form), disabledBySymbol: validation.disabledBySymbol(formHook.form), isCustomMainnet, shouldNetworkSwitchPopToWallet, diff --git a/app/components/Views/NetworksManagement/NetworkDetailsView/components/NetworkFormFields.tsx b/app/components/Views/NetworksManagement/NetworkDetailsView/components/NetworkFormFields.tsx index 021ed1fcb22..0ec4bf7e39b 100644 --- a/app/components/Views/NetworksManagement/NetworkDetailsView/components/NetworkFormFields.tsx +++ b/app/components/Views/NetworksManagement/NetworkDetailsView/components/NetworkFormFields.tsx @@ -131,6 +131,8 @@ const NetworkNameField: React.FC = ({ } = formHook; const { warningName } = validation; + const isRequiredNameWarning = + warningName === strings('app_settings.required'); const handleNameBlur = useCallback(() => { onValidateName(); @@ -146,7 +148,7 @@ const NetworkNameField: React.FC = ({ value={nickname} isDisabled={isAnyModalVisible || editable === false} onChangeText={onNicknameChange} - placeholder={strings('app_settings.network_name_placeholder')} + placeholder={strings('app_settings.network_name_label')} placeholderTextColor={placeholderTextColor} onBlur={handleNameBlur} onFocus={onNameFocused} @@ -157,19 +159,33 @@ const NetworkNameField: React.FC = ({ /> {warningName ? ( - - {strings('wallet.incorrect_network_name_warning')} - - - {strings('wallet.suggested_name')}{' '} - autoFillNameField(warningName)} - > + {isRequiredNameWarning ? ( + {warningName} - + ) : ( + <> + + {strings('wallet.incorrect_network_name_warning')} + + + {strings('wallet.suggested_name')}{' '} + autoFillNameField(warningName)} + > + {warningName} + + + + )} ) : null} diff --git a/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkOperations.test.ts b/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkOperations.test.ts index 59d49dbf382..1aac1acb359 100644 --- a/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkOperations.test.ts +++ b/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkOperations.test.ts @@ -137,6 +137,7 @@ const baseForm: NetworkFormState = { const defaultSaveOpts = () => ({ enableAction: true, disabledByChainId: false, + disabledByName: false, disabledBySymbol: false, isCustomMainnet: false, shouldNetworkSwitchPopToWallet: true, @@ -314,6 +315,20 @@ describe('useNetworkOperations', () => { expect(mockUpdateNetwork).not.toHaveBeenCalled(); }); + it('does nothing when disabledByName is true', async () => { + const { result } = renderHook(() => useNetworkOperations()); + + await act(async () => { + await result.current.saveNetwork(baseForm, { + ...defaultSaveOpts(), + disabledByName: true, + }); + }); + + expect(mockAddNetwork).not.toHaveBeenCalled(); + expect(mockUpdateNetwork).not.toHaveBeenCalled(); + }); + it('does nothing when rpcUrl is missing', async () => { const { result } = renderHook(() => useNetworkOperations()); @@ -355,7 +370,7 @@ describe('useNetworkOperations', () => { expect(callArgs.nativeCurrency).toBe(''); }); - it('uses empty string when nickname is undefined', async () => { + it('does nothing when nickname is undefined', async () => { const { result } = renderHook(() => useNetworkOperations()); await act(async () => { @@ -365,8 +380,8 @@ describe('useNetworkOperations', () => { ); }); - const callArgs = mockAddNetwork.mock.calls[0][0]; - expect(callArgs.name).toBe(''); + expect(mockAddNetwork).not.toHaveBeenCalled(); + expect(mockUpdateNetwork).not.toHaveBeenCalled(); }); it('sets individual chain filter when isAllNetworks is false', async () => { diff --git a/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkOperations.ts b/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkOperations.ts index 0080501a131..0bc35c9626d 100644 --- a/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkOperations.ts +++ b/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkOperations.ts @@ -45,6 +45,7 @@ export interface UseNetworkOperationsReturn { params: { enableAction: boolean; disabledByChainId: boolean; + disabledByName: boolean; disabledBySymbol: boolean; isCustomMainnet: boolean; shouldNetworkSwitchPopToWallet: boolean; @@ -197,6 +198,7 @@ export const useNetworkOperations = (): UseNetworkOperationsReturn => { params: { enableAction: boolean; disabledByChainId: boolean; + disabledByName: boolean; disabledBySymbol: boolean; isCustomMainnet: boolean; shouldNetworkSwitchPopToWallet: boolean; @@ -211,6 +213,7 @@ export const useNetworkOperations = (): UseNetworkOperationsReturn => { const { enableAction, disabledByChainId, + disabledByName, disabledBySymbol, isCustomMainnet, shouldNetworkSwitchPopToWallet, @@ -218,7 +221,14 @@ export const useNetworkOperations = (): UseNetworkOperationsReturn => { validateChainIdOnSubmit, } = params; - if (!enableAction || disabledByChainId || disabledBySymbol) return; + if ( + !enableAction || + disabledByChainId || + disabledByName || + disabledBySymbol + ) { + return; + } const { rpcUrl, @@ -232,7 +242,7 @@ export const useNetworkOperations = (): UseNetworkOperationsReturn => { const ticker = form.ticker ? form.ticker.toUpperCase() : undefined; - if (!stateChainId || !rpcUrl) return; + if (!stateChainId || !rpcUrl || !nickname?.trim()) return; // Check if network with this chainId already exists const isNetworkNew = addMode diff --git a/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkValidation.test.ts b/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkValidation.test.ts index 5adec78d523..03f5e76355c 100644 --- a/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkValidation.test.ts +++ b/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkValidation.test.ts @@ -192,6 +192,20 @@ describe('useNetworkValidation', () => { }); }); + describe('disabledByName', () => { + it('returns true when network name is empty', () => { + const { result } = renderHook(() => useNetworkValidation()); + expect( + result.current.disabledByName({ ...baseForm, nickname: undefined }), + ).toBe(true); + }); + + it('returns false when network name is present', () => { + const { result } = renderHook(() => useNetworkValidation()); + expect(result.current.disabledByName(baseForm)).toBe(false); + }); + }); + describe('onRpcUrlValidationChange', () => { it('updates validatedRpcURL state', () => { const { result } = renderHook(() => useNetworkValidation()); @@ -333,6 +347,16 @@ describe('useNetworkValidation', () => { }); describe('validateName', () => { + it('sets required warning when network name is empty', () => { + const { result } = renderHook(() => useNetworkValidation()); + + act(() => { + result.current.validateName({ ...baseForm, nickname: '' }); + }); + + expect(result.current.warningName).toBeDefined(); + }); + it('does nothing when useSafeChainsListValidation is false', () => { mockUseSelector.mockImplementation((selector) => { if (selector.name?.includes('SafeChainsListValidation')) return false; diff --git a/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkValidation.ts b/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkValidation.ts index e5749c03974..655373b47d5 100644 --- a/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkValidation.ts +++ b/app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkValidation.ts @@ -40,6 +40,7 @@ export interface UseNetworkValidationReturn extends ValidationState { ) => void; validateRpcAndChainId: (form: NetworkFormState) => void; disabledByChainId: (form: NetworkFormState) => boolean; + disabledByName: (form: NetworkFormState) => boolean; disabledBySymbol: (form: NetworkFormState) => boolean; checkIfChainIdExists: (chainId: string) => boolean; checkIfNetworkExists: (rpcUrl: string) => Promise; @@ -302,7 +303,15 @@ export const useNetworkValidation = (): UseNetworkValidationReturn => { const validateName = useCallback( (form: NetworkFormState, chainToMatch: SafeChain | null = null) => { const { nickname, chainId } = form; - if (!useSafeChainsListValidation) return; + const trimmedNickname = nickname?.trim(); + if (!trimmedNickname) { + setWarningName(strings('app_settings.required')); + return; + } + if (!useSafeChainsListValidation) { + setWarningName(undefined); + return; + } const name = NETWORK_TO_NAME_MAP[chainId as keyof typeof NETWORK_TO_NAME_MAP] || @@ -310,7 +319,7 @@ export const useNetworkValidation = (): UseNetworkValidationReturn => { networkList?.name || null; - const nameToUse = isValidNetworkName(chainId ?? '', name, nickname ?? '') + const nameToUse = isValidNetworkName(chainId ?? '', name, trimmedNickname) ? undefined : name; @@ -343,6 +352,11 @@ export const useNetworkValidation = (): UseNetworkValidationReturn => { [validatedChainId, warningChainId], ); + const disabledByName = useCallback( + (form: NetworkFormState): boolean => !form.nickname?.trim(), + [], + ); + const disabledBySymbol = useCallback( (form: NetworkFormState): boolean => !form.ticker, [], @@ -362,6 +376,7 @@ export const useNetworkValidation = (): UseNetworkValidationReturn => { validateName, validateRpcAndChainId, disabledByChainId, + disabledByName, disabledBySymbol, checkIfChainIdExists, checkIfNetworkExists, diff --git a/locales/languages/en.json b/locales/languages/en.json index 924a714c0e7..1f115aca38b 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -3060,6 +3060,7 @@ "networks_no_results": "No networks found", "network_name_label": "Network name", "network_name_placeholder": "Network name (optional)", + "required": "Required", "network_rpc_url_label": "RPC URL", "network_rpc_name_label": "RPC name", "network_rpc_placeholder": "New RPC network", From 678abd6c31f81db1e0ae07a6e1052a867ce64d4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20=C5=81ucka?= <5708018+PatrykLucka@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:00:41 +0100 Subject: [PATCH 135/206] fix(accounts-menu): remove network management feature flag gate (#27591) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Removed the `mobileUxNetworkManagement` feature flag gate from mobile Accounts Menu so Networks is always shown. ### What changed - Deleted obsolete selector/hook files: - `app/selectors/featureFlagController/networkManagement/index.ts` - `app/selectors/featureFlagController/networkManagement/useNetworkManagementEnabled.ts` - Updated `AccountsMenu` to always render the Networks row (no remote-flag check). - Added unit tests for Networks row rendering and navigation. - Updated the AccountsMenu snapshot to reflect the always-visible Networks row. ## **Changelog** CHANGELOG entry: Removed a stale feature-flag gate so the Networks menu item is always available. ## **Related issues** Refs: TMCU-575 ## **Manual testing steps** ```gherkin Feature: Accounts menu network access Scenario: User opens network management from Accounts menu Given the user is on the wallet screen When the user opens the accounts menu Then the "accounts_menu.networks" row is visible When the user taps the Networks row Then the app navigates to Routes.SETTINGS.NETWORKS_MANAGEMENT ``` ## **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. ## **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.
Open in Web View Automation 
--- > [!NOTE] > **Medium Risk** > Behavior change makes the Networks entry point visible to all users, so any incomplete/unsupported network management flows will now be reachable broadly. Changes are localized to UI/menu rendering plus selector deletions and test updates. > > **Overview** > **Accounts Menu now always shows the Networks row** by removing the `mobileUxNetworkManagement` remote feature-flag check, so tapping it consistently navigates to `Routes.SETTINGS.NETWORKS_MANAGEMENT`. > > This PR also removes the now-unused network-management feature-flag selector/hook and updates coverage (new Networks row tests + snapshot updates) to reflect the always-visible menu item. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit b81e3fe7635b2d399e149e3df7d4fe9ce939ae83. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Cursor Agent Co-authored-by: Patryk Łucka --- .../Views/AccountsMenu/AccountsMenu.test.tsx | 20 +++ .../Views/AccountsMenu/AccountsMenu.tsx | 21 +-- .../__snapshots__/AccountsMenu.test.tsx.snap | 145 ++++++++++++++++++ .../networkManagement/index.ts | 22 --- .../useNetworkManagementEnabled.ts | 14 -- 5 files changed, 172 insertions(+), 50 deletions(-) delete mode 100644 app/selectors/featureFlagController/networkManagement/index.ts delete mode 100644 app/selectors/featureFlagController/networkManagement/useNetworkManagementEnabled.ts diff --git a/app/components/Views/AccountsMenu/AccountsMenu.test.tsx b/app/components/Views/AccountsMenu/AccountsMenu.test.tsx index b8c9eb5ae4b..523ac78c818 100644 --- a/app/components/Views/AccountsMenu/AccountsMenu.test.tsx +++ b/app/components/Views/AccountsMenu/AccountsMenu.test.tsx @@ -637,6 +637,26 @@ describe('AccountsMenu', () => { }); }); + describe('Networks Row', () => { + it('render Networks row', () => { + const { getByText, getByTestId } = render(); + + expect(getByText('accounts_menu.networks')).toBeOnTheScreen(); + expect(getByTestId(AccountsMenuSelectorsIDs.NETWORKS)).toBeOnTheScreen(); + }); + + it('navigate to NetworksManagement when Networks is pressed', () => { + const { getByTestId } = render(); + const networksButton = getByTestId(AccountsMenuSelectorsIDs.NETWORKS); + + fireEvent.press(networksButton); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.SETTINGS.NETWORKS_MANAGEMENT, + ); + }); + }); + describe('RESOURCES Section', () => { describe('About MetaMask Row', () => { it('render About MetaMask row', () => { diff --git a/app/components/Views/AccountsMenu/AccountsMenu.tsx b/app/components/Views/AccountsMenu/AccountsMenu.tsx index 039b176b34e..474c6917a41 100644 --- a/app/components/Views/AccountsMenu/AccountsMenu.tsx +++ b/app/components/Views/AccountsMenu/AccountsMenu.tsx @@ -42,7 +42,6 @@ import { selectIsMetamaskNotificationsEnabled, } from '../../../selectors/notifications'; import { selectIsBackupAndSyncEnabled } from '../../../selectors/identity'; -import { useNetworkManagementEnabled } from '../../../selectors/featureFlagController/networkManagement/useNetworkManagementEnabled'; const AccountsMenu = () => { const tw = useTailwind(); @@ -121,8 +120,6 @@ const AccountsMenu = () => { readNotificationCount, isBackupAndSyncEnabled, ]); - const isNetworkManagementEnabled = useNetworkManagementEnabled(); - const handleBack = useCallback(() => { navigation.goBack(); }, [navigation]); @@ -458,17 +455,13 @@ const AccountsMenu = () => { )} {/* Networks Row */} - {isNetworkManagementEnabled && ( - - } - label={strings('accounts_menu.networks')} - endAccessory={arrowRightIcon} - onPress={onPressNetworks} - testID={AccountsMenuSelectorsIDs.NETWORKS} - /> - )} + } + label={strings('accounts_menu.networks')} + endAccessory={arrowRightIcon} + onPress={onPressNetworks} + testID={AccountsMenuSelectorsIDs.NETWORKS} + /> {separator} diff --git a/app/components/Views/AccountsMenu/__snapshots__/AccountsMenu.test.tsx.snap b/app/components/Views/AccountsMenu/__snapshots__/AccountsMenu.test.tsx.snap index db0ee9055a4..298f661af2c 100644 --- a/app/components/Views/AccountsMenu/__snapshots__/AccountsMenu.test.tsx.snap +++ b/app/components/Views/AccountsMenu/__snapshots__/AccountsMenu.test.tsx.snap @@ -770,6 +770,151 @@ exports[`AccountsMenu Snapshots match snapshot when MetaMask Card is hidden 1`]
+ + + + + + + accounts_menu.networks + + + + + + + + { - const remoteFlag = remoteFeatureFlags[ - NETWORK_MANAGEMENT_FLAG_KEY - ] as unknown as VersionGatedFeatureFlag; - return validatedVersionGatedFeatureFlag(remoteFlag) ?? false; - }, -); diff --git a/app/selectors/featureFlagController/networkManagement/useNetworkManagementEnabled.ts b/app/selectors/featureFlagController/networkManagement/useNetworkManagementEnabled.ts deleted file mode 100644 index 218e7f883ed..00000000000 --- a/app/selectors/featureFlagController/networkManagement/useNetworkManagementEnabled.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { useSelector } from 'react-redux'; -import { selectNetworkManagementEnabled } from '.'; - -/** - * Hook to check if the Network Management feature is enabled. - * Returns true if the feature is enabled AND the current version meets the minimum version requirement. - */ -export const useNetworkManagementEnabled = () => { - const isNetworkManagementEnabled = useSelector( - selectNetworkManagementEnabled, - ); - - return isNetworkManagementEnabled; -}; From 84d4ae934c0b478ecdc415d4db298e56acf52f09 Mon Sep 17 00:00:00 2001 From: cmd-ob Date: Thu, 19 Mar 2026 14:06:43 +0000 Subject: [PATCH 136/206] feat: extend smart E2E selection to release branch PRs and skip drafts (#27680) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** ## Summary - Enables AI-powered E2E test selection on PRs targeting `release/*` branches (previously only ran on PRs targeting `main`) - Skips smart E2E analysis on draft PRs, falling back to the full suite - Restricts risk label application to non-draft PRs targeting `main` or `release/*` only ## Changes - `action.yml`: Added `is-draft` input; updated base-ref guard to allow `main` or `release/*`; added draft check before analysis; scoped risk label step with matching guards - `ci.yml`: Passes `github.event.pull_request.draft` to the action as `is-draft` ## Behaviour matrix | PR type | Analysis runs | Risk label applied | |---|---|---| | Non-draft → `main` | Yes (if confidence ≥ 80%) | Yes | | Non-draft → `release/*` | Yes (if confidence ≥ 80%) | Yes | | Draft → `main` | No (falls back to ALL) | No | | Draft → `release/*` | No (falls back to ALL) | No | | Any → other branch | No (falls back to ALL) | No | | Push to `main` / schedule | No (falls back to ALL) | No | ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Smart E2E selection on draft and open PRs Scenario: Draft PR targeting a release branch skips analysis Given a PR targeting a release/* branch is in draft state When CI runs the Smart E2E Selection job Then the AI analysis is skipped with reason "draft PR" And selected_tags falls back to ["ALL"] And no risk label is applied to the PR Scenario: Open PR targeting a release branch runs analysis Given a PR targeting a release/* branch is marked ready for review When CI runs the Smart E2E Selection job Then the AI analysis runs against the release branch diff And selected_tags reflects the AI-selected tags (or ["ALL"] if confidence < 80%) And the risk label is applied to the PR ``` ## **Testing** Draft PR Screenshot 2026-03-19 at 13 00 48 Open PR Screenshot 2026-03-19 at 13 12 17 ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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 CI gating for AI-driven E2E selection and risk labeling, which can alter what tests run on PRs and potentially impact coverage if misconfigured. Limited in scope to GitHub Action/workflow logic. > > **Overview** > **Smart E2E selection now supports PRs targeting `release/*` branches** (in addition to `main`) by fetching the PR’s base ref and passing it through to the analyzer via `-b origin/`. > > **Draft PRs now skip AI analysis** (falling back to `['ALL']`) and **risk labeling is suppressed** unless the PR is non-draft and targeting `main` or `release/*`. The CI workflow now passes the PR draft status into the composite action via the new `is-draft` input. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 8e569a2dfa38631e35e760708330a4ea9e2a73b6. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../actions/smart-e2e-selection/action.yml | 24 ++++++++++++------- .github/scripts/e2e-smart-selection.mjs | 9 ++++--- .github/workflows/ci.yml | 1 + 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/.github/actions/smart-e2e-selection/action.yml b/.github/actions/smart-e2e-selection/action.yml index 4af12a52ae0..ac4243b68dd 100644 --- a/.github/actions/smart-e2e-selection/action.yml +++ b/.github/actions/smart-e2e-selection/action.yml @@ -30,9 +30,13 @@ inputs: required: false default: 'false' base-ref: - description: 'Base branch ref (must be main for analysis to run)' + description: 'Base branch ref (must be main or release/* for analysis to run)' required: false default: '' + is-draft: + description: 'Whether the PR is a draft' + required: false + default: 'false' outputs: ai_e2e_test_tags: @@ -80,14 +84,14 @@ runs: git sparse-checkout disable git checkout HEAD -- . - - name: Fetch main branch for comparison + - name: Fetch base branch for comparison if: steps.check-skip-label.outputs.SKIP != 'true' shell: bash run: | # Unshallow the repository first (if it's shallow) git fetch --unshallow 2>/dev/null || true - # Fetch main branch for diff comparison - git fetch origin main 2>/dev/null || true + # Fetch the base branch for diff comparison (main or release/*) + git fetch origin "${{ inputs.base-ref || 'main' }}" 2>/dev/null || true - name: Setup Node.js if: steps.check-skip-label.outputs.SKIP != 'true' @@ -131,6 +135,7 @@ runs: GITHUB_REPOSITORY: ${{ inputs.repository }} GITHUB_RUN_ID: ${{ github.run_id }} BASE_REF: ${{ inputs.base-ref }} + IS_DRAFT: ${{ inputs.is-draft }} run: | echo "ai_e2e_test_tags=[\"ALL\"]" >> "$GITHUB_OUTPUT" echo "ai_confidence=0" >> "$GITHUB_OUTPUT" @@ -142,14 +147,17 @@ runs: if [[ "$EVENT_NAME" != "pull_request" ]]; then SHOULD_SKIP=true SKIP_REASON="only runs on PRs" - elif [[ "$BASE_REF" != "main" ]]; then - SHOULD_SKIP=true - SKIP_REASON="base branch is not main (base: $BASE_REF)" elif [[ -n "${{ steps.check-skip-label.outputs.SKIP }}" ]] && [[ "${{ steps.check-skip-label.outputs.SKIP }}" == "true" ]]; then SHOULD_SKIP=true SKIP_REASON="skip-smart-e2e-selection label found" FORCE_RUN=true echo "ai_confidence=100" >> "$GITHUB_OUTPUT" + elif [[ "$IS_DRAFT" == "true" ]]; then + SHOULD_SKIP=true + SKIP_REASON="draft PR" + elif [[ "$BASE_REF" != "main" && "$BASE_REF" != release/* ]]; then + SHOULD_SKIP=true + SKIP_REASON="base branch is not main or a release branch (base: $BASE_REF)" fi # Export skip status, reason, and force run flag for downstream jobs @@ -245,7 +253,7 @@ runs: fi - name: Apply risk label to PR - if: inputs.pr-number != '' && inputs.github-token != '' + if: inputs.pr-number != '' && inputs.github-token != '' && inputs.is-draft != 'true' && (inputs.base-ref == 'main' || startsWith(inputs.base-ref, 'release/')) shell: bash env: GH_TOKEN: ${{ inputs.github-token }} diff --git a/.github/scripts/e2e-smart-selection.mjs b/.github/scripts/e2e-smart-selection.mjs index e6c1ea1736d..933949fb4a3 100644 --- a/.github/scripts/e2e-smart-selection.mjs +++ b/.github/scripts/e2e-smart-selection.mjs @@ -11,6 +11,7 @@ const env = { PR_NUMBER: process.env.PR_NUMBER || '', GITHUB_OUTPUT: process.env.GITHUB_OUTPUT || '', GITHUB_STEP_SUMMARY: process.env.GITHUB_STEP_SUMMARY || '', + BASE_REF: process.env.BASE_REF || 'main', }; const PR_COMMENT_FILE = 'pr_comment.md'; @@ -77,9 +78,11 @@ async function main() { return; } - // Build command - always uses origin/main as base (job only runs on PRs targeting main) - const baseCmd = `node -r esbuild-register tests/tools/e2e-ai-analyzer --mode select-tags --pr ${env.PR_NUMBER}`; - console.log(`🎯 Analyzing PR against origin/main`); + // Build command - uses GitHub API (PR number) for changed files list; -b ensures + // file diffs are computed against the correct base branch (main or release/*) + const baseBranch = `origin/${env.BASE_REF}`; + const baseCmd = `node -r esbuild-register tests/tools/e2e-ai-analyzer --mode select-tags --pr ${env.PR_NUMBER} -b ${baseBranch}`; + console.log(`🎯 Analyzing PR #${env.PR_NUMBER} against base branch: ${baseBranch}`); try { execSync(baseCmd, { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 257a165406a..aca85f8ad59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -465,6 +465,7 @@ jobs: repository: ${{ github.repository }} post-comment: 'true' base-ref: ${{ github.event.pull_request.base.ref }} + is-draft: ${{ github.event.pull_request.draft }} # Main E2E tests From 62cbdf54fd00b7fc98dbd6c162a99edef00af6fe Mon Sep 17 00:00:00 2001 From: cryptodev-2s <109512101+cryptodev-2s@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:09:02 +0100 Subject: [PATCH 137/206] chore: update controller usage for breaking changes from core#8183 (#27391) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adapts metamask-mobile to breaking changes from [MetaMask/core#8183](https://github.com/MetaMask/core/pull/8183), which migrates confirmations-owned controllers to the `MESSENGER_EXPOSED_METHODS` pattern. ### Changes: **ApprovalController method renames:** - `accept()` → `acceptRequest()` - `reject()` → `rejectRequest()` - `clear()` → `clearRequests()` - `has()` → `hasRequest()` **Type renames across multiple packages:** - `AddApprovalRequest` → `ApprovalControllerAddRequestAction` - `AcceptRequest` → `ApprovalControllerAcceptRequestAction` - `RejectRequest` → `ApprovalControllerRejectRequestAction` - `UpdateRequestState` → `ApprovalControllerUpdateRequestStateAction` - `EndFlow` → `ApprovalControllerEndFlowAction` - `ShowError` → `ApprovalControllerShowErrorAction` - `ShowSuccess` → `ApprovalControllerShowSuccessAction` - `StartFlow` → `ApprovalControllerStartFlowAction` **Other updates:** - Added `AssetsController:getStateForTransactionPay` to `TransactionPayController` messenger (now required by transaction-pay-controller v17) - Replaced all preview build resolutions with released package versions - Removed stale bridge-controller and bridge-status-controller patches **Package version bumps:** - `@metamask/approval-controller` ^8.0.0 → ^9.0.0 - `@metamask/assets-controller` ^2.0.0 → ^2.3.0 - `@metamask/assets-controllers` ^100.2.0 → ^101.0.0 - `@metamask/address-book-controller` ^7.0.1 → ^7.1.0 - `@metamask/gas-fee-controller` ^25.0.0 → ^26.1.0 - `@metamask/logging-controller` ^7.0.0 → ^8.0.0 - `@metamask/permission-controller` ^12.1.0 → ^12.2.1 - `@metamask/signature-controller` ^35.0.0 → ^39.1.0 - `@metamask/transaction-controller` ^62.22.0 → ^63.0.0 - `@metamask/transaction-pay-controller` ^17.0.0 → ^17.1.0 ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: N/A - Depends on: https://github.com/MetaMask/core/pull/8183 - [WPC-416](https://consensyssoftware.atlassian.net/browse/WPC-416) ## **Manual testing steps** 1. Build the app 2. Verify approval flows work (transaction confirmations, signature requests, permission approvals) 3. Verify transaction pay flows work ## **Screenshots/Recordings** N/A — no UI changes, only internal method/type renames and dependency bumps. ## **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 - [ ] I've included tests if applicable - [ ] 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. ## **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** > Touches approval/confirmation plumbing across transactions, snaps, RPC middleware, and SDK connect; incorrect method mapping could break user approvals or leave stale requests. Changes are mostly mechanical renames plus dependency bumps, but the surface area is broad. > > **Overview** > Updates app code to match `@metamask/approval-controller` v9 breaking API changes by renaming all call sites from `accept/reject/clear/has` to `acceptRequest/rejectRequest/clearRequests/hasRequest`, including Engine helpers, transaction/QR/Ledger flows, RPC middleware, SDK connect, sagas, and permission utilities (with corresponding test updates). > > Refreshes Snap/messenger action type names to the new `ApprovalController*Action` exports, and extends the `TransactionPayController` messenger delegation to include `AssetsController:getStateForTransactionPay`. > > Bumps multiple MetaMask controller package versions and removes the previously vendored Yarn patch for `@metamask/assets-controllers` (moving back to released `^101.0.1`), with lockfile updates accordingly. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 19ddd9da62511c71bd4ab02f907b3f46b798d9b2. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- ...s-controllers-npm-100.2.1-4f7a0c8320.patch | 48 ---- .../UI/Earn/hooks/useMusdConversion.test.ts | 8 +- .../UI/Earn/hooks/useMusdConversion.ts | 2 +- .../utils/musdConversionTransaction.test.ts | 4 +- .../Earn/utils/musdConversionTransaction.ts | 2 +- .../LedgerModals/LedgerTransactionModal.tsx | 2 +- .../QRSigningTransactionModal.test.tsx | 14 +- .../QRHardware/QRSigningTransactionModal.tsx | 2 +- app/components/UI/Transactions/index.js | 2 +- app/components/UI/Transactions/index.test.tsx | 6 +- .../useUnifiedTxActions.test.ts | 9 +- .../useUnifiedTxActions.ts | 2 +- .../hooks/useConfirmNavigation.test.ts | 6 +- .../hooks/useConfirmNavigation.ts | 5 +- app/core/Engine/Engine.ts | 6 +- .../snaps/snap-controller-messenger.ts | 8 +- .../transaction-pay-controller-messenger.ts | 1 + .../RPCMethods/RPCMethodMiddleware.test.ts | 6 +- app/core/RPCMethods/RPCMethodMiddleware.ts | 4 +- .../RPCMethods/wallet_addEthereumChain.js | 2 +- .../wallet_addEthereumChain.test.js | 8 +- .../handlers/handleConnectionReady.ts | 4 +- .../SDKConnectV2/services/connection.test.ts | 16 +- app/core/SDKConnectV2/services/connection.ts | 2 +- app/core/SnapKeyring/types.ts | 28 +- app/core/Snaps/permissions/specifications.ts | 4 +- .../WalletConnect/WalletConnect2Session.ts | 2 +- app/store/sagas/index.ts | 2 +- app/store/sagas/sagas.test.ts | 4 +- app/util/permissions/index.test.ts | 4 +- app/util/permissions/index.ts | 11 +- package.json | 22 +- yarn.lock | 245 +++++++++--------- 33 files changed, 229 insertions(+), 262 deletions(-) delete mode 100644 .yarn/patches/@metamask-assets-controllers-npm-100.2.1-4f7a0c8320.patch diff --git a/.yarn/patches/@metamask-assets-controllers-npm-100.2.1-4f7a0c8320.patch b/.yarn/patches/@metamask-assets-controllers-npm-100.2.1-4f7a0c8320.patch deleted file mode 100644 index b4f5cc118e8..00000000000 --- a/.yarn/patches/@metamask-assets-controllers-npm-100.2.1-4f7a0c8320.patch +++ /dev/null @@ -1,48 +0,0 @@ -diff --git a/dist/TokenBalancesController.cjs b/dist/TokenBalancesController.cjs -index 8df3058aea9bd28aaabe1d09f8c3b38fa5949600..39468505fed9c6776a193917fed0cea73de65c23 100644 ---- a/dist/TokenBalancesController.cjs -+++ b/dist/TokenBalancesController.cjs -@@ -805,18 +805,30 @@ async function _TokenBalancesController_importUntrackedTokens(balances) { - if (!changes.length) { - return; - } -- const chainConfigs = {}; -- for (const [chainId, status] of changes) { -- const hexChainId = (0, exports.caipChainIdToHex)(chainId); -- chainConfigs[hexChainId] = -- status === 'down' -- ? { interval: __classPrivateFieldGet(this, _TokenBalancesController_defaultInterval, "f") } -- : { interval: __classPrivateFieldGet(this, _TokenBalancesController_websocketActivePollingInterval, "f") }; -- } -- const jitterDelay = Math.random() * __classPrivateFieldGet(this, _TokenBalancesController_defaultInterval, "f"); -- setTimeout(() => { -- this.updateChainPollingConfigs(chainConfigs, { immediateUpdate: true }); -- }, jitterDelay); -+ try { -+ const chainConfigs = {}; -+ for (const [chainId, status] of changes) { -+ if ((0, utils_1.isCaipChainId)(chainId) && -+ (0, utils_1.parseCaipChainId)(chainId).namespace !== 'eip155') { -+ continue; -+ } -+ const hexChainId = (0, exports.caipChainIdToHex)(chainId); -+ chainConfigs[hexChainId] = -+ status === 'down' -+ ? { interval: __classPrivateFieldGet(this, _TokenBalancesController_defaultInterval, "f") } -+ : { interval: __classPrivateFieldGet(this, _TokenBalancesController_websocketActivePollingInterval, "f") }; -+ } -+ if (Object.keys(chainConfigs).length === 0) { -+ return; -+ } -+ const jitterDelay = Math.random() * __classPrivateFieldGet(this, _TokenBalancesController_defaultInterval, "f"); -+ setTimeout(() => { -+ this.updateChainPollingConfigs(chainConfigs, { immediateUpdate: true }); -+ }, jitterDelay); -+ } -+ catch (error) { -+ console.warn('Error processing accumulated status changes:', error); -+ } - }; - exports.default = TokenBalancesController; - //# sourceMappingURL=TokenBalancesController.cjs.map -\ No newline at end of file diff --git a/app/components/UI/Earn/hooks/useMusdConversion.test.ts b/app/components/UI/Earn/hooks/useMusdConversion.test.ts index 0a6f5137ab9..b3be0d3c9e8 100644 --- a/app/components/UI/Earn/hooks/useMusdConversion.test.ts +++ b/app/components/UI/Earn/hooks/useMusdConversion.test.ts @@ -85,7 +85,7 @@ const mockTransactionPayController = { }; const mockApprovalController = { - reject: jest.fn(), + rejectRequest: jest.fn(), }; const mockFetchGasFeeEstimates = jest.fn().mockResolvedValue(undefined); @@ -967,7 +967,7 @@ describe('useMusdConversion', () => { ).rejects.toThrow(postCreationError); }); - expect(mockApprovalController.reject).toHaveBeenCalledWith( + expect(mockApprovalController.rejectRequest).toHaveBeenCalledWith( 'tx-max-123', expect.objectContaining({ message: @@ -1004,7 +1004,7 @@ describe('useMusdConversion', () => { ); const rejectCleanupError = new Error('Failed to reject pending approval'); - mockApprovalController.reject.mockImplementation(() => { + mockApprovalController.rejectRequest.mockImplementation(() => { throw rejectCleanupError; }); @@ -1016,7 +1016,7 @@ describe('useMusdConversion', () => { ).rejects.toThrow(postCreationError); }); - expect(mockApprovalController.reject).toHaveBeenCalledTimes(1); + expect(mockApprovalController.rejectRequest).toHaveBeenCalledTimes(1); expect(Logger.error).toHaveBeenCalledWith( rejectCleanupError, '[mUSD Max Conversion] Failed to reject transaction after post-creation configuration error', diff --git a/app/components/UI/Earn/hooks/useMusdConversion.ts b/app/components/UI/Earn/hooks/useMusdConversion.ts index b8d033a8553..4425ce87d7c 100644 --- a/app/components/UI/Earn/hooks/useMusdConversion.ts +++ b/app/components/UI/Earn/hooks/useMusdConversion.ts @@ -308,7 +308,7 @@ export const useMusdConversion = () => { 'Error creating max conversion transaction', ); try { - ApprovalController.reject( + ApprovalController.rejectRequest( transactionId, providerErrors.userRejectedRequest({ message: diff --git a/app/components/UI/Earn/utils/musdConversionTransaction.test.ts b/app/components/UI/Earn/utils/musdConversionTransaction.test.ts index 272f96aa12a..7d208760176 100644 --- a/app/components/UI/Earn/utils/musdConversionTransaction.test.ts +++ b/app/components/UI/Earn/utils/musdConversionTransaction.test.ts @@ -89,7 +89,7 @@ interface MockedEngineContext { >; }; ApprovalController?: { - reject: jest.Mock; + rejectRequest: jest.Mock; }; } @@ -180,7 +180,7 @@ describe('musdConversionTransaction', () => { updatePaymentToken: transactionPayControllerUpdatePaymentToken, }, ApprovalController: { - reject: approvalControllerReject, + rejectRequest: approvalControllerReject, }, }; diff --git a/app/components/UI/Earn/utils/musdConversionTransaction.ts b/app/components/UI/Earn/utils/musdConversionTransaction.ts index 27880df7abe..eb457d09aa3 100644 --- a/app/components/UI/Earn/utils/musdConversionTransaction.ts +++ b/app/components/UI/Earn/utils/musdConversionTransaction.ts @@ -239,7 +239,7 @@ export async function replaceMusdConversionTransactionForPayToken( // This is an automatic rejection (not user-initiated) try { - ApprovalController.reject( + ApprovalController.rejectRequest( transactionMeta.id, providerErrors.userRejectedRequest({ message: diff --git a/app/components/UI/LedgerModals/LedgerTransactionModal.tsx b/app/components/UI/LedgerModals/LedgerTransactionModal.tsx index 5ca95213815..84c07bb212d 100644 --- a/app/components/UI/LedgerModals/LedgerTransactionModal.tsx +++ b/app/components/UI/LedgerModals/LedgerTransactionModal.tsx @@ -64,7 +64,7 @@ const LedgerTransactionModal = () => { await TransactionController.stopTransaction(transactionId, gasFeeParams); } else { // This requires the user to confirm on the ledger device - await ApprovalController.accept(transactionId, undefined, { + await ApprovalController.acceptRequest(transactionId, undefined, { waitForResult: true, }); } diff --git a/app/components/UI/QRHardware/QRSigningTransactionModal.test.tsx b/app/components/UI/QRHardware/QRSigningTransactionModal.test.tsx index 44a1af937e3..950af62a651 100644 --- a/app/components/UI/QRHardware/QRSigningTransactionModal.test.tsx +++ b/app/components/UI/QRHardware/QRSigningTransactionModal.test.tsx @@ -122,10 +122,10 @@ describe('QRSigningTransactionModal', () => { ( Engine.context as unknown as { - ApprovalController: { accept: jest.Mock }; + ApprovalController: { acceptRequest: jest.Mock }; } ).ApprovalController = { - accept: jest.fn().mockResolvedValue(undefined), + acceptRequest: jest.fn().mockResolvedValue(undefined), }; }); @@ -171,11 +171,11 @@ describe('QRSigningTransactionModal', () => { await successCallback(); - expect(Engine.context.ApprovalController.accept).toHaveBeenCalledWith( - mockTransactionId, - undefined, - { waitForResult: true }, - ); + expect( + Engine.context.ApprovalController.acceptRequest, + ).toHaveBeenCalledWith(mockTransactionId, undefined, { + waitForResult: true, + }); expect(mockOnConfirmationComplete).toHaveBeenCalledWith(true); }); diff --git a/app/components/UI/QRHardware/QRSigningTransactionModal.tsx b/app/components/UI/QRHardware/QRSigningTransactionModal.tsx index 0d9dc6c28ae..7313dd45931 100644 --- a/app/components/UI/QRHardware/QRSigningTransactionModal.tsx +++ b/app/components/UI/QRHardware/QRSigningTransactionModal.tsx @@ -61,7 +61,7 @@ const QRSigningTransactionModal = () => { setSigningStarted(true); try { // This triggers the QR keyring which populates pendingScanRequest - await ApprovalController.accept(transactionId, undefined, { + await ApprovalController.acceptRequest(transactionId, undefined, { waitForResult: true, }); onConfirmationComplete(true); diff --git a/app/components/UI/Transactions/index.js b/app/components/UI/Transactions/index.js index 68535026c27..36b0de58aa1 100644 --- a/app/components/UI/Transactions/index.js +++ b/app/components/UI/Transactions/index.js @@ -587,7 +587,7 @@ class Transactions extends PureComponent { }; cancelUnsignedQRTransaction = async (tx) => { - await Engine.context.ApprovalController.reject( + await Engine.context.ApprovalController.rejectRequest( tx.id, providerErrors.userRejectedRequest(), ); diff --git a/app/components/UI/Transactions/index.test.tsx b/app/components/UI/Transactions/index.test.tsx index 0890fa98738..d30212e9ff1 100644 --- a/app/components/UI/Transactions/index.test.tsx +++ b/app/components/UI/Transactions/index.test.tsx @@ -59,8 +59,8 @@ jest.mock('../../../util/transaction-controller', () => ({ jest.mock('../../../core/Engine', () => ({ context: { ApprovalController: { - accept: jest.fn(), - reject: jest.fn(), + acceptRequest: jest.fn(), + rejectRequest: jest.fn(), }, TransactionController: { stopTransaction: jest.fn(), @@ -626,7 +626,7 @@ describe('Transactions', () => { }); it('should test Engine context methods', () => { - expect(Engine.context.ApprovalController.accept).toBeDefined(); + expect(Engine.context.ApprovalController.acceptRequest).toBeDefined(); expect( Engine.context.TransactionController.stopTransaction, ).toBeDefined(); diff --git a/app/components/Views/UnifiedTransactionsView/useUnifiedTxActions.test.ts b/app/components/Views/UnifiedTransactionsView/useUnifiedTxActions.test.ts index d9c940caf6f..7f12c0efd53 100644 --- a/app/components/Views/UnifiedTransactionsView/useUnifiedTxActions.test.ts +++ b/app/components/Views/UnifiedTransactionsView/useUnifiedTxActions.test.ts @@ -85,8 +85,8 @@ jest.mock('../../../core/Engine', () => ({ stopTransaction: jest.fn(), }, ApprovalController: { - accept: jest.fn(), - reject: jest.fn(), + acceptRequest: jest.fn(), + rejectRequest: jest.fn(), }, GasFeeController: { startPolling: jest.fn(), @@ -109,7 +109,7 @@ describe('useUnifiedTxActions', () => { interface EngineContextMock { TransactionController: { stopTransaction: jest.Mock }; - ApprovalController: { accept: jest.Mock; reject: jest.Mock }; + ApprovalController: { acceptRequest: jest.Mock; rejectRequest: jest.Mock }; } const engineContext = Engine.context as unknown as EngineContextMock; @@ -449,7 +449,8 @@ describe('useUnifiedTxActions', () => { await result.current.cancelUnsignedQRTransaction(tx); }); - const rejectMock = engineContext.ApprovalController.reject as jest.Mock; + const rejectMock = engineContext.ApprovalController + .rejectRequest as jest.Mock; expect(rejectMock).toHaveBeenCalled(); const [id] = rejectMock.mock.calls[0]; expect(id).toBe('13'); diff --git a/app/components/Views/UnifiedTransactionsView/useUnifiedTxActions.ts b/app/components/Views/UnifiedTransactionsView/useUnifiedTxActions.ts index 9b7ebc4606c..459ebf99604 100644 --- a/app/components/Views/UnifiedTransactionsView/useUnifiedTxActions.ts +++ b/app/components/Views/UnifiedTransactionsView/useUnifiedTxActions.ts @@ -290,7 +290,7 @@ export function useUnifiedTxActions() { ); const cancelUnsignedQRTransaction = async (tx: TransactionMeta) => { - await Engine.context.ApprovalController.reject( + await Engine.context.ApprovalController.rejectRequest( tx.id, providerErrors.userRejectedRequest(), ); diff --git a/app/components/Views/confirmations/hooks/useConfirmNavigation.test.ts b/app/components/Views/confirmations/hooks/useConfirmNavigation.test.ts index e781d0ff737..d842ad6aea1 100644 --- a/app/components/Views/confirmations/hooks/useConfirmNavigation.test.ts +++ b/app/components/Views/confirmations/hooks/useConfirmNavigation.test.ts @@ -21,7 +21,7 @@ jest.mock('@react-navigation/native', () => ({ jest.mock('../../../../core/Engine', () => ({ context: { ApprovalController: { - reject: jest.fn(), + rejectRequest: jest.fn(), }, }, })); @@ -111,8 +111,8 @@ describe('useConfirmNavigation', () => { Engine.context.ApprovalController, ); - expect(approvalControllerMock.reject).toHaveBeenCalledTimes(1); - expect(approvalControllerMock.reject).toHaveBeenCalledWith( + expect(approvalControllerMock.rejectRequest).toHaveBeenCalledTimes(1); + expect(approvalControllerMock.rejectRequest).toHaveBeenCalledWith( TRANSACTION_ID_MOCK, expect.anything(), ); diff --git a/app/components/Views/confirmations/hooks/useConfirmNavigation.ts b/app/components/Views/confirmations/hooks/useConfirmNavigation.ts index 2d456c4e5b9..7dac76a53fa 100644 --- a/app/components/Views/confirmations/hooks/useConfirmNavigation.ts +++ b/app/components/Views/confirmations/hooks/useConfirmNavigation.ts @@ -104,7 +104,10 @@ function rejectTransactions(transactions: TransactionMeta[]) { for (const tx of transactions) { try { - ApprovalController.reject(tx.id, providerErrors.userRejectedRequest()); + ApprovalController.rejectRequest( + tx.id, + providerErrors.userRejectedRequest(), + ); log('Rejected transaction', tx.type, tx.id); } catch { log('Failed to reject transaction', tx.type, tx.id); diff --git a/app/core/Engine/Engine.ts b/app/core/Engine/Engine.ts index 4438d373f8b..eeac3b61b85 100644 --- a/app/core/Engine/Engine.ts +++ b/app/core/Engine/Engine.ts @@ -1167,12 +1167,12 @@ export class Engine { ) { const { ApprovalController } = this.context; - if (opts.ignoreMissing && !ApprovalController.has({ id })) { + if (opts.ignoreMissing && !ApprovalController.hasRequest({ id })) { return; } try { - ApprovalController.reject(id, reason); + ApprovalController.rejectRequest(id, reason); // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { @@ -1197,7 +1197,7 @@ export class Engine { const { ApprovalController } = this.context; try { - return await ApprovalController.accept(id, requestData, { + return await ApprovalController.acceptRequest(id, requestData, { waitForResult: opts.waitForResult, deleteAfterResult: opts.deleteAfterResult, }); diff --git a/app/core/Engine/messengers/snaps/snap-controller-messenger.ts b/app/core/Engine/messengers/snaps/snap-controller-messenger.ts index 43f961172e7..b1f12139a56 100644 --- a/app/core/Engine/messengers/snaps/snap-controller-messenger.ts +++ b/app/core/Engine/messengers/snaps/snap-controller-messenger.ts @@ -33,8 +33,8 @@ import { UpdateCaveat, } from '@metamask/permission-controller'; import { - AddApprovalRequest, - UpdateRequestState, + ApprovalControllerAddRequestAction, + ApprovalControllerUpdateRequestStateAction, } from '@metamask/approval-controller'; import { KeyringControllerLockEvent, @@ -63,8 +63,8 @@ type Actions = | RevokePermissions | RevokePermissionForAllSubjects | GetSubjects - | AddApprovalRequest - | UpdateRequestState + | ApprovalControllerAddRequestAction + | ApprovalControllerUpdateRequestStateAction | GrantPermissions | GetSubjectMetadata | UpdateCaveat diff --git a/app/core/Engine/messengers/transaction-pay-controller-messenger/transaction-pay-controller-messenger.ts b/app/core/Engine/messengers/transaction-pay-controller-messenger/transaction-pay-controller-messenger.ts index 82caa797531..0ff3b876c17 100644 --- a/app/core/Engine/messengers/transaction-pay-controller-messenger/transaction-pay-controller-messenger.ts +++ b/app/core/Engine/messengers/transaction-pay-controller-messenger/transaction-pay-controller-messenger.ts @@ -24,6 +24,7 @@ export function getTransactionPayControllerMessenger( rootMessenger.delegate({ actions: [ 'AccountTrackerController:getState', + 'AssetsController:getStateForTransactionPay', 'BridgeController:fetchQuotes', 'BridgeStatusController:submitTx', 'CurrencyRateController:getState', diff --git a/app/core/RPCMethods/RPCMethodMiddleware.test.ts b/app/core/RPCMethods/RPCMethodMiddleware.test.ts index 4a1faa36cdf..856f9240ee8 100644 --- a/app/core/RPCMethods/RPCMethodMiddleware.test.ts +++ b/app/core/RPCMethods/RPCMethodMiddleware.test.ts @@ -85,7 +85,7 @@ jest.mock('../Engine', () => ({ }, context: { ApprovalController: { - has: jest.fn(), + hasRequest: jest.fn(), }, SelectedNetworkController: { getNetworkClientIdForDomain: jest.fn(), @@ -1916,7 +1916,9 @@ describe('getRpcMethodMiddlewareHooks', () => { it('should call ApprovalController.has with correct origin', () => { hooks.hasApprovalRequestsForOrigin(); - expect(MockEngine.context.ApprovalController.has).toHaveBeenCalledWith({ + expect( + MockEngine.context.ApprovalController.hasRequest, + ).toHaveBeenCalledWith({ origin: testOrigin, }); }); diff --git a/app/core/RPCMethods/RPCMethodMiddleware.ts b/app/core/RPCMethods/RPCMethodMiddleware.ts index eb1e085a32c..24176feb9b9 100644 --- a/app/core/RPCMethods/RPCMethodMiddleware.ts +++ b/app/core/RPCMethods/RPCMethodMiddleware.ts @@ -367,7 +367,7 @@ export const getRpcMethodMiddlewareHooks = ({ }, }), hasApprovalRequestsForOrigin: () => - Engine.context.ApprovalController.has({ origin }), + Engine.context.ApprovalController.hasRequest({ origin }), getCurrentChainIdForDomain: (domain: string) => { const networkClientId = Engine.context.SelectedNetworkController.getNetworkClientIdForDomain( @@ -457,7 +457,7 @@ export const getRpcMethodMiddleware = ({ const requestUserApproval = async ({ type = '', requestData = {} }) => { checkTabActive(); - await Engine.context.ApprovalController.clear( + await Engine.context.ApprovalController.clearRequests( providerErrors.userRejectedRequest(), ); diff --git a/app/core/RPCMethods/wallet_addEthereumChain.js b/app/core/RPCMethods/wallet_addEthereumChain.js index 21266060991..096a72ae98b 100644 --- a/app/core/RPCMethods/wallet_addEthereumChain.js +++ b/app/core/RPCMethods/wallet_addEthereumChain.js @@ -131,7 +131,7 @@ export const wallet_addEthereumChain = async ({ ); // Remove all existing approvals, including other add network requests. - ApprovalController.clear(providerErrors.userRejectedRequest()); + ApprovalController.clearRequests(providerErrors.userRejectedRequest()); // If existing approval request was an add network request, wait for // it to be rejected and for the corresponding approval flow to be ended. diff --git a/app/core/RPCMethods/wallet_addEthereumChain.test.js b/app/core/RPCMethods/wallet_addEthereumChain.test.js index 7057159ecb6..43d1c64a591 100644 --- a/app/core/RPCMethods/wallet_addEthereumChain.test.js +++ b/app/core/RPCMethods/wallet_addEthereumChain.test.js @@ -40,7 +40,7 @@ jest.mock('../Engine', () => ({ setActiveNetwork: jest.fn(), }, ApprovalController: { - clear: jest.fn(), + clearRequests: jest.fn(), }, PermissionController: { hasPermission: jest.fn().mockReturnValue(true), @@ -371,7 +371,7 @@ describe('RPC Method - wallet_addEthereumChain', () => { describe('Approval Flow', () => { it('clears existing approval requests', async () => { - Engine.context.ApprovalController.clear.mockClear(); + Engine.context.ApprovalController.clearRequests.mockClear(); await wallet_addEthereumChain({ req: { @@ -380,7 +380,9 @@ describe('RPC Method - wallet_addEthereumChain', () => { ...otherOptions, }); - expect(Engine.context.ApprovalController.clear).toBeCalledTimes(1); + expect(Engine.context.ApprovalController.clearRequests).toBeCalledTimes( + 1, + ); }); }); diff --git a/app/core/SDKConnect/handlers/handleConnectionReady.ts b/app/core/SDKConnect/handlers/handleConnectionReady.ts index 8989f7c65a6..5c8c45e88d5 100644 --- a/app/core/SDKConnect/handlers/handleConnectionReady.ts +++ b/app/core/SDKConnect/handlers/handleConnectionReady.ts @@ -46,7 +46,7 @@ export const handleConnectionReady = async ({ if (!apiVersion) { // clear previous pending approval if (approvalController.get(connection.channelId)) { - approvalController.reject( + approvalController.rejectRequest( connection.channelId, providerErrors.userRejectedRequest(), ); @@ -143,7 +143,7 @@ export const handleConnectionReady = async ({ if (approvalController.get(connection.channelId)) { DevLogger.log(`SDKConnect::CLIENTS_READY reject previous approval`); // cleaning previous pending approval - approvalController.reject( + approvalController.rejectRequest( connection.channelId, providerErrors.userRejectedRequest(), ); diff --git a/app/core/SDKConnectV2/services/connection.test.ts b/app/core/SDKConnectV2/services/connection.test.ts index 100602cdeb5..e4c7cee77d0 100644 --- a/app/core/SDKConnectV2/services/connection.test.ts +++ b/app/core/SDKConnectV2/services/connection.test.ts @@ -33,7 +33,7 @@ jest.mock('../../Engine', () => ({ context: { ApprovalController: { getTotalApprovalCount: jest.fn(), - clear: jest.fn().mockResolvedValue(undefined), + clearRequests: jest.fn().mockResolvedValue(undefined), }, }, })); @@ -275,10 +275,12 @@ describe('Connection', () => { await onClientMessageCallback(walletCreateSessionPayload); expect(NavigationService.navigation?.goBack).toHaveBeenCalledTimes(1); - expect(Engine.context.ApprovalController.clear).toHaveBeenCalledTimes( - 1, - ); - expect(Engine.context.ApprovalController.clear).toHaveBeenCalledWith( + expect( + Engine.context.ApprovalController.clearRequests, + ).toHaveBeenCalledTimes(1); + expect( + Engine.context.ApprovalController.clearRequests, + ).toHaveBeenCalledWith( providerErrors.userRejectedRequest({ data: { cause: 'rejectAllApprovals', @@ -314,7 +316,9 @@ describe('Connection', () => { await onClientMessageCallback(walletCreateSessionPayload); expect(NavigationService.navigation?.goBack).not.toHaveBeenCalled(); - expect(Engine.context.ApprovalController.clear).not.toHaveBeenCalled(); + expect( + Engine.context.ApprovalController.clearRequests, + ).not.toHaveBeenCalled(); expect(mockBridgeInstance.send).toHaveBeenCalledWith( walletCreateSessionPayload, ); diff --git a/app/core/SDKConnectV2/services/connection.ts b/app/core/SDKConnectV2/services/connection.ts index 619bf85fb6b..30b39280678 100644 --- a/app/core/SDKConnectV2/services/connection.ts +++ b/app/core/SDKConnectV2/services/connection.ts @@ -104,7 +104,7 @@ export class Connection { // We must manually navigate away from the currently open approval request, otherwise an approval component may be rendered // with an approval request prop that it cannot handle and cause the wallet to throw an exception. NavigationService.navigation?.goBack(); - await Engine.context.ApprovalController.clear( + await Engine.context.ApprovalController.clearRequests( providerErrors.userRejectedRequest({ data: { cause: 'rejectAllApprovals', diff --git a/app/core/SnapKeyring/types.ts b/app/core/SnapKeyring/types.ts index e5d208080f7..9ec69302dbc 100644 --- a/app/core/SnapKeyring/types.ts +++ b/app/core/SnapKeyring/types.ts @@ -10,24 +10,24 @@ import { AccountsControllerListMultichainAccountsAction, } from '@metamask/accounts-controller'; import type { - AcceptRequest, - AddApprovalRequest, - EndFlow, - RejectRequest, - ShowError, - ShowSuccess, - StartFlow, + ApprovalControllerAcceptRequestAction, + ApprovalControllerAddRequestAction, + ApprovalControllerEndFlowAction, + ApprovalControllerRejectRequestAction, + ApprovalControllerShowErrorAction, + ApprovalControllerShowSuccessAction, + ApprovalControllerStartFlowAction, } from '@metamask/approval-controller'; import { SnapKeyringAllowedActions } from '@metamask/eth-snap-keyring'; export type SnapKeyringBuilderAllowActions = - | StartFlow - | EndFlow - | ShowSuccess - | ShowError - | AddApprovalRequest - | AcceptRequest - | RejectRequest + | ApprovalControllerStartFlowAction + | ApprovalControllerEndFlowAction + | ApprovalControllerShowSuccessAction + | ApprovalControllerShowErrorAction + | ApprovalControllerAddRequestAction + | ApprovalControllerAcceptRequestAction + | ApprovalControllerRejectRequestAction | MaybeUpdateState | TestOrigin | KeyringControllerGetAccountsAction diff --git a/app/core/Snaps/permissions/specifications.ts b/app/core/Snaps/permissions/specifications.ts index 8b151b14d63..ec93423d52e 100644 --- a/app/core/Snaps/permissions/specifications.ts +++ b/app/core/Snaps/permissions/specifications.ts @@ -30,7 +30,7 @@ import { PreferencesControllerGetStateAction } from '@metamask/preferences-contr import { DialogType, EnumToUnion } from '@metamask/snaps-sdk'; import { AddApprovalOptions, - AddApprovalRequest, + ApprovalControllerAddRequestAction, } from '@metamask/approval-controller'; import Logger from '../../../util/Logger'; import { HasPermission } from '@metamask/permission-controller'; @@ -41,7 +41,7 @@ import { ExcludedSnapEndowments, ExcludedSnapPermissions } from './permissions'; import { getMnemonic, getMnemonicSeed } from './utils'; export type SnapPermissionSpecificationsActions = - | AddApprovalRequest + | ApprovalControllerAddRequestAction | ClearSnapState | ControllerGetStateAction< 'CurrencyRateController', diff --git a/app/core/WalletConnect/WalletConnect2Session.ts b/app/core/WalletConnect/WalletConnect2Session.ts index addd4b22b06..7deb3418523 100644 --- a/app/core/WalletConnect/WalletConnect2Session.ts +++ b/app/core/WalletConnect/WalletConnect2Session.ts @@ -583,7 +583,7 @@ class WalletConnect2Session { // Clear any pending approvals before prompting the user to permit a new chain. // Unsure why this is needed, but it was previously found here before this code was refactored. // https://github.com/MetaMask/metamask-mobile/blob/081e412f6680e03ad509194acd620c67a273a92b/app/core/WalletConnect/wc-utils.ts#L242 - Engine.context.ApprovalController.clear( + Engine.context.ApprovalController.clearRequests( providerErrors.userRejectedRequest(), ); return originalRequestPermittedChainsPermissionIncrementalForOrigin( diff --git a/app/store/sagas/index.ts b/app/store/sagas/index.ts index 6ce9589bedd..13e812c0131 100644 --- a/app/store/sagas/index.ts +++ b/app/store/sagas/index.ts @@ -114,7 +114,7 @@ export function* appLockStateMachine() { try { const { ApprovalController } = Engine.context; if (ApprovalController) { - ApprovalController.clear(providerErrors.userRejectedRequest()); + ApprovalController.clearRequests(providerErrors.userRejectedRequest()); } } catch (error) { Logger.error( diff --git a/app/store/sagas/sagas.test.ts b/app/store/sagas/sagas.test.ts index 76033332988..ceb12a9dada 100644 --- a/app/store/sagas/sagas.test.ts +++ b/app/store/sagas/sagas.test.ts @@ -77,7 +77,7 @@ jest.mock('../../core/Engine', () => ({ updateAccounts: jest.fn(), }, ApprovalController: { - clear: jest.fn(), + clearRequests: jest.fn(), }, RemoteFeatureFlagController: { state: { @@ -349,7 +349,7 @@ describe('appStateListenerTask', () => { describe('appLockStateMachine', () => { const mockApprovalControllerClear = Engine.context.ApprovalController - .clear as jest.Mock; + .clearRequests as jest.Mock; beforeEach(() => { mockNavigate.mockClear(); diff --git a/app/util/permissions/index.test.ts b/app/util/permissions/index.test.ts index 78e3a94e6f5..07bcaea9b07 100644 --- a/app/util/permissions/index.test.ts +++ b/app/util/permissions/index.test.ts @@ -14,7 +14,7 @@ jest.mock('../../core/Engine', () => ({ }, context: { ApprovalController: { - reject: jest.fn(), + rejectRequest: jest.fn(), state: { pendingApprovals: {}, }, @@ -26,7 +26,7 @@ jest.mock('../../core/Engine', () => ({ }, })); -const mockReject = Engine.context.ApprovalController.reject as jest.Mock; +const mockReject = Engine.context.ApprovalController.rejectRequest as jest.Mock; describe('Permission Utils', () => { afterEach(() => { diff --git a/app/util/permissions/index.ts b/app/util/permissions/index.ts index a8cccf8b744..de782df224c 100644 --- a/app/util/permissions/index.ts +++ b/app/util/permissions/index.ts @@ -38,7 +38,7 @@ const rejectApproval = ({ case ApprovalType.SnapDialogPrompt: case DIALOG_APPROVAL_TYPES.default: approvalLog('Rejecting snap dialog', { id, interfaceId, origin, type }); - ApprovalController.accept(id, null); + ApprovalController.acceptRequest(id, null); deleteInterface?.(interfaceId); break; @@ -49,7 +49,7 @@ const rejectApproval = ({ origin, type, }); - ApprovalController.accept(id, false); + ApprovalController.acceptRequest(id, false); deleteInterface?.(interfaceId); break; @@ -58,13 +58,16 @@ const rejectApproval = ({ case SNAP_MANAGE_ACCOUNTS_CONFIRMATION_TYPES.confirmAccountRemoval: case SNAP_MANAGE_ACCOUNTS_CONFIRMATION_TYPES.showSnapAccountRedirect: approvalLog('Rejecting snap account confirmation', { id, origin, type }); - ApprovalController.accept(id, false); + ApprovalController.acceptRequest(id, false); break; ///: END:ONLY_INCLUDE_IF default: approvalLog('Rejecting pending approval', { id, origin, type }); - ApprovalController.reject(id, providerErrors.userRejectedRequest()); + ApprovalController.rejectRequest( + id, + providerErrors.userRejectedRequest(), + ); break; } }; diff --git a/package.json b/package.json index e2b8ecf282a..01fadf2a7cd 100644 --- a/package.json +++ b/package.json @@ -185,9 +185,7 @@ "@metamask/accounts-controller": "37.0.0", "@metamask/core-backend": "^5.0.0", "bn.js@npm:4.11.6": "4.12.3", - "bn.js@npm:5.2.1": "5.2.3", - "@metamask/assets-controllers@npm:^100.2.1": "patch:@metamask/assets-controllers@npm%3A100.2.1#~/.yarn/patches/@metamask-assets-controllers-npm-100.2.1-4f7a0c8320.patch", - "@metamask/assets-controllers@npm:^101.0.0": "patch:@metamask/assets-controllers@npm%3A100.2.1#~/.yarn/patches/@metamask-assets-controllers-npm-100.2.1-4f7a0c8320.patch" + "bn.js@npm:5.2.1": "5.2.3" }, "dependencies": { "@config-plugins/detox": "^9.0.0", @@ -207,13 +205,13 @@ "@metamask/account-api": "^1.0.0", "@metamask/account-tree-controller": "^5.0.0", "@metamask/accounts-controller": "^37.0.0", - "@metamask/address-book-controller": "^7.0.1", + "@metamask/address-book-controller": "^7.1.0", "@metamask/ai-controllers": "^0.3.0", "@metamask/analytics-controller": "^1.0.0", "@metamask/app-metadata-controller": "^2.0.0", - "@metamask/approval-controller": "^8.0.0", - "@metamask/assets-controller": "^2.0.0", - "@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A100.2.1#~/.yarn/patches/@metamask-assets-controllers-npm-100.2.1-4f7a0c8320.patch", + "@metamask/approval-controller": "^9.0.0", + "@metamask/assets-controller": "^2.3.0", + "@metamask/assets-controllers": "^101.0.1", "@metamask/base-controller": "^9.0.0", "@metamask/bitcoin-wallet-snap": "^1.10.0", "@metamask/bridge-controller": "^69.1.1", @@ -256,7 +254,7 @@ "@metamask/keyring-internal-api": "^10.0.0", "@metamask/keyring-snap-client": "^8.1.0", "@metamask/keyring-utils": "^3.2.0", - "@metamask/logging-controller": "^7.0.0", + "@metamask/logging-controller": "^8.0.0", "@metamask/message-signing-snap": "^1.1.2", "@metamask/messenger": "^0.3.0", "@metamask/metamask-eth-abis": "3.1.1", @@ -271,7 +269,7 @@ "@metamask/network-controller": "^30.0.0", "@metamask/network-enablement-controller": "^4.2.0", "@metamask/notification-services-controller": "^22.0.0", - "@metamask/permission-controller": "^12.1.0", + "@metamask/permission-controller": "^12.2.1", "@metamask/phishing-controller": "^16.3.0", "@metamask/post-message-stream": "^10.0.0", "@metamask/preferences-controller": "^23.0.0", @@ -293,7 +291,7 @@ "@metamask/sdk-communication-layer": "0.33.1", "@metamask/seedless-onboarding-controller": "^8.1.0", "@metamask/selected-network-controller": "^25.0.0", - "@metamask/signature-controller": "^35.0.0", + "@metamask/signature-controller": "^39.1.0", "@metamask/slip44": "^4.2.0", "@metamask/smart-transactions-controller": "^22.7.0", "@metamask/snaps-controllers": "^18.0.4", @@ -308,8 +306,8 @@ "@metamask/superstruct": "^3.2.1", "@metamask/swappable-obj-proxy": "^2.1.0", "@metamask/swaps-controller": "^15.0.0", - "@metamask/transaction-controller": "^62.22.0", - "@metamask/transaction-pay-controller": "^17.0.0", + "@metamask/transaction-controller": "^63.0.0", + "@metamask/transaction-pay-controller": "^17.1.0", "@metamask/tron-wallet-snap": "1.24.0", "@metamask/utils": "^11.8.1", "@myx-trade/sdk": "^0.1.265", diff --git a/yarn.lock b/yarn.lock index c3541c76c96..a2ca5ff968e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6626,13 +6626,6 @@ __metadata: languageName: node linkType: hard -"@isaacs/cliui@npm:^9.0.0": - version: 9.0.0 - resolution: "@isaacs/cliui@npm:9.0.0" - checksum: 10/8ea3d1009fd29071419209bb91ede20cf27e6e2a1630c5e0702d8b3f47f9e1a3f1c5a587fa2cb96d22d18219790327df49db1bcced573346bbaf4577cf46b643 - languageName: node - linkType: hard - "@isaacs/fs-minipass@npm:^4.0.0": version: 4.0.1 resolution: "@isaacs/fs-minipass@npm:4.0.1" @@ -7532,6 +7525,13 @@ __metadata: languageName: node linkType: hard +"@metamask/7715-permission-types@npm:^0.5.0": + version: 0.5.0 + resolution: "@metamask/7715-permission-types@npm:0.5.0" + checksum: 10/f01dcf7ffc3e39536f7cc4d54c088ea659c392de5bdfcaafb9f4d67bbe6b56010358ed2a2ba3adba4e454af51412a2fd5be377cac5c7ab101b032d30711e0b37 + languageName: node + linkType: hard + "@metamask/abi-utils@npm:^2.0.3": version: 2.0.4 resolution: "@metamask/abi-utils@npm:2.0.4" @@ -7617,15 +7617,15 @@ __metadata: languageName: node linkType: hard -"@metamask/address-book-controller@npm:^7.0.1": - version: 7.0.1 - resolution: "@metamask/address-book-controller@npm:7.0.1" +"@metamask/address-book-controller@npm:^7.0.1, @metamask/address-book-controller@npm:^7.1.0": + version: 7.1.0 + resolution: "@metamask/address-book-controller@npm:7.1.0" dependencies: "@metamask/base-controller": "npm:^9.0.0" - "@metamask/controller-utils": "npm:^11.16.0" + "@metamask/controller-utils": "npm:^11.19.0" "@metamask/messenger": "npm:^0.3.0" - "@metamask/utils": "npm:^11.8.1" - checksum: 10/5ada2568b7093e33b93f9caad64e3e72dd7b581ee2758cee5ea8b261b2efedbfd355659f2d2917080399e4ca782bfaa0c12be5383f2db017302d98d2902a480d + "@metamask/utils": "npm:^11.9.0" + checksum: 10/0c2feddcfcd16e535bc6af2268917a8327ad4c54f6ae6c6df4cfe1ddcda2045e5984ae42e8cb7b9a32e7b5604bfcc70c72015b3756fe9773cb2d18542d33f5b4 languageName: node linkType: hard @@ -7695,7 +7695,7 @@ __metadata: languageName: node linkType: hard -"@metamask/assets-controller@npm:^2.0.0, @metamask/assets-controller@npm:^2.3.0, @metamask/assets-controller@npm:^2.4.0": +"@metamask/assets-controller@npm:^2.3.0, @metamask/assets-controller@npm:^2.4.0": version: 2.4.0 resolution: "@metamask/assets-controller@npm:2.4.0" dependencies: @@ -7730,9 +7730,9 @@ __metadata: languageName: node linkType: hard -"@metamask/assets-controllers@npm:100.2.1": - version: 100.2.1 - resolution: "@metamask/assets-controllers@npm:100.2.1" +"@metamask/assets-controllers@npm:^101.0.0, @metamask/assets-controllers@npm:^101.0.1": + version: 101.0.1 + resolution: "@metamask/assets-controllers@npm:101.0.1" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@ethersproject/abi": "npm:^5.7.0" @@ -7743,67 +7743,11 @@ __metadata: "@metamask/abi-utils": "npm:^2.0.3" "@metamask/account-tree-controller": "npm:^5.0.1" "@metamask/accounts-controller": "npm:^37.0.0" - "@metamask/approval-controller": "npm:^8.0.0" - "@metamask/base-controller": "npm:^9.0.0" - "@metamask/contract-metadata": "npm:^2.4.0" - "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/core-backend": "npm:^6.1.1" - "@metamask/eth-query": "npm:^4.0.0" - "@metamask/keyring-api": "npm:^21.5.0" - "@metamask/keyring-controller": "npm:^25.1.0" - "@metamask/messenger": "npm:^0.3.0" - "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/multichain-account-service": "npm:^7.1.0" - "@metamask/network-controller": "npm:^30.0.0" - "@metamask/network-enablement-controller": "npm:^4.2.0" - "@metamask/permission-controller": "npm:^12.2.0" - "@metamask/phishing-controller": "npm:^16.3.0" - "@metamask/polling-controller": "npm:^16.0.3" - "@metamask/preferences-controller": "npm:^23.0.0" - "@metamask/profile-sync-controller": "npm:^28.0.0" - "@metamask/rpc-errors": "npm:^7.0.2" - "@metamask/snaps-controllers": "npm:^17.2.0" - "@metamask/snaps-sdk": "npm:^10.3.0" - "@metamask/snaps-utils": "npm:^11.7.0" - "@metamask/storage-service": "npm:^1.0.0" - "@metamask/transaction-controller": "npm:^62.21.0" - "@metamask/utils": "npm:^11.9.0" - "@types/bn.js": "npm:^5.1.5" - "@types/uuid": "npm:^8.3.0" - async-mutex: "npm:^0.5.0" - bitcoin-address-validation: "npm:^2.2.3" - bn.js: "npm:^5.2.1" - immer: "npm:^9.0.6" - lodash: "npm:^4.17.21" - multiformats: "npm:^9.9.0" - reselect: "npm:^5.1.1" - single-call-balance-checker-abi: "npm:^1.0.0" - uuid: "npm:^8.3.2" - peerDependencies: - "@metamask/providers": ^22.0.0 - webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/383546ac2d9263332d32b3e50b0afd8eb1356e5fd5feb4b2aadb2cee01d5737757925304b484e3ca2d5e27dc727431fd0ef4de6b077859e15733d99ce1d67066 - languageName: node - linkType: hard - -"@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A100.2.1#~/.yarn/patches/@metamask-assets-controllers-npm-100.2.1-4f7a0c8320.patch": - version: 100.2.1 - resolution: "@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A100.2.1#~/.yarn/patches/@metamask-assets-controllers-npm-100.2.1-4f7a0c8320.patch::version=100.2.1&hash=eff93b" - dependencies: - "@ethereumjs/util": "npm:^9.1.0" - "@ethersproject/abi": "npm:^5.7.0" - "@ethersproject/address": "npm:^5.7.0" - "@ethersproject/bignumber": "npm:^5.7.0" - "@ethersproject/contracts": "npm:^5.7.0" - "@ethersproject/providers": "npm:^5.7.0" - "@metamask/abi-utils": "npm:^2.0.3" - "@metamask/account-tree-controller": "npm:^5.0.1" - "@metamask/accounts-controller": "npm:^37.0.0" - "@metamask/approval-controller": "npm:^8.0.0" + "@metamask/approval-controller": "npm:^9.0.0" "@metamask/base-controller": "npm:^9.0.0" "@metamask/contract-metadata": "npm:^2.4.0" "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/core-backend": "npm:^6.1.1" + "@metamask/core-backend": "npm:^6.2.0" "@metamask/eth-query": "npm:^4.0.0" "@metamask/keyring-api": "npm:^21.5.0" "@metamask/keyring-controller": "npm:^25.1.0" @@ -7811,9 +7755,9 @@ __metadata: "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/multichain-account-service": "npm:^7.1.0" "@metamask/network-controller": "npm:^30.0.0" - "@metamask/network-enablement-controller": "npm:^4.2.0" - "@metamask/permission-controller": "npm:^12.2.0" - "@metamask/phishing-controller": "npm:^16.3.0" + "@metamask/network-enablement-controller": "npm:^5.0.0" + "@metamask/permission-controller": "npm:^12.2.1" + "@metamask/phishing-controller": "npm:^17.0.0" "@metamask/polling-controller": "npm:^16.0.3" "@metamask/preferences-controller": "npm:^23.0.0" "@metamask/profile-sync-controller": "npm:^28.0.0" @@ -7822,7 +7766,7 @@ __metadata: "@metamask/snaps-sdk": "npm:^10.3.0" "@metamask/snaps-utils": "npm:^11.7.0" "@metamask/storage-service": "npm:^1.0.0" - "@metamask/transaction-controller": "npm:^62.21.0" + "@metamask/transaction-controller": "npm:^63.0.0" "@metamask/utils": "npm:^11.9.0" "@types/bn.js": "npm:^5.1.5" "@types/uuid": "npm:^8.3.0" @@ -7838,7 +7782,7 @@ __metadata: peerDependencies: "@metamask/providers": ^22.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/ff1211e812c580e0c0d4fd584e7c83335bca8995a7a9975060a215c40ca1a5f0a1de1f35791f3b706cc266406ee0028ab11e2e360e230ffb4764c4103acc433d + checksum: 10/183443bcc72fa08eabda0a7c7d853bc97fb18afb89fca9440fce4fcdd7bf5d34dfdaa3de7ea0497a9c5312faaf60565ad67f23ff2c5166ed732ba523701659e9 languageName: node linkType: hard @@ -7967,6 +7911,29 @@ __metadata: languageName: node linkType: hard +"@metamask/bridge-status-controller@npm:^69.0.0": + version: 69.0.0 + resolution: "@metamask/bridge-status-controller@npm:69.0.0" + dependencies: + "@metamask/accounts-controller": "npm:^37.0.0" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/bridge-controller": "npm:^69.1.1" + "@metamask/controller-utils": "npm:^11.19.0" + "@metamask/gas-fee-controller": "npm:^26.1.0" + "@metamask/keyring-controller": "npm:^25.1.0" + "@metamask/network-controller": "npm:^30.0.0" + "@metamask/polling-controller": "npm:^16.0.3" + "@metamask/profile-sync-controller": "npm:^28.0.0" + "@metamask/snaps-controllers": "npm:^17.2.0" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/transaction-controller": "npm:^63.0.0" + "@metamask/utils": "npm:^11.9.0" + bignumber.js: "npm:^9.1.2" + uuid: "npm:^8.3.2" + checksum: 10/65a08737028eff08ce1b0438e3836418f70a7c30fd49ed5117df98271cd94923e08cfe29790dfb600c8d8232401afb0807b7d92d237204d4b86190bccc47d809 + languageName: node + linkType: hard + "@metamask/browser-passworder@npm:^5.0.0": version: 5.0.0 resolution: "@metamask/browser-passworder@npm:5.0.0" @@ -8625,6 +8592,24 @@ __metadata: languageName: node linkType: hard +"@metamask/gator-permissions-controller@npm:^2.1.1": + version: 2.1.1 + resolution: "@metamask/gator-permissions-controller@npm:2.1.1" + dependencies: + "@metamask/7715-permission-types": "npm:^0.5.0" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/delegation-core": "npm:^0.2.0" + "@metamask/delegation-deployments": "npm:^0.12.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/snaps-controllers": "npm:^17.2.0" + "@metamask/snaps-sdk": "npm:^10.3.0" + "@metamask/snaps-utils": "npm:^11.7.0" + "@metamask/transaction-controller": "npm:^63.0.0" + "@metamask/utils": "npm:^11.9.0" + checksum: 10/f78fc482e79a426921f3f98922fe4c26d890d6b20e12ce0a84fa5787778ec237bdae1c3cfc3530e1911b5d58e00091db62f64cc4b41519b0b24bbf6e2a6dc17c + languageName: node + linkType: hard + "@metamask/geolocation-controller@npm:^0.1.1": version: 0.1.1 resolution: "@metamask/geolocation-controller@npm:0.1.1" @@ -8814,15 +8799,15 @@ __metadata: languageName: node linkType: hard -"@metamask/logging-controller@npm:^7.0.0": - version: 7.0.0 - resolution: "@metamask/logging-controller@npm:7.0.0" +"@metamask/logging-controller@npm:^8.0.0": + version: 8.0.0 + resolution: "@metamask/logging-controller@npm:8.0.0" dependencies: "@metamask/base-controller": "npm:^9.0.0" - "@metamask/controller-utils": "npm:^11.14.1" + "@metamask/controller-utils": "npm:^11.19.0" "@metamask/messenger": "npm:^0.3.0" uuid: "npm:^8.3.2" - checksum: 10/ab5a86ceceebcc0781bcfc94766b884c5a9b6776f24128169acbf7700d771e9fac9fffb3c3f16bbb9e0a4c3e1843053ab096e35d11db669a670f5eabaee82ce4 + checksum: 10/08fc0b7ab2f6f1c1ccfafd2c47c9952ca19a828e11c502868a3fc434193d763c7685748de7454670e15155047d5879a1980e89eba34b0695f1754078f97f29cd languageName: node linkType: hard @@ -9183,7 +9168,7 @@ __metadata: languageName: node linkType: hard -"@metamask/permission-controller@npm:^12.0.0, @metamask/permission-controller@npm:^12.1.0, @metamask/permission-controller@npm:^12.1.1, @metamask/permission-controller@npm:^12.2.0, @metamask/permission-controller@npm:^12.2.1": +"@metamask/permission-controller@npm:^12.0.0, @metamask/permission-controller@npm:^12.1.1, @metamask/permission-controller@npm:^12.2.0, @metamask/permission-controller@npm:^12.2.1": version: 12.2.1 resolution: "@metamask/permission-controller@npm:12.2.1" dependencies: @@ -9219,6 +9204,23 @@ __metadata: languageName: node linkType: hard +"@metamask/phishing-controller@npm:^17.0.0": + version: 17.0.0 + resolution: "@metamask/phishing-controller@npm:17.0.0" + dependencies: + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/controller-utils": "npm:^11.19.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/transaction-controller": "npm:^63.0.0" + "@noble/hashes": "npm:^1.8.0" + "@types/punycode": "npm:^2.1.0" + ethereum-cryptography: "npm:^2.1.2" + fastest-levenshtein: "npm:^1.0.16" + punycode: "npm:^2.1.1" + checksum: 10/a1917ad63feb5c6287b7a191f78750d6455239909b0df5d07a965279638ccccb67de73d2f3cbe5596252e14b394565978bb86aa52e0adf388059d031531d0e93 + languageName: node + linkType: hard + "@metamask/polling-controller@npm:^15.0.0": version: 15.0.0 resolution: "@metamask/polling-controller@npm:15.0.0" @@ -9581,26 +9583,25 @@ __metadata: languageName: node linkType: hard -"@metamask/signature-controller@npm:^35.0.0": - version: 35.0.0 - resolution: "@metamask/signature-controller@npm:35.0.0" +"@metamask/signature-controller@npm:^39.1.0": + version: 39.1.0 + resolution: "@metamask/signature-controller@npm:39.1.0" dependencies: + "@metamask/accounts-controller": "npm:^37.0.0" + "@metamask/approval-controller": "npm:^9.0.0" "@metamask/base-controller": "npm:^9.0.0" - "@metamask/controller-utils": "npm:^11.14.1" + "@metamask/controller-utils": "npm:^11.19.0" "@metamask/eth-sig-util": "npm:^8.2.0" + "@metamask/gator-permissions-controller": "npm:^2.1.1" + "@metamask/keyring-controller": "npm:^25.1.0" + "@metamask/logging-controller": "npm:^8.0.0" "@metamask/messenger": "npm:^0.3.0" - "@metamask/utils": "npm:^11.8.1" + "@metamask/network-controller": "npm:^30.0.0" + "@metamask/utils": "npm:^11.9.0" jsonschema: "npm:^1.4.1" lodash: "npm:^4.17.21" uuid: "npm:^8.3.2" - peerDependencies: - "@metamask/accounts-controller": ^34.0.0 - "@metamask/approval-controller": ^8.0.0 - "@metamask/gator-permissions-controller": ^0.3.0 - "@metamask/keyring-controller": ^24.0.0 - "@metamask/logging-controller": ^7.0.0 - "@metamask/network-controller": ^25.0.0 - checksum: 10/aef26b1453c286a96a0ceead056e07518fc0880ff5f9b08cf896f6e072a1e44886786ceb8abbbc2ee1186f1224ff315bf27a99d937d9587c1c60bf4dde5183d3 + checksum: 10/4fb9d6492753241ec8a2681ef37ac81365cedf004a289ee9782b47341e1783112f46effdee73e2d917bbf5dc352ba1b303058917817d4139bfa044b0494b67d8 languageName: node linkType: hard @@ -10159,31 +10160,31 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-pay-controller@npm:^17.0.0": - version: 17.0.0 - resolution: "@metamask/transaction-pay-controller@npm:17.0.0" +"@metamask/transaction-pay-controller@npm:^17.1.0": + version: 17.1.0 + resolution: "@metamask/transaction-pay-controller@npm:17.1.0" dependencies: "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/assets-controller": "npm:^2.3.0" - "@metamask/assets-controllers": "npm:^100.2.1" + "@metamask/assets-controller": "npm:^2.4.0" + "@metamask/assets-controllers": "npm:^101.0.0" "@metamask/base-controller": "npm:^9.0.0" - "@metamask/bridge-controller": "npm:^69.1.0" - "@metamask/bridge-status-controller": "npm:^68.1.0" + "@metamask/bridge-controller": "npm:^69.1.1" + "@metamask/bridge-status-controller": "npm:^69.0.0" "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/gas-fee-controller": "npm:^26.0.3" + "@metamask/gas-fee-controller": "npm:^26.1.0" "@metamask/messenger": "npm:^0.3.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/network-controller": "npm:^30.0.0" "@metamask/remote-feature-flag-controller": "npm:^4.1.0" - "@metamask/transaction-controller": "npm:^62.22.0" + "@metamask/transaction-controller": "npm:^63.0.0" "@metamask/utils": "npm:^11.9.0" bignumber.js: "npm:^9.1.2" bn.js: "npm:^5.2.1" immer: "npm:^9.0.6" lodash: "npm:^4.17.21" - checksum: 10/3563c46ae779c24b46cf245874504b94ea9be205015976092a275433f3600699993bbfbcf8b11181800c0f8f94dc222b7d55b403f61e3699588c1968258447b3 + checksum: 10/79d8cb54d010551c63cd34f5dd9d4d0b3fab3186ae770133a573c528b16598c2421241219801a4c81bf3dc38805c95b5c8a680bfe5d070dece50cf4dac891799 languageName: node linkType: hard @@ -33088,11 +33089,11 @@ __metadata: linkType: hard "jackspeak@npm:^4.0.1, jackspeak@npm:^4.1.1": - version: 4.2.3 - resolution: "jackspeak@npm:4.2.3" + version: 4.1.1 + resolution: "jackspeak@npm:4.1.1" dependencies: - "@isaacs/cliui": "npm:^9.0.0" - checksum: 10/b88e3fe5fa04d34f0f939a15b7cef4a8589999b7a366ef89a3e0f2c45d2a7666066b67cbf46d57c3a4796a76d27b9d869b23d96a803dd834200d222c2a70de7e + "@isaacs/cliui": "npm:^8.0.2" + checksum: 10/ffceb270ec286841f48413bfb4a50b188662dfd599378ce142b6540f3f0a66821dc9dcb1e9ebc55c6c3b24dc2226c96e5819ba9bd7a241bd29031b61911718c7 languageName: node linkType: hard @@ -35518,13 +35519,13 @@ __metadata: "@metamask/account-api": "npm:^1.0.0" "@metamask/account-tree-controller": "npm:^5.0.0" "@metamask/accounts-controller": "npm:^37.0.0" - "@metamask/address-book-controller": "npm:^7.0.1" + "@metamask/address-book-controller": "npm:^7.1.0" "@metamask/ai-controllers": "npm:^0.3.0" "@metamask/analytics-controller": "npm:^1.0.0" "@metamask/app-metadata-controller": "npm:^2.0.0" - "@metamask/approval-controller": "npm:^8.0.0" - "@metamask/assets-controller": "npm:^2.0.0" - "@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A100.2.1#~/.yarn/patches/@metamask-assets-controllers-npm-100.2.1-4f7a0c8320.patch" + "@metamask/approval-controller": "npm:^9.0.0" + "@metamask/assets-controller": "npm:^2.3.0" + "@metamask/assets-controllers": "npm:^101.0.1" "@metamask/auto-changelog": "npm:^5.3.0" "@metamask/base-controller": "npm:^9.0.0" "@metamask/bitcoin-wallet-snap": "npm:^1.10.0" @@ -35574,7 +35575,7 @@ __metadata: "@metamask/keyring-internal-api": "npm:^10.0.0" "@metamask/keyring-snap-client": "npm:^8.1.0" "@metamask/keyring-utils": "npm:^3.2.0" - "@metamask/logging-controller": "npm:^7.0.0" + "@metamask/logging-controller": "npm:^8.0.0" "@metamask/message-signing-snap": "npm:^1.1.2" "@metamask/messenger": "npm:^0.3.0" "@metamask/metamask-eth-abis": "npm:3.1.1" @@ -35591,7 +35592,7 @@ __metadata: "@metamask/network-enablement-controller": "npm:^4.2.0" "@metamask/notification-services-controller": "npm:^22.0.0" "@metamask/object-multiplex": "npm:^1.1.0" - "@metamask/permission-controller": "npm:^12.1.0" + "@metamask/permission-controller": "npm:^12.2.1" "@metamask/phishing-controller": "npm:^16.3.0" "@metamask/post-message-stream": "npm:^10.0.0" "@metamask/preferences-controller": "npm:^23.0.0" @@ -35614,7 +35615,7 @@ __metadata: "@metamask/sdk-communication-layer": "npm:0.33.1" "@metamask/seedless-onboarding-controller": "npm:^8.1.0" "@metamask/selected-network-controller": "npm:^25.0.0" - "@metamask/signature-controller": "npm:^35.0.0" + "@metamask/signature-controller": "npm:^39.1.0" "@metamask/slip44": "npm:^4.2.0" "@metamask/smart-transactions-controller": "npm:^22.7.0" "@metamask/snaps-controllers": "npm:^18.0.4" @@ -35632,8 +35633,8 @@ __metadata: "@metamask/test-dapp": "npm:9.5.0" "@metamask/test-dapp-multichain": "npm:^0.17.1" "@metamask/test-dapp-solana": "npm:^0.3.0" - "@metamask/transaction-controller": "npm:^62.22.0" - "@metamask/transaction-pay-controller": "npm:^17.0.0" + "@metamask/transaction-controller": "npm:^63.0.0" + "@metamask/transaction-pay-controller": "npm:^17.1.0" "@metamask/tron-wallet-snap": "npm:1.24.0" "@metamask/utils": "npm:^11.8.1" "@myx-trade/sdk": "npm:^0.1.265" From a3ec12ca967a73b5f79cacd2301277af6fe4df59 Mon Sep 17 00:00:00 2001 From: Bruno Nascimento Date: Thu, 19 Mar 2026 11:14:12 -0300 Subject: [PATCH 138/206] feat(card): Card Authentication migration from CardSDK to CardController (#27656) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Migrates Card authentication to the CardController, introducing a controller-based auth flow that delegates to BaanxProvider and a new `useCardAuth` hook for UI consumption. **Why**: Centralizing auth logic in the CardController aligns with the MetaMask controller architecture, provides a single source of truth for auth state, and enables future provider-agnostic auth flows. The new `useCardAuth` hook exposes mutations (initiate, submit, stepAction, logout) backed by the controller, with React Query for cache invalidation on success. **What changed**: - **CardController**: Added auth methods `initiateAuth`, `submitCredentials`, `executeStepAction`, and `logout` that delegate to the active provider (BaanxProvider). Manages `currentSession` for multi-step flows (email/password → OTP → complete). - **BaanxProvider**: Implemented `executeStepAction` (generic OTP trigger) that posts `userId` to `/v1/auth/login/otp`. Renamed from provider-specific `sendOtp` to align with `ICardProvider` interface. - **provider-types**: Added optional `executeStepAction?(session: CardAuthSession): Promise` to `ICardProvider`. - **useCardAuth hook**: New hook exposing `currentStep`, `initiate`, `submit`, `stepAction`, and `logout` mutations. Uses CardController for all auth operations; updates `currentStep` on submit results (OTP, onboarding required, done); invalidates card queries on login success; removes queries on logout. - **auth queries**: Added `authKeys` (initiate, submit, step-action, logout) to `cardQueries.auth.keys`. - **getCardProviderErrorMessage**: New utility mapping `CardProviderError` codes to localized strings for Card authentication UI. - **BaanxService / baanx-config**: Minor adjustments for config resolution and service usage. - **Tests**: Updated BaanxProvider tests (`sendOtp` → `executeStepAction`); fixed baanx-config test mock isolation with `beforeEach` mock clear; expanded CardController tests for auth methods; added `useCardAuth` unit tests. ## **Changelog** CHANGELOG entry: Migrated Card authentication to CardController with new `useCardAuth` hook for controller-based auth flow. ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Card authentication via CardController Scenario: User logs in with email and password (no OTP) Given I am on the Card authentication screen And I have valid email and password credentials When I enter my email and password And I tap the login button Then I should be authenticated and see the Card home/dashboard Scenario: User logs in with email, password, and OTP Given I am on the Card authentication screen And I have credentials that require OTP verification When I enter my email and password And I tap the login button Then I should see the OTP input screen When I tap "Resend code" (or wait for cooldown) Then an OTP should be sent to my device/email When I enter the OTP code and submit Then I should be authenticated and see the Card home/dashboard Scenario: User logs out Given I am authenticated in the Card flow When I log out (or navigate away and return to auth) Then I should see the login screen again And card queries should be cleared ``` ## **Screenshots/Recordings** No visual design changes — authentication flow (email/password, OTP, logout) behaves the same; only the underlying implementation (CardController + useCardAuth vs SDK) changed. ## **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. ## **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] > **High Risk** > High risk because it introduces a new controller-driven authentication/session lifecycle (multi-step auth, token storage, refresh, and logout) and changes default provider selection/state, which can impact login persistence and security-sensitive flows. > > **Overview** > Migrates card authentication off the UI/SDK path into `CardController`, adding controller-level methods for `initiateAuth`, `submitCredentials`, `executeStepAction`, `logout`, and `validateAndRefreshSession` with token persistence via `CardTokenStore`, provider delegation, and session/step tracking. > > Adds a new UI hook `useCardAuth` backed by React Query mutations (with new `cardQueries.auth` keys) to drive the multi-step login flow and to invalidate/remove card queries on login/logout. > > Standardizes provider step actions by renaming `ICardProvider.sendOtp` to `executeStepAction` and updating `BaanxProvider` accordingly, plus adds localized error mapping via `getCardProviderErrorMessage` and extends Baanx config/service behavior (env override for base URL; per-request location override for `x-us-env`). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit a91ba1934aece383d61a39eb9ab1c3cc0f50faa3. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../UI/Card/hooks/useCardAuth.test.ts | 249 ++++++++++++ app/components/UI/Card/hooks/useCardAuth.ts | 75 ++++ app/components/UI/Card/queries/auth.ts | 7 + app/components/UI/Card/queries/index.ts | 4 + .../util/getCardProviderErrorMessage.test.ts | 117 ++++++ .../Card/util/getCardProviderErrorMessage.ts | 27 ++ .../card-controller/CardController.test.ts | 362 +++++++++++++++++- .../card-controller/CardController.ts | 194 +++++++++- .../controllers/card-controller/index.ts | 14 +- .../card-controller/provider-types.ts | 2 +- .../providers/BaanxProvider.test.ts | 8 +- .../providers/BaanxProvider.ts | 7 +- .../services/BaanxService.test.ts | 70 ++++ .../card-controller/services/BaanxService.ts | 18 +- .../services/baanx-config.test.ts | 16 +- .../card-controller/services/baanx-config.ts | 6 +- .../logs/__snapshots__/index.test.ts.snap | 4 +- app/util/test/initial-background-state.json | 2 +- 18 files changed, 1159 insertions(+), 23 deletions(-) create mode 100644 app/components/UI/Card/hooks/useCardAuth.test.ts create mode 100644 app/components/UI/Card/hooks/useCardAuth.ts create mode 100644 app/components/UI/Card/queries/auth.ts create mode 100644 app/components/UI/Card/util/getCardProviderErrorMessage.test.ts create mode 100644 app/components/UI/Card/util/getCardProviderErrorMessage.ts diff --git a/app/components/UI/Card/hooks/useCardAuth.test.ts b/app/components/UI/Card/hooks/useCardAuth.test.ts new file mode 100644 index 00000000000..9dab7e0c6fb --- /dev/null +++ b/app/components/UI/Card/hooks/useCardAuth.test.ts @@ -0,0 +1,249 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import Engine from '../../../../core/Engine'; +import { cardQueries } from '../queries'; +import { useCardAuth } from './useCardAuth'; + +jest.mock('@tanstack/react-query', () => ({ + useMutation: jest.fn(), + useQueryClient: jest.fn(), +})); + +jest.mock('../../../../core/Engine', () => ({ + context: { + CardController: { + initiateAuth: jest.fn(), + submitCredentials: jest.fn(), + executeStepAction: jest.fn(), + logout: jest.fn(), + }, + }, +})); + +jest.mock('../../../../util/Logger', () => ({ + error: jest.fn(), +})); + +const mockUseMutation = useMutation as jest.Mock; +const mockUseQueryClient = useQueryClient as jest.Mock; +const mockController = Engine.context.CardController as jest.Mocked< + typeof Engine.context.CardController +>; + +const mockInvalidateQueries = jest.fn(); +const mockRemoveQueries = jest.fn(); + +describe('useCardAuth', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockUseQueryClient.mockReturnValue({ + invalidateQueries: mockInvalidateQueries, + removeQueries: mockRemoveQueries, + }); + + mockUseMutation.mockImplementation( + (opts: { + mutationFn: (...args: unknown[]) => Promise; + onSuccess?: (result: unknown) => void; + }) => ({ + mutateAsync: jest.fn(async (...args: unknown[]) => { + const res = await opts.mutationFn(...args); + opts.onSuccess?.(res); + return res; + }), + isPending: false, + error: null, + data: null, + }), + ); + }); + + it('currentStep starts as email_password', () => { + const { result } = renderHook(() => useCardAuth()); + + expect(result.current.currentStep).toStrictEqual({ + type: 'email_password', + }); + }); + + it('exposes getErrorMessage for displaying auth errors', () => { + const { result } = renderHook(() => useCardAuth()); + + expect(result.current.getErrorMessage).toBeDefined(); + expect(typeof result.current.getErrorMessage).toBe('function'); + }); + + describe('initiate', () => { + it('calls controller.initiateAuth', async () => { + mockController.initiateAuth.mockResolvedValue(undefined); + const { result } = renderHook(() => useCardAuth()); + + await act(async () => { + await result.current.initiate.mutateAsync('US'); + }); + + expect(mockController.initiateAuth).toHaveBeenCalledWith('US'); + }); + }); + + describe('submit', () => { + it('calls controller.submitCredentials with credentials', async () => { + mockController.submitCredentials.mockResolvedValue({ + done: true, + tokenSet: {} as never, + }); + const { result } = renderHook(() => useCardAuth()); + + await act(async () => { + await result.current.submit.mutateAsync({ + type: 'email_password', + email: 'a@b.com', + password: 'p', + }); + }); + + expect(mockController.submitCredentials).toHaveBeenCalledWith({ + type: 'email_password', + email: 'a@b.com', + password: 'p', + }); + }); + + it('resets currentStep and invalidates queries on done:true', async () => { + mockController.submitCredentials.mockResolvedValue({ done: true }); + const { result } = renderHook(() => useCardAuth()); + + await act(async () => { + await result.current.submit.mutateAsync({ + type: 'email_password', + email: 'a@b.com', + password: 'p', + }); + }); + + expect(result.current.currentStep).toStrictEqual({ + type: 'email_password', + }); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: cardQueries.keys.all(), + }); + }); + + it('updates currentStep when nextStep is returned', async () => { + mockController.submitCredentials.mockResolvedValue({ + done: false, + nextStep: { type: 'otp', destination: '+1555****90' }, + }); + const { result } = renderHook(() => useCardAuth()); + + await act(async () => { + await result.current.submit.mutateAsync({ + type: 'email_password', + email: 'a@b.com', + password: 'p', + }); + }); + + expect(result.current.currentStep).toStrictEqual({ + type: 'otp', + destination: '+1555****90', + }); + }); + + it('resets currentStep when onboardingRequired is returned', async () => { + mockController.submitCredentials.mockResolvedValue({ + done: false, + onboardingRequired: { sessionId: 'ob-1', phase: 'kyc' }, + }); + const { result } = renderHook(() => useCardAuth()); + + await act(async () => { + await result.current.submit.mutateAsync({ + type: 'email_password', + email: 'a@b.com', + password: 'p', + }); + }); + + expect(result.current.currentStep).toStrictEqual({ + type: 'email_password', + }); + }); + + it('resets currentStep when done:false without nextStep or onboardingRequired', async () => { + mockController.submitCredentials.mockResolvedValue({ done: false }); + const { result } = renderHook(() => useCardAuth()); + + await act(async () => { + await result.current.submit.mutateAsync({ + type: 'email_password', + email: 'a@b.com', + password: 'p', + }); + }); + + expect(result.current.currentStep).toStrictEqual({ + type: 'email_password', + }); + }); + }); + + describe('stepAction', () => { + it('calls controller.executeStepAction', async () => { + mockController.executeStepAction.mockResolvedValue(undefined); + const { result } = renderHook(() => useCardAuth()); + + await act(async () => { + await result.current.stepAction.mutateAsync(); + }); + + expect(mockController.executeStepAction).toHaveBeenCalled(); + }); + }); + + describe('logout', () => { + it('calls controller.logout', async () => { + mockController.logout.mockResolvedValue(undefined); + const { result } = renderHook(() => useCardAuth()); + + await act(async () => { + await result.current.logout.mutateAsync(); + }); + + expect(mockController.logout).toHaveBeenCalled(); + }); + + it('resets currentStep and removes queries on success', async () => { + mockController.submitCredentials.mockResolvedValue({ + done: false, + nextStep: { type: 'otp', destination: '+1555****90' }, + }); + mockController.logout.mockResolvedValue(undefined); + const { result } = renderHook(() => useCardAuth()); + + await act(async () => { + await result.current.submit.mutateAsync({ + type: 'email_password', + email: 'a@b.com', + password: 'p', + }); + }); + expect(result.current.currentStep).toStrictEqual({ + type: 'otp', + destination: '+1555****90', + }); + + await act(async () => { + await result.current.logout.mutateAsync(); + }); + + expect(result.current.currentStep).toStrictEqual({ + type: 'email_password', + }); + expect(mockRemoveQueries).toHaveBeenCalledWith({ + queryKey: cardQueries.keys.all(), + }); + }); + }); +}); diff --git a/app/components/UI/Card/hooks/useCardAuth.ts b/app/components/UI/Card/hooks/useCardAuth.ts new file mode 100644 index 00000000000..17f8954c2f8 --- /dev/null +++ b/app/components/UI/Card/hooks/useCardAuth.ts @@ -0,0 +1,75 @@ +import { useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import Engine from '../../../../core/Engine'; +import { cardQueries } from '../queries'; +import { + CardAuthStep, + CardCredentials, +} from '../../../../core/Engine/controllers/card-controller/provider-types'; +import { getCardProviderErrorMessage } from '../util/getCardProviderErrorMessage'; + +function getController() { + const controller = Engine.context?.CardController; + if (!controller) { + throw new Error('CardController not initialized'); + } + return controller; +} + +const LOGIN_STEP: CardAuthStep = { type: 'email_password' }; + +export const useCardAuth = () => { + const queryClient = useQueryClient(); + const [currentStep, setCurrentStep] = useState(LOGIN_STEP); + + const initiate = useMutation({ + mutationKey: cardQueries.auth.keys.initiate(), + mutationFn: (country: string) => getController().initiateAuth(country), + retry: false, + }); + + const submit = useMutation({ + mutationKey: cardQueries.auth.keys.submit(), + mutationFn: (credentials: CardCredentials) => + getController().submitCredentials(credentials), + onSuccess: (result) => { + if (result.done || result.onboardingRequired) { + setCurrentStep(LOGIN_STEP); + if (result.done) { + queryClient.invalidateQueries({ queryKey: cardQueries.keys.all() }); + } + } else if (result.nextStep) { + setCurrentStep(result.nextStep); + } else { + // Controller cleared session (done:false without nextStep/onboardingRequired) + setCurrentStep(LOGIN_STEP); + } + }, + retry: false, + }); + + const stepAction = useMutation({ + mutationKey: cardQueries.auth.keys.stepAction(), + mutationFn: () => getController().executeStepAction(), + retry: false, + }); + + const logout = useMutation({ + mutationKey: cardQueries.auth.keys.logout(), + mutationFn: () => getController().logout(), + onSuccess: () => { + setCurrentStep(LOGIN_STEP); + queryClient.removeQueries({ queryKey: cardQueries.keys.all() }); + }, + retry: false, + }); + + return { + currentStep, + initiate, + submit, + stepAction, + logout, + getErrorMessage: getCardProviderErrorMessage, + }; +}; diff --git a/app/components/UI/Card/queries/auth.ts b/app/components/UI/Card/queries/auth.ts new file mode 100644 index 00000000000..740ab4f34d3 --- /dev/null +++ b/app/components/UI/Card/queries/auth.ts @@ -0,0 +1,7 @@ +export const authKeys = { + all: () => ['card', 'auth'] as const, + initiate: () => [...authKeys.all(), 'initiate'] as const, + submit: () => [...authKeys.all(), 'submit'] as const, + stepAction: () => [...authKeys.all(), 'step-action'] as const, + logout: () => [...authKeys.all(), 'logout'] as const, +}; diff --git a/app/components/UI/Card/queries/index.ts b/app/components/UI/Card/queries/index.ts index ccbd3433ec4..3cc259a0c5f 100644 --- a/app/components/UI/Card/queries/index.ts +++ b/app/components/UI/Card/queries/index.ts @@ -5,6 +5,7 @@ import { cashbackWithdrawEstimationOptions, } from './cashback'; import { dashboardKeys } from './dashboard'; +import { authKeys } from './auth'; export const cardQueries = { keys: { @@ -22,4 +23,7 @@ export const cardQueries = { walletOptions: cashbackWalletOptions, withdrawEstimationOptions: cashbackWithdrawEstimationOptions, }, + auth: { + keys: authKeys, + }, }; diff --git a/app/components/UI/Card/util/getCardProviderErrorMessage.test.ts b/app/components/UI/Card/util/getCardProviderErrorMessage.test.ts new file mode 100644 index 00000000000..38d9409ed40 --- /dev/null +++ b/app/components/UI/Card/util/getCardProviderErrorMessage.test.ts @@ -0,0 +1,117 @@ +import { getCardProviderErrorMessage } from './getCardProviderErrorMessage'; +import { + CardProviderError, + CardProviderErrorCode, +} from '../../../../core/Engine/controllers/card-controller/provider-types'; +import { strings } from '../../../../../locales/i18n'; + +jest.mock('../../../../../locales/i18n', () => ({ + strings: jest.fn(), +})); + +const mockStrings = strings as jest.MockedFunction; + +describe('getCardProviderErrorMessage', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockStrings.mockImplementation((key: string) => `mocked_${key}`); + }); + + describe('CardProviderError handling', () => { + const cardProviderErrorTestCases = [ + { + code: CardProviderErrorCode.InvalidCredentials, + expectedStringKey: + 'card.card_authentication.errors.invalid_credentials', + description: 'InvalidCredentials', + }, + { + code: CardProviderErrorCode.InvalidOtp, + expectedStringKey: 'card.card_authentication.errors.invalid_otp_code', + description: 'InvalidOtp', + }, + { + code: CardProviderErrorCode.Network, + expectedStringKey: 'card.card_authentication.errors.network_error', + description: 'Network', + }, + { + code: CardProviderErrorCode.Timeout, + expectedStringKey: 'card.card_authentication.errors.timeout_error', + description: 'Timeout', + }, + { + code: CardProviderErrorCode.ServerError, + expectedStringKey: 'card.card_authentication.errors.server_error', + description: 'ServerError', + }, + { + code: CardProviderErrorCode.Unknown, + expectedStringKey: 'card.card_authentication.errors.unknown_error', + description: 'Unknown', + }, + ] as const; + + it.each(cardProviderErrorTestCases)( + 'returns correct localized message for $description', + ({ code, expectedStringKey }) => { + const error = new CardProviderError(code, 'Test error message'); + + const result = getCardProviderErrorMessage(error); + + expect(mockStrings).toHaveBeenCalledWith(expectedStringKey); + expect(result).toBe(`mocked_${expectedStringKey}`); + }, + ); + + it('returns original message for AccountDisabled', () => { + const customMessage = 'Account has been disabled'; + const error = new CardProviderError( + CardProviderErrorCode.AccountDisabled, + customMessage, + ); + + const result = getCardProviderErrorMessage(error); + + expect(mockStrings).not.toHaveBeenCalled(); + expect(result).toBe(customMessage); + }); + }); + + describe('Non-CardProviderError handling', () => { + it('returns unknown error for generic Error', () => { + const error = new Error('Generic error'); + + const result = getCardProviderErrorMessage(error); + + expect(mockStrings).toHaveBeenCalledWith( + 'card.card_authentication.errors.unknown_error', + ); + expect(result).toBe( + 'mocked_card.card_authentication.errors.unknown_error', + ); + }); + + it('returns unknown error for null', () => { + const result = getCardProviderErrorMessage(null); + + expect(mockStrings).toHaveBeenCalledWith( + 'card.card_authentication.errors.unknown_error', + ); + expect(result).toBe( + 'mocked_card.card_authentication.errors.unknown_error', + ); + }); + + it('returns unknown error for undefined', () => { + const result = getCardProviderErrorMessage(undefined); + + expect(mockStrings).toHaveBeenCalledWith( + 'card.card_authentication.errors.unknown_error', + ); + expect(result).toBe( + 'mocked_card.card_authentication.errors.unknown_error', + ); + }); + }); +}); diff --git a/app/components/UI/Card/util/getCardProviderErrorMessage.ts b/app/components/UI/Card/util/getCardProviderErrorMessage.ts new file mode 100644 index 00000000000..c4703d87e9b --- /dev/null +++ b/app/components/UI/Card/util/getCardProviderErrorMessage.ts @@ -0,0 +1,27 @@ +import { strings } from '../../../../../locales/i18n'; +import { + CardProviderError, + CardProviderErrorCode, +} from '../../../../core/Engine/controllers/card-controller/provider-types'; + +export function getCardProviderErrorMessage(err: unknown): string { + if (err instanceof CardProviderError) { + switch (err.code) { + case CardProviderErrorCode.InvalidCredentials: + return strings('card.card_authentication.errors.invalid_credentials'); + case CardProviderErrorCode.AccountDisabled: + return err.message; + case CardProviderErrorCode.InvalidOtp: + return strings('card.card_authentication.errors.invalid_otp_code'); + case CardProviderErrorCode.Network: + return strings('card.card_authentication.errors.network_error'); + case CardProviderErrorCode.Timeout: + return strings('card.card_authentication.errors.timeout_error'); + case CardProviderErrorCode.ServerError: + return strings('card.card_authentication.errors.server_error'); + default: + return strings('card.card_authentication.errors.unknown_error'); + } + } + return strings('card.card_authentication.errors.unknown_error'); +} diff --git a/app/core/Engine/controllers/card-controller/CardController.test.ts b/app/core/Engine/controllers/card-controller/CardController.test.ts index b13b98ef09b..bb2ab718064 100644 --- a/app/core/Engine/controllers/card-controller/CardController.test.ts +++ b/app/core/Engine/controllers/card-controller/CardController.test.ts @@ -1,6 +1,19 @@ import { Messenger } from '@metamask/messenger'; import { CardController, defaultCardControllerState } from './CardController'; import { type CardControllerActions, type CardControllerEvents } from './types'; +import { + CardProviderError, + CardProviderErrorCode, + type ICardProvider, + type CardAuthSession, + type CardAuthTokens, +} from './provider-types'; +import { CardTokenStore } from './CardTokenStore'; + +jest.mock('./CardTokenStore'); +jest.mock('../../../../util/Logger'); + +const mockTokenStore = CardTokenStore as jest.Mocked; function buildMessenger() { return new Messenger< @@ -10,10 +23,64 @@ function buildMessenger() { >({ namespace: 'CardController' }); } +function buildMockProvider( + overrides: Partial = {}, +): jest.Mocked { + return { + id: 'baanx', + capabilities: {} as ICardProvider['capabilities'], + initiateAuth: jest.fn(), + submitCredentials: jest.fn(), + executeStepAction: jest.fn(), + refreshTokens: jest.fn(), + validateTokens: jest.fn(), + logout: jest.fn(), + getCardHomeData: jest.fn(), + getCardDetails: jest.fn(), + freezeCard: jest.fn(), + unfreezeCard: jest.fn(), + ...overrides, + } as jest.Mocked; +} + +function buildController( + provider: ICardProvider, + stateOverrides: Partial = {}, +) { + return new CardController({ + messenger: buildMessenger(), + providers: { baanx: provider }, + state: { + activeProviderId: 'baanx', + ...stateOverrides, + }, + }); +} + +const mockSession: CardAuthSession = { + id: 'session-1', + currentStep: { type: 'email_password' }, + _metadata: { + initiateToken: 'tok', + location: 'international', + state: 's', + codeVerifier: 'cv', + }, +}; + +const mockTokenSet: CardAuthTokens = { + accessToken: 'at', + refreshToken: 'rt', + accessTokenExpiresAt: Date.now() + 3_600_000, + refreshTokenExpiresAt: Date.now() + 86_400_000, + location: 'international', +}; + describe('CardController', () => { it('initializes with default state when no state is provided', () => { const controller = new CardController({ messenger: buildMessenger(), + providers: {}, }); expect(controller.state).toStrictEqual(defaultCardControllerState); @@ -22,6 +89,7 @@ describe('CardController', () => { it('initializes with provided state merged over defaults', () => { const controller = new CardController({ messenger: buildMessenger(), + providers: {}, state: { selectedCountry: 'US', activeProviderId: 'baanx', @@ -41,13 +109,14 @@ describe('CardController', () => { it('preserves default values for fields not in partial state', () => { const controller = new CardController({ messenger: buildMessenger(), + providers: {}, state: { selectedCountry: 'GB', }, }); expect(controller.state.selectedCountry).toBe('GB'); - expect(controller.state.activeProviderId).toBeNull(); + expect(controller.state.activeProviderId).toBe('baanx'); expect(controller.state.isAuthenticated).toBe(false); expect(controller.state.cardholderAccounts).toStrictEqual([]); expect(controller.state.providerData).toStrictEqual({}); @@ -56,6 +125,7 @@ describe('CardController', () => { it('initializes with full persisted state including providerData', () => { const controller = new CardController({ messenger: buildMessenger(), + providers: {}, state: { selectedCountry: 'US', activeProviderId: 'baanx', @@ -75,3 +145,293 @@ describe('CardController', () => { }); }); }); + +describe('CardController — auth methods', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('initiateAuth', () => { + it('delegates to the active provider and stores the session internally', async () => { + const provider = buildMockProvider(); + provider.initiateAuth.mockResolvedValue(mockSession); + const controller = buildController(provider); + + await controller.initiateAuth('US'); + + expect(provider.initiateAuth).toHaveBeenCalledWith('US'); + expect(controller.getCurrentAuthStep()).toStrictEqual( + mockSession.currentStep, + ); + }); + + it('throws CardProviderError when there is no active provider', async () => { + const controller = new CardController({ + messenger: buildMessenger(), + providers: {}, + state: { activeProviderId: null }, + }); + + await expect(controller.initiateAuth('US')).rejects.toBeInstanceOf( + CardProviderError, + ); + }); + }); + + describe('submitCredentials', () => { + it('throws when no session has been initiated', async () => { + const provider = buildMockProvider(); + const controller = buildController(provider); + + await expect( + controller.submitCredentials({ + type: 'email_password', + email: 'a@b.com', + password: 'pass', + }), + ).rejects.toMatchObject({ code: CardProviderErrorCode.Unknown }); + }); + + it('stores tokens, sets isAuthenticated and providerData on done:true', async () => { + const provider = buildMockProvider(); + provider.initiateAuth.mockResolvedValue(mockSession); + provider.submitCredentials.mockResolvedValue({ + done: true, + tokenSet: mockTokenSet, + }); + mockTokenStore.set.mockResolvedValue(true); + const controller = buildController(provider); + + await controller.initiateAuth('US'); + const result = await controller.submitCredentials({ + type: 'email_password', + email: 'a@b.com', + password: 'pass', + }); + + expect(mockTokenStore.set).toHaveBeenCalledWith('baanx', mockTokenSet); + expect(controller.state.isAuthenticated).toBe(true); + expect(controller.state.providerData.baanx).toStrictEqual({ + location: 'international', + }); + expect(result.done).toBe(true); + }); + + it('still sets isAuthenticated when token store write fails', async () => { + const provider = buildMockProvider(); + provider.initiateAuth.mockResolvedValue(mockSession); + provider.submitCredentials.mockResolvedValue({ + done: true, + tokenSet: mockTokenSet, + }); + mockTokenStore.set.mockResolvedValue(false); + const controller = buildController(provider); + + await controller.initiateAuth('US'); + await controller.submitCredentials({ + type: 'email_password', + email: 'a@b.com', + password: 'pass', + }); + + expect(controller.state.isAuthenticated).toBe(true); + }); + + it('updates currentSession step when OTP step is required', async () => { + const provider = buildMockProvider(); + provider.initiateAuth.mockResolvedValue(mockSession); + provider.submitCredentials.mockResolvedValue({ + done: false, + nextStep: { type: 'otp', destination: '+1555****90' }, + }); + const controller = buildController(provider); + + await controller.initiateAuth('US'); + const result = await controller.submitCredentials({ + type: 'email_password', + email: 'a@b.com', + password: 'pass', + }); + + expect(controller.state.isAuthenticated).toBe(false); + expect(result.done).toBe(false); + expect(controller.getCurrentAuthStep()).toStrictEqual({ + type: 'otp', + destination: '+1555****90', + }); + }); + + it('clears session when onboarding is required', async () => { + const provider = buildMockProvider(); + provider.initiateAuth.mockResolvedValue(mockSession); + provider.submitCredentials.mockResolvedValue({ + done: false, + onboardingRequired: { sessionId: 'ob-session', phase: 'kyc' }, + }); + const controller = buildController(provider); + + await controller.initiateAuth('US'); + const result = await controller.submitCredentials({ + type: 'email_password', + email: 'a@b.com', + password: 'pass', + }); + + expect(controller.state.isAuthenticated).toBe(false); + expect(result.onboardingRequired?.phase).toBe('kyc'); + expect(controller.getCurrentAuthStep()).toBeNull(); + }); + }); + + describe('executeStepAction', () => { + it('throws when no session has been initiated', async () => { + const provider = buildMockProvider(); + const controller = buildController(provider); + + await expect(controller.executeStepAction()).rejects.toMatchObject({ + code: CardProviderErrorCode.Unknown, + }); + }); + + it('delegates to the provider with the current session', async () => { + const provider = buildMockProvider(); + provider.initiateAuth.mockResolvedValue(mockSession); + (provider.executeStepAction as jest.Mock).mockResolvedValue(undefined); + const controller = buildController(provider); + + await controller.initiateAuth('US'); + await controller.executeStepAction(); + + expect(provider.executeStepAction).toHaveBeenCalledWith(mockSession); + }); + + it('is a no-op when the provider does not implement executeStepAction', async () => { + const provider = buildMockProvider({ executeStepAction: undefined }); + provider.initiateAuth.mockResolvedValue(mockSession); + const controller = buildController(provider); + + await controller.initiateAuth('US'); + await expect(controller.executeStepAction()).resolves.toBeUndefined(); + }); + }); + + describe('logout', () => { + it('calls provider.logout, removes tokens, sets isAuthenticated to false', async () => { + const provider = buildMockProvider(); + provider.logout.mockResolvedValue(undefined); + mockTokenStore.get.mockResolvedValue(mockTokenSet); + mockTokenStore.remove.mockResolvedValue(true); + const controller = buildController(provider, { isAuthenticated: true }); + + await controller.logout(); + + expect(provider.logout).toHaveBeenCalledWith(mockTokenSet); + expect(mockTokenStore.remove).toHaveBeenCalledWith('baanx'); + expect(controller.state.isAuthenticated).toBe(false); + }); + + it('still clears local state when provider.logout throws', async () => { + const provider = buildMockProvider(); + provider.logout.mockRejectedValue(new Error('Server error')); + mockTokenStore.get.mockResolvedValue(mockTokenSet); + mockTokenStore.remove.mockResolvedValue(true); + const controller = buildController(provider, { isAuthenticated: true }); + + await controller.logout(); + + expect(mockTokenStore.remove).toHaveBeenCalledWith('baanx'); + expect(controller.state.isAuthenticated).toBe(false); + }); + + it('skips provider.logout call when no tokens exist', async () => { + const provider = buildMockProvider(); + mockTokenStore.get.mockResolvedValue(null); + mockTokenStore.remove.mockResolvedValue(true); + const controller = buildController(provider); + + await controller.logout(); + + expect(provider.logout).not.toHaveBeenCalled(); + expect(mockTokenStore.remove).toHaveBeenCalledWith('baanx'); + }); + }); + + describe('validateAndRefreshSession', () => { + it('returns isAuthenticated:false when no tokens exist', async () => { + const provider = buildMockProvider(); + mockTokenStore.get.mockResolvedValue(null); + const controller = buildController(provider); + + const result = await controller.validateAndRefreshSession(); + + expect(result).toStrictEqual({ isAuthenticated: false }); + expect(controller.state.isAuthenticated).toBe(false); + }); + + it('returns isAuthenticated:true with location when tokens are valid', async () => { + const provider = buildMockProvider(); + mockTokenStore.get.mockResolvedValue(mockTokenSet); + provider.validateTokens.mockReturnValue('valid'); + const controller = buildController(provider); + + const result = await controller.validateAndRefreshSession(); + + expect(result).toStrictEqual({ + isAuthenticated: true, + location: 'international', + }); + expect(controller.state.isAuthenticated).toBe(true); + }); + + it('refreshes tokens and returns authenticated when needs_refresh', async () => { + const provider = buildMockProvider(); + const refreshedTokens: CardAuthTokens = { + ...mockTokenSet, + accessToken: 'new-at', + }; + mockTokenStore.get.mockResolvedValue(mockTokenSet); + provider.validateTokens.mockReturnValue('needs_refresh'); + provider.refreshTokens.mockResolvedValue(refreshedTokens); + mockTokenStore.set.mockResolvedValue(true); + const controller = buildController(provider); + + const result = await controller.validateAndRefreshSession(); + + expect(provider.refreshTokens).toHaveBeenCalledWith(mockTokenSet); + expect(mockTokenStore.set).toHaveBeenCalledWith('baanx', refreshedTokens); + expect(result).toStrictEqual({ + isAuthenticated: true, + location: 'international', + }); + }); + + it('clears tokens and returns unauthenticated when refresh fails', async () => { + const provider = buildMockProvider(); + mockTokenStore.get.mockResolvedValue(mockTokenSet); + provider.validateTokens.mockReturnValue('needs_refresh'); + provider.refreshTokens.mockRejectedValue(new Error('Refresh failed')); + mockTokenStore.remove.mockResolvedValue(true); + const controller = buildController(provider); + + const result = await controller.validateAndRefreshSession(); + + expect(mockTokenStore.remove).toHaveBeenCalledWith('baanx'); + expect(result).toStrictEqual({ isAuthenticated: false }); + expect(controller.state.isAuthenticated).toBe(false); + }); + + it('clears tokens and returns unauthenticated when tokens are expired', async () => { + const provider = buildMockProvider(); + mockTokenStore.get.mockResolvedValue(mockTokenSet); + provider.validateTokens.mockReturnValue('expired'); + mockTokenStore.remove.mockResolvedValue(true); + const controller = buildController(provider); + + const result = await controller.validateAndRefreshSession(); + + expect(mockTokenStore.remove).toHaveBeenCalledWith('baanx'); + expect(result).toStrictEqual({ isAuthenticated: false }); + expect(controller.state.isAuthenticated).toBe(false); + }); + }); +}); diff --git a/app/core/Engine/controllers/card-controller/CardController.ts b/app/core/Engine/controllers/card-controller/CardController.ts index 4095077945a..680873d39c5 100644 --- a/app/core/Engine/controllers/card-controller/CardController.ts +++ b/app/core/Engine/controllers/card-controller/CardController.ts @@ -1,9 +1,20 @@ import { BaseController, type StateMetadata } from '@metamask/base-controller'; +import Logger from '../../../../util/Logger'; import { CARD_CONTROLLER_NAME, type CardControllerMessenger, type CardControllerState, } from './types'; +import { + CardProviderError, + CardProviderErrorCode, + type CardAuthSession, + type CardAuthResult, + type CardAuthStep, + type CardCredentials, + type ICardProvider, +} from './provider-types'; +import { CardTokenStore } from './CardTokenStore'; const metadata: StateMetadata = { selectedCountry: { @@ -40,7 +51,7 @@ const metadata: StateMetadata = { export const defaultCardControllerState: CardControllerState = { selectedCountry: null, - activeProviderId: null, + activeProviderId: 'baanx', isAuthenticated: false, cardholderAccounts: [], providerData: {}, @@ -59,12 +70,17 @@ export class CardController extends BaseController< CardControllerState, CardControllerMessenger > { + private readonly providers: Record; + private currentSession: CardAuthSession | null = null; + constructor({ messenger, state, + providers, }: { messenger: CardControllerMessenger; state?: Partial; + providers: Record; }) { super({ name: CARD_CONTROLLER_NAME, @@ -75,5 +91,181 @@ export class CardController extends BaseController< ...state, }, }); + this.providers = providers; + } + + private getActiveProvider(): ICardProvider { + const pid = this.state.activeProviderId; + const provider = pid ? this.providers[pid] : undefined; + if (!provider) { + throw new CardProviderError( + CardProviderErrorCode.Unknown, + `No active provider: ${pid}`, + ); + } + return provider; + } + + private markAuthenticated(): void { + this.update((s) => { + s.isAuthenticated = true; + }); + } + + private markUnauthenticated(): void { + this.update((s) => { + s.isAuthenticated = false; + }); + } + + private async clearTokens(): Promise { + const pid = this.state.activeProviderId; + if (pid) { + await CardTokenStore.remove(pid); + } + } + + async initiateAuth(country: string): Promise { + this.currentSession = await this.getActiveProvider().initiateAuth(country); + } + + getCurrentAuthStep(): CardAuthStep | null { + return this.currentSession?.currentStep ?? null; + } + + async submitCredentials( + credentials: CardCredentials, + ): Promise { + if (!this.currentSession) { + throw new CardProviderError( + CardProviderErrorCode.Unknown, + 'submitCredentials: no active auth session', + ); + } + + const provider = this.getActiveProvider(); + const pid = this.state.activeProviderId as string; + const result = await provider.submitCredentials( + this.currentSession, + credentials, + ); + + if (result.nextStep) { + this.currentSession = { + ...this.currentSession, + currentStep: result.nextStep, + }; + } else { + this.currentSession = null; + } + + if (result.done && result.tokenSet) { + const { tokenSet } = result; + const stored = await CardTokenStore.set(pid, tokenSet); + if (!stored) { + Logger.error(new Error('Token store write failed after auth'), { + tags: { feature: 'card', provider: pid }, + context: { + name: 'CardController', + data: { method: 'submitCredentials' }, + }, + }); + } + this.update((s) => { + s.isAuthenticated = true; + (s.providerData as unknown as Record>)[ + pid + ] = { location: tokenSet.location }; + }); + } + + return result; + } + + async executeStepAction(): Promise { + if (!this.currentSession) { + throw new CardProviderError( + CardProviderErrorCode.Unknown, + 'executeStepAction: no active auth session', + ); + } + const provider = this.getActiveProvider(); + await provider.executeStepAction?.(this.currentSession); + } + + async logout(): Promise { + const pid = this.state.activeProviderId; + if (!pid) return; + const tokens = await CardTokenStore.get(pid); + + if (tokens) { + try { + await this.getActiveProvider().logout(tokens); + } catch (error) { + Logger.error(error as Error, { + tags: { feature: 'card', provider: pid }, + context: { name: 'CardController', data: { method: 'logout' } }, + }); + } + } + + this.currentSession = null; + await this.clearTokens(); + this.update((s) => { + s.isAuthenticated = false; + (s.providerData as unknown as Record>)[ + pid + ] = {}; + }); + } + + async validateAndRefreshSession(): Promise<{ + isAuthenticated: boolean; + location?: string; + }> { + const pid = this.state.activeProviderId; + if (!pid) { + this.markUnauthenticated(); + return { isAuthenticated: false }; + } + const tokens = await CardTokenStore.get(pid); + + if (!tokens) { + this.markUnauthenticated(); + return { isAuthenticated: false }; + } + + const provider = this.getActiveProvider(); + const validity = provider.validateTokens(tokens); + + if (validity === 'valid') { + this.markAuthenticated(); + return { isAuthenticated: true, location: tokens.location }; + } + + if (validity === 'needs_refresh') { + try { + const newTokens = await provider.refreshTokens(tokens); + await CardTokenStore.set(pid, newTokens); + this.markAuthenticated(); + return { isAuthenticated: true, location: newTokens.location }; + } catch (error) { + Logger.error(error as Error, { + tags: { feature: 'card', provider: pid }, + context: { + name: 'CardController', + data: { method: 'validateAndRefreshSession' }, + }, + }); + await this.clearTokens(); + this.markUnauthenticated(); + return { isAuthenticated: false }; + } + } + + // expired + await this.clearTokens(); + this.markUnauthenticated(); + return { isAuthenticated: false }; } } diff --git a/app/core/Engine/controllers/card-controller/index.ts b/app/core/Engine/controllers/card-controller/index.ts index f5b1d1f04ce..a81cb60f33b 100644 --- a/app/core/Engine/controllers/card-controller/index.ts +++ b/app/core/Engine/controllers/card-controller/index.ts @@ -1,6 +1,9 @@ import type { ControllerInitFunction } from '../../types'; import { CardController, defaultCardControllerState } from './CardController'; import type { CardControllerMessenger } from './types'; +import { BaanxService } from './services/BaanxService'; +import { BaanxProvider } from './providers/BaanxProvider'; +import { resolveBaanxConfig } from './services/baanx-config'; /** * Initialize the CardController. @@ -14,9 +17,18 @@ export const cardControllerInit: ControllerInitFunction< > = (request) => { const { controllerMessenger, persistedState } = request; + const baanxConfig = resolveBaanxConfig(); + const baanxProvider = new BaanxProvider({ + service: new BaanxService(baanxConfig), + }); + const controller = new CardController({ messenger: controllerMessenger, - state: persistedState.CardController ?? defaultCardControllerState, + state: { + ...(persistedState.CardController ?? defaultCardControllerState), + activeProviderId: 'baanx', + }, + providers: { baanx: baanxProvider }, }); return { controller }; diff --git a/app/core/Engine/controllers/card-controller/provider-types.ts b/app/core/Engine/controllers/card-controller/provider-types.ts index 6704f11676c..9a636242835 100644 --- a/app/core/Engine/controllers/card-controller/provider-types.ts +++ b/app/core/Engine/controllers/card-controller/provider-types.ts @@ -275,7 +275,7 @@ export interface ICardProvider { session: CardAuthSession, credentials: CardCredentials, ): Promise; - sendOtp?(session: CardAuthSession): Promise; + executeStepAction?(session: CardAuthSession): Promise; refreshTokens(tokens: CardAuthTokens): Promise; validateTokens(tokens: CardAuthTokens): AuthTokenValidity; logout(tokens: CardAuthTokens): Promise; diff --git a/app/core/Engine/controllers/card-controller/providers/BaanxProvider.test.ts b/app/core/Engine/controllers/card-controller/providers/BaanxProvider.test.ts index 52145ea8779..5f9e9ead476 100644 --- a/app/core/Engine/controllers/card-controller/providers/BaanxProvider.test.ts +++ b/app/core/Engine/controllers/card-controller/providers/BaanxProvider.test.ts @@ -728,7 +728,7 @@ describe('BaanxProvider', () => { }); }); - describe('sendOtp', () => { + describe('executeStepAction', () => { it('posts userId to the OTP trigger endpoint', async () => { service.get.mockResolvedValue({ token: 'init-token', url: '' }); const session = await provider.initiateAuth('US'); @@ -736,7 +736,7 @@ describe('BaanxProvider', () => { session._metadata.otpUserId = 'user-1'; service.post.mockResolvedValue({}); - await provider.sendOtp(session); + await provider.executeStepAction(session); expect(service.post).toHaveBeenCalledWith('/v1/auth/login/otp', { userId: 'user-1', @@ -747,8 +747,8 @@ describe('BaanxProvider', () => { service.get.mockResolvedValue({ token: 'init-token', url: '' }); const session = await provider.initiateAuth('US'); - await expect(provider.sendOtp(session)).rejects.toThrow( - 'No userId in session', + await expect(provider.executeStepAction(session)).rejects.toThrow( + 'executeStepAction: session missing otpUserId', ); }); }); diff --git a/app/core/Engine/controllers/card-controller/providers/BaanxProvider.ts b/app/core/Engine/controllers/card-controller/providers/BaanxProvider.ts index bf493425dc4..04df1557cc1 100644 --- a/app/core/Engine/controllers/card-controller/providers/BaanxProvider.ts +++ b/app/core/Engine/controllers/card-controller/providers/BaanxProvider.ts @@ -270,11 +270,12 @@ export class BaanxProvider implements ICardProvider { throw new Error(`Unsupported credential type: ${credentials.type}`); } - async sendOtp(session: CardAuthSession): Promise { + async executeStepAction(session: CardAuthSession): Promise { const userId = session._metadata.otpUserId as string | undefined; if (!userId) { - throw new Error( - 'No userId in session — initiateAuth or login must be called first', + throw new CardProviderError( + CardProviderErrorCode.Unknown, + 'executeStepAction: session missing otpUserId', ); } await this.service.post('/v1/auth/login/otp', { userId }); diff --git a/app/core/Engine/controllers/card-controller/services/BaanxService.test.ts b/app/core/Engine/controllers/card-controller/services/BaanxService.test.ts index eadb748feb9..fbe29517fbb 100644 --- a/app/core/Engine/controllers/card-controller/services/BaanxService.test.ts +++ b/app/core/Engine/controllers/card-controller/services/BaanxService.test.ts @@ -172,4 +172,74 @@ describe('BaanxService', () => { expect(service.location).toBe('us'); }); }); + + describe('per-request location override', () => { + it('uses x-us-env:true when location:us is passed to get(), regardless of currentLocation', async () => { + mockRequest.mockResolvedValue({ data: {} }); + const service = createService(); + // currentLocation is 'international' (default) + + await service.get('/v1/test', undefined, 'us'); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ 'x-us-env': 'true' }), + }), + ); + }); + + it('uses x-us-env:false when location:international is passed to get(), even after setLocation(us)', async () => { + mockRequest.mockResolvedValue({ data: {} }); + const service = createService(); + service.setLocation('us'); + + await service.get('/v1/test', undefined, 'international'); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ 'x-us-env': 'false' }), + }), + ); + }); + + it('falls back to currentLocation when no per-request location is given', async () => { + mockRequest.mockResolvedValue({ data: {} }); + const service = createService(); + service.setLocation('us'); + + await service.get('/v1/test'); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ 'x-us-env': 'true' }), + }), + ); + }); + + it('post() threads per-request location through correctly', async () => { + mockRequest.mockResolvedValue({ data: {} }); + const service = createService(); + + await service.post('/v1/test', {}, undefined, 'us'); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ 'x-us-env': 'true' }), + }), + ); + }); + + it('put() threads per-request location through correctly', async () => { + mockRequest.mockResolvedValue({ data: {} }); + const service = createService(); + + await service.put('/v1/test', {}, undefined, 'us'); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ 'x-us-env': 'true' }), + }), + ); + }); + }); }); diff --git a/app/core/Engine/controllers/card-controller/services/BaanxService.ts b/app/core/Engine/controllers/card-controller/services/BaanxService.ts index 1d238cb75a1..66c0daba947 100644 --- a/app/core/Engine/controllers/card-controller/services/BaanxService.ts +++ b/app/core/Engine/controllers/card-controller/services/BaanxService.ts @@ -25,6 +25,7 @@ interface RequestOptions { tokenSet?: CardAuthTokens; timeout?: number; headers?: Record; + location?: CardLocation; } export class BaanxService { @@ -57,8 +58,9 @@ export class BaanxService { } async request(path: string, opts: RequestOptions = {}): Promise { + const effectiveLocation = opts.location ?? this.currentLocation; const headers: Record = { - 'x-us-env': String(this.currentLocation === 'us'), + 'x-us-env': String(effectiveLocation === 'us'), ...opts.headers, }; @@ -108,23 +110,29 @@ export class BaanxService { } } - async get(path: string, tokenSet?: CardAuthTokens): Promise { - return this.request(path, { tokenSet }); + async get( + path: string, + tokenSet?: CardAuthTokens, + location?: CardLocation, + ): Promise { + return this.request(path, { tokenSet, location }); } async post( path: string, body: unknown, tokenSet?: CardAuthTokens, + location?: CardLocation, ): Promise { - return this.request(path, { method: 'POST', body, tokenSet }); + return this.request(path, { method: 'POST', body, tokenSet, location }); } async put( path: string, body: unknown, tokenSet?: CardAuthTokens, + location?: CardLocation, ): Promise { - return this.request(path, { method: 'PUT', body, tokenSet }); + return this.request(path, { method: 'PUT', body, tokenSet, location }); } } diff --git a/app/core/Engine/controllers/card-controller/services/baanx-config.test.ts b/app/core/Engine/controllers/card-controller/services/baanx-config.test.ts index eab8bdb6325..cabe31acdb4 100644 --- a/app/core/Engine/controllers/card-controller/services/baanx-config.test.ts +++ b/app/core/Engine/controllers/card-controller/services/baanx-config.test.ts @@ -29,7 +29,21 @@ describe('resolveBaanxConfig', () => { }); describe('baseUrl', () => { - it('delegates to getDefaultBaanxApiBaseUrlForMetaMaskEnv', () => { + beforeEach(() => { + (getDefaultBaanxApiBaseUrlForMetaMaskEnv as jest.Mock).mockClear(); + }); + + it('uses BAANX_API_URL directly when set', () => { + process.env.BAANX_API_URL = 'https://override-url'; + + const config = resolveBaanxConfig(); + + expect(config.baseUrl).toBe('https://override-url'); + expect(getDefaultBaanxApiBaseUrlForMetaMaskEnv).not.toHaveBeenCalled(); + }); + + it('delegates to getDefaultBaanxApiBaseUrlForMetaMaskEnv when BAANX_API_URL is not set', () => { + delete process.env.BAANX_API_URL; process.env.METAMASK_ENVIRONMENT = 'dev'; const config = resolveBaanxConfig(); diff --git a/app/core/Engine/controllers/card-controller/services/baanx-config.ts b/app/core/Engine/controllers/card-controller/services/baanx-config.ts index b8dbc4f7675..c31bf35e071 100644 --- a/app/core/Engine/controllers/card-controller/services/baanx-config.ts +++ b/app/core/Engine/controllers/card-controller/services/baanx-config.ts @@ -13,8 +13,8 @@ import type { CardProviderConfig } from '../provider-config'; export function resolveBaanxConfig(): CardProviderConfig { return { apiKey: process.env.MM_CARD_BAANX_API_CLIENT_KEY ?? '', - baseUrl: getDefaultBaanxApiBaseUrlForMetaMaskEnv( - process.env.METAMASK_ENVIRONMENT, - ), + baseUrl: + process.env.BAANX_API_URL || + getDefaultBaanxApiBaseUrlForMetaMaskEnv(process.env.METAMASK_ENVIRONMENT), }; } diff --git a/app/util/logs/__snapshots__/index.test.ts.snap b/app/util/logs/__snapshots__/index.test.ts.snap index a6eedf7d26a..5ff1130d0ca 100644 --- a/app/util/logs/__snapshots__/index.test.ts.snap +++ b/app/util/logs/__snapshots__/index.test.ts.snap @@ -70,7 +70,7 @@ exports[`logs :: generateStateLogs Sanitized SeedlessOnboardingController State "txHistory": {}, }, "CardController": { - "activeProviderId": null, + "activeProviderId": "baanx", "cardholderAccounts": [], "isAuthenticated": false, "providerData": {}, @@ -935,7 +935,7 @@ exports[`logs :: generateStateLogs generates a valid json export 1`] = ` "txHistory": {}, }, "CardController": { - "activeProviderId": null, + "activeProviderId": "baanx", "cardholderAccounts": [], "isAuthenticated": false, "providerData": {}, diff --git a/app/util/test/initial-background-state.json b/app/util/test/initial-background-state.json index cc85b0ce369..639bfd5eba5 100644 --- a/app/util/test/initial-background-state.json +++ b/app/util/test/initial-background-state.json @@ -681,7 +681,7 @@ }, "CardController": { "selectedCountry": null, - "activeProviderId": null, + "activeProviderId": "baanx", "isAuthenticated": false, "cardholderAccounts": [], "providerData": {} From 25ced94b09300a24de2a91771ec21e1902d8283c Mon Sep 17 00:00:00 2001 From: Ramon AC <36987446+racitores@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:33:25 +0100 Subject: [PATCH 139/206] test(e2e): MMQA-1614: declarative MetaMetrics assertions via withFixtures (#27678) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context E2E runs proxy MetaMetrics/Segment through Mockttp. We lacked a **shared, declarative** way to assert captured events after a flow—easy to miss analytics regressions or duplicate ad-hoc checks. ## What changed - **`analyticsExpectations`** on `withFixtures` (runs after the test body, before mock drain). - **`runAnalyticsExpectations` / `assertCapturedMetaMetricsEvents`** — declarative rules (counts, per-event shape, match/contain properties) + unit tests. - **Flow presets** in `tests/helpers/analytics/expectations/*.analytics.ts`**; wired in **import-wallet**, **dapp-initiated-transfer**, and **predict-cash-out** smoke specs. - **Docs**: `tests/docs/analytics-e2e.md`; **e2e-test** skill + `tests/AGENTS.md` pointers. ## Commits 1. `feat(e2e): add analyticsExpectations to withFixtures` 2. `feat(e2e): add MetaMetrics analytics assertion runner` 3. `test(e2e): wire analyticsExpectations in smoke flows` 4. `docs(e2e): MetaMetrics E2E guide and agent skill pointers` ## Testing - `yarn jest tests/helpers/analytics/runAnalyticsExpectations.test.ts` Made with [Cursor](https://cursor.com) --- > [!NOTE] > **Medium Risk** > Introduces new E2E teardown-time assertion logic and additional Mockttp logging that can cause previously passing tests to fail if analytics payloads change or timing differs. Scope is limited to the test framework and smoke specs (no production runtime paths). > > **Overview** > Adds first-class **declarative MetaMetrics/Segment assertions** to Detox E2E by introducing `analyticsExpectations` on `withFixtures`, executed after the test (and optional `endTestfn`) but before the mock server drains. > > Implements `runAnalyticsExpectations`/`assertCapturedMetaMetricsEvents` with `SoftAssert` aggregation, exports the helpers via `tests/framework/index.ts`, and extends framework types with `AnalyticsExpectations`/`AnalyticsEventExpectation`. > > Refactors existing smoke specs (import-wallet, dapp-initiated transfer, predict cash-out) to use reusable expectation presets under `tests/helpers/analytics/expectations/` instead of bespoke in-test analytics checks, and adds optional analytics debug logging (`E2E_ANALYTICS_DEBUG`) plus new documentation and agent-skill pointers (`tests/docs/analytics-e2e.md`, SKILL/AGENTS updates). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit a86f153a9dd909f053b091f5fd0d0d9d21916956. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .agents/skills/e2e-test/SKILL.md | 8 +- tests/AGENTS.md | 3 +- tests/api-mocking/MockServerE2E.ts | 5 + tests/docs/analytics-e2e.md | 91 ++++++ tests/framework/fixtures/FixtureHelper.ts | 22 +- tests/framework/fixtures/README.md | 1 + tests/framework/index.ts | 6 + tests/framework/types.ts | 67 +++++ tests/helpers/analytics/analyticsDebug.ts | 93 ++++++ .../dapp-initiated-transfer.analytics.ts | 67 +++++ .../expectations/import-wallet.analytics.ts | 61 ++++ .../predict-cash-out.analytics.ts | 28 ++ tests/helpers/analytics/helpers.ts | 12 +- .../runAnalyticsExpectations.test.ts | 273 ++++++++++++++++++ .../analytics/runAnalyticsExpectations.ts | 159 ++++++++++ .../dapp-initiated-transfer.spec.ts | 156 +--------- tests/smoke/predict/predict-cash-out.spec.ts | 72 +---- .../wallet/analytics/import-wallet.spec.ts | 147 +--------- 18 files changed, 915 insertions(+), 356 deletions(-) create mode 100644 tests/docs/analytics-e2e.md create mode 100644 tests/helpers/analytics/analyticsDebug.ts create mode 100644 tests/helpers/analytics/expectations/dapp-initiated-transfer.analytics.ts create mode 100644 tests/helpers/analytics/expectations/import-wallet.analytics.ts create mode 100644 tests/helpers/analytics/expectations/predict-cash-out.analytics.ts create mode 100644 tests/helpers/analytics/runAnalyticsExpectations.test.ts create mode 100644 tests/helpers/analytics/runAnalyticsExpectations.ts diff --git a/.agents/skills/e2e-test/SKILL.md b/.agents/skills/e2e-test/SKILL.md index bdbeb07681e..a64fcf9b5f3 100644 --- a/.agents/skills/e2e-test/SKILL.md +++ b/.agents/skills/e2e-test/SKILL.md @@ -3,7 +3,8 @@ name: e2e-test description: Add and fix Detox E2E tests (smoke and regression) for MetaMask Mobile using withFixtures, Page Objects, and tests/framework. Use when creating a new spec, - fixing a failing E2E test, or adding page objects and selectors. + fixing a failing E2E test, adding page objects and selectors, or adding + MetaMetrics analytics expectations (analyticsExpectations). --- # E2E Test Builder — Skill @@ -44,6 +45,10 @@ Task → What do you need? │ → Open references/mocking.md (testSpecificMock, setupRemoteFeatureFlagsMock, setupMockRequest) │ → When writing the spec: open references/writing-tests.md │ +├─ MetaMetrics / Segment analytics assertions (`analyticsExpectations` on `withFixtures`) +│ → Open [tests/docs/analytics-e2e.md](../../../tests/docs/analytics-e2e.md) (config shape, teardown order, presets under `tests/helpers/analytics/expectations/`, `runAnalyticsExpectations`) +│ → When wiring a spec: still follow references/writing-tests.md for `withFixtures` usage +│ └─ Run tests, debug failures, or self-review → Open references/running-tests.md (build check, detox commands, common failures, retry patterns) ``` @@ -89,4 +94,5 @@ Documentation is split by **action**. Open only the reference that matches what | **Writing or updating a spec** | [references/writing-tests.md](references/writing-tests.md) | New spec file, spec structure, FixtureBuilder patterns, smoke/regression templates. | | **Page Objects and selectors** | [references/page-objects.md](references/page-objects.md) | Create or update POM classes, selector/testId conventions, Matchers/Gestures/Assertions API. | | **API and feature flag mocking** | [references/mocking.md](references/mocking.md) | testSpecificMock, setupRemoteFeatureFlagsMock, setupMockRequest, shared mock files. | +| **MetaMetrics / analytics expectations** | [tests/docs/analytics-e2e.md](../../../tests/docs/analytics-e2e.md) | `analyticsExpectations` on `withFixtures`, declarative checks, presets in `tests/helpers/analytics/expectations/`. | | **Running tests, debugging, fixing failures** | [references/running-tests.md](references/running-tests.md) | Build check, detox run commands, lint/tsc, common failures table, retry patterns, iteration loop. | diff --git a/tests/AGENTS.md b/tests/AGENTS.md index c920ab4f88a..49a2ff458cd 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -16,7 +16,7 @@ Single agent index for **tests/**, and **wdio/**. Pointers only; details live in ### E2E Tests (Detox smoke/regression) -- [.agents/skills/e2e-test/SKILL.md](../.agents/skills/e2e-test/SKILL.md) — Canonical skill for adding or fixing E2E specs: workflow, decision tree, references (writing-tests, page-objects, mocking, running-tests). +- [.agents/skills/e2e-test/SKILL.md](../.agents/skills/e2e-test/SKILL.md) — Canonical skill for adding or fixing E2E specs: workflow, decision tree, references (writing-tests, page-objects, mocking, analytics-e2e, running-tests). ## Canonical Sources (read these, do not duplicate) @@ -24,6 +24,7 @@ Single agent index for **tests/**, and **wdio/**. Pointers only; details live in - [docs/readme/e2e-testing.md](../docs/readme/e2e-testing.md) — Setup, run commands, build types, Metro, Detox, Flask; legacy Appium; Appwright. - [tests/docs/README.md](docs/README.md) — Framework structure, withFixtures, FixtureBuilder, anti-patterns, checklist. - [tests/docs/MOCKING.md](docs/MOCKING.md) — API mocking, default and test-specific mocks. +- [tests/docs/analytics-e2e.md](docs/analytics-e2e.md) — MetaMetrics E2E: `analyticsExpectations` on `withFixtures`, presets, `runAnalyticsExpectations`. - [tests/docs/CONTROLLER_MOCKING.md](docs/CONTROLLER_MOCKING.md) — Controller mocking. - [tests/docs/MODULE_MOCKING.md](docs/MODULE_MOCKING.md) — Module mocking. - [tests/framework/index.ts](framework/index.ts) — Assertions, Gestures, Matchers, Utilities, PlaywrightAdapter. diff --git a/tests/api-mocking/MockServerE2E.ts b/tests/api-mocking/MockServerE2E.ts index 226e59c5ac0..87fbf0b3ab2 100644 --- a/tests/api-mocking/MockServerE2E.ts +++ b/tests/api-mocking/MockServerE2E.ts @@ -22,6 +22,7 @@ import { FALLBACK_DAPP_SERVER_PORT, } from '../framework/Constants.ts'; import { DEFAULT_ANVIL_PORT } from '../seeder/anvil-manager.ts'; +import { logLiveMetaMetricsPostIfDebug } from '../helpers/analytics/analyticsDebug.ts'; const logger = createLogger({ name: 'MockServer', @@ -308,6 +309,10 @@ export default class MockServerE2E implements Resource { } } + if (method === 'POST') { + logLiveMetaMetricsPostIfDebug(urlEndpoint, requestBodyJson); + } + const methodEvents = this._events[method] || []; const candidateEvents = methodEvents.filter( (event: MockApiEndpoint) => { diff --git a/tests/docs/analytics-e2e.md b/tests/docs/analytics-e2e.md new file mode 100644 index 00000000000..320639178b1 --- /dev/null +++ b/tests/docs/analytics-e2e.md @@ -0,0 +1,91 @@ +# MetaMetrics / Segment analytics in E2E + +MetaMetrics events are proxied through the E2E **Mockttp** server. Helpers in `tests/helpers/analytics/helpers.ts` read captured request bodies. + +## `withFixtures` — `analyticsExpectations` + +Optional **`analyticsExpectations`** on `withFixtures` options runs **after** your test body and **`endTestfn`**, and **before** the mock server enters drain mode—so captured events are still available (see `FixtureHelper.ts` teardown order). + +Validation logic lives in **`tests/helpers/analytics/runAnalyticsExpectations.ts`** (single code path for all tests). Specs typically **import a preset object** from `tests/helpers/analytics/expectations/*.analytics.ts` and pass it to `analyticsExpectations`. + +### Configuration (`AnalyticsExpectations`) + +| Field | Purpose | +| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `eventNames` | Subset of event names passed to `getEventsPayloads` (faster, less noise). If omitted and no `events[]` entries, **all** captured MetaMetrics payloads are loaded. | +| `expectedTotalCount` | Assert exact length of that filtered list. | +| `events` | Declarative per-event rules (see below). | +| `validate` | Optional escape hatch; runs **after** declarative checks succeed. Receives `{ events, mockServer }`. | + +All declarative rules and the optional `validate` callback are evaluated with **`SoftAssert`**: every configured expected event is checked and **all** failures are reported in one thrown error (not stop-on-first). Property checks for an event are skipped if that event did not meet `minCount`, so you get a clear “missing event” line without extra property noise. + +### Per-event rules (`AnalyticsEventExpectation`) + +| Field | Purpose | +| ----------------------------- | ------------------------------------------------------------------------------------------------------ | +| `name` | MetaMetrics event name. | +| `minCount` | Minimum payloads with this name (@default 1). | +| `matchEventIndex` | Which occurrence to use for single-payload checks (@default 0). | +| `requiredProperties` | Type/shape map for **every** payload with this name (`Assertions.checkIfObjectHasKeysAndValidValues`). | +| `matchProperties` | Exact property object for payload at `matchEventIndex` (`Assertions.checkIfObjectsMatch`). | +| `containProperties` | Subset match for payload at `matchEventIndex` (`Assertions.checkIfObjectContains`). | +| `requiredDefinedPropertyKeys` | Keys that must be defined on payload at `matchEventIndex`. | + +### Preset expectations (data-only, reusable) + +Place shared configs under **`tests/helpers/analytics/expectations/`** (e.g. `import-wallet.analytics.ts`). Import the preset from that file directly (same pattern as other test helpers—no barrel `index.ts`). + +```typescript +import { importWalletWithMetricsOptInExpectations } from '../../../helpers/analytics/expectations/import-wallet.analytics'; + +await withFixtures( + { + fixture: ..., + analyticsExpectations: importWalletWithMetricsOptInExpectations, + }, + async () => { + /* UI only */ + }, +); +``` + +Imports: + +```typescript +import { withFixtures } from '../framework/fixtures/FixtureHelper'; +import type { AnalyticsExpectations } from '../framework'; +``` + +Example (inline declarative, no `validate`): + +```typescript +analyticsExpectations: { + eventNames: ['Wallet Imported'], + expectedTotalCount: 1, + events: [ + { + name: 'Wallet Imported', + matchProperties: { biometrics_enabled: false }, + }, + ], +}, +``` + +Example (no events expected): + +```typescript +analyticsExpectations: { + expectedTotalCount: 0, +}, +``` + +Programmatic use: `runAnalyticsExpectations` and `assertCapturedMetaMetricsEvents` are exported from `tests/framework/index.ts`. + +## Debug logging (`E2E_ANALYTICS_DEBUG`) + +Set **`E2E_ANALYTICS_DEBUG=1`** (also accepts `true`, `yes`, `on`) when running E2E to log MetaMetrics traffic: + +1. **Live (as requests hit the mock)** — When the app POSTs through `/proxy` to the MetaMetrics track URL, the mock server logs a line like `Event sent (live): ""` (see `logLiveMetaMetricsPostIfDebug` in `tests/helpers/analytics/analyticsDebug.ts` and `MockServerE2E.ts`). +2. **Batch (when payloads are read)** — Whenever `getEventsPayloads` runs, it logs each captured event as `Captured event: ""` plus debug-level property JSON (truncated). This runs at the end of the flow when analytics assertions (or custom code) fetch captured events. + +Properties are logged at **debug** level to limit noise; event names are **info** level. Adjust log level if your harness filters debug output. diff --git a/tests/framework/fixtures/FixtureHelper.ts b/tests/framework/fixtures/FixtureHelper.ts index 1788cce7435..f6f7cba3fef 100644 --- a/tests/framework/fixtures/FixtureHelper.ts +++ b/tests/framework/fixtures/FixtureHelper.ts @@ -46,6 +46,10 @@ import ContractAddressRegistry from '../../../app/util/test/contract-address-reg import FixtureBuilder from './FixtureBuilder'; import { createLogger } from '../logger'; import { mockNotificationServices } from '../../smoke/notifications/utils/mocks'; +import { + runAnalyticsExpectations, + shouldRunAnalyticsExpectations, +} from '../../helpers/analytics/runAnalyticsExpectations'; import PortManager, { ResourceType } from '../PortManager'; import { DEFAULT_MOCKS } from '../../api-mocking/mock-responses/defaults'; import type { Fixture } from './types'; @@ -512,6 +516,7 @@ export async function withFixtures( endTestfn, skipReactNativeReload = false, useCommandQueueServer = false, + analyticsExpectations, } = options; // Clean up any stale port forwarding from previous failed tests @@ -672,7 +677,22 @@ export async function withFixtures( } } - // Enter drain mode AFTER endTestfn so analytics events are still captured, + if ( + mockServerInstance && + shouldRunAnalyticsExpectations(analyticsExpectations) + ) { + try { + await runAnalyticsExpectations( + mockServerInstance.server, + analyticsExpectations, + ); + } catch (analyticsError) { + logger.error('Error in analyticsExpectations:', analyticsError); + cleanupErrors.push(analyticsError as Error); + } + } + + // Enter drain mode AFTER endTestfn / analyticsExpectations so analytics events are still captured, // but BEFORE stopping backends — prevents forwarding to dead Anvil/Ganache. if (mockServerInstance) { mockServerInstance.startDraining(); diff --git a/tests/framework/fixtures/README.md b/tests/framework/fixtures/README.md index 5995f2b8ac6..4c56c254562 100644 --- a/tests/framework/fixtures/README.md +++ b/tests/framework/fixtures/README.md @@ -42,6 +42,7 @@ describe('My Test Suite', () => { | `languageAndLocale` | `LanguageAndLocale` | `false` | - | Set the device Language and Locale of the device | | `permissions` | `object` | `false` | - | Allows setting specific device permissions | | `endTestfn` | `fn()` | `false` | - | Allows providing a function that is executed at the end of the test before the cleanup | +| `analyticsExpectations` | `AnalyticsExpectations` | `false` | - | Optional MetaMetrics checks after `endTestfn`, before mock drain; see `tests/docs/analytics-e2e.md` | | `skipReactNativeReload` | `boolean` | `false` | `false` | Skip React Native reload during cleanup to preserve app state between tests | | `useCommandQueueServer` | `boolean` | `false` | `false` | Launches an instance of CommandQueueServer to create a queue of items the app consumes on E2E context | diff --git a/tests/framework/index.ts b/tests/framework/index.ts index 68cd0e478a4..e706948c162 100644 --- a/tests/framework/index.ts +++ b/tests/framework/index.ts @@ -6,6 +6,12 @@ export { default as Utilities, BASE_DEFAULTS, sleep } from './Utilities.ts'; export { Logger, createLogger, LogLevel, logger } from './logger.ts'; export { default as PortManager, ResourceType } from './PortManager.ts'; export * from './types.ts'; +export { + runAnalyticsExpectations, + assertCapturedMetaMetricsEvents, + deriveEventNamesForFetch, + shouldRunAnalyticsExpectations, +} from '../helpers/analytics/runAnalyticsExpectations.ts'; export { boxedStep, getDriver } from './PlaywrightUtilities.ts'; // Mock server utilities diff --git a/tests/framework/types.ts b/tests/framework/types.ts index d8fe0aafe1c..665eb86e441 100644 --- a/tests/framework/types.ts +++ b/tests/framework/types.ts @@ -6,6 +6,7 @@ import { AnvilManager, Hardfork } from '../seeder/anvil-manager.ts'; import ContractAddressRegistry from '../../app/util/test/contract-address-registry'; import Ganache from '../../app/util/test/ganache'; import { Mockttp } from 'mockttp'; +import type { EventPayload } from '../helpers/analytics/helpers.ts'; import FixtureBuilder from './fixtures/FixtureBuilder.ts'; import type { Fixture } from './fixtures/types.ts'; import CommandQueueServer from './fixtures/CommandQueueServer.ts'; @@ -311,6 +312,70 @@ export interface MockApiEndpoint { export type TestSpecificMock = (mockServer: Mockttp) => Promise; +/** + * Declarative expectation for a single MetaMetrics event name captured during E2E. + */ +export interface AnalyticsEventExpectation { + /** MetaMetrics event name (e.g. `'Wallet Imported'`). */ + name: string; + /** Minimum number of payloads with this event name. @default 1 */ + minCount?: number; + /** + * Which captured occurrence to use for `matchProperties`, `containProperties`, and + * `requiredDefinedPropertyKeys`. @default 0 + */ + matchEventIndex?: number; + /** + * Property shape checks using the same value contract as + * `Assertions.checkIfObjectHasKeysAndValidValues` (e.g. `'string'`, `'number'`, `'array'`). + * Applied to **every** captured payload with this event name. + */ + requiredProperties?: Record boolean)>; + /** + * Exact property match on the payload at `matchEventIndex` via + * `Assertions.checkIfObjectsMatch`. + */ + matchProperties?: Record; + /** + * Subset property match on the payload at `matchEventIndex` via + * `Assertions.checkIfObjectContains`. + */ + containProperties?: Record; + /** + * Keys on the payload at `matchEventIndex` that must be defined (via + * `Assertions.checkIfValueIsDefined`). + */ + requiredDefinedPropertyKeys?: string[]; +} + +/** + * Optional MetaMetrics validation for `withFixtures`. + * Executed after `testSuite` and `endTestfn`, before the mock server enters drain mode, + * so captured Segment/MetaMetrics requests are still available on `mockServer`. + */ +export interface AnalyticsExpectations { + /** + * Subset of event names to pass to `getEventsPayloads`. + * When omitted and no `events` entries exist, all captured MetaMetrics payloads are loaded. + */ + eventNames?: string[]; + /** + * When set, asserts the filtered payload list length equals this value (after `getEventsPayloads`). + */ + expectedTotalCount?: number; + /** Declarative per-event count and property checks. */ + events?: AnalyticsEventExpectation[]; + /** + * Custom validation after declarative rules are evaluated. Runs in the same `SoftAssert` pass as + * declarative checks, so failures are merged: all expected events are still checked even when + * some already failed (unless you throw before returning from `validate`). + */ + validate?: (ctx: { + events: EventPayload[]; + mockServer: Mockttp; + }) => Promise; +} + /** * The options for the withFixtures function. * @param {FixtureBuilder | ((ctx: { localNodes?: LocalNode[] }) => FixtureBuilder | Promise)} fixture - The state of the fixture to load or a function that returns a fixture builder. @@ -324,6 +389,7 @@ export type TestSpecificMock = (mockServer: Mockttp) => Promise; * @param {LanguageAndLocale} [languageAndLocale] - The language and locale to use for the test. * @param {Record} [permissions] - The permissions to set for the device. * @param {() => Promise} [endTestfn] - The function to execute after the test is finished. + * @param {AnalyticsExpectations} [analyticsExpectations] - Optional MetaMetrics assertions run after `endTestfn`, before mock drain. */ export interface WithFixturesOptions { fixture: @@ -350,4 +416,5 @@ export interface WithFixturesOptions { */ skipReactNativeReload?: boolean; useCommandQueueServer?: boolean; + analyticsExpectations?: AnalyticsExpectations; } diff --git a/tests/helpers/analytics/analyticsDebug.ts b/tests/helpers/analytics/analyticsDebug.ts new file mode 100644 index 00000000000..66c49328c35 --- /dev/null +++ b/tests/helpers/analytics/analyticsDebug.ts @@ -0,0 +1,93 @@ +import { E2E_METAMETRICS_TRACK_URL } from '../../../app/util/test/utils'; +import { createLogger } from '../../framework/logger'; + +/** Same shape as `EventPayload` in `helpers.ts` (kept local to avoid circular imports). */ +export interface MetaMetricsEventPayload { + event: string; + properties: Record; +} + +const logger = createLogger({ + name: 'E2EAnalyticsDebug', +}); + +const TRUTHY = new Set(['1', 'true', 'yes', 'on']); + +/** + * When enabled (`E2E_ANALYTICS_DEBUG=1`), logs MetaMetrics payloads when they are + * captured and optionally when each POST hits the E2E mock proxy. + * + * @see tests/docs/analytics-e2e.md + */ +export function isE2EAnalyticsDebugEnabled(): boolean { + const raw = process.env.E2E_ANALYTICS_DEBUG; + if (raw === undefined || raw === '') { + return false; + } + return TRUTHY.has(raw.toLowerCase().trim()); +} + +const MAX_PROPERTIES_JSON_LENGTH = 2000; + +/** + * Logs each captured event after `getEventsPayloads` (batch read, end of flow or whenever fetch runs). + */ +export function logCapturedMetaMetricsPayloads( + events: readonly MetaMetricsEventPayload[], + context: string, +): void { + if (!isE2EAnalyticsDebugEnabled()) { + return; + } + if (events.length === 0) { + logger.info(`[E2E Analytics] ${context}: 0 MetaMetrics payloads`); + return; + } + logger.info( + `[E2E Analytics] ${context}: ${String(events.length)} MetaMetrics payload(s)`, + ); + events.forEach((payload, index) => { + logger.info( + `[E2E Analytics] [${String(index + 1)}/${String(events.length)}] Captured event: "${payload.event}"`, + ); + const serialized = JSON.stringify(payload.properties); + const preview = + serialized.length > MAX_PROPERTIES_JSON_LENGTH + ? `${serialized.slice(0, MAX_PROPERTIES_JSON_LENGTH)}…` + : serialized; + logger.debug(`[E2E Analytics] Properties: ${preview}`); + }); +} + +/** + * Logs as soon as a MetaMetrics track POST is handled by the mock `/proxy` handler + * (real-time relative to the test run, not only at teardown). + */ +export function logLiveMetaMetricsPostIfDebug( + proxiedTargetUrl: string, + requestBodyJson: unknown, +): void { + if (!isE2EAnalyticsDebugEnabled()) { + return; + } + if (!proxiedTargetUrl.includes(E2E_METAMETRICS_TRACK_URL)) { + return; + } + const body = requestBodyJson as Record | null | undefined; + if (body && typeof body.event === 'string') { + logger.info(`[E2E Analytics] Event sent (live): "${body.event}"`); + const props = body.properties; + if (props !== undefined && props !== null) { + const serialized = JSON.stringify(props); + const preview = + serialized.length > MAX_PROPERTIES_JSON_LENGTH + ? `${serialized.slice(0, MAX_PROPERTIES_JSON_LENGTH)}…` + : serialized; + logger.debug(`[E2E Analytics] Properties: ${preview}`); + } + return; + } + logger.debug( + `[E2E Analytics] MetaMetrics POST (unparsed or batch shape): ${JSON.stringify(requestBodyJson)?.slice(0, 500)}`, + ); +} diff --git a/tests/helpers/analytics/expectations/dapp-initiated-transfer.analytics.ts b/tests/helpers/analytics/expectations/dapp-initiated-transfer.analytics.ts new file mode 100644 index 00000000000..43e7c31c72c --- /dev/null +++ b/tests/helpers/analytics/expectations/dapp-initiated-transfer.analytics.ts @@ -0,0 +1,67 @@ +import type { AnalyticsExpectations } from '../../../framework'; +import { commonTransactionPropertiesAndTypes } from '../common-transaction-properties'; + +const TRANSACTION_ADDED = 'Transaction Added'; +const TRANSACTION_SUBMITTED = 'Transaction Submitted'; +const TRANSACTION_APPROVED = 'Transaction Approved'; +const TRANSACTION_FINALIZED = 'Transaction Finalized'; + +const transactionEventNames = [ + TRANSACTION_ADDED, + TRANSACTION_SUBMITTED, + TRANSACTION_APPROVED, + TRANSACTION_FINALIZED, +]; + +const simulationLifecycleProperties: Record< + string, + string | ((value: unknown) => boolean) +> = { + ...commonTransactionPropertiesAndTypes, + simulation_response: 'string', + simulation_latency: 'number', + simulation_receiving_assets_quantity: 'number', + simulation_receiving_assets_type: 'array', + simulation_receiving_assets_value: 'array', + simulation_sending_assets_quantity: 'number', + simulation_sending_assets_type: 'array', + simulation_sending_assets_value: 'array', + asset_type: 'string', + simulation_receiving_assets_total_value: 'number', + simulation_sending_assets_total_value: 'number', +}; + +const transactionFinalizedProperties: Record< + string, + string | ((value: unknown) => boolean) +> = { + ...simulationLifecycleProperties, + rpc_domain: 'string', +}; + +/** + * Expected MetaMetrics payloads after confirming a dapp-initiated native transfer (local Anvil). + */ +export const dappInitiatedTransferAnalyticsExpectations: AnalyticsExpectations = + { + eventNames: [...transactionEventNames], + expectedTotalCount: transactionEventNames.length, + events: [ + { + name: TRANSACTION_ADDED, + requiredProperties: { ...commonTransactionPropertiesAndTypes }, + }, + { + name: TRANSACTION_SUBMITTED, + requiredProperties: { ...simulationLifecycleProperties }, + }, + { + name: TRANSACTION_APPROVED, + requiredProperties: { ...simulationLifecycleProperties }, + }, + { + name: TRANSACTION_FINALIZED, + requiredProperties: { ...transactionFinalizedProperties }, + }, + ], + }; diff --git a/tests/helpers/analytics/expectations/import-wallet.analytics.ts b/tests/helpers/analytics/expectations/import-wallet.analytics.ts new file mode 100644 index 00000000000..dd9d2e39bd5 --- /dev/null +++ b/tests/helpers/analytics/expectations/import-wallet.analytics.ts @@ -0,0 +1,61 @@ +import type { AnalyticsExpectations } from '../../../framework'; +import { onboardingEvents } from '../helpers'; + +const importWalletFlowExpectedEventNames = [ + onboardingEvents.ANALYTICS_PREFERENCE_SELECTED, + onboardingEvents.WALLET_IMPORTED, + onboardingEvents.WALLET_SETUP_COMPLETED, + onboardingEvents.WALLET_IMPORT_STARTED, + onboardingEvents.WALLET_IMPORT_ATTEMPTED, +]; + +/** + * Expected MetaMetrics payloads after importing a wallet with metrics opt-in. + */ +export const importWalletWithMetricsOptInExpectations: AnalyticsExpectations = { + eventNames: importWalletFlowExpectedEventNames, + expectedTotalCount: importWalletFlowExpectedEventNames.length, + events: [ + { + name: onboardingEvents.ANALYTICS_PREFERENCE_SELECTED, + matchProperties: { + has_marketing_consent: false, + is_metrics_opted_in: true, + location: 'onboarding_metametrics', + updated_after_onboarding: false, + account_type: 'imported', + }, + }, + { + name: onboardingEvents.WALLET_IMPORT_STARTED, + matchProperties: { + account_type: 'imported', + }, + }, + { + name: onboardingEvents.WALLET_IMPORT_ATTEMPTED, + matchProperties: {}, + }, + { + name: onboardingEvents.WALLET_IMPORTED, + matchProperties: { + biometrics_enabled: false, + }, + }, + { + name: onboardingEvents.WALLET_SETUP_COMPLETED, + matchProperties: { + wallet_setup_type: 'import', + new_wallet: false, + account_type: 'imported', + }, + }, + ], +}; + +/** + * No MetaMetrics payloads when the user opts out during onboarding import. + */ +export const importWalletMetricsOptOutExpectations: AnalyticsExpectations = { + expectedTotalCount: 0, +}; diff --git a/tests/helpers/analytics/expectations/predict-cash-out.analytics.ts b/tests/helpers/analytics/expectations/predict-cash-out.analytics.ts new file mode 100644 index 00000000000..9027f2d1fe7 --- /dev/null +++ b/tests/helpers/analytics/expectations/predict-cash-out.analytics.ts @@ -0,0 +1,28 @@ +import type { AnalyticsExpectations } from '../../../framework'; + +const MARKET_DETAILS_OPENED = 'Predict Market Details Opened'; +const ACTIVITY_VIEWED = 'Predict Activity Viewed'; + +/** + * Expected MetaMetrics payloads after the predictions cash-out flow (Spurs vs. Pelicans scenario). + * + * Note: "Predict Position Viewed" is not asserted here. It is only emitted from legacy + * `PredictHomePositions` inside `PredictTabView` when the wallet Predict tab is visible. + * With homepage sections v1 (`remoteFeatureFlagHomepageSectionsV1Enabled`), the wallet uses + * `Homepage` + `PredictionsSection` instead — that path does not call `trackPositionViewed`. + */ +export const predictCashOutFlowAnalyticsExpectations: AnalyticsExpectations = { + eventNames: [MARKET_DETAILS_OPENED, ACTIVITY_VIEWED], + events: [ + { + name: MARKET_DETAILS_OPENED, + requiredDefinedPropertyKeys: ['entry_point', 'market_details_viewed'], + }, + { + name: ACTIVITY_VIEWED, + containProperties: { + activity_type: 'activity_list', + }, + }, + ], +}; diff --git a/tests/helpers/analytics/helpers.ts b/tests/helpers/analytics/helpers.ts index c3134ae23c0..f452d47a33b 100644 --- a/tests/helpers/analytics/helpers.ts +++ b/tests/helpers/analytics/helpers.ts @@ -1,6 +1,7 @@ import { MockedEndpoint, Mockttp, MockttpServer } from 'mockttp'; import { E2E_METAMETRICS_TRACK_URL } from '../../../app/util/test/utils'; import { createLogger } from '../../framework/logger'; +import { logCapturedMetaMetricsPayloads } from './analyticsDebug'; const logger = createLogger({ name: 'AnalyticsHelpers', @@ -85,9 +86,18 @@ export const getEventsPayloads = async ( await Promise.all(matchingRequests.map((req) => req.body?.getJson())) ).filter(Boolean); - return (payloads as EventPayload[]) + const result = (payloads as EventPayload[]) .filter((payload) => events.length === 0 || events.includes(payload.event)) .map(({ event, properties }) => ({ event, properties })); + + logCapturedMetaMetricsPayloads( + result, + events.length > 0 + ? `getEventsPayloads (filtered to ${String(events.length)} name(s))` + : 'getEventsPayloads (all MetaMetrics)', + ); + + return result; }; /** diff --git a/tests/helpers/analytics/runAnalyticsExpectations.test.ts b/tests/helpers/analytics/runAnalyticsExpectations.test.ts new file mode 100644 index 00000000000..a0903795d8e --- /dev/null +++ b/tests/helpers/analytics/runAnalyticsExpectations.test.ts @@ -0,0 +1,273 @@ +import type { Mockttp } from 'mockttp'; +import { + assertCapturedMetaMetricsEvents, + deriveEventNamesForFetch, + shouldRunAnalyticsExpectations, +} from './runAnalyticsExpectations'; +import type { AnalyticsExpectations } from '../../framework'; +import type { EventPayload } from './helpers'; + +const mockServer = {} as Mockttp; + +describe('shouldRunAnalyticsExpectations', () => { + it('returns false for undefined', () => { + expect(shouldRunAnalyticsExpectations(undefined)).toBe(false); + }); + + it('returns false for empty object', () => { + expect(shouldRunAnalyticsExpectations({})).toBe(false); + }); + + it('returns true when validate is set', () => { + expect( + shouldRunAnalyticsExpectations({ + validate: async () => undefined, + }), + ).toBe(true); + }); + + it('returns true when expectedTotalCount is set including zero', () => { + expect(shouldRunAnalyticsExpectations({ expectedTotalCount: 0 })).toBe( + true, + ); + }); + + it('returns true when events array is non-empty', () => { + expect( + shouldRunAnalyticsExpectations({ + events: [{ name: 'Test Event' }], + }), + ).toBe(true); + }); + + it('returns true when eventNames is non-empty', () => { + expect( + shouldRunAnalyticsExpectations({ + eventNames: ['A'], + }), + ).toBe(true); + }); +}); + +describe('deriveEventNamesForFetch', () => { + it('prefers explicit eventNames', () => { + const expectations: AnalyticsExpectations = { + eventNames: ['One', 'Two'], + events: [{ name: 'Three' }], + }; + expect(deriveEventNamesForFetch(expectations)).toEqual(['One', 'Two']); + }); + + it('uses unique names from events when eventNames missing', () => { + const expectations: AnalyticsExpectations = { + events: [{ name: 'Dup' }, { name: 'Dup' }, { name: 'Other' }], + }; + expect(deriveEventNamesForFetch(expectations)).toEqual(['Dup', 'Other']); + }); + + it('returns empty array when no names source', () => { + expect( + deriveEventNamesForFetch({ validate: async () => undefined }), + ).toEqual([]); + }); +}); + +describe('assertCapturedMetaMetricsEvents', () => { + const sample: EventPayload[] = [ + { event: 'Alpha', properties: { x: 'a' } }, + { event: 'Alpha', properties: { x: 'b' } }, + ]; + + it('throws when expectedTotalCount does not match', async () => { + await expect( + assertCapturedMetaMetricsEvents( + sample, + { expectedTotalCount: 99 }, + mockServer, + ), + ).rejects.toThrow(); + }); + + it('passes when expectedTotalCount matches', async () => { + await expect( + assertCapturedMetaMetricsEvents( + sample, + { expectedTotalCount: 2 }, + mockServer, + ), + ).resolves.toBeUndefined(); + }); + + it('throws when minCount not met for an event', async () => { + await expect( + assertCapturedMetaMetricsEvents( + sample, + { + events: [{ name: 'Missing', minCount: 1 }], + }, + mockServer, + ), + ).rejects.toThrow(/Missing/); + }); + + it('validates requiredProperties on each matching payload', async () => { + await expect( + assertCapturedMetaMetricsEvents( + sample, + { + events: [ + { + name: 'Alpha', + minCount: 2, + requiredProperties: { x: 'string' }, + }, + ], + }, + mockServer, + ), + ).resolves.toBeUndefined(); + }); + + it('runs validate in the same SoftAssert pass as declarative checks', async () => { + let ran = false; + await assertCapturedMetaMetricsEvents( + [{ event: 'Z', properties: {} }], + { + expectedTotalCount: 1, + validate: async () => { + ran = true; + }, + }, + mockServer, + ); + expect(ran).toBe(true); + }); + + it('still runs validate when declarative checks failed and aggregates errors', async () => { + let ran = false; + await expect( + assertCapturedMetaMetricsEvents( + [{ event: 'Z', properties: {} }], + { + expectedTotalCount: 99, + validate: async () => { + ran = true; + throw new Error('validate failed'); + }, + }, + mockServer, + ), + ).rejects.toThrow(/validate failed/); + expect(ran).toBe(true); + }); + + it('evaluates every expected event and reports all failures together', async () => { + let caught: Error | undefined; + try { + await assertCapturedMetaMetricsEvents( + [{ event: 'OnlyThis', properties: {} }], + { + events: [ + { name: 'MissingOne', minCount: 1 }, + { + name: 'OnlyThis', + matchProperties: { wrong: true }, + }, + ], + }, + mockServer, + ); + } catch (error) { + caught = error as Error; + } + expect(caught).toBeDefined(); + expect(caught?.message).toContain('MissingOne'); + expect(caught?.message).toContain('OnlyThis'); + expect(caught?.message).toContain('match expected object'); + }); + + it('validates matchProperties on target payload', async () => { + await expect( + assertCapturedMetaMetricsEvents( + [{ event: 'E', properties: { id: 1, name: 'ok' } }], + { + events: [ + { + name: 'E', + matchProperties: { id: 1, name: 'ok' }, + }, + ], + }, + mockServer, + ), + ).resolves.toBeUndefined(); + }); + + it('fails matchProperties when values differ', async () => { + await expect( + assertCapturedMetaMetricsEvents( + [{ event: 'E', properties: { id: 2 } }], + { + events: [ + { + name: 'E', + matchProperties: { id: 1 }, + }, + ], + }, + mockServer, + ), + ).rejects.toThrow(); + }); + + it('validates containProperties', async () => { + await expect( + assertCapturedMetaMetricsEvents( + [{ event: 'E', properties: { a: 1, b: 2 } }], + { + events: [ + { + name: 'E', + containProperties: { b: 2 }, + }, + ], + }, + mockServer, + ), + ).resolves.toBeUndefined(); + }); + + it('validates requiredDefinedPropertyKeys', async () => { + await expect( + assertCapturedMetaMetricsEvents( + [{ event: 'E', properties: { x: 'set' } }], + { + events: [ + { + name: 'E', + requiredDefinedPropertyKeys: ['x'], + }, + ], + }, + mockServer, + ), + ).resolves.toBeUndefined(); + }); + + it('fails requiredDefinedPropertyKeys when key missing', async () => { + await expect( + assertCapturedMetaMetricsEvents( + [{ event: 'E', properties: {} }], + { + events: [ + { + name: 'E', + requiredDefinedPropertyKeys: ['missing'], + }, + ], + }, + mockServer, + ), + ).rejects.toThrow(); + }); +}); diff --git a/tests/helpers/analytics/runAnalyticsExpectations.ts b/tests/helpers/analytics/runAnalyticsExpectations.ts new file mode 100644 index 00000000000..811e9808c54 --- /dev/null +++ b/tests/helpers/analytics/runAnalyticsExpectations.ts @@ -0,0 +1,159 @@ +import { Mockttp, MockttpServer } from 'mockttp'; +import Assertions from '../../framework/Assertions'; +import SoftAssert from '../../framework/SoftAssert'; +import type { AnalyticsExpectations } from '../../framework/types'; +import { EventPayload, filterEvents, getEventsPayloads } from './helpers'; + +/** + * Returns true when `analyticsExpectations` should run (non-empty configuration). + */ +export function shouldRunAnalyticsExpectations( + config: AnalyticsExpectations | undefined, +): config is AnalyticsExpectations { + if (!config) { + return false; + } + return ( + config.validate !== undefined || + config.expectedTotalCount !== undefined || + (config.events !== undefined && config.events.length > 0) || + (config.eventNames !== undefined && config.eventNames.length > 0) + ); +} + +/** + * Derives the event-name filter passed to `getEventsPayloads`. + * Prefers explicit `eventNames`; otherwise uses unique names from `events`; otherwise all payloads. + */ +export function deriveEventNamesForFetch( + expectations: AnalyticsExpectations, +): string[] { + if (expectations.eventNames && expectations.eventNames.length > 0) { + return expectations.eventNames; + } + if (expectations.events && expectations.events.length > 0) { + return [...new Set(expectations.events.map((entry) => entry.name))]; + } + return []; +} + +/** + * Runs declarative MetaMetrics checks, then optional `validate`, aggregating **all** + * failures via `SoftAssert` so every expected event / rule is evaluated (not stop-on-first). + * Property checks for an event are skipped when that event did not meet `minCount`, to avoid + * noisy follow-on errors; other expected events are still fully checked. + */ +export async function assertCapturedMetaMetricsEvents( + events: EventPayload[], + expectations: AnalyticsExpectations, + mockServer: Mockttp | MockttpServer, +): Promise { + const softAssert = new SoftAssert(); + + const expectedTotalCount = expectations.expectedTotalCount; + if (expectedTotalCount !== undefined) { + await softAssert.checkAndCollect( + async () => Assertions.checkIfArrayHasLength(events, expectedTotalCount), + `Expected ${String(expectedTotalCount)} MetaMetrics events, got ${events.length}`, + ); + } + + if (expectations.events && expectations.events.length > 0) { + for (const eventExpectation of expectations.events) { + const minCount = eventExpectation.minCount ?? 1; + const matching = filterEvents(events, eventExpectation.name); + + await softAssert.checkAndCollect(async () => { + if (matching.length < minCount) { + throw new Error( + `Expected at least ${String(minCount)} "${eventExpectation.name}" events, got ${String(matching.length)}`, + ); + } + }, `MetaMetrics event count for "${eventExpectation.name}"`); + + const targetIndex = Math.min( + eventExpectation.matchEventIndex ?? 0, + Math.max(matching.length - 1, 0), + ); + const targetPayload = matching[targetIndex]; + const canInspectPayload = + matching.length >= minCount && targetPayload !== undefined; + + const requiredProperties = eventExpectation.requiredProperties; + if (requiredProperties && canInspectPayload) { + for (let i = 0; i < matching.length; i += 1) { + const payload = matching[i]; + await softAssert.checkAndCollect( + async () => + Assertions.checkIfObjectHasKeysAndValidValues( + payload.properties, + requiredProperties, + ), + `"${eventExpectation.name}" event[${String(i)}] property shape`, + ); + } + } + + if (!canInspectPayload) { + continue; + } + + const definedKeys = eventExpectation.requiredDefinedPropertyKeys; + if (definedKeys && definedKeys.length > 0) { + for (const key of definedKeys) { + await softAssert.checkAndCollect( + async () => + Assertions.checkIfValueIsDefined(targetPayload.properties[key]), + `"${eventExpectation.name}" property "${key}" should be defined`, + ); + } + } + + const matchProperties = eventExpectation.matchProperties; + if (matchProperties !== undefined) { + await softAssert.checkAndCollect( + async () => + Assertions.checkIfObjectsMatch( + targetPayload.properties as object, + matchProperties as object, + ), + `"${eventExpectation.name}" properties should match expected object`, + ); + } + + const containProperties = eventExpectation.containProperties; + if (containProperties !== undefined) { + await softAssert.checkAndCollect( + async () => + Assertions.checkIfObjectContains( + targetPayload.properties, + containProperties, + ), + `"${eventExpectation.name}" properties should contain expected subset`, + ); + } + } + } + + const customValidate = expectations.validate; + if (customValidate) { + await softAssert.checkAndCollect( + async () => customValidate({ events, mockServer }), + 'analyticsExpectations.validate', + ); + } + + softAssert.throwIfErrors(); +} + +/** + * Fetches MetaMetrics payloads from the mock server and runs `assertCapturedMetaMetricsEvents`. + */ +export async function runAnalyticsExpectations( + mockServer: Mockttp | MockttpServer, + expectations: AnalyticsExpectations, +): Promise { + const names = deriveEventNamesForFetch(expectations); + const events = await getEventsPayloads(mockServer, names); + await assertCapturedMetaMetricsEvents(events, expectations, mockServer); +} diff --git a/tests/smoke/confirmations/transactions/dapp-initiated-transfer.spec.ts b/tests/smoke/confirmations/transactions/dapp-initiated-transfer.spec.ts index c5f9cc12b0e..52c52d1fe23 100644 --- a/tests/smoke/confirmations/transactions/dapp-initiated-transfer.spec.ts +++ b/tests/smoke/confirmations/transactions/dapp-initiated-transfer.spec.ts @@ -16,11 +16,6 @@ import { } from '../../../api-mocking/mock-responses/simulations'; import TestDApp from '../../../page-objects/Browser/TestDApp'; import { DappVariants } from '../../../framework/Constants'; -import { - EventPayload, - getEventsPayloads, -} from '../../../helpers/analytics/helpers'; -import SoftAssert from '../../../framework/SoftAssert'; import { Mockttp } from 'mockttp'; import { setupMockRequest, @@ -35,21 +30,7 @@ import { import { setupRemoteFeatureFlagsMock } from '../../../api-mocking/helpers/remoteFeatureFlagsHelper'; import { confirmationFeatureFlags } from '../../../api-mocking/mock-responses/feature-flags-mocks'; import { DEFAULT_ANVIL_PORT } from '../../../seeder/anvil-manager'; -import { commonTransactionPropertiesAndTypes } from '../../../helpers/analytics/common-transaction-properties'; - -const expectedEvents = { - TRANSACTION_ADDED: 'Transaction Added', - TRANSACTION_SUBMITTED: 'Transaction Submitted', - TRANSACTION_APPROVED: 'Transaction Approved', - TRANSACTION_FINALIZED: 'Transaction Finalized', -}; - -const expectedEventNames = [ - expectedEvents.TRANSACTION_ADDED, - expectedEvents.TRANSACTION_SUBMITTED, - expectedEvents.TRANSACTION_APPROVED, - expectedEvents.TRANSACTION_FINALIZED, -]; +import { dappInitiatedTransferAnalyticsExpectations } from '../../../helpers/analytics/expectations/dapp-initiated-transfer.analytics'; describe(SmokeConfirmations('DApp Initiated Transfer'), () => { const testSpecificMock = async (mockServer: Mockttp) => { @@ -101,13 +82,12 @@ describe(SmokeConfirmations('DApp Initiated Transfer'), () => { Object.assign({}, ...confirmationFeatureFlags), ); }; - let eventsToCheck: EventPayload[]; beforeAll(async () => { jest.setTimeout(2500000); }); - it('sends native asset', async () => { + it('sends native asset and validates MetaMetrics transaction events', async () => { await withFixtures( { dapps: [ @@ -133,12 +113,7 @@ describe(SmokeConfirmations('DApp Initiated Transfer'), () => { .build(), restartDevice: true, testSpecificMock, - endTestfn: async ({ mockServer }) => { - eventsToCheck = await getEventsPayloads( - mockServer, - expectedEventNames, - ); - }, + analyticsExpectations: dappInitiatedTransferAnalyticsExpectations, }, async () => { await loginToApp(); @@ -153,7 +128,6 @@ describe(SmokeConfirmations('DApp Initiated Transfer'), () => { }); await TestDApp.tapSendEIP1559Button(); - // Check all expected elements are visible await Assertions.expectElementToBeVisible( ConfirmationUITypes.ModalConfirmationContainer, { @@ -169,14 +143,12 @@ describe(SmokeConfirmations('DApp Initiated Transfer'), () => { RowComponents.SimulationDetails, ); - // Scroll to reveal GasFeesDetails on Android due to taller From/To row if (device.getPlatform() === 'android') { await Gestures.swipe(RowComponents.SimulationDetails, 'up'); } await Assertions.expectElementToBeVisible(RowComponents.GasFeesDetails); - // Scroll to Advanced Details section on Android if (device.getPlatform() === 'android') { await Gestures.swipe( ConfirmationUITypes.ModalConfirmationContainer, @@ -208,134 +180,12 @@ describe(SmokeConfirmations('DApp Initiated Transfer'), () => { }, ); - // Accept confirmation await FooterActions.tapConfirmButton(); - // Close browser to reveal app tab bar, then check activity await Browser.tapCloseBrowserButton(); await TabBarComponent.tapActivity(); await Assertions.expectTextDisplayed('Confirmed'); }, ); }); - - it('validates the segment events from the dapp initiated transfer', async () => { - if (!eventsToCheck) { - throw new Error('Events to check are not defined'); - } - - const softAssert = new SoftAssert(); - - // Transaction Added - const transactionAddedEvent = eventsToCheck.find( - (event) => event.event === expectedEvents.TRANSACTION_ADDED, - ); - await softAssert.checkAndCollect( - () => Assertions.checkIfValueIsDefined(transactionAddedEvent), - 'Transaction Added: Should be defined', - ); - await softAssert.checkAndCollect( - () => - Assertions.checkIfObjectHasKeysAndValidValues( - transactionAddedEvent?.properties ?? {}, - { - ...commonTransactionPropertiesAndTypes, - }, - ), - 'Transaction Added: Should have the correct properties', - ); - - // Transaction Submitted - const transactionSubmittedEvent = eventsToCheck.find( - (event) => event.event === expectedEvents.TRANSACTION_SUBMITTED, - ); - await softAssert.checkAndCollect( - () => Assertions.checkIfValueIsDefined(transactionSubmittedEvent), - 'Transaction Submitted: Should be defined', - ); - await softAssert.checkAndCollect( - () => - Assertions.checkIfObjectHasKeysAndValidValues( - transactionSubmittedEvent?.properties ?? {}, - { - ...commonTransactionPropertiesAndTypes, - simulation_response: 'string', - simulation_latency: 'number', - simulation_receiving_assets_quantity: 'number', - simulation_receiving_assets_type: 'array', - simulation_receiving_assets_value: 'array', - simulation_sending_assets_quantity: 'number', - simulation_sending_assets_type: 'array', - simulation_sending_assets_value: 'array', - asset_type: 'string', - simulation_receiving_assets_total_value: 'number', - simulation_sending_assets_total_value: 'number', - }, - ), - 'Transaction Submitted: Should have the correct properties', - ); - - // Transaction Approved - const transactionApprovedEvent = eventsToCheck.find( - (event) => event.event === expectedEvents.TRANSACTION_APPROVED, - ); - await softAssert.checkAndCollect( - () => Assertions.checkIfValueIsDefined(transactionApprovedEvent), - 'Transaction Approved: Should be defined', - ); - await softAssert.checkAndCollect( - () => - Assertions.checkIfObjectHasKeysAndValidValues( - transactionApprovedEvent?.properties ?? {}, - { - ...commonTransactionPropertiesAndTypes, - simulation_response: 'string', - simulation_latency: 'number', - simulation_receiving_assets_quantity: 'number', - simulation_receiving_assets_type: 'array', - simulation_receiving_assets_value: 'array', - simulation_sending_assets_quantity: 'number', - simulation_sending_assets_type: 'array', - simulation_sending_assets_value: 'array', - asset_type: 'string', - simulation_receiving_assets_total_value: 'number', - simulation_sending_assets_total_value: 'number', - }, - ), - 'Transaction Approved: Should have the correct properties', - ); - - // Transaction Finalized - const transactionFinalizedEvent = eventsToCheck.find( - (event) => event.event === expectedEvents.TRANSACTION_FINALIZED, - ); - await softAssert.checkAndCollect( - () => Assertions.checkIfValueIsDefined(transactionFinalizedEvent), - 'Transaction Finalized: Should be defined', - ); - await softAssert.checkAndCollect( - () => - Assertions.checkIfObjectHasKeysAndValidValues( - transactionFinalizedEvent?.properties ?? {}, - { - ...commonTransactionPropertiesAndTypes, - simulation_response: 'string', - simulation_latency: 'number', - simulation_receiving_assets_quantity: 'number', - simulation_receiving_assets_type: 'array', - simulation_receiving_assets_value: 'array', - simulation_sending_assets_quantity: 'number', - simulation_sending_assets_type: 'array', - simulation_sending_assets_value: 'array', - asset_type: 'string', - rpc_domain: 'string', - simulation_receiving_assets_total_value: 'number', - simulation_sending_assets_total_value: 'number', - }, - ), - 'Transaction Finalized: Should have the correct properties', - ); - - softAssert.throwIfErrors(); - }); }); diff --git a/tests/smoke/predict/predict-cash-out.spec.ts b/tests/smoke/predict/predict-cash-out.spec.ts index af1b06a2197..eccb5145054 100644 --- a/tests/smoke/predict/predict-cash-out.spec.ts +++ b/tests/smoke/predict/predict-cash-out.spec.ts @@ -23,16 +23,14 @@ import PredictCashOutPage from '../../page-objects/Predict/PredictCashOutPage'; import TabBarComponent from '../../page-objects/wallet/TabBarComponent'; import ActivitiesView from '../../page-objects/Transactions/ActivitiesView'; import PredictActivityDetails from '../../page-objects/Transactions/predictionsActivityDetails'; -import { getEventsPayloads } from '../../helpers/analytics/helpers'; -import SoftAssert from '../../framework/SoftAssert'; +import { predictCashOutFlowAnalyticsExpectations } from '../../helpers/analytics/expectations/predict-cash-out.analytics'; /* Test Scenario: Cash out on open position - Spurs vs. Pelicans - Verifies the cash out flow for a predictions position: - 1. Navigate to Predictions tab and verify balance is $28.16 - 2. Open Spurs vs. Pelicans position details - 3. Cash out the position with updated mocks - 4. Verify cash out appears in Activities tab + Verifies the cash out flow for a predictions position (homepage sections v1 — no wallet tabs): + 1. From wallet homepage Predictions section, open Spurs vs. Pelicans position details + 2. Cash out the position with updated mocks + 3. Verify balance and cash out in Activities tab */ const positionDetails = { name: 'Spurs vs. Pelicans', @@ -52,7 +50,7 @@ const PredictionMarketFeature = async (mockServer: Mockttp) => { }; describe(SmokePredictions('Predictions'), () => { - it('should cash out on open position: Spurs vs. Pelicans', async () => { + it('cashes out on open position: Spurs vs. Pelicans', async () => { await withFixtures( { fixture: new FixtureBuilder() @@ -61,6 +59,7 @@ describe(SmokePredictions('Predictions'), () => { .build(), restartDevice: true, testSpecificMock: PredictionMarketFeature, + analyticsExpectations: predictCashOutFlowAnalyticsExpectations, }, async ({ mockServer }) => { await loginToApp(); @@ -103,63 +102,6 @@ describe(SmokePredictions('Predictions'), () => { PredictActivityDetails.container, ); await Assertions.expectTextDisplayed(positionDetails.cashOutValue); - - // Verify analytics events - const events = await getEventsPayloads(mockServer); - const softAssert = new SoftAssert(); - - const expectedEvents = { - MARKET_DETAILS_OPENED: 'Predict Market Details Opened', - POSITION_VIEWED: 'Predict Position Viewed', - ACTIVITY_VIEWED: 'Predict Activity Viewed', - }; - - // Event 1: PREDICT_MARKET_DETAILS_OPENED - await softAssert.checkAndCollect(async () => { - const marketDetailsOpened = events.filter( - (event) => event.event === expectedEvents.MARKET_DETAILS_OPENED, - ); - await Assertions.checkIfValueIsDefined(marketDetailsOpened); - if (marketDetailsOpened.length > 0) { - await Assertions.checkIfValueIsDefined( - marketDetailsOpened[0].properties.entry_point, - ); - await Assertions.checkIfValueIsDefined( - marketDetailsOpened[0].properties.market_details_viewed, - ); - } - }, 'Market Details Opened event should be tracked'); - - // Event 2: PREDICT_POSITION_VIEWED - await softAssert.checkAndCollect(async () => { - const positionViewed = events.filter( - (event) => event.event === expectedEvents.POSITION_VIEWED, - ); - await Assertions.checkIfValueIsDefined(positionViewed); - if (positionViewed.length > 0) { - await Assertions.checkIfValueIsDefined( - positionViewed[0].properties.open_positions_count, - ); - } - }, 'Position Viewed event should be tracked'); - - // Event 3: PREDICT_ACTIVITY_VIEWED - await softAssert.checkAndCollect(async () => { - const activityViewed = events.filter( - (event) => event.event === expectedEvents.ACTIVITY_VIEWED, - ); - await Assertions.checkIfValueIsDefined(activityViewed); - if (activityViewed.length > 0) { - await Assertions.checkIfObjectContains( - activityViewed[0].properties, - { - activity_type: 'activity_list', - }, - ); - } - }, 'Activity Viewed event should be tracked'); - - softAssert.throwIfErrors(); }, ); }); diff --git a/tests/smoke/wallet/analytics/import-wallet.spec.ts b/tests/smoke/wallet/analytics/import-wallet.spec.ts index 1805b6f4553..5cd60b9aabd 100644 --- a/tests/smoke/wallet/analytics/import-wallet.spec.ts +++ b/tests/smoke/wallet/analytics/import-wallet.spec.ts @@ -2,30 +2,26 @@ import { SmokeWalletPlatform } from '../../../tags'; import { importWalletWithRecoveryPhrase } from '../../../flows/wallet.flow'; import TestHelpers from '../../../helpers'; -import Assertions from '../../../framework/Assertions'; -import { - EventPayload, - findEvent, - getEventsPayloads, - onboardingEvents, -} from '../../../helpers/analytics/helpers'; -import { - IDENTITY_TEAM_PASSWORD, - IDENTITY_TEAM_SEED_PHRASE, -} from '../../identity/utils/constants'; -import SoftAssert from '../../../framework/SoftAssert'; import { withFixtures } from '../../../framework/fixtures/FixtureHelper'; import FixtureBuilder from '../../../framework/fixtures/FixtureBuilder'; import { Mockttp } from 'mockttp'; import { setupRemoteFeatureFlagsMock } from '../../../api-mocking/helpers/remoteFeatureFlagsHelper'; import { remoteFeatureMultichainAccountsAccountDetails } from '../../../api-mocking/mock-responses/feature-flags-mocks'; +import { + importWalletMetricsOptOutExpectations, + importWalletWithMetricsOptInExpectations, +} from '../../../helpers/analytics/expectations/import-wallet.analytics'; +import { + IDENTITY_TEAM_PASSWORD, + IDENTITY_TEAM_SEED_PHRASE, +} from '../../identity/utils/constants'; describe(SmokeWalletPlatform('Analytics during import wallet flow'), () => { beforeAll(async () => { await TestHelpers.reverseServerPort(); }); - it('should track analytics events during wallet import flow', async () => { + it('tracks analytics events during wallet import flow', async () => { await withFixtures( { fixture: new FixtureBuilder().withOnboardingFixture().build(), @@ -36,122 +32,7 @@ describe(SmokeWalletPlatform('Analytics during import wallet flow'), () => { remoteFeatureMultichainAccountsAccountDetails(), ); }, - endTestfn: async ({ mockServer }) => { - const expectedEvents = [ - onboardingEvents.ANALYTICS_PREFERENCE_SELECTED, - onboardingEvents.WALLET_IMPORTED, - onboardingEvents.WALLET_SETUP_COMPLETED, - onboardingEvents.WALLET_IMPORT_STARTED, - onboardingEvents.WALLET_IMPORT_ATTEMPTED, - ]; - const events = await getEventsPayloads(mockServer, expectedEvents); - - const softAssert = new SoftAssert(); - - const checkEventCount = softAssert.checkAndCollect( - () => - Assertions.checkIfArrayHasLength(events, expectedEvents.length), - `Expected ${expectedEvents.length} events to be tracked, but found ${events.length}`, - ); - - // ANALYTICS_PREFERENCE_SELECTED - const analyticsPreferenceSelectedEvent = findEvent( - events, - onboardingEvents.ANALYTICS_PREFERENCE_SELECTED, - ) as EventPayload; - - const checkAnalyticsPreference = softAssert.checkAndCollect( - async () => { - Assertions.checkIfObjectsMatch( - analyticsPreferenceSelectedEvent?.properties, - { - has_marketing_consent: false, - is_metrics_opted_in: true, - location: 'onboarding_metametrics', - updated_after_onboarding: false, - account_type: 'imported', - }, - ); - }, - 'Analytics Preference Selected event properties do not match expected values', - ); - - // WALLET IMPORTED STARTED - const walletImportStartedEvent = findEvent( - events, - onboardingEvents.WALLET_IMPORT_STARTED, - ) as EventPayload; - - const checkWalletImportStarted = softAssert.checkAndCollect( - () => - Assertions.checkIfObjectsMatch( - walletImportStartedEvent.properties, - { - account_type: 'imported', - }, - ), - 'Wallet Import Started event properties do not match expected values', - ); - - // WALLET IMPORTED ATTEMPTED - const walletImportAttemptedEvent = findEvent( - events, - onboardingEvents.WALLET_IMPORT_ATTEMPTED, - ) as EventPayload; - - const checkWalletImportAttempted = softAssert.checkAndCollect( - () => - Assertions.checkIfObjectsMatch( - walletImportAttemptedEvent.properties, - {}, - ), - 'Wallet Import Attempted event properties do not match expected values', - ); - - // WALLET IMPORTED - const walletImportedEvent = findEvent( - events, - onboardingEvents.WALLET_IMPORTED, - ) as EventPayload; - - const checkWalletImported = softAssert.checkAndCollect( - () => - Assertions.checkIfObjectsMatch(walletImportedEvent.properties, { - biometrics_enabled: false, - }), - 'Wallet Imported event properties do not match expected values', - ); - - // WALLET SETUP COMPLETED - const walletSetupCompletedEvent = findEvent( - events, - onboardingEvents.WALLET_SETUP_COMPLETED, - ) as EventPayload; - - const checkWalletSetupCompleted = softAssert.checkAndCollect( - () => - Assertions.checkIfObjectsMatch( - walletSetupCompletedEvent.properties, - { - wallet_setup_type: 'import', - new_wallet: false, - account_type: 'imported', - }, - ), - 'Wallet Setup Completed event properties do not match expected values', - ); - - await Promise.all([ - checkEventCount, - checkAnalyticsPreference, - checkWalletImportStarted, - checkWalletImportAttempted, - checkWalletImported, - checkWalletSetupCompleted, - ]); - - softAssert.throwIfErrors(); - }, + analyticsExpectations: importWalletWithMetricsOptInExpectations, }, async () => { await importWalletWithRecoveryPhrase({ @@ -162,7 +43,8 @@ describe(SmokeWalletPlatform('Analytics during import wallet flow'), () => { }, ); }); - it('should not track analytics events when opt-in to metrics is off', async () => { + + it('does not track analytics events when opt-in to metrics is off', async () => { await withFixtures( { fixture: new FixtureBuilder().withOnboardingFixture().build(), @@ -173,10 +55,7 @@ describe(SmokeWalletPlatform('Analytics during import wallet flow'), () => { remoteFeatureMultichainAccountsAccountDetails(), ); }, - endTestfn: async ({ mockServer }) => { - const events = await getEventsPayloads(mockServer); - await Assertions.checkIfArrayHasLength(events, 0); - }, + analyticsExpectations: importWalletMetricsOptOutExpectations, }, async () => { await importWalletWithRecoveryPhrase({ From 3735f049b97fac3f6158a940a4ad72486e0d54c8 Mon Sep 17 00:00:00 2001 From: Kirill Zyusko Date: Thu, 19 Mar 2026 15:45:49 +0100 Subject: [PATCH 140/206] feat: migrate Button component (rewards scope) (#27623) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Migrated `Button` component to be used from DSRN package. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/DSYS-445 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Low risk UI refactor that swaps the Rewards settings "Add all accounts" button to the design-system `Button` API; behavior should be unchanged aside from potential styling/prop differences. > > **Overview** > Migrates the Rewards Settings account group list’s **“Add all accounts”** CTA from the legacy component-library `Button` to `@metamask/design-system-react-native`’s `Button`, updating props to use `variant={ButtonVariant.Primary}` and render the label as children. > > Updates the associated test mocks to include `ButtonVariant` so the component can be rendered under test with the new design-system button API. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit fdd0a5a4e0f4c48ef7760b24e96410278501b243. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../RewardSettingsAccountGroupList.test.tsx | 4 ++++ .../Settings/RewardSettingsAccountGroupList.tsx | 13 ++++++------- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/app/components/UI/Rewards/components/Settings/RewardSettingsAccountGroupList.test.tsx b/app/components/UI/Rewards/components/Settings/RewardSettingsAccountGroupList.test.tsx index 7aec12b50a4..99f5247950a 100644 --- a/app/components/UI/Rewards/components/Settings/RewardSettingsAccountGroupList.test.tsx +++ b/app/components/UI/Rewards/components/Settings/RewardSettingsAccountGroupList.test.tsx @@ -211,6 +211,10 @@ jest.mock('@metamask/design-system-react-native', () => { FontWeight: { Medium: 'medium', }, + ButtonVariant: { + Primary: 'primary', + Secondary: 'secondary', + }, ButtonVariants: { Primary: 'primary', Secondary: 'secondary', diff --git a/app/components/UI/Rewards/components/Settings/RewardSettingsAccountGroupList.tsx b/app/components/UI/Rewards/components/Settings/RewardSettingsAccountGroupList.tsx index 8d9924ed3ef..2e2b767eb83 100644 --- a/app/components/UI/Rewards/components/Settings/RewardSettingsAccountGroupList.tsx +++ b/app/components/UI/Rewards/components/Settings/RewardSettingsAccountGroupList.tsx @@ -10,6 +10,8 @@ import { BoxFlexDirection, BoxAlignItems, BoxJustifyContent, + Button, + ButtonVariant, ButtonBase, Icon, IconName, @@ -21,9 +23,6 @@ import { Skeleton } from '../../../../../component-library/components-temp/Skele import { useRewardOptinSummary } from '../../hooks/useRewardOptinSummary'; import { selectAvatarAccountType } from '../../../../../selectors/settings'; import { selectInternalAccountsByGroupId } from '../../../../../selectors/multichainAccounts/accounts'; -import Button, { - ButtonVariants, -} from '../../../../../component-library/components/Buttons/Button'; import RewardSettingsAccountGroup from './RewardSettingsAccountGroup'; import ReferredByCodeSection from './ReferredByCodeSection'; import { RewardSettingsAccountGroupListFlatListItem } from './types'; @@ -137,12 +136,12 @@ const AccountProgressSection: React.FC = memo( {showAddAllButton && ( )} ); From ea55f696cb5fb1134ba97ac55541ebbf26a9b5ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20=C5=81ucka?= <5708018+PatrykLucka@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:46:19 +0100 Subject: [PATCH 141/206] refactor: normalize networkClientId handling in useGasFeeEstimates hook (#27681) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** **TMCU-582** — Sentry was reporting `No network client ID was provided` (thrown by `NetworkController.getNetworkClientById` when given a falsy id). That surfaced in flows tied to the Cash / homepage area because shared hooks (e.g. `useGasFeeEstimates` via confirmations gas UI) could run with `transactionMeta.networkClientId` missing or with `''` from patterns like `networkClientId ?? ''`. **Solution:** `useGasFeeEstimates` now accepts `string | undefined`, derives `effectiveNetworkClientId` only when the value is non-empty after trim, skips `getNetworkConfigurationByNetworkClientId` when absent, and passes **no** polling input to `GasFeeController.startPolling` until a valid `networkClientId` exists. `GasFeeController` only skips `getNetworkClientById` for `undefined`, not for `''`, so empty string previously caused the throw; we never pass `''` into that path anymore. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TMCU-582 ## **Manual testing steps** ```gherkin Feature: Gas fee estimates hook with valid network client Scenario: Confirmation flow still receives gas estimates when network client id is set Given the user has opened a transaction confirmation that includes a valid networkClientId When the gas fee UI loads Then gas fee estimates should populate as before (no regression) Scenario: No crash or Sentry noise when network client id is temporarily missing Given a code path passes undefined or empty string into useGasFeeEstimates When the hook runs Then the app should not throw "No network client ID was provided" and polling should not start until a valid id exists ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Moderate risk: changes when gas fee polling/config lookup runs, which could impact gas estimate availability if callers pass an empty/whitespace ID, but it primarily prevents a known runtime throw. > > **Overview** > Prevents `useGasFeeEstimates` from calling into `NetworkController`/`GasFeeController` with falsy `networkClientId` values (notably `''`), which previously triggered the "No network client ID was provided" error. > > The hook now accepts `string | undefined`, normalizes empty/whitespace IDs to `undefined`, skips the network config lookup when missing, and avoids starting gas fee polling until a valid ID is available. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 6e6fc5398365560cc7ba20d47cad2bf002ccf184. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../hooks/gas/useGasFeeEstimates.ts | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/app/components/Views/confirmations/hooks/gas/useGasFeeEstimates.ts b/app/components/Views/confirmations/hooks/gas/useGasFeeEstimates.ts index 2f845aadced..099917bc314 100644 --- a/app/components/Views/confirmations/hooks/gas/useGasFeeEstimates.ts +++ b/app/components/Views/confirmations/hooks/gas/useGasFeeEstimates.ts @@ -13,12 +13,22 @@ import Engine from '../../../../../core/Engine'; * GasFeeController that it is done requiring new gas estimates. Also checks * the returned gas estimate for validity on the current network. * + * Normalizes falsy networkClientId (e.g. '' from networkClientId ?? '') to + * undefined so NetworkController.getNetworkClientById is never called with + * a falsy value (which throws "No network client ID was provided"). + * * @param _networkClientId - The optional network client ID to get gas fee estimates for. Defaults to the currently selected network. * @returns {GasEstimates} GasEstimates object */ -export function useGasFeeEstimates(networkClientId: string) { +export function useGasFeeEstimates(networkClientId: string | undefined) { const [chainId, setChainId] = useState(''); + // Avoid passing '' or other falsy values to NetworkController/GasFeeController; + // getNetworkClientById(undefined) is never called, getNetworkClientById('') throws. + const effectiveNetworkClientId = networkClientId?.trim() + ? networkClientId + : undefined; + const gasFeeEstimates = useSelector( (state: RootState) => selectGasFeeEstimatesByChainId(state, chainId), isEqual, @@ -26,10 +36,13 @@ export function useGasFeeEstimates(networkClientId: string) { const { NetworkController } = Engine.context; useEffect(() => { + if (!effectiveNetworkClientId) { + return; + } let isMounted = true; const networkConfig = NetworkController.getNetworkConfigurationByNetworkClientId( - networkClientId, + effectiveNetworkClientId, ); if (networkConfig && isMounted) { @@ -39,7 +52,7 @@ export function useGasFeeEstimates(networkClientId: string) { return () => { isMounted = false; }; - }, [networkClientId, NetworkController]); + }, [effectiveNetworkClientId, NetworkController]); usePolling({ startPolling: Engine.context.GasFeeController.startPolling.bind( @@ -49,7 +62,11 @@ export function useGasFeeEstimates(networkClientId: string) { Engine.context.GasFeeController.stopPollingByPollingToken.bind( Engine.context.GasFeeController, ), - input: [{ networkClientId }], + // GasFeeController.startPolling requires networkClientId: string; never pass + // undefined/'' (see hook JSDoc). When missing, skip polling until we have an id. + input: effectiveNetworkClientId + ? [{ networkClientId: effectiveNetworkClientId }] + : [], }); return { From e18557153c216610ad96e2e54a2259554a5948d7 Mon Sep 17 00:00:00 2001 From: Matthew Grainger <46547583+Matt561@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:51:27 -0400 Subject: [PATCH 142/206] feat: fix incorrect styling of claimable bonus font weight and tooltip color cp-7.70.0 (#27666) ## **Description** Fixes some incorrect styling for the "Claimable bonus" row. ### Changes: - `"Claimable Bonus"` font weight updated to match other rows - `"Claimable Bonus"` tooltip color changed to `Alternative` - Centered Quick Convert bottom sheet error messages - `"Rate"` row text font weight updated to match other rows ## **Changelog** CHANGELOG entry: fix percentage-row inconsistent styling ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: mUSD conversion confirmation styling Scenario: user views claimable bonus row with correct styling Given user is on the mUSD conversion confirmation screen When user views the "Claimable bonus" info row Then the label font weight matches other small-variant info rows And the tooltip icon color is Alternative, consistent with the transaction fee row Scenario: user views blocking alert message centered Given user triggers ``` ## **Screenshots/Recordings** ### **Before** image image ### **After** image image ## **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. ## **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** > Low risk UI-only styling adjustments to confirmation rows and blocking alert text; no business logic or data flow changes. > > **Overview** > Fixes styling inconsistencies in the mUSD conversion confirmation UI by rendering the "Claimable bonus" and conversion rate rows using the `InfoRowVariant.Small` variant (matching label font weight) and updating the claimable bonus tooltip icon color to `IconColor.Alternative`. > > Also centers blocking alert error text in the quick convert bottom sheet by applying `textAlign: 'center'` to string-based blocking alert messages. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit fe91c3b35bbc7da4f180038d5065f5828c20096c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../blocking-alert-message/blocking-alert-message.styles.ts | 3 +++ .../blocking-alert-message/blocking-alert-message.tsx | 6 +++++- .../components/rows/percentage-row/percentage-row.tsx | 5 +++-- .../token-conversion-rate-row/token-conversion-rate-row.tsx | 6 +++++- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/app/components/Views/confirmations/components/alerts/blocking-alert-message/blocking-alert-message.styles.ts b/app/components/Views/confirmations/components/alerts/blocking-alert-message/blocking-alert-message.styles.ts index e69079dc841..22dafcdc1c5 100644 --- a/app/components/Views/confirmations/components/alerts/blocking-alert-message/blocking-alert-message.styles.ts +++ b/app/components/Views/confirmations/components/alerts/blocking-alert-message/blocking-alert-message.styles.ts @@ -5,6 +5,9 @@ const styleSheet = () => container: { paddingTop: 32, }, + message: { + textAlign: 'center', + }, }); export default styleSheet; diff --git a/app/components/Views/confirmations/components/alerts/blocking-alert-message/blocking-alert-message.tsx b/app/components/Views/confirmations/components/alerts/blocking-alert-message/blocking-alert-message.tsx index e3ba1641a8a..4bbd88b534c 100644 --- a/app/components/Views/confirmations/components/alerts/blocking-alert-message/blocking-alert-message.tsx +++ b/app/components/Views/confirmations/components/alerts/blocking-alert-message/blocking-alert-message.tsx @@ -26,7 +26,11 @@ export const BlockingAlertMessage: React.FC = React.memo(() => { return ( {typeof blockingAlertMessage === 'string' ? ( - + {blockingAlertMessage} ) : ( diff --git a/app/components/Views/confirmations/components/rows/percentage-row/percentage-row.tsx b/app/components/Views/confirmations/components/rows/percentage-row/percentage-row.tsx index 59a922ed3be..2cc07902e7d 100644 --- a/app/components/Views/confirmations/components/rows/percentage-row/percentage-row.tsx +++ b/app/components/Views/confirmations/components/rows/percentage-row/percentage-row.tsx @@ -7,7 +7,7 @@ import Text, { TextColor, } from '../../../../../../component-library/components/Texts/Text'; import { useIsTransactionPayLoading } from '../../../hooks/pay/useTransactionPayData'; -import { InfoRowSkeleton } from '../../UI/info-row/info-row'; +import { InfoRowSkeleton, InfoRowVariant } from '../../UI/info-row/info-row'; import { strings } from '../../../../../../../locales/i18n'; import { IconColor } from '../../../../../../component-library/components/Icons/Icon'; import AppConstants from '../../../../../../core/AppConstants'; @@ -59,6 +59,8 @@ export function PercentageRow() { return ( {strings('earn.claimable_bonus_tooltip')}{' '} @@ -67,7 +69,6 @@ export function PercentageRow() { } - tooltipColor={IconColor.Muted} > {MUSD_CONVERSION_APY}% diff --git a/app/components/Views/confirmations/components/rows/token-conversion-rate-row/token-conversion-rate-row.tsx b/app/components/Views/confirmations/components/rows/token-conversion-rate-row/token-conversion-rate-row.tsx index 821c7147c02..2ff2acc1947 100644 --- a/app/components/Views/confirmations/components/rows/token-conversion-rate-row/token-conversion-rate-row.tsx +++ b/app/components/Views/confirmations/components/rows/token-conversion-rate-row/token-conversion-rate-row.tsx @@ -2,7 +2,10 @@ import React, { useMemo } from 'react'; import { useSelector } from 'react-redux'; import { BigNumber } from 'bignumber.js'; import { Hex } from '@metamask/utils'; -import InfoRow, { InfoRowSkeleton } from '../../UI/info-row/info-row'; +import InfoRow, { + InfoRowSkeleton, + InfoRowVariant, +} from '../../UI/info-row/info-row'; import Text, { TextColor, TextVariant, @@ -74,6 +77,7 @@ export function TokenConversionRateRow() { {`1 ${inputTokenSymbol} = ${conversionRate} ${outputTokenSymbol}`} From 623cfc0977bbde50f143cc53315c0a0422a39942 Mon Sep 17 00:00:00 2001 From: jiexi Date: Thu, 19 Mar 2026 08:15:48 -0700 Subject: [PATCH 143/206] fix: make SDKv1 and WC origin handling better (#27230) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** - Cleanup SDKv1 origin checks - Cleanup SDKv2 origin checks - Tighten DeepLinkProtocolService origin check to all requests, not just eth_sendTransaction ## **Changelog** CHANGELOG entry: null. Not user facing exactly. Not a user feature ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/WAPI-1079 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Touches connection/request gating for SDK deeplink and WalletConnect flows; mistakes could incorrectly reject legitimate dapps or change error semantics for clients. > > **Overview** > **Tightens origin validation for external connections (SDK + WalletConnect).** All SDK deeplink bridges and WalletConnect v2 session proposals/requests are now rejected if the *self‑reported* URL/title matches any value in `INTERNAL_ORIGINS` (replacing the narrower `ORIGIN_METAMASK` equality checks). > > **Applies the check more broadly and updates error responses.** `DeeplinkProtocolService.processDappRpcRequest` now blocks *any* RPC request with an internal `params.url` (not just `eth_sendTransaction`) and returns `providerErrors.unauthorized` (`Invalid origin`). WalletConnect moves the internal-origin transaction check to `handleRequest` so it short-circuits earlier, responding with `errorCodes.provider.unauthorized` and the new `ERROR_MESSAGES.INVALID_ORIGIN`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 940edb7bf0ca2518eb4a3ecf80a2c7780117aa02. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../DeeplinkProtocolService.ts | 16 +++++----- app/core/SDKConnect/handlers/setupBridge.ts | 26 ++++++---------- .../WalletConnect/WalletConnect2Session.ts | 30 ++++++++++++------- app/core/WalletConnect/WalletConnectV2.ts | 25 ++++------------ 4 files changed, 42 insertions(+), 55 deletions(-) diff --git a/app/core/SDKConnect/SDKDeeplinkProtocol/DeeplinkProtocolService.ts b/app/core/SDKConnect/SDKDeeplinkProtocol/DeeplinkProtocolService.ts index fbcd2dbba15..5e3bf8120b2 100644 --- a/app/core/SDKConnect/SDKDeeplinkProtocol/DeeplinkProtocolService.ts +++ b/app/core/SDKConnect/SDKDeeplinkProtocol/DeeplinkProtocolService.ts @@ -36,7 +36,7 @@ import { getPermittedAccounts, } from '../../Permissions'; import { INTERNAL_ORIGINS } from '../../../constants/transaction'; -import { rpcErrors } from '@metamask/rpc-errors'; +import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; import { areAddressesEqual, toFormattedAddress } from '../../../util/address'; /** @@ -119,8 +119,8 @@ export default class DeeplinkProtocolService { } if ( - (selfReportedUrl && selfReportedUrl === ORIGIN_METAMASK) || - (selfReportedTitle && selfReportedTitle === ORIGIN_METAMASK) + INTERNAL_ORIGINS.includes(selfReportedUrl) || + INTERNAL_ORIGINS.includes(selfReportedTitle) ) { throw new Error('Connections from metamask origin are not allowed'); } @@ -549,12 +549,10 @@ export default class DeeplinkProtocolService { // This is an external connection (SDK deeplink protocol), so block any internal origin. // NOTE: params.url is self-reported by the dapp via the deeplink URL and is unverified. const selfReportedRequestUrl = params.url; - if (requestObject.method === 'eth_sendTransaction') { - if (INTERNAL_ORIGINS.includes(selfReportedRequestUrl)) { - throw rpcErrors.invalidParams({ - message: 'External transactions cannot use internal origins', - }); - } + if (INTERNAL_ORIGINS.includes(selfReportedRequestUrl)) { + throw providerErrors.unauthorized({ + message: 'Invalid origin', + }); } // Handle custom rpc method diff --git a/app/core/SDKConnect/handlers/setupBridge.ts b/app/core/SDKConnect/handlers/setupBridge.ts index 9c0cb78b52b..32d04024d40 100644 --- a/app/core/SDKConnect/handlers/setupBridge.ts +++ b/app/core/SDKConnect/handlers/setupBridge.ts @@ -35,13 +35,6 @@ export const setupBridge = ({ return connection.backgroundBridge; } - if ( - (originatorInfo.url && originatorInfo.url === ORIGIN_METAMASK) || - (originatorInfo.title && originatorInfo.title === ORIGIN_METAMASK) - ) { - throw new Error('Connections from metamask origin are not allowed'); - } - // WARNING: originatorInfo.url is self-reported by the dapp and unverified. // It is shown in the confirmation/approval UI to indicate the claimed source // of the request. It should NOT be treated as equivalent to a verified @@ -50,6 +43,15 @@ export const setupBridge = ({ const selfReportedTitle = originatorInfo.title; const selfReportedIcon = originatorInfo.icon; + // Prevent external connections from using internal origins + // This is an external connection (SDK), so block any internal origin + if ( + INTERNAL_ORIGINS.includes(selfReportedUrl) || + INTERNAL_ORIGINS.includes(selfReportedTitle) + ) { + throw new Error('Connections from metamask origin are not allowed'); + } + const backgroundBridge = new BackgroundBridge({ webview: null, isMMSDK: true, @@ -75,16 +77,6 @@ export const setupBridge = ({ DevLogger.log( `getRpcMethodMiddleware origin=${connection.origin} selfReportedUrl=${selfReportedUrl} `, ); - // Prevent external connections from using internal origins - // This is an external connection (SDK), so block any internal origin - if ( - INTERNAL_ORIGINS.includes(selfReportedUrl) || - INTERNAL_ORIGINS.includes(selfReportedTitle) - ) { - throw rpcErrors.invalidParams({ - message: 'External transactions cannot use internal origins', - }); - } return getRpcMethodMiddleware({ hostname: connection.origin, channelId: connection.channelId, diff --git a/app/core/WalletConnect/WalletConnect2Session.ts b/app/core/WalletConnect/WalletConnect2Session.ts index 7deb3418523..5466d009dc5 100644 --- a/app/core/WalletConnect/WalletConnect2Session.ts +++ b/app/core/WalletConnect/WalletConnect2Session.ts @@ -37,7 +37,7 @@ import { normalizeDappUrl, } from './wc-utils'; import { selectPerOriginChainId } from '../../selectors/selectedNetworkController'; -import { providerErrors, rpcErrors } from '@metamask/rpc-errors'; +import { errorCodes, providerErrors, rpcErrors } from '@metamask/rpc-errors'; import { switchToNetwork } from '../RPCMethods/lib/ethereum-chain-utils'; import { updateWC2Metadata } from '../../actions/sdk'; import AppConstants from '../AppConstants'; @@ -645,6 +645,25 @@ class WalletConnect2Session { requestEvent, this.selfReportedUrl, ); + + // Prevent external transactions from using internal origins. + // This is an external connection (WalletConnect), so block any internal origin. + // NOTE: unverifiedOrigin is self-reported by the dapp. + if (INTERNAL_ORIGINS.includes(unverifiedOrigin)) { + this._isHandlingRequest = false; + return this.web3Wallet.respondSessionRequest({ + topic: this.session.topic, + response: { + id: requestEvent.id, + jsonrpc: '2.0', + error: { + code: errorCodes.provider.unauthorized, + message: ERROR_MESSAGES.INVALID_ORIGIN, + }, + }, + }); + } + const method = requestEvent.params.request.method; const isSwitchingChain = isSwitchingChainRequest(requestEvent); @@ -784,15 +803,6 @@ class WalletConnect2Session { unverifiedOrigin: string, ) { try { - // Prevent external transactions from using internal origins. - // This is an external connection (WalletConnect), so block any internal origin. - // NOTE: unverifiedOrigin is self-reported by the dapp. - if (INTERNAL_ORIGINS.includes(unverifiedOrigin)) { - throw rpcErrors.invalidParams({ - message: 'External transactions cannot use internal origins', - }); - } - const networkClientId = getNetworkClientIdForCaipChainId(caip2ChainId); const trx = await addTransaction(methodParams[0], { deviceConfirmedOn: WalletDevice.MM_MOBILE, diff --git a/app/core/WalletConnect/WalletConnectV2.ts b/app/core/WalletConnect/WalletConnectV2.ts index 8e254150cf2..a5b3e88546e 100644 --- a/app/core/WalletConnect/WalletConnectV2.ts +++ b/app/core/WalletConnect/WalletConnectV2.ts @@ -1,8 +1,5 @@ import { AccountsController } from '@metamask/accounts-controller'; -import { - toChecksumHexAddress, - ORIGIN_METAMASK, -} from '@metamask/controller-utils'; +import { toChecksumHexAddress } from '@metamask/controller-utils'; import { KeyringController } from '@metamask/keyring-controller'; import { PermissionController } from '@metamask/permission-controller'; import { NavigationContainerRef } from '@react-navigation/native'; @@ -10,7 +7,6 @@ import { IWalletKit, WalletKit, WalletKitTypes } from '@reown/walletkit'; import { Core } from '@walletconnect/core'; import { SessionTypes } from '@walletconnect/types'; import { getSdkError } from '@walletconnect/utils'; -import { rpcErrors } from '@metamask/rpc-errors'; import { updateWC2Metadata } from '../../../app/actions/sdk'; import { @@ -59,6 +55,7 @@ export const ERROR_MESSAGES = { USER_REJECT: 'User reject', AUTO_REMOVE: 'Automatic removal', INVALID_ID: 'Invalid Id', + INVALID_ORIGIN: 'Invalid origin', }; // Safety timeout for the proposal serialization lock. @@ -144,7 +141,7 @@ export class WC2Manager { if (activeSessions) { activeSessions.forEach(async (session) => { - if (session.peer.metadata.url === ORIGIN_METAMASK) { + if (INTERNAL_ORIGINS.includes(session.peer.metadata.url)) { console.warn( `WC2::init skipping session with invalid url: ${session.topic}`, ); @@ -538,7 +535,9 @@ export class WC2Manager { return; } - if (url === ORIGIN_METAMASK) { + // Prevent external connections from using internal origins + // This is an external connection (WalletConnect), so block any internal origin + if (INTERNAL_ORIGINS.includes(url)) { console.warn(`WC2::session_proposal rejected - invalid url: ${url}`); await this.web3Wallet.rejectSession({ id: proposal.id, @@ -566,18 +565,6 @@ export class WC2Manager { const walletChainIdDecimal = parseInt(walletChainIdHex, 16); try { - // Prevent external connections from using internal origins - // This is an external connection (WalletConnect), so block any internal origin - if (INTERNAL_ORIGINS.includes(origin)) { - await this.web3Wallet.rejectSession({ - id: proposal.id, - reason: getSdkError('USER_REJECTED_METHODS'), - }); - throw rpcErrors.invalidParams({ - message: 'External transactions cannot use internal origins', - }); - } - // Create a modified CAIP-25 caveat value that includes the current chain const caveatValue = getDefaultCaip25CaveatValue(); From 1a588d5586110c6c890e8bc84e905a0bd98f9fbe Mon Sep 17 00:00:00 2001 From: Bruno Nascimento Date: Thu, 19 Mar 2026 12:32:06 -0300 Subject: [PATCH 144/206] feat(card): add selectIsUserInSupportedCardCountry (#27695) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Extracts the Card supported-country check into a dedicated `selectIsUserInSupportedCardCountry` selector and refactors `selectDisplayCardButton` to use it. **Why**: The geo-location vs supported-countries logic was inlined in `selectDisplayCardButton`. Extracting it into a named selector improves readability, enables reuse, and simplifies testing. The display logic becomes clearer: show the Card button when the user is in a supported country and the feature flag is on. **What changed**: - **`selectIsUserInSupportedCardCountry`**: New selector that returns whether the user's `geoLocation` is in the feature-flag `cardSupportedCountries` map with value `true`. Uses `selectCardGeoLocation` and `selectCardSupportedCountries`. - **`selectDisplayCardButton`**: Refactored to use `selectIsUserInSupportedCardCountry` instead of inline geo/countries logic. The condition `(cardSupportedCountries)?.[geoLocation] === true && displayCardButtonFeatureFlag` is now `isUserInSupportedCardCountry && displayCardButtonFeatureFlag`. - **Tests**: Added unit tests for `selectIsUserInSupportedCardCountry` covering supported country, unsupported country, UNKNOWN geoLocation, explicitly false country, and empty/missing supported countries. ## **Changelog** CHANGELOG entry: Extracted Card supported-country check into `selectIsUserInSupportedCardCountry` selector. ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Card button display in supported vs unsupported countries Scenario: Card button shows in supported country with feature flag Given I am in a supported country (e.g. US, GB) And the Card display feature flag is enabled When I view the app home/tab bar Then the Card button should be visible Scenario: Card button hidden in unsupported country Given I am in an unsupported country (or geoLocation is UNKNOWN) And the Card display feature flag is enabled When I view the app home/tab bar Then the Card button should not be visible (unless cardholder/alwaysShow) ``` ## **Screenshots/Recordings** No visual design changes — Card button visibility logic is unchanged; only the selector structure was refactored. ## **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. ## **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** > Low risk refactor that extracts existing geoLocation/supported-country logic into a dedicated selector and wires it into `selectDisplayCardButton`, with added unit coverage; behavior should remain the same aside from potential edge cases around missing/empty country maps. > > **Overview** > Introduces a new selector, `selectIsUserInSupportedCardCountry`, to centralize the "geoLocation is in `cardSupportedCountries` with value `true`" check. > > Refactors `selectDisplayCardButton` to depend on this selector instead of inlining the supported-country lookup, and adds focused unit tests covering supported/unsupported/`UNKNOWN` geoLocation, explicitly disabled countries, and empty supported-country maps. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit d09eb3cdec0482e6327d38e256785575971bcf38. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/core/redux/slices/card/index.test.ts | 79 ++++++++++++++++++++++++ app/core/redux/slices/card/index.ts | 17 ++--- 2 files changed, 89 insertions(+), 7 deletions(-) diff --git a/app/core/redux/slices/card/index.test.ts b/app/core/redux/slices/card/index.test.ts index 4255c829d99..34f3014c5d2 100644 --- a/app/core/redux/slices/card/index.test.ts +++ b/app/core/redux/slices/card/index.test.ts @@ -25,6 +25,7 @@ import cardReducer, { selectContactVerificationId, selectConsentSetId, resetAuthenticatedData, + selectIsUserInSupportedCardCountry, } from '.'; // Mock the multichain selectors @@ -720,6 +721,84 @@ describe('Card Button Display Selectors', () => { jest.clearAllMocks(); }); + describe('selectIsUserInSupportedCardCountry', () => { + it('returns true when geoLocation is in supported countries', () => { + mockSelectCardSupportedCountries.mockReturnValue({ + US: true, + GB: true, + }); + + const stateWithUs: CardSliceState = { + ...initialState, + geoLocation: 'US', + }; + const mockRootState = { + card: stateWithUs, + } as unknown as RootState; + + expect(selectIsUserInSupportedCardCountry(mockRootState)).toBe(true); + }); + + it('returns false when geoLocation is not in supported countries', () => { + mockSelectCardSupportedCountries.mockReturnValue({ US: true }); + + const stateWithUnsupported: CardSliceState = { + ...initialState, + geoLocation: 'CN', + }; + const mockRootState = { + card: stateWithUnsupported, + } as unknown as RootState; + + expect(selectIsUserInSupportedCardCountry(mockRootState)).toBe(false); + }); + + it('returns false when geoLocation is UNKNOWN', () => { + mockSelectCardSupportedCountries.mockReturnValue({ US: true }); + + const stateWithUnknown: CardSliceState = { + ...initialState, + geoLocation: 'UNKNOWN', + }; + const mockRootState = { + card: stateWithUnknown, + } as unknown as RootState; + + expect(selectIsUserInSupportedCardCountry(mockRootState)).toBe(false); + }); + + it('returns false when country is explicitly false in supported countries', () => { + mockSelectCardSupportedCountries.mockReturnValue({ + US: true, + DE: false, + }); + + const stateWithDe: CardSliceState = { + ...initialState, + geoLocation: 'DE', + }; + const mockRootState = { + card: stateWithDe, + } as unknown as RootState; + + expect(selectIsUserInSupportedCardCountry(mockRootState)).toBe(false); + }); + + it('returns false when cardSupportedCountries is empty or missing', () => { + mockSelectCardSupportedCountries.mockReturnValue({}); + + const stateWithUs: CardSliceState = { + ...initialState, + geoLocation: 'US', + }; + const mockRootState = { + card: stateWithUs, + } as unknown as RootState; + + expect(selectIsUserInSupportedCardCountry(mockRootState)).toBe(false); + }); + }); + describe('selectAlwaysShowCardButton', () => { it('should return false when experimental switch is disabled', () => { mockSelectCardExperimentalSwitch.mockReturnValue(false); diff --git a/app/core/redux/slices/card/index.ts b/app/core/redux/slices/card/index.ts index d92135d6663..3395624c95f 100644 --- a/app/core/redux/slices/card/index.ts +++ b/app/core/redux/slices/card/index.ts @@ -210,28 +210,31 @@ export const selectIsDaimoDemo = createSelector( (card) => card.isDaimoDemo, ); +export const selectIsUserInSupportedCardCountry = createSelector( + selectCardGeoLocation, + selectCardSupportedCountries, + (geoLocation, cardSupportedCountries) => + (cardSupportedCountries as Record)?.[geoLocation] === true, +); + export const selectDisplayCardButton = createSelector( selectIsCardholder, selectAlwaysShowCardButton, - selectCardGeoLocation, - selectCardSupportedCountries, selectDisplayCardButtonFeatureFlag, selectIsAuthenticatedCard, + selectIsUserInSupportedCardCountry, ( isCardholder, alwaysShowCardButton, - geoLocation, - cardSupportedCountries, displayCardButtonFeatureFlag, isAuthenticated, + isUserInSupportedCardCountry, ) => { if ( alwaysShowCardButton || isCardholder || isAuthenticated || - ((cardSupportedCountries as Record)?.[geoLocation] === - true && - displayCardButtonFeatureFlag) + (isUserInSupportedCardCountry && displayCardButtonFeatureFlag) ) { return true; } From 35fb472ccdda78adb9ad4d368df0e46b2c48c8af Mon Sep 17 00:00:00 2001 From: Matthew Grainger <46547583+Matt561@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:32:32 -0400 Subject: [PATCH 145/206] feat: MUSD-455 bring back claim section on asset details screen (#27567) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR brings back the mUSD bonus claim section displayed on the asset overview screen. It was previously removed as part of this [PR](https://github.com/MetaMask/metamask-mobile/pull/26147). ## **Changelog** CHANGELOG entry: Restored mUSD claimable bonus claim section on asset overview screen ## **Related issues** Fixes: [MUSD-455: Bring back claim section on asset details screen](https://consensyssoftware.atlassian.net/browse/MUSD-455) ## **Manual testing steps** ```gherkin Feature: mUSD claimable bonus on asset overview Scenario: user views and claims their mUSD bonus Given user has claimable Merkl rewards above the minimum threshold When user navigates to the mUSD asset details screen Then a claim bonus section is displayed with the claimable amount When user taps "Claim" Then the claim confirmation bottom sheet appears Then the "Claim" button is guarded against repeated clicks after the first ``` ## **Screenshots/Recordings** ### **Before** Bonus claim section wasn't rendered on the asset overview screen. ### **After** https://github.com/user-attachments/assets/a4b1f910-a943-46a5-b5d2-ab183bba208f ## **Notes** - Fixed an edge case where the Claim button press-guard could get stuck if `claimRewards()` exits early (e.g. missing selected account or Linea network client id), causing subsequent taps to be ignored. --- > [!NOTE] > **Medium Risk** > Adds a new claim CTA into the token details render path and wires it to rewards-claiming hooks, analytics, and external linking; this could impact performance (hook execution for many assets) and claim UX if gating/chain selection is wrong. > > **Overview** > Restores an **mUSD “Claimable bonus”** section on the asset details screen, showing the user’s claimable amount and a `Claim` button that triggers `useMerklBonusClaim`’s claim flow and disables/rejects repeated presses while a claim is in flight/pending. > > Adds an info tooltip with a link to Terms of Use (with new analytics location), plus new i18n strings and test IDs to support UI copy, tracking, and E2E automation. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 0d81e8fc18c02e5fd88e4b5ebd33fd31a8ce7c77. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).
Open in Web Open in Cursor 
--- .../AssetOverviewClaimBonus.test.tsx | 299 ++++++++++++++++++ .../AssetOverviewClaimBonus.testIds.ts | 6 + .../AssetOverviewClaimBonus.tsx | 230 ++++++++++++++ .../AssetOverviewClaimBonus/index.ts | 1 + .../UI/Earn/constants/events/musdEvents.ts | 2 + .../components/AssetOverviewContent.tsx | 19 ++ locales/languages/en.json | 4 + 7 files changed, 561 insertions(+) create mode 100644 app/components/UI/Earn/components/AssetOverviewClaimBonus/AssetOverviewClaimBonus.test.tsx create mode 100644 app/components/UI/Earn/components/AssetOverviewClaimBonus/AssetOverviewClaimBonus.testIds.ts create mode 100644 app/components/UI/Earn/components/AssetOverviewClaimBonus/AssetOverviewClaimBonus.tsx create mode 100644 app/components/UI/Earn/components/AssetOverviewClaimBonus/index.ts diff --git a/app/components/UI/Earn/components/AssetOverviewClaimBonus/AssetOverviewClaimBonus.test.tsx b/app/components/UI/Earn/components/AssetOverviewClaimBonus/AssetOverviewClaimBonus.test.tsx new file mode 100644 index 00000000000..23562cd8c51 --- /dev/null +++ b/app/components/UI/Earn/components/AssetOverviewClaimBonus/AssetOverviewClaimBonus.test.tsx @@ -0,0 +1,299 @@ +import React from 'react'; +import { fireEvent } from '@testing-library/react-native'; +import { Linking } from 'react-native'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import AssetOverviewClaimBonus from '.'; +import { + useMerklBonusClaim, + MerklClaimData, +} from '../MerklRewards/hooks/useMerklBonusClaim'; +import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; +import useTooltipModal from '../../../../hooks/useTooltipModal'; +import { MetaMetricsEvents, EVENT_NAME } from '../../../../../core/Analytics'; +import { MUSD_EVENTS_CONSTANTS } from '../../constants/events/musdEvents'; +import AppConstants from '../../../../../core/AppConstants'; +import { ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS } from './AssetOverviewClaimBonus.testIds'; +import { TokenI } from '../../../Tokens/types'; + +jest.mock('../MerklRewards/hooks/useMerklBonusClaim'); +jest.mock('../../../../hooks/useAnalytics/useAnalytics'); +jest.mock('../../../../hooks/useTooltipModal'); +jest.mock('react-native/Libraries/Linking/Linking', () => ({ + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + openURL: jest.fn(), + canOpenURL: jest.fn(), + getInitialURL: jest.fn(), +})); + +const mockUseMerklBonusClaim = useMerklBonusClaim as jest.MockedFunction< + typeof useMerklBonusClaim +>; + +const createMockAsset = (overrides: Partial = {}): TokenI => ({ + address: '0x8d652c6d4A8F3Db96Cd866C1a9220B1447F29898', + chainId: '0x1', + symbol: 'aglaMerkl', + aggregators: [], + decimals: 18, + image: '', + name: 'Angle Merkl', + balance: '1000', + balanceFiat: '$100', + logo: '', + isETH: false, + isNative: false, + ...overrides, +}); + +const createMockMerklClaimData = ( + overrides: Partial = {}, +): MerklClaimData => ({ + claimableReward: null, + hasPendingClaim: false, + isClaiming: false, + claimRewards: jest.fn().mockResolvedValue(undefined), + ...overrides, +}); + +describe('AssetOverviewClaimBonus', () => { + const mockTrackEvent = jest.fn(); + const mockCreateEventBuilder = jest.fn(); + const mockAddProperties = jest.fn(); + const mockBuild = jest.fn(); + const mockOpenTooltipModal = jest.fn(); + const mockClaimRewards = jest.fn().mockResolvedValue(undefined); + + beforeEach(() => { + jest.clearAllMocks(); + + mockBuild.mockReturnValue({ name: 'mock-built-event' }); + mockAddProperties.mockReturnValue({ build: mockBuild }); + mockCreateEventBuilder.mockReturnValue({ + addProperties: mockAddProperties, + }); + + (useAnalytics as jest.MockedFunction).mockReturnValue({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + } as unknown as ReturnType); + + ( + useTooltipModal as jest.MockedFunction + ).mockReturnValue({ + openTooltipModal: mockOpenTooltipModal, + }); + + mockUseMerklBonusClaim.mockReturnValue( + createMockMerklClaimData({ + claimableReward: '10.01', + claimRewards: mockClaimRewards, + }), + ); + }); + + describe('rendering and visibility', () => { + it('renders nothing when claimableReward is null', () => { + mockUseMerklBonusClaim.mockReturnValue( + createMockMerklClaimData({ claimableReward: null }), + ); + + const { queryByTestId } = renderWithProvider( + , + ); + + expect( + queryByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.CONTAINER), + ).toBeNull(); + }); + + it('renders claim section when claimableReward is present', () => { + const { getByTestId } = renderWithProvider( + , + ); + + expect( + getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.CONTAINER), + ).toBeOnTheScreen(); + expect( + getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.CLAIM_BUTTON), + ).toBeOnTheScreen(); + expect( + getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.INFO_BUTTON), + ).toBeOnTheScreen(); + expect( + getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.CLAIMABLE_AMOUNT), + ).toBeOnTheScreen(); + }); + + it('displays formatted claimable reward amount with dollar sign', () => { + mockUseMerklBonusClaim.mockReturnValue( + createMockMerklClaimData({ claimableReward: '42.50' }), + ); + + const { getByTestId } = renderWithProvider( + , + ); + + expect( + getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.CLAIMABLE_AMOUNT), + ).toHaveTextContent('$42.50'); + }); + }); + + describe('loading state', () => { + it('disables claim button when isClaiming is true', () => { + mockUseMerklBonusClaim.mockReturnValue( + createMockMerklClaimData({ + claimableReward: '10.01', + isClaiming: true, + }), + ); + + const { getByTestId } = renderWithProvider( + , + ); + + const button = getByTestId( + ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.CLAIM_BUTTON, + ); + expect(button).toBeDisabled(); + }); + + it('disables claim button when hasPendingClaim is true', () => { + mockUseMerklBonusClaim.mockReturnValue( + createMockMerklClaimData({ + claimableReward: '10.01', + hasPendingClaim: true, + }), + ); + + const { getByTestId } = renderWithProvider( + , + ); + + const button = getByTestId( + ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.CLAIM_BUTTON, + ); + expect(button).toBeDisabled(); + }); + }); + + describe('claim action', () => { + it('calls claimRewards on claim button press', () => { + const { getByTestId } = renderWithProvider( + , + ); + + fireEvent.press( + getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.CLAIM_BUTTON), + ); + + expect(mockClaimRewards).toHaveBeenCalledTimes(1); + }); + + it('fires MUSD_CLAIM_BONUS_BUTTON_CLICKED with correct properties on claim press', () => { + const asset = createMockAsset(); + + const { getByTestId } = renderWithProvider( + , + ); + + fireEvent.press( + getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.CLAIM_BUTTON), + ); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.MUSD_CLAIM_BONUS_BUTTON_CLICKED, + ); + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + location: MUSD_EVENTS_CONSTANTS.EVENT_LOCATIONS.ASSET_OVERVIEW, + action_type: 'claim_bonus', + network_chain_id: asset.chainId, + asset_symbol: asset.symbol, + }), + ); + expect(mockTrackEvent).toHaveBeenCalledWith({ name: 'mock-built-event' }); + }); + + it('prevents duplicate claim presses via isClaimPressedRef guard', () => { + const { getByTestId } = renderWithProvider( + , + ); + + const button = getByTestId( + ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.CLAIM_BUTTON, + ); + fireEvent.press(button); + fireEvent.press(button); + + expect(mockClaimRewards).toHaveBeenCalledTimes(1); + }); + }); + + describe('tooltip / info button', () => { + it('opens tooltip modal with correct content on info button press', () => { + const { getByTestId } = renderWithProvider( + , + ); + + fireEvent.press( + getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.INFO_BUTTON), + ); + + expect(mockOpenTooltipModal).toHaveBeenCalledTimes(1); + + const [title, , footer, buttonText] = mockOpenTooltipModal.mock.calls[0]; + + expect(title).toBe('Claimable bonus'); + expect(footer).toBeUndefined(); + expect(buttonText).toBe('Sounds good'); + }); + + it('fires TOOLTIP_OPENED analytics on info button press', () => { + const { getByTestId } = renderWithProvider( + , + ); + + fireEvent.press( + getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.INFO_BUTTON), + ); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + EVENT_NAME.TOOLTIP_OPENED, + ); + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + location: MUSD_EVENTS_CONSTANTS.EVENT_LOCATIONS.ASSET_OVERVIEW, + tooltip_name: 'claim_bonus_info', + }), + ); + expect(mockTrackEvent).toHaveBeenCalledWith({ name: 'mock-built-event' }); + }); + }); + + describe('terms link', () => { + it('opens terms URL and fires analytics when terms link is pressed', () => { + const { getByTestId } = renderWithProvider( + , + ); + + fireEvent.press( + getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.INFO_BUTTON), + ); + + const tooltipDescription = mockOpenTooltipModal.mock.calls[0][1]; + + const { getByText } = renderWithProvider(tooltipDescription); + fireEvent.press(getByText('Terms apply.')); + + expect(Linking.openURL).toHaveBeenCalledWith( + AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE, + ); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.MUSD_BONUS_TERMS_OF_USE_PRESSED, + ); + }); + }); +}); diff --git a/app/components/UI/Earn/components/AssetOverviewClaimBonus/AssetOverviewClaimBonus.testIds.ts b/app/components/UI/Earn/components/AssetOverviewClaimBonus/AssetOverviewClaimBonus.testIds.ts new file mode 100644 index 00000000000..4130f4b4833 --- /dev/null +++ b/app/components/UI/Earn/components/AssetOverviewClaimBonus/AssetOverviewClaimBonus.testIds.ts @@ -0,0 +1,6 @@ +export const ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS = { + CONTAINER: 'asset-overview-claim-bonus-container', + INFO_BUTTON: 'asset-overview-claim-bonus-info-button', + CLAIM_BUTTON: 'asset-overview-claim-bonus-claim-button', + CLAIMABLE_AMOUNT: 'asset-overview-claim-bonus-claimable-amount', +}; diff --git a/app/components/UI/Earn/components/AssetOverviewClaimBonus/AssetOverviewClaimBonus.tsx b/app/components/UI/Earn/components/AssetOverviewClaimBonus/AssetOverviewClaimBonus.tsx new file mode 100644 index 00000000000..a501baf12bd --- /dev/null +++ b/app/components/UI/Earn/components/AssetOverviewClaimBonus/AssetOverviewClaimBonus.tsx @@ -0,0 +1,230 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import { Linking } from 'react-native'; +import { useSelector } from 'react-redux'; +import { Hex } from '@metamask/utils'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + BoxJustifyContent, + Button, + ButtonIcon, + ButtonIconSize, + ButtonSize, + ButtonVariant, + FontWeight, + Icon, + IconColor, + IconName, + IconSize, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../locales/i18n'; +import { TokenI } from '../../../Tokens/types'; +import { useMerklBonusClaim } from '../MerklRewards/hooks/useMerklBonusClaim'; +import useTooltipModal from '../../../../hooks/useTooltipModal'; +import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; +import { MetaMetricsEvents, EVENT_NAME } from '../../../../../core/Analytics'; +import { MUSD_EVENTS_CONSTANTS } from '../../constants/events/musdEvents'; +import AppConstants from '../../../../../core/AppConstants'; +import { selectNetworkConfigurationByChainId } from '../../../../../selectors/networkController'; +import { RootState } from '../../../../../reducers'; +import { ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS } from './AssetOverviewClaimBonus.testIds'; +import { MUSD_CONVERSION_APY } from '../../constants/musd'; + +const { EVENT_LOCATIONS } = MUSD_EVENTS_CONSTANTS; + +interface AssetOverviewClaimBonusProps { + asset: TokenI; +} + +const AssetOverviewClaimBonus: React.FC = ({ + asset, +}) => { + const { claimableReward, hasPendingClaim, isClaiming, claimRewards } = + useMerklBonusClaim(asset, EVENT_LOCATIONS.ASSET_OVERVIEW); + + const { openTooltipModal } = useTooltipModal(); + const { trackEvent, createEventBuilder } = useAnalytics(); + + // Used to prevent duplicate presses of the claim button. + const isClaimPressedRef = useRef(false); + const isLoading = isClaiming || hasPendingClaim; + + useEffect(() => { + if (!isLoading) { + isClaimPressedRef.current = false; + } + }, [isLoading]); + + const network = useSelector((state: RootState) => + selectNetworkConfigurationByChainId(state, asset.chainId as Hex), + ); + + const handleTermsPress = useCallback(() => { + trackEvent( + createEventBuilder(MetaMetricsEvents.MUSD_BONUS_TERMS_OF_USE_PRESSED) + .addProperties({ + location: EVENT_LOCATIONS.ASSET_OVERVIEW_CLAIMABLE_BONUS_TOOLTIP, + url: AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE, + }) + .build(), + ); + + Linking.openURL(AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE); + }, [createEventBuilder, trackEvent]); + + const handleInfoPress = useCallback(() => { + trackEvent( + createEventBuilder(EVENT_NAME.TOOLTIP_OPENED) + .addProperties({ + location: EVENT_LOCATIONS.ASSET_OVERVIEW, + tooltip_name: 'claim_bonus_info', + related_text: 'Claimable bonus', + }) + .build(), + ); + + openTooltipModal( + strings('earn.claimable_bonus'), + + {strings('earn.claimable_bonus_tooltip_with_percentage', { + percentage: MUSD_CONVERSION_APY, + })}{' '} + + {strings('earn.musd_conversion.education.terms_apply')} + + , + undefined, + strings('earn.sounds_good'), + ); + }, [openTooltipModal, handleTermsPress, trackEvent, createEventBuilder]); + + const handleClaimPress = useCallback(() => { + if (isClaimPressedRef.current || isLoading) return; + isClaimPressedRef.current = true; + + trackEvent( + createEventBuilder(MetaMetricsEvents.MUSD_CLAIM_BONUS_BUTTON_CLICKED) + .addProperties({ + location: EVENT_LOCATIONS.ASSET_OVERVIEW, + action_type: 'claim_bonus', + button_text: strings('earn.claim'), + network_chain_id: asset.chainId, + network_name: network?.name, + asset_symbol: asset.symbol, + }) + .build(), + ); + claimRewards(); + }, [ + isLoading, + trackEvent, + createEventBuilder, + asset.chainId, + asset.symbol, + network?.name, + claimRewards, + ]); + + if (!claimableReward) { + return null; + } + + return ( + + {/* Divider */} + + + + + + + + + + + + {strings('earn.claimable_bonus')} + + + + + {strings('earn.percentage_bonus_on_linea', { + percentage: MUSD_CONVERSION_APY, + })} + + + + + + + ${claimableReward} + + + + + + + ); +}; + +export default AssetOverviewClaimBonus; diff --git a/app/components/UI/Earn/components/AssetOverviewClaimBonus/index.ts b/app/components/UI/Earn/components/AssetOverviewClaimBonus/index.ts new file mode 100644 index 00000000000..07bdd246b74 --- /dev/null +++ b/app/components/UI/Earn/components/AssetOverviewClaimBonus/index.ts @@ -0,0 +1 @@ +export { default } from './AssetOverviewClaimBonus'; diff --git a/app/components/UI/Earn/constants/events/musdEvents.ts b/app/components/UI/Earn/constants/events/musdEvents.ts index b6acbe1a1b0..45b6b037161 100644 --- a/app/components/UI/Earn/constants/events/musdEvents.ts +++ b/app/components/UI/Earn/constants/events/musdEvents.ts @@ -8,6 +8,8 @@ const EVENT_LOCATIONS = { HOME_CASH_SECTION: 'home_cash_section', TOKEN_LIST_ITEM: 'token_list_item', ASSET_OVERVIEW: 'asset_overview', + ASSET_OVERVIEW_CLAIMABLE_BONUS_TOOLTIP: + 'asset_overview_claimable_bonus_tooltip', CONVERSION_EDUCATION_SCREEN: 'conversion_education_screen', CUSTOM_AMOUNT_SCREEN: 'custom_amount_screen', // Single convert screen. BUY_SCREEN: 'buy_screen', // Buy mUSD screen. diff --git a/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx b/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx index d1c9e525418..9d2fcbedfe1 100644 --- a/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx +++ b/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx @@ -49,6 +49,9 @@ import TokenDetails from '../../AssetOverview/TokenDetails'; import { PriceChartProvider } from '../../AssetOverview/PriceChart/PriceChart.context'; import AssetDetailsActions from '../../../Views/AssetDetails/AssetDetailsActions'; import { TokenDetailsActions } from './TokenDetailsActions'; +import AssetOverviewClaimBonus from '../../Earn/components/AssetOverviewClaimBonus'; +import { isTokenEligibleForMerklRewards } from '../../Earn/components/MerklRewards/hooks/useMerklRewards'; +import { selectMerklCampaignClaimingEnabledFlag } from '../../Earn/selectors/featureFlags'; import PerpsDiscoveryBanner from '../../Perps/components/PerpsDiscoveryBanner'; import { isTokenTrustworthyForPerps } from '../../Perps/constants/perpsConfig'; import { useTokenDetailsABTest } from '../hooks/useTokenDetailsABTest'; @@ -330,6 +333,19 @@ const AssetOverviewContent: React.FC = ({ const isMarketInsightsEnabled = useSelector(selectMarketInsightsEnabled); + const isMerklClaimingEnabled = useSelector( + selectMerklCampaignClaimingEnabledFlag, + ); + const isTokenEligibleForMerklClaim = useMemo( + () => + isMerklClaimingEnabled && + isTokenEligibleForMerklRewards( + token.chainId as Hex, + token.address as Hex | undefined, + ), + [isMerklClaimingEnabled, token.chainId, token.address], + ); + const securityBadge = useMemo(() => { switch (securityData?.resultType) { case 'Verified': @@ -832,6 +848,9 @@ const AssetOverviewContent: React.FC = ({ secondaryBalance={secondaryBalance} /> )} + {isTokenEligibleForMerklClaim && ( + + )} { ///: BEGIN:ONLY_INCLUDE_IF(tron) isTronNative && stakedTrxAsset && ( diff --git a/locales/languages/en.json b/locales/languages/en.json index 1f115aca38b..830f8db5ce7 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -5985,6 +5985,10 @@ "claimable_bonus": "Claimable bonus", "claim_bonus": "Claim bonus", "claim_bonus_subtitle": "Bonus will be paid out on {{networkName}}.", + "percentage_bonus_on_linea": "{{percentage}}% bonus on Linea", + "claim": "Claim", + "sounds_good": "Sounds good", + "claimable_bonus_tooltip_with_percentage": "{{percentage}}% annualized bonus you've earned for holding mUSD. Your bonus is claimable daily on Linea.", "empty_state_cta": { "heading": "Lend {{tokenSymbol}} and earn", "body": "Lend your {{tokenSymbol}} with {{protocol}} and earn", From 5d8659ef4c148c24e87cbf73e8885d71ee82b7f7 Mon Sep 17 00:00:00 2001 From: Curtis David Date: Thu, 19 Mar 2026 11:38:32 -0400 Subject: [PATCH 146/206] test: update Browserstack config in unified framework (#27659) ## **Description** > Adjusts Playwright defaults to **run with a single worker everywhere** and to **only retry on CI** (`retries: isCI ? 1 : 0`). > > Updates BrowserStack capabilities to be more environment-driven: enables `local` via `BROWSERSTACK_LOCAL`, adds network log content capture, makes `buildIdentifier` use GitHub run IDs in CI, switches geo default to `ES` (overridable), and supports optional `localIdentifier` and `otherApps` via env vars. > ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Moderate risk: changes affect test execution concurrency/retry behavior and BrowserStack session capabilities, which could alter CI stability, debugging output, and test environment assumptions. > > **Overview** > Updates Playwright test defaults to **always run with a single worker** and to **only retry on CI** (`retries: isCI ? 1 : 0`). > > Refactors BrowserStack capabilities to be more *environment-driven*: toggles `local` via `BROWSERSTACK_LOCAL`, enables network log content capture, changes CI `buildIdentifier` to use `GITHUB_RUN_ID`, switches default `geoLocation` to `ES` (overridable), and adds optional `localIdentifier`/`otherApps` wiring via env vars. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit a4e0da9e8f3ac8da20d3bb4cc66c5cac8c78f37c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- tests/framework/config/ConfigHandler.ts | 4 ++-- .../browserstack/BrowserStackConfigBuilder.ts | 22 ++++++++++++++----- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/tests/framework/config/ConfigHandler.ts b/tests/framework/config/ConfigHandler.ts index e65bc9b6efb..42f570c0e6d 100644 --- a/tests/framework/config/ConfigHandler.ts +++ b/tests/framework/config/ConfigHandler.ts @@ -18,8 +18,8 @@ const defaultConfig: PlaywrightTestConfig = { // used across tests in a file where they run sequentially fullyParallel: false, forbidOnly: false, - retries: 1, - workers: isCI ? 2 : 1, + retries: isCI ? 1 : 0, + workers: 1, reporter: [['list'], ['html', { open: 'always' }]], timeout: 300_000, }; diff --git a/tests/framework/services/providers/browserstack/BrowserStackConfigBuilder.ts b/tests/framework/services/providers/browserstack/BrowserStackConfigBuilder.ts index d4e572e2342..d87aa877bc2 100644 --- a/tests/framework/services/providers/browserstack/BrowserStackConfigBuilder.ts +++ b/tests/framework/services/providers/browserstack/BrowserStackConfigBuilder.ts @@ -46,8 +46,11 @@ export class BrowserStackConfigBuilder { capabilities: { 'bstack:options': { debug: true, - local: false, // To be implemented + local: process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true', interactiveDebugging: true, + networkLogsOptions: { + captureContent: true, + }, networkLogs: true, appiumVersion: '2.6.0', // BrowserStack doesn't support Appium 3 yet idleTimeout: 180, @@ -60,11 +63,20 @@ export class BrowserStackConfigBuilder { `${projectName} ${platformName}`, sessionName: `${projectName} ${platformName} test`, buildIdentifier: - process.env.GITHUB_ACTIONS === 'true' ? '' : process.env.USER, - appProfiling: 'true', - selfHeal: 'true', + process.env.GITHUB_ACTIONS === 'true' + ? `CI ${process.env.GITHUB_RUN_ID}` + : process.env.USER, + appProfiling: true, + selfHeal: true, networkProfile: '4g-lte-advanced-good', - geoLocation: 'FR', + geoLocation: process.env.BROWSERSTACK_GEO_LOCATION || 'ES', + enableCameraImageInjection: device.enableCameraImageInjection, + ...(process.env.BROWSERSTACK_LOCAL_IDENTIFIER + ? { localIdentifier: process.env.BROWSERSTACK_LOCAL_IDENTIFIER } + : {}), + ...(process.env.BROWSERSTACK_RN_PLAYGROUND_URL + ? { otherApps: [process.env.BROWSERSTACK_RN_PLAYGROUND_URL] } + : {}), }, 'appium:autoGrantPermissions': true, 'appium:app': appBsUrl, From 1e425e6c0ab1d248bc63cfacf54258170c75e182 Mon Sep 17 00:00:00 2001 From: cryptodev-2s <109512101+cryptodev-2s@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:42:36 +0100 Subject: [PATCH 147/206] fix: update test descriptions to use ApprovalController new method names (#27687) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Update test descriptions to use ApprovalController new method names Follow-up to #27391 - accept -> acceptRequest in QRSigningTransactionModal.test - clear -> clearRequests in sagas.test - has -> hasRequest in RPCMethodMiddleware.test ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Test-only updates that rename referenced ApprovalController methods in assertions/descriptions; no production logic changes. > > **Overview** > Updates unit test wording and expectations to match renamed `ApprovalController` APIs: `accept` → `acceptRequest` in `QRSigningTransactionModal.test.tsx`, `has` → `hasRequest` in `RPCMethodMiddleware.test.ts`, and `clear` → `clearRequests` in `sagas.test.ts`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit cb3361782827d0e3e9dbc4e39d6074f5c3443aae. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../UI/QRHardware/QRSigningTransactionModal.test.tsx | 2 +- app/core/RPCMethods/RPCMethodMiddleware.test.ts | 2 +- app/store/sagas/sagas.test.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/components/UI/QRHardware/QRSigningTransactionModal.test.tsx b/app/components/UI/QRHardware/QRSigningTransactionModal.test.tsx index 950af62a651..c656312c48d 100644 --- a/app/components/UI/QRHardware/QRSigningTransactionModal.test.tsx +++ b/app/components/UI/QRHardware/QRSigningTransactionModal.test.tsx @@ -157,7 +157,7 @@ describe('QRSigningTransactionModal', () => { expect(queryByTestId('QRSigningDetails')).toBeNull(); }); - it('calls ApprovalController.accept and onConfirmationComplete on success callback', async () => { + it('calls ApprovalController.acceptRequest and onConfirmationComplete on success callback', async () => { const initialState = createInitialState(); const { getByTestId } = renderScreen( diff --git a/app/core/RPCMethods/RPCMethodMiddleware.test.ts b/app/core/RPCMethods/RPCMethodMiddleware.test.ts index 856f9240ee8..b99d3e67b24 100644 --- a/app/core/RPCMethods/RPCMethodMiddleware.test.ts +++ b/app/core/RPCMethods/RPCMethodMiddleware.test.ts @@ -1913,7 +1913,7 @@ describe('getRpcMethodMiddlewareHooks', () => { }); describe('hasApprovalRequestsForOrigin', () => { - it('should call ApprovalController.has with correct origin', () => { + it('should call ApprovalController.hasRequest with correct origin', () => { hooks.hasApprovalRequestsForOrigin(); expect( diff --git a/app/store/sagas/sagas.test.ts b/app/store/sagas/sagas.test.ts index ceb12a9dada..85e584e2cb3 100644 --- a/app/store/sagas/sagas.test.ts +++ b/app/store/sagas/sagas.test.ts @@ -368,7 +368,7 @@ describe('appLockStateMachine', () => { expect(mockNavigate).toHaveBeenCalledWith(Routes.LOCK_SCREEN); }); - it('clears pending approvals via ApprovalController.clear when app is locked', async () => { + it('clears pending approvals via ApprovalController.clearRequests when app is locked', async () => { await expectSaga(appLockStateMachine) .dispatch({ type: UserActionType.LOCKED_APP }) .run(); @@ -379,7 +379,7 @@ describe('appLockStateMachine', () => { expect(mockNavigate).toHaveBeenCalledWith(Routes.LOCK_SCREEN); }); - it('navigates to LockScreen even when ApprovalController.clear throws', async () => { + it('navigates to LockScreen even when ApprovalController.clearRequests throws', async () => { mockApprovalControllerClear.mockImplementationOnce(() => { throw new Error('clear failed'); }); From bb507cca419cbf45b247c75d16d7d7e3c182d2c5 Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Fri, 20 Mar 2026 00:04:06 +0800 Subject: [PATCH 148/206] feat(perps): add deferEligibilityCheck option to PerpsController (#27483) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adds `deferEligibilityCheck` constructor option to `PerpsController` that prevents the eager geolocation fetch during wallet onboarding (privacy compliance). Also adds `startEligibilityMonitoring()` method to resume checks post-onboarding. Follow-up fixes included in this PR: - **Remove mobile-only imports from perps controller code** — `@sentry/react-native` (PerpsController, TradingService) and `AppConstants` (MYXClientService) were breaking core sync. Routed `addBreadcrumb` through `PerpsTracer` infrastructure injection, replaced `AppConstants.ZERO_ADDRESS` with existing perps constant. - **Fix core sync script** — `--ext .ts` no longer works with core's flat eslint config; replaced with glob patterns. - **Fix `@metamask/geolocation-controller` missing from core tsconfig/package.json** — added as devDependency and project reference so perps-controller builds in core. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: Core PR [#8197](https://github.com/MetaMask/core/pull/8197) ## **Manual testing steps** ```gherkin Feature: Deferred eligibility check Scenario: PerpsController defers geolocation during onboarding Given PerpsController is instantiated with deferEligibilityCheck: true When refreshEligibility is called Then it returns immediately without fetching geolocation Scenario: Eligibility monitoring resumes post-onboarding Given PerpsController was instantiated with deferEligibilityCheck: true When startEligibilityMonitoring() is called Then eligibility checks resume using current remote feature flag state ``` ## **Screenshots/Recordings** N/A — internal controller changes, no UI impact. ## **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)). ## **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** > Touches eligibility/geo-blocking flow by optionally skipping geolocation and adding a new entry point to re-enable checks, which could affect region-gating if misused. Default behavior is unchanged when the new option is not set, and coverage is added via unit tests. > > **Overview** > Adds an optional `deferEligibilityCheck` constructor flag to `PerpsController` that **prevents the initial geolocation-based eligibility check** from running until explicitly resumed. > > Introduces `startEligibilityMonitoring()` (also exposed via messenger action types) to clear the deferral, read current remote feature flags, and immediately trigger an eligibility refresh, with error logging if feature-flag state lookup fails. > > Extends `PerpsController` tests to cover the deferred/ resumed behavior and error logging, plus a small typing cleanup in preload cache key assertions. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit cf17d6b6c6605cff8e2ac044ee834af40d98de82. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../PerpsController-method-action-types.ts | 8 +- app/controllers/perps/PerpsController.test.ts | 125 +++++++++++++++++- app/controllers/perps/PerpsController.ts | 38 ++++++ 3 files changed, 168 insertions(+), 3 deletions(-) diff --git a/app/controllers/perps/PerpsController-method-action-types.ts b/app/controllers/perps/PerpsController-method-action-types.ts index f42cd24cbb3..6231b294c9f 100644 --- a/app/controllers/perps/PerpsController-method-action-types.ts +++ b/app/controllers/perps/PerpsController-method-action-types.ts @@ -170,6 +170,11 @@ export type PerpsControllerResetSelectedPaymentTokenAction = { handler: PerpsController['resetSelectedPaymentToken']; }; +export type PerpsControllerStartEligibilityMonitoringAction = { + type: 'PerpsController:startEligibilityMonitoring'; + handler: PerpsController['startEligibilityMonitoring']; +}; + export type PerpsControllerMethodActions = | PerpsControllerPlaceOrderAction | PerpsControllerEditOrderAction @@ -204,4 +209,5 @@ export type PerpsControllerMethodActions = | PerpsControllerGetOrderBookGroupingAction | PerpsControllerSaveOrderBookGroupingAction | PerpsControllerSetSelectedPaymentTokenAction - | PerpsControllerResetSelectedPaymentTokenAction; + | PerpsControllerResetSelectedPaymentTokenAction + | PerpsControllerStartEligibilityMonitoringAction; diff --git a/app/controllers/perps/PerpsController.test.ts b/app/controllers/perps/PerpsController.test.ts index 3fd6857d49a..9d190be6321 100644 --- a/app/controllers/perps/PerpsController.test.ts +++ b/app/controllers/perps/PerpsController.test.ts @@ -681,6 +681,127 @@ describe('PerpsController', () => { }); }); + describe('deferEligibilityCheck', () => { + it('skips refreshEligibility when eligibility check is deferred', async () => { + // Arrange + const testMockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + if (action === 'GeolocationController:getGeolocation') { + return 'US'; + } + return undefined; + }); + + const deferredController = new TestablePerpsController({ + messenger: createMockMessenger({ call: testMockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: createMockInfrastructure(), + deferEligibilityCheck: true, + }); + + // Act + await deferredController.refreshEligibility(); + + // Assert — geolocation was never called because refreshEligibility returned early + expect(testMockCall).not.toHaveBeenCalledWith( + 'GeolocationController:getGeolocation', + ); + }); + + it('resumes eligibility checks after startEligibilityMonitoring is called', () => { + // Arrange + const testMockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: ['US'], + }, + }, + }; + } + return undefined; + }); + + const deferredController = new TestablePerpsController({ + messenger: createMockMessenger({ call: testMockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: createMockInfrastructure(), + deferEligibilityCheck: true, + }); + + // Reset mocks after construction to isolate startEligibilityMonitoring behavior + testMockCall.mockClear(); + mockFeatureFlagConfigurationServiceInstance.refreshEligibility.mockClear(); + + // Re-wire the mock so it still returns flags when called again + testMockCall.mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: ['US'], + }, + }, + }; + } + return undefined; + }); + + // Act + deferredController.startEligibilityMonitoring(); + + // Assert — startEligibilityMonitoring itself reads remote flags and triggers eligibility + expect(testMockCall).toHaveBeenCalledWith( + 'RemoteFeatureFlagController:getState', + ); + expect( + mockFeatureFlagConfigurationServiceInstance.refreshEligibility, + ).toHaveBeenCalled(); + }); + + it('logs error when RemoteFeatureFlagController throws during startEligibilityMonitoring', () => { + // Arrange + const testInfrastructure = createMockInfrastructure(); + const testMockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + throw new Error('Controller not ready'); + } + return undefined; + }); + + const deferredController = new TestablePerpsController({ + messenger: createMockMessenger({ call: testMockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: testInfrastructure, + deferEligibilityCheck: true, + }); + + // Reset mock to isolate startEligibilityMonitoring errors from constructor errors + (testInfrastructure.logger.error as jest.Mock).mockClear(); + + // Act — should not throw + expect(() => + deferredController.startEligibilityMonitoring(), + ).not.toThrow(); + + // Assert — error was logged + expect(testInfrastructure.logger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + context: expect.objectContaining({ + data: expect.objectContaining({ + method: 'startEligibilityMonitoring', + operation: 'readRemoteFeatureFlags', + }), + }), + }), + ); + }); + }); + describe('HIP-3 Configuration Integration', () => { it('delegates HIP-3 config updates to FeatureFlagConfigurationService', () => { const remoteFlags = { @@ -5036,7 +5157,7 @@ describe('PerpsController', () => { await jest.advanceTimersByTimeAsync(500); const userCache = preloadController.state.cachedUserDataByProvider; - const cacheKey = Object.keys(userCache)[0] as string; + const cacheKey = Object.keys(userCache)[0]; expect(cacheKey).toBeDefined(); const entry = userCache[cacheKey]; expect(entry.positions).toEqual(mockPositions); @@ -5112,7 +5233,7 @@ describe('PerpsController', () => { await jest.advanceTimersByTimeAsync(500); const freshCache = preloadController.state.cachedUserDataByProvider; - const freshKey = Object.keys(freshCache)[0] as string; + const freshKey = Object.keys(freshCache)[0]; expect(freshKey).toBeDefined(); expect(freshCache[freshKey].address).toBe(mockEvmAccount.address); expect(freshCache[freshKey].timestamp).toBeGreaterThan(0); diff --git a/app/controllers/perps/PerpsController.ts b/app/controllers/perps/PerpsController.ts index ae8a20d8e2f..904c8130860 100644 --- a/app/controllers/perps/PerpsController.ts +++ b/app/controllers/perps/PerpsController.ts @@ -590,6 +590,12 @@ export type PerpsControllerOptions = { * Must be provided by the platform (mobile/extension) at instantiation time. */ infrastructure: PerpsPlatformDependencies; + /** + * When true, defers the initial eligibility (geolocation) check until + * `startEligibilityMonitoring()` is called. This prevents the eager + * geolocation fetch from firing during wallet onboarding (privacy compliance). + */ + deferEligibilityCheck?: boolean; }; type BlockedRegionList = { @@ -632,6 +638,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'saveOrderBookGrouping', 'setSelectedPaymentToken', 'resetSelectedPaymentToken', + 'startEligibilityMonitoring', ] as const; /** @@ -746,6 +753,8 @@ export class PerpsController extends BaseController< #standaloneProviderHip3Version: number | null = null; + #eligibilityCheckDeferred: boolean; + // Store options for dependency injection (allows core package to inject platform-specific services) readonly #options: PerpsControllerOptions; @@ -771,6 +780,7 @@ export class PerpsController extends BaseController< state = {}, clientConfig = {}, infrastructure, + deferEligibilityCheck = false, }: PerpsControllerOptions) { super({ name: 'PerpsController', @@ -779,6 +789,8 @@ export class PerpsController extends BaseController< state: { ...getDefaultPerpsControllerState(), ...state }, }); + this.#eligibilityCheckDeferred = deferEligibilityCheck; + // Store options for dependency injection this.#options = { messenger, @@ -3918,7 +3930,33 @@ export class PerpsController extends BaseController< /** * Refresh eligibility status */ + /** + * Resume eligibility monitoring after onboarding completes. + * Clears the deferred flag and triggers an immediate eligibility check + * using the current remote feature flag state. + */ + startEligibilityMonitoring(): void { + this.#eligibilityCheckDeferred = false; + try { + const currentState = this.messenger.call( + 'RemoteFeatureFlagController:getState', + ); + this.refreshEligibilityOnFeatureFlagChange(currentState); + } catch (error) { + this.#logError( + ensureError(error, 'PerpsController.startEligibilityMonitoring'), + this.#getErrorContext('startEligibilityMonitoring', { + operation: 'readRemoteFeatureFlags', + }), + ); + } + } + async refreshEligibility(): Promise { + if (this.#eligibilityCheckDeferred) { + return; + } + // Capture the current version before starting the async operation. // This prevents race conditions where stale eligibility checks // (started with fallback config) overwrite results from newer checks From eb832ce664efaec534124a962b7c672a5b11b5c4 Mon Sep 17 00:00:00 2001 From: cryptodev-2s <109512101+cryptodev-2s@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:14:57 +0100 Subject: [PATCH 149/206] chore: bump `@metamask/smart-transactions-controller` to ^23.0.0 (#27692) ## **Description** Bump `@metamask/smart-transactions-controller` from `^22.7.0` to `^23.0.0`. Adapts to breaking changes per the [v23.0.0 changelog](https://github.com/MetaMask/smart-transactions-controller/blob/main/CHANGELOG.md#2300): - Remove `ErrorReportingService:captureException` from messenger allowed actions and tests - Controller deps moved from peer to direct (network-controller, transaction-controller, remote-feature-flag-controller, polling-controller) - ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Medium risk due to a major-version dependency bump that changes controller dependency wiring and allowed messenger actions; issues would surface at runtime in smart transaction flows. > > **Overview** > Updates `@metamask/smart-transactions-controller` from `^22.7.0` to `^23.0.0` (and lockfile), aligning with its breaking changes. > > Removes `ErrorReportingService:captureException` from the Smart Transactions messenger allowed-actions list and updates related tests (`smart-transactions-controller-messenger*.ts` and `smart-publish-hook.test.ts`) to match the new permission surface. The lockfile reflects the controller moving several deps from peer to direct (notably newer `@metamask/polling-controller` plus direct `network`/`remote-feature-flag`/`transaction` controller deps). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 39fd5bbbbae414344e6901e6b3c8b011120ddcbe. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- ...-transactions-controller-messenger.test.ts | 1 - ...smart-transactions-controller-messenger.ts | 1 - .../smart-publish-hook.test.ts | 1 - package.json | 2 +- yarn.lock | 38 +++++-------------- 5 files changed, 11 insertions(+), 32 deletions(-) diff --git a/app/core/Engine/messengers/smart-transactions-controller-messenger.test.ts b/app/core/Engine/messengers/smart-transactions-controller-messenger.test.ts index f61293252d3..8af3005d1b2 100644 --- a/app/core/Engine/messengers/smart-transactions-controller-messenger.test.ts +++ b/app/core/Engine/messengers/smart-transactions-controller-messenger.test.ts @@ -37,7 +37,6 @@ describe('getSmartTransactionsControllerMessenger', () => { expect(delegateSpy).toHaveBeenCalledWith( expect.objectContaining({ actions: expect.arrayContaining([ - 'ErrorReportingService:captureException', 'NetworkController:getNetworkClientById', 'NetworkController:getState', 'RemoteFeatureFlagController:getState', diff --git a/app/core/Engine/messengers/smart-transactions-controller-messenger.ts b/app/core/Engine/messengers/smart-transactions-controller-messenger.ts index 89631def64e..93ceda1b52d 100644 --- a/app/core/Engine/messengers/smart-transactions-controller-messenger.ts +++ b/app/core/Engine/messengers/smart-transactions-controller-messenger.ts @@ -29,7 +29,6 @@ export function getSmartTransactionsControllerMessenger( }); rootMessenger.delegate({ actions: [ - 'ErrorReportingService:captureException', 'NetworkController:getNetworkClientById', 'NetworkController:getState', 'RemoteFeatureFlagController:getState', diff --git a/app/util/smart-transactions/smart-publish-hook.test.ts b/app/util/smart-transactions/smart-publish-hook.test.ts index bca21ac33a9..f0ace208e58 100644 --- a/app/util/smart-transactions/smart-publish-hook.test.ts +++ b/app/util/smart-transactions/smart-publish-hook.test.ts @@ -134,7 +134,6 @@ function withRequest( 'TransactionController:getTransactions', 'TransactionController:updateTransaction', 'RemoteFeatureFlagController:getState', - 'ErrorReportingService:captureException', ], events: [ 'NetworkController:stateChange', diff --git a/package.json b/package.json index 01fadf2a7cd..b18f96349f5 100644 --- a/package.json +++ b/package.json @@ -293,7 +293,7 @@ "@metamask/selected-network-controller": "^25.0.0", "@metamask/signature-controller": "^39.1.0", "@metamask/slip44": "^4.2.0", - "@metamask/smart-transactions-controller": "^22.7.0", + "@metamask/smart-transactions-controller": "^23.0.0", "@metamask/snaps-controllers": "^18.0.4", "@metamask/snaps-execution-environments": "^11.0.1", "@metamask/snaps-rpc-methods": "^15.0.0", diff --git a/yarn.lock b/yarn.lock index a2ca5ff968e..f9bda4a5ec3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9221,23 +9221,7 @@ __metadata: languageName: node linkType: hard -"@metamask/polling-controller@npm:^15.0.0": - version: 15.0.0 - resolution: "@metamask/polling-controller@npm:15.0.0" - dependencies: - "@metamask/base-controller": "npm:^9.0.0" - "@metamask/controller-utils": "npm:^11.14.1" - "@metamask/utils": "npm:^11.8.1" - "@types/uuid": "npm:^8.3.0" - fast-json-stable-stringify: "npm:^2.1.0" - uuid: "npm:^8.3.2" - peerDependencies: - "@metamask/network-controller": ^25.0.0 - checksum: 10/e1f5d45ce3b083d154ccacba54453bdeea6bbfafc461be559ce15ae435e0df500ebab8ffd06a5e7bc52e904c29d06c1a5ca0a29193f8778ca39dfd134a7f0794 - languageName: node - linkType: hard - -"@metamask/polling-controller@npm:^16.0.3": +"@metamask/polling-controller@npm:^16.0.0, @metamask/polling-controller@npm:^16.0.3": version: 16.0.3 resolution: "@metamask/polling-controller@npm:16.0.3" dependencies: @@ -9612,9 +9596,9 @@ __metadata: languageName: node linkType: hard -"@metamask/smart-transactions-controller@npm:^22.7.0": - version: 22.7.0 - resolution: "@metamask/smart-transactions-controller@npm:22.7.0" +"@metamask/smart-transactions-controller@npm:^23.0.0": + version: 23.0.0 + resolution: "@metamask/smart-transactions-controller@npm:23.0.0" dependencies: "@babel/runtime": "npm:^7.24.1" "@ethereumjs/tx": "npm:^5.2.1" @@ -9627,18 +9611,16 @@ __metadata: "@metamask/eth-json-rpc-provider": "npm:^4.1.6" "@metamask/eth-query": "npm:^4.0.0" "@metamask/messenger": "npm:^0.3.0" - "@metamask/polling-controller": "npm:^15.0.0" + "@metamask/network-controller": "npm:^30.0.0" + "@metamask/polling-controller": "npm:^16.0.0" + "@metamask/remote-feature-flag-controller": "npm:^4.1.0" "@metamask/superstruct": "npm:^3.1.0" + "@metamask/transaction-controller": "npm:^63.0.0" "@metamask/utils": "npm:^11.0.0" bignumber.js: "npm:^9.0.1" fast-json-patch: "npm:^3.1.0" lodash: "npm:^4.17.21" reselect: "npm:^5.1.1" - peerDependencies: - "@metamask/error-reporting-service": ^3.0.0 - "@metamask/network-controller": ^25.0.0 - "@metamask/remote-feature-flag-controller": ^2.0.0 - "@metamask/transaction-controller": ^61.0.0 peerDependenciesMeta: "@metamask/accounts-controller": optional: true @@ -9648,7 +9630,7 @@ __metadata: optional: true "@metamask/gas-fee-controller": optional: true - checksum: 10/49acf33fc852c109d2ce821a1a3a702068e8b9cd16b7a457838bb2ae0fce958ea1cc1eaf5395d876f77630939b0c8569b0032754554274bbc88e20d5d0f74de2 + checksum: 10/5dc6e3fdc8ad93967da8e1a8ec9334b3fd82444793074689981ac56a38159a8c7f651d86ac2282463af424519ca5630d46f09d0833da149928974761f8fac7fe languageName: node linkType: hard @@ -35617,7 +35599,7 @@ __metadata: "@metamask/selected-network-controller": "npm:^25.0.0" "@metamask/signature-controller": "npm:^39.1.0" "@metamask/slip44": "npm:^4.2.0" - "@metamask/smart-transactions-controller": "npm:^22.7.0" + "@metamask/smart-transactions-controller": "npm:^23.0.0" "@metamask/snaps-controllers": "npm:^18.0.4" "@metamask/snaps-execution-environments": "npm:^11.0.1" "@metamask/snaps-rpc-methods": "npm:^15.0.0" From 5a86bf1e7f8245fdda5daf1743fc94669a07a267 Mon Sep 17 00:00:00 2001 From: tommasini <46944231+tommasini@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:54:05 +0000 Subject: [PATCH 150/206] ci: make TestFlight external distribution configurable (#27683) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The nightly build's TestFlight upload was failing with "This request is forbidden for security reasons - The API key in use does not allow this request" because the App Store Connect API key lacks permissions for external distribution (`distribute_external: true`). This PR makes the `distribute_external` parameter configurable across the upload chain (Fastfile → shell script → workflow), defaulting to `true` to preserve existing behavior for all callers. The nightly build workflow now passes `false` to skip external distribution, unblocking uploads while the API key permissions are resolved. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: TestFlight upload with configurable external distribution Scenario: nightly build uploads without external distribution Given the nightly build workflow runs on the test/temp-nightly branch When the upload-to-testflight.sh script is invoked with distribute_external set to "false" Then fastlane should call upload_to_testflight with distribute_external: false And notify_external_testers should be false And the upload should succeed without requiring external distribution permissions Scenario: default invocation preserves external distribution Given a caller invokes upload-to-testflight.sh without the 5th argument When the script runs Then distribute_external should default to "true" And fastlane should call upload_to_testflight with distribute_external: true ``` ## **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. ## **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. Made with [Cursor](https://cursor.com) --- > [!NOTE] > **Medium Risk** > Changes the TestFlight upload pipeline to optionally skip external distribution; misconfiguration could prevent builds from being distributed to intended testers. > > **Overview** > Makes TestFlight external distribution configurable end-to-end by adding a `distribute_external` parameter to `scripts/upload-to-testflight.sh` and plumbing it into the Fastlane `upload_to_testflight_only` lane. > > Updates the nightly GitHub Actions workflow to pass `distribute_external=false` for exp/rc uploads so builds can be uploaded without distributing to external testers, while defaulting to `true` for existing callers. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit aee4144eb51ea0b50e5a9dbe90afa220fd0840f6. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .github/workflows/nightly-build.yml | 8 ++++++-- ios/fastlane/Fastfile | 5 +++-- scripts/upload-to-testflight.sh | 15 +++++++++------ 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index eee26495e63..846bad6f383 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -141,7 +141,8 @@ jobs: "github_actions_main-exp" \ "${{ github.ref_name }}" \ "${{ steps.ipa.outputs.path }}" \ - "MetaMask BETA & Release Candidates" + "MetaMask BETA & Release Candidates" \ + "false" - name: Cleanup API Key if: always() @@ -196,11 +197,14 @@ jobs: - name: Upload to TestFlight run: | + # Group arg is required as positional placeholder for the 5th arg (distribute_external=false). + # With distribute_external=false the build is uploaded but NOT distributed to external testers. bash scripts/upload-to-testflight.sh \ "github_actions_main-rc" \ "${{ github.ref_name }}" \ "${{ steps.ipa.outputs.path }}" \ - "MetaMask BETA & Release Candidates" + "MetaMask BETA & Release Candidates" \ + "false" - name: Cleanup API Key if: always() diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile index 0e68753d26d..e0b667efe51 100644 --- a/ios/fastlane/Fastfile +++ b/ios/fastlane/Fastfile @@ -20,7 +20,8 @@ platform :ios do # Convert groups parameter to array (Fastlane CLI passes strings, not arrays) groups_param = options[:groups] || ['MetaMask BETA & Release Candidates'] groups = groups_param.is_a?(Array) ? groups_param : [groups_param] - notify_external_testers = true + distribute_external = options[:distribute_external].nil? ? true : options[:distribute_external].to_s.downcase == 'true' + notify_external_testers = distribute_external if ipa_path.nil? || ipa_path.empty? UI.user_error!("You must provide an ipa_path using ipa_path: '/path/to/app.ipa'") @@ -96,7 +97,7 @@ platform :ios do upload_to_testflight( api_key: api_key, ipa: ipa_path, - distribute_external: true, + distribute_external: distribute_external, groups: groups, notify_external_testers: notify_external_testers, changelog: changelog_message diff --git a/scripts/upload-to-testflight.sh b/scripts/upload-to-testflight.sh index f67e358c054..f8c167b3f93 100644 --- a/scripts/upload-to-testflight.sh +++ b/scripts/upload-to-testflight.sh @@ -4,13 +4,14 @@ # This script can be used in both Bitrise and GitHub Actions workflows # # Usage: -# ./scripts/upload-to-testflight.sh [ipa_path] [testflight_group] +# ./scripts/upload-to-testflight.sh [ipa_path] [testflight_group] [distribute_external] # # Arguments: -# pipeline_name - Pipeline or workflow name (required) -# branch - Git branch name (required) -# ipa_path - Optional: Direct path to IPA file (if not provided, uses find-ipa-file.sh) -# testflight_group - Optional: TestFlight external testing group name (default: MetaMask BETA & Release Candidates) +# pipeline_name - Pipeline or workflow name (required) +# branch - Git branch name (required) +# ipa_path - Optional: Direct path to IPA file (if not provided, uses find-ipa-file.sh) +# testflight_group - Optional: TestFlight external testing group name (default: MetaMask BETA & Release Candidates) +# distribute_external - Optional: Whether to distribute to external testers (default: true) # # Environment variables: # IPA_PATH - IPA path (set by find-ipa-file.sh if not provided as argument) @@ -27,6 +28,7 @@ PIPELINE_NAME="$1" BRANCH="$2" LOCAL_IPA_PATH="$3" TESTFLIGHT_GROUP="${4:-MetaMask BETA & Release Candidates}" +DISTRIBUTE_EXTERNAL="${5:-true}" # Get IPA path: use argument if provided, otherwise use find-ipa-file.sh if [ -n "$LOCAL_IPA_PATH" ]; then @@ -74,5 +76,6 @@ cd ios bundle exec fastlane upload_to_testflight_only \ ipa_path:"$IPA_PATH" \ groups:"$TESTFLIGHT_GROUP" \ - changelog:"$CHANGELOG" + changelog:"$CHANGELOG" \ + distribute_external:"$DISTRIBUTE_EXTERNAL" From 43130d61cd7ede840d7af283ba63017cd81a5455 Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Fri, 20 Mar 2026 01:24:59 +0800 Subject: [PATCH 151/206] fix(perps): BRENTOIL shows up in the 'explore crypto' section (#27699) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** BRENTOIL (Brent Crude Oil) and 14 other recently added HIP-3 assets appeared in the "Explore Crypto" section because they were missing from the `HIP3_ASSET_MARKET_TYPES` mapping in `hyperLiquidConfig.ts`. Without a `marketType`, the crypto filter (`!m.marketType`) incorrectly captured them. **Root cause:** `app/controllers/perps/constants/hyperLiquidConfig.ts:305` — 15 HIP-3 assets added to HyperLiquid's xyz DEX were not mapped in the app's asset classification config. **Fix:** (1) Added all 15 missing assets to `HIP3_ASSET_MARKET_TYPES` with correct categories. (2) Added `&& !m.isHip3` guard to the crypto filter in `usePerpsHomeData` as defense-in-depth against future unmapped HIP-3 assets. ## **Changelog** CHANGELOG entry: Fixed miscategorization of BRENTOIL and other non-crypto instruments appearing in the "Explore Crypto" section on Perps Home ## **Related issues** Fixes: [TAT-2672](https://consensyssoftware.atlassian.net/browse/TAT-2672) ## **Manual testing steps** ```gherkin Feature: Perps Home market categorization Scenario: BRENTOIL appears in Commodities, not Explore Crypto Given the user is on the Perps Home screen When the market data loads Then BRENTOIL should appear in the "Commodities" section And BRENTOIL should NOT appear in the "Explore Crypto" section And BTC, ETH, SOL remain in the "Explore Crypto" section ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/60b1f75c-823e-4788-8173-dd58629a323c ### **After** https://github.com/user-attachments/assets/a2235b45-8a40-4f23-b0ca-aad8d12506aa ## **Validation Recipe**
Automated validation recipe (validate-recipe.sh) ```json { "pr": "27699", "title": "BRENTOIL excluded from Explore Crypto section", "jira": "TAT-2672", "acceptance_criteria": [ "BRENTOIL does not appear in the Explore Crypto section", "Other non-crypto instruments are excluded from Explore Crypto", "Filter logic enforced at data level, not manual exclusion", "BRENTOIL appears in Commodities section", "No crypto markets removed from Explore Crypto" ], "validate": { "static": ["yarn lint:tsc"], "runtime": { "pre_conditions": ["CDP connected", "Wallet unlocked on Wallet route"], "steps": [ { "id": "nav_perps", "description": "Navigate to perps home", "action": "navigate", "target": "Perps" }, { "id": "wait_load", "description": "Wait for market data to load", "action": "wait", "ms": 3000 }, { "id": "check_brentoil_not_in_crypto", "description": "BRENTOIL must not be in crypto markets (markets without marketType)", "action": "eval_async", "expression": "Engine.context.PerpsController.getMarketDataWithPrices().then(function(markets) { var cryptoMarkets = markets.filter(function(m) { return !m.marketType; }); var hasBrent = cryptoMarkets.some(function(m) { return (m.symbol || '').indexOf('BRENTOIL') >= 0; }); return JSON.stringify({hasBrentInCrypto: hasBrent, cryptoCount: cryptoMarkets.length}); })", "assert": { "operator": "eq", "field": "hasBrentInCrypto", "value": false } }, { "id": "check_brentoil_in_commodities", "description": "BRENTOIL must be in commodities section", "action": "eval_async", "expression": "Engine.context.PerpsController.getMarketDataWithPrices().then(function(markets) { var commodities = markets.filter(function(m) { return m.marketType === 'commodity'; }); var hasBrent = commodities.some(function(m) { return (m.symbol || '').indexOf('BRENTOIL') >= 0; }); return JSON.stringify({hasBrentInCommodities: hasBrent, commodityCount: commodities.length}); })", "assert": { "operator": "eq", "field": "hasBrentInCommodities", "value": true } }, { "id": "check_no_hip3_in_crypto", "description": "No HIP-3 markets should appear in crypto section", "action": "eval_async", "expression": "Engine.context.PerpsController.getMarketDataWithPrices().then(function(markets) { var hip3InCrypto = markets.filter(function(m) { return !m.marketType && m.isHip3; }); return JSON.stringify({hip3InCryptoCount: hip3InCrypto.length, symbols: hip3InCrypto.map(function(m) { return m.symbol; })}); })", "assert": { "operator": "eq", "field": "hip3InCryptoCount", "value": 0 } }, { "id": "check_crypto_markets_exist", "description": "Crypto markets still exist in Explore Crypto", "action": "eval_async", "expression": "Engine.context.PerpsController.getMarketDataWithPrices().then(function(markets) { var crypto = markets.filter(function(m) { return !m.marketType && !m.isHip3; }); var hasBTC = crypto.some(function(m) { return m.symbol === 'BTC'; }); var hasETH = crypto.some(function(m) { return m.symbol === 'ETH'; }); return JSON.stringify({cryptoCount: crypto.length, hasBTC: hasBTC, hasETH: hasETH}); })", "assert": { "operator": "eq", "field": "hasBTC", "value": true } }, { "id": "check_all_hip3_categorized", "description": "All HIP-3 markets have a marketType assigned", "action": "eval_async", "expression": "Engine.context.PerpsController.getMarketDataWithPrices().then(function(markets) { var uncategorized = markets.filter(function(m) { return m.isHip3 && !m.marketType; }); return JSON.stringify({uncategorizedCount: uncategorized.length, symbols: uncategorized.map(function(m) { return m.symbol; })}); })", "assert": { "operator": "eq", "field": "uncategorizedCount", "value": 0 } }, { "id": "check_no_errors", "description": "No errors in Metro logs", "action": "log_watch", "window_seconds": 10, "must_not_appear": ["TypeError", "undefined is not an object"] } ] } } } ```
## **Pre-merge author checklist** - [x] I've followed MetaMask Contributor Docs and Coding Standards - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [ ] I've documented my code using JSDoc format if applicable - [x] I've applied the right labels on the PR ## **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** > Low risk UI/data-filtering change that only affects how perps markets are categorized and displayed; behavior is covered by new unit tests guarding HIP-3 filtering in both trending and search results. > > **Overview** > Prevents HIP-3 xyz DEX instruments (e.g., `xyz:BRENTOIL`) from being treated as crypto on Perps Home by **excluding HIP-3 markets** from the crypto `perpsMarkets` list (including search results). > > Expands `HIP3_ASSET_MARKET_TYPES` to classify newly added xyz assets across *equity/commodity/forex*, and adds unit tests to ensure HIP-3 markets don’t appear under **Explore Crypto** while correctly showing under their respective sections. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit f35150df1172873fcaf4476723b8e45cd0e0f2cf. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../UI/Perps/hooks/usePerpsHomeData.test.ts | 96 +++++++++++++++++++ .../UI/Perps/hooks/usePerpsHomeData.ts | 4 +- .../perps/constants/hyperLiquidConfig.ts | 16 ++++ 3 files changed, 114 insertions(+), 2 deletions(-) diff --git a/app/components/UI/Perps/hooks/usePerpsHomeData.test.ts b/app/components/UI/Perps/hooks/usePerpsHomeData.test.ts index 7a81635caeb..304bab74e86 100644 --- a/app/components/UI/Perps/hooks/usePerpsHomeData.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsHomeData.test.ts @@ -486,6 +486,102 @@ describe('usePerpsHomeData', () => { }); }); + describe('Market type filtering', () => { + it('excludes HIP-3 markets from crypto (perpsMarkets) section', () => { + const marketsWithHip3 = [ + ...mockMarkets, + createMockMarket({ + symbol: 'xyz:BRENTOIL', + name: 'Brent Oil', + isHip3: true, + isNewMarket: true, + }), + createMockMarket({ + symbol: 'xyz:GOLD', + name: 'Gold', + marketType: 'commodity', + isHip3: true, + }), + ]; + + mockUsePerpsMarkets.mockReturnValue({ + markets: marketsWithHip3, + isLoading: false, + isRefreshing: false, + error: null, + refresh: mockRefreshMarkets, + }); + + mockSortMarkets.mockImplementation(({ markets }) => markets); + + const { result } = renderHook(() => usePerpsHomeData()); + + // Only non-HIP3 crypto markets should be in perpsMarkets + expect(result.current.perpsMarkets).toHaveLength(3); + expect(result.current.perpsMarkets.every((m) => !m.isHip3)).toBe(true); + // BRENTOIL (unmapped HIP-3) must not appear in crypto + expect( + result.current.perpsMarkets.find((m) => m.symbol === 'xyz:BRENTOIL'), + ).toBeUndefined(); + }); + + it('includes HIP-3 commodity markets in commoditiesMarkets', () => { + const marketsWithCommodity = [ + ...mockMarkets, + createMockMarket({ + symbol: 'xyz:BRENTOIL', + name: 'Brent Oil', + marketType: 'commodity', + isHip3: true, + }), + ]; + + mockUsePerpsMarkets.mockReturnValue({ + markets: marketsWithCommodity, + isLoading: false, + isRefreshing: false, + error: null, + refresh: mockRefreshMarkets, + }); + + mockSortMarkets.mockImplementation(({ markets }) => markets); + + const { result } = renderHook(() => usePerpsHomeData()); + + expect(result.current.commoditiesMarkets).toHaveLength(1); + expect(result.current.commoditiesMarkets[0].symbol).toBe('xyz:BRENTOIL'); + }); + + it('excludes unmapped HIP-3 markets from search crypto results', () => { + const marketsWithHip3 = [ + ...mockMarkets, + createMockMarket({ + symbol: 'xyz:BRENTOIL', + name: 'Brent Oil', + isHip3: true, + isNewMarket: true, + }), + ]; + + mockUsePerpsMarkets.mockReturnValue({ + markets: marketsWithHip3, + isLoading: false, + isRefreshing: false, + error: null, + refresh: mockRefreshMarkets, + }); + + const { result } = renderHook(() => + usePerpsHomeData({ searchQuery: 'BRENT' }), + ); + + // BRENTOIL should not appear in perpsMarkets (crypto) during search + expect( + result.current.perpsMarkets.find((m) => m.symbol === 'xyz:BRENTOIL'), + ).toBeUndefined(); + }); + }); + describe('Trending markets sorting', () => { it('sorts markets using saved sort preference', () => { renderHook(() => usePerpsHomeData()); diff --git a/app/components/UI/Perps/hooks/usePerpsHomeData.ts b/app/components/UI/Perps/hooks/usePerpsHomeData.ts index f540ddc5b3d..531716bd08d 100644 --- a/app/components/UI/Perps/hooks/usePerpsHomeData.ts +++ b/app/components/UI/Perps/hooks/usePerpsHomeData.ts @@ -182,7 +182,7 @@ export const usePerpsHomeData = ({ const perpsMarkets = useMemo( () => sortMarkets({ - markets: allMarkets.filter((m) => !m.marketType), // Crypto markets have no marketType + markets: allMarkets.filter((m) => !m.marketType && !m.isHip3), sortBy, direction, }).slice(0, trendingLimit), @@ -321,7 +321,7 @@ export const usePerpsHomeData = ({ if (!searchQuery.trim()) { return perpsMarkets; } - return filteredData.markets.filter((m) => !m.marketType); + return filteredData.markets.filter((m) => !m.marketType && !m.isHip3); }, [searchQuery, perpsMarkets, filteredData.markets]); const searchedStocksMarkets = useMemo(() => { diff --git a/app/controllers/perps/constants/hyperLiquidConfig.ts b/app/controllers/perps/constants/hyperLiquidConfig.ts index 4e463c222d5..1159c2277c1 100644 --- a/app/controllers/perps/constants/hyperLiquidConfig.ts +++ b/app/controllers/perps/constants/hyperLiquidConfig.ts @@ -335,6 +335,19 @@ export const HIP3_ASSET_MARKET_TYPES: Record< 'xyz:CRWV': 'equity', 'xyz:SMSN': 'equity', + 'xyz:GME': 'equity', + 'xyz:SOFTBANK': 'equity', + 'xyz:HYUNDAI': 'equity', + 'xyz:KIOXIA': 'equity', + 'xyz:HIMS': 'equity', + 'xyz:URNM': 'equity', + 'xyz:EWY': 'equity', + 'xyz:EWJ': 'equity', + 'xyz:SP500': 'equity', + 'xyz:JP225': 'equity', + 'xyz:KR200': 'equity', + 'xyz:VIX': 'equity', + // xyz DEX - Commodities 'xyz:GOLD': 'commodity', 'xyz:SILVER': 'commodity', @@ -345,10 +358,13 @@ export const HIP3_ASSET_MARKET_TYPES: Record< 'xyz:USAR': 'commodity', 'xyz:NATGAS': 'commodity', 'xyz:PLATINUM': 'commodity', + 'xyz:PALLADIUM': 'commodity', + 'xyz:BRENTOIL': 'commodity', // xyz DEX - Forex 'xyz:EUR': 'forex', 'xyz:JPY': 'forex', + 'xyz:DXY': 'forex', } as const; /** From 3a853ff75118fb638a07086c278d2b8f1b4d3df0 Mon Sep 17 00:00:00 2001 From: Matthew Grainger <46547583+Matt561@users.noreply.github.com> Date: Thu, 19 Mar 2026 13:35:03 -0400 Subject: [PATCH 152/206] feat: MUSD-531 updated mUSD aggregated balance row to redirect to cash list when user has mUSD on any network (#27703) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The mUSD aggregated balance row on the home screen now redirects users to the correct destination based on whether they hold mUSD on any network. Users with mUSD on mainnet or Linea are taken to the Cash tokens list view; users with no mUSD balance are redirected to the mainnet mUSD asset details screen. ## **Changelog** CHANGELOG entry: Updated mUSD aggregated balance row to redirect to the Cash tokens list when the user holds mUSD on any network ## **Related issues** Fixes: - [MUSD-531: Improve navigation for aggregated mUSD balance on home screen](https://consensyssoftware.atlassian.net/browse/MUSD-531) ## **Manual testing steps** ```gherkin Feature: mUSD aggregated balance row navigation on home screen Scenario: user with mUSD balance taps the aggregated mUSD row Given the user holds mUSD on mainnet or Linea And the user is on the home screen When user taps the mUSD aggregated balance row Then the Cash tokens list view opens Scenario: user with no mUSD balance taps the aggregated mUSD row Given the user has no mUSD balance on any network And the user is on the home screen When user taps the mUSD aggregated balance row Then the mainnet mUSD asset details screen opens ``` ## **Screenshots/Recordings** ### **Before** Clicking the aggregated mUSD balance row always redirected to mainnet mUSD asset details. ### **After** https://github.com/user-attachments/assets/74bc7179-7a92-472e-97cf-c6f64a43825a ## **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. ## **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** > Low risk UI navigation change gated by a balance flag; main risk is misrouting users if `hasMusdBalanceOnAnyChain` is incorrect. > > **Overview** > Updates the home screen `MusdAggregatedRow` press behavior to **conditionally navigate**: if the user holds mUSD on any supported chain it opens the Cash tokens list (`Routes.WALLET.CASH_TOKENS_FULL_VIEW`), otherwise it continues to open the mainnet mUSD Asset details view. > > Extends the unit tests to mock `NavigationService` and `useMusdBalance`’s new `hasMusdBalanceOnAnyChain` output, and adds coverage for both navigation paths. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 81109c035b5425312adaadde3a329020b90fb2ea. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../Sections/Cash/MusdAggregatedRow.test.tsx | 57 +++++++++++++++++-- .../Sections/Cash/MusdAggregatedRow.tsx | 17 +++++- 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/app/components/Views/Homepage/Sections/Cash/MusdAggregatedRow.test.tsx b/app/components/Views/Homepage/Sections/Cash/MusdAggregatedRow.test.tsx index 431a2973296..e4395ff2acd 100644 --- a/app/components/Views/Homepage/Sections/Cash/MusdAggregatedRow.test.tsx +++ b/app/components/Views/Homepage/Sections/Cash/MusdAggregatedRow.test.tsx @@ -2,6 +2,9 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react-native'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; import MusdAggregatedRow from './MusdAggregatedRow'; +import Routes from '../../../../../constants/navigation/Routes'; +import { TokenDetailsSource } from '../../../../UI/TokenDetails/constants/constants'; +import NavigationService from '../../../../../core/NavigationService'; const mockClaimRewards = jest.fn(); const mockTrackEvent = jest.fn(); @@ -9,12 +12,22 @@ const mockCreateEventBuilder = jest.fn(() => ({ addProperties: jest.fn().mockReturnThis(), build: jest.fn(), })); +jest.mock('../../../../../core/NavigationService', () => ({ + __esModule: true, + default: { + navigation: { + navigate: jest.fn(), + }, + }, +})); +const mockUseMusdBalance = jest.fn(() => ({ + tokenBalanceAggregated: '1800.5', + fiatBalanceAggregatedFormatted: '$1,800.50', + hasMusdBalanceOnAnyChain: false, +})); jest.mock('../../../../UI/Earn/hooks/useMusdBalance', () => ({ - useMusdBalance: () => ({ - tokenBalanceAggregated: '1800.5', - fiatBalanceAggregatedFormatted: '$1,800.50', - }), + useMusdBalance: () => mockUseMusdBalance(), })); const mockUseMerklBonusClaim = jest.fn(() => ({ @@ -48,6 +61,11 @@ jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({ describe('MusdAggregatedRow', () => { beforeEach(() => { jest.clearAllMocks(); + mockUseMusdBalance.mockReturnValue({ + tokenBalanceAggregated: '1800.5', + fiatBalanceAggregatedFormatted: '$1,800.50', + hasMusdBalanceOnAnyChain: false, + }); mockUseMerklBonusClaim.mockReturnValue({ claimableReward: '10', hasPendingClaim: false, @@ -110,6 +128,37 @@ describe('MusdAggregatedRow', () => { expect(screen.getByText('3% bonus')).toBeOnTheScreen(); }); + describe('handleTokenRowPress', () => { + it('navigates to CASH_TOKENS_FULL_VIEW when user has mUSD balance on any chain', () => { + mockUseMusdBalance.mockReturnValueOnce({ + tokenBalanceAggregated: '1800.5', + fiatBalanceAggregatedFormatted: '$1,800.50', + hasMusdBalanceOnAnyChain: true, + }); + + renderWithProvider(); + + fireEvent.press(screen.getByTestId('cash-section-musd-row')); + + expect(NavigationService.navigation.navigate).toHaveBeenCalledWith( + Routes.WALLET.CASH_TOKENS_FULL_VIEW, + ); + }); + + it('navigates to mUSD mainnet Asset details when user has no mUSD balance on any chain', () => { + renderWithProvider(); + + fireEvent.press(screen.getByTestId('cash-section-musd-row')); + + expect(NavigationService.navigation.navigate).toHaveBeenCalledWith( + 'Asset', + expect.objectContaining({ + source: TokenDetailsSource.MobileTokenListPage, + }), + ); + }); + }); + describe('claimable bonus threshold (min $0.01)', () => { it('hides Claim bonus when claimable reward is "< 0.01"', () => { mockUseMerklBonusClaim.mockReturnValue({ diff --git a/app/components/Views/Homepage/Sections/Cash/MusdAggregatedRow.tsx b/app/components/Views/Homepage/Sections/Cash/MusdAggregatedRow.tsx index 56a02f2dfbb..53ebb26656b 100644 --- a/app/components/Views/Homepage/Sections/Cash/MusdAggregatedRow.tsx +++ b/app/components/Views/Homepage/Sections/Cash/MusdAggregatedRow.tsx @@ -42,6 +42,7 @@ import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { MUSD_MAINNET_ASSET_FOR_DETAILS } from './CashGetMusdEmptyState.constants'; import NavigationService from '../../../../../core/NavigationService'; import { TokenDetailsSource } from '../../../../UI/TokenDetails/constants/constants'; +import Routes from '../../../../../constants/navigation/Routes'; /** * Minimal mUSD asset for useMerklBonusClaim (claim runs on Linea). @@ -62,8 +63,11 @@ const LINEA_MUSD_ASSET: TokenI = { const MusdAggregatedRow = () => { const tw = useTailwind(); const privacyMode = useSelector(selectPrivacyMode); - const { tokenBalanceAggregated, fiatBalanceAggregatedFormatted } = - useMusdBalance(); + const { + tokenBalanceAggregated, + fiatBalanceAggregatedFormatted, + hasMusdBalanceOnAnyChain, + } = useMusdBalance(); const { claimableReward, hasPendingClaim, claimRewards, isClaiming } = useMerklBonusClaim( LINEA_MUSD_ASSET, @@ -91,6 +95,13 @@ const MusdAggregatedRow = () => { }, [trackEvent, createEventBuilder, networkName, claimRewards]); const handleTokenRowPress = useCallback(() => { + if (hasMusdBalanceOnAnyChain) { + NavigationService.navigation.navigate( + Routes.WALLET.CASH_TOKENS_FULL_VIEW as never, + ); + return; + } + NavigationService.navigation.navigate( 'Asset' as never, { @@ -98,7 +109,7 @@ const MusdAggregatedRow = () => { source: TokenDetailsSource.MobileTokenListPage, } as never, ); - }, []); + }, [hasMusdBalanceOnAnyChain]); const tokenBalanceDisplay = `${getIntlNumberFormatter(I18n.locale, { minimumFractionDigits: 0, From b68e892235f34d8cc95665605858138458877220 Mon Sep 17 00:00:00 2001 From: Kirill Zyusko Date: Thu, 19 Mar 2026 18:57:02 +0100 Subject: [PATCH 153/206] feat: migrate Button component (notifications scope) (#27632) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Replace Button component using DSRN. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/DSYS-445 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/fc1a3116-4c08-42ac-b561-f2c63baaa969 ### **After** https://github.com/user-attachments/assets/4c53334c-68f9-4a97-9a58-d9de66d53260 ## **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. ## **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** > Low risk UI refactor that swaps the notifications-scoped `Button` implementation to `@metamask/design-system-react-native`, with minor prop/behavior differences (disabled/loading/full-width) that could affect interaction/styling. > > **Overview** > Migrates notifications UI screens/modals from the legacy `component-library` `Button` to `@metamask/design-system-react-native` `Button`, updating prop names (e.g., `ButtonVariants`→`ButtonVariant`, `disabled`/`loading`→`isDisabled`/`isLoading`, `width`→`isFullWidth`) and switching from `label` prop to children. > > Adjusts related styling (e.g., loader button width) and updates snapshots to reflect the new button render tree/accessibility output. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 83ad008d828049c146a16aef5302f61111b50b60. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- ...firmTurnOnBackupAndSyncModal.test.tsx.snap | 205 +++++++++++++----- .../UI/Notification/Modal/index.tsx | 23 +- .../SwitchLoadingModal/Loader.tsx | 17 +- .../Details/Footers/AnnouncementCtaFooter.tsx | 14 +- .../Details/Footers/BlockExplorerFooter.tsx | 18 +- .../Views/Notifications/OptIn/index.tsx | 18 +- app/components/Views/Notifications/index.tsx | 16 +- 7 files changed, 208 insertions(+), 103 deletions(-) diff --git a/app/components/UI/Identity/ConfirmTurnOnBackupAndSyncModal/__snapshots__/ConfirmTurnOnBackupAndSyncModal.test.tsx.snap b/app/components/UI/Identity/ConfirmTurnOnBackupAndSyncModal/__snapshots__/ConfirmTurnOnBackupAndSyncModal.test.tsx.snap index e0e2b0f56d2..9d3b45e09d9 100644 --- a/app/components/UI/Identity/ConfirmTurnOnBackupAndSyncModal/__snapshots__/ConfirmTurnOnBackupAndSyncModal.test.tsx.snap +++ b/app/components/UI/Identity/ConfirmTurnOnBackupAndSyncModal/__snapshots__/ConfirmTurnOnBackupAndSyncModal.test.tsx.snap @@ -189,45 +189,95 @@ exports[`ConfirmTurnOnBackupAndSyncModal renders correctly 1`] = ` } } > - Cancel - +
- Turn on - +
diff --git a/app/components/UI/Notification/Modal/index.tsx b/app/components/UI/Notification/Modal/index.tsx index 266fd4639c5..9a37c8f3cae 100644 --- a/app/components/UI/Notification/Modal/index.tsx +++ b/app/components/UI/Notification/Modal/index.tsx @@ -9,10 +9,11 @@ import Icon, { import Text, { TextVariant, } from '../../../../component-library/components/Texts/Text'; -import Button, { +import { + Button, + ButtonVariant, ButtonSize, - ButtonVariants, -} from '../../../../component-library/components/Buttons/Button'; +} from '@metamask/design-system-react-native'; import createStyles from './styles'; import { useTheme } from '../../../../util/theme'; interface ModalContentProps { @@ -75,27 +76,29 @@ const ModalContent = ({ )}
diff --git a/app/components/UI/Notification/SwitchLoadingModal/Loader.tsx b/app/components/UI/Notification/SwitchLoadingModal/Loader.tsx index abbee535eee..598532bd779 100644 --- a/app/components/UI/Notification/SwitchLoadingModal/Loader.tsx +++ b/app/components/UI/Notification/SwitchLoadingModal/Loader.tsx @@ -13,11 +13,11 @@ import Icon, { } from '../../../../component-library/components/Icons/Icon'; import Spinner from '../../AnimatedSpinner'; -import Button, { +import { + Button, + ButtonVariant, ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../component-library/components/Buttons/Button'; +} from '@metamask/design-system-react-native'; import { strings } from '../../../../../locales/i18n'; import type { ThemeColors } from '@metamask/design-tokens'; @@ -44,6 +44,7 @@ const createStyles = (colors: ThemeColors) => }, button: { alignSelf: 'center', + width: '90%', }, }); @@ -79,13 +80,13 @@ const Loader = ({ {!!errorText && ( )} ); diff --git a/app/components/Views/Notifications/Details/Footers/AnnouncementCtaFooter.tsx b/app/components/Views/Notifications/Details/Footers/AnnouncementCtaFooter.tsx index 70da1eb0ba6..072bf8316f4 100644 --- a/app/components/Views/Notifications/Details/Footers/AnnouncementCtaFooter.tsx +++ b/app/components/Views/Notifications/Details/Footers/AnnouncementCtaFooter.tsx @@ -1,9 +1,6 @@ import React from 'react'; import { Linking } from 'react-native'; -import Button, { - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; +import { Button, ButtonVariant } from '@metamask/design-system-react-native'; import { ModalFooterAnnouncementCta } from '../../../../../util/notifications/notification-states/types/NotificationModalDetails'; import useStyles from '../useStyles'; import SharedDeeplinkManager from '../../../../../core/DeeplinkManager/DeeplinkManager'; @@ -66,14 +63,15 @@ export default function AnnouncementCtaFooter( return ( ); } diff --git a/app/components/Views/Notifications/Details/Footers/BlockExplorerFooter.tsx b/app/components/Views/Notifications/Details/Footers/BlockExplorerFooter.tsx index 094c700cbda..71f4fb1dd1e 100644 --- a/app/components/Views/Notifications/Details/Footers/BlockExplorerFooter.tsx +++ b/app/components/Views/Notifications/Details/Footers/BlockExplorerFooter.tsx @@ -3,14 +3,15 @@ import React, { useMemo } from 'react'; import { Linking } from 'react-native'; import { useSelector } from 'react-redux'; import { strings } from '../../../../../../locales/i18n'; -import Button, { - ButtonVariants, -} from '../../../../../component-library/components/Buttons/Button'; +import { + Button, + ButtonVariant, + IconName as DesignSystemIconName, +} from '@metamask/design-system-react-native'; import { selectEvmNetworkConfigurationsByChainId } from '../../../../../selectors/networkController'; import { getNetworkDetailsFromNotifPayload } from '../../../../../util/notifications'; import { ModalFooterBlockExplorer } from '../../../../../util/notifications/notification-states/types/NotificationModalDetails'; import useStyles from '../useStyles'; -import { IconName } from '../../../../../component-library/components/Icons/Icon'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import onChainAnalyticProperties from '../../../../../util/notifications/methods/notification-analytics'; @@ -70,11 +71,12 @@ export default function BlockExplorerFooter(props: BlockExplorerFooterProps) { return ( ); } diff --git a/app/components/Views/Notifications/OptIn/index.tsx b/app/components/Views/Notifications/OptIn/index.tsx index bc78cd77191..0a27b8efd51 100644 --- a/app/components/Views/Notifications/OptIn/index.tsx +++ b/app/components/Views/Notifications/OptIn/index.tsx @@ -3,9 +3,7 @@ import { Image, View, Linking, ScrollView } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; -import Button, { - ButtonVariants, -} from '../../../../component-library/components/Buttons/Button'; +import { Button, ButtonVariant } from '@metamask/design-system-react-native'; import { strings } from '../../../../../locales/i18n'; import Text, { TextColor, @@ -100,19 +98,21 @@ const OptIn = () => { {!isLoading && unreadCount > 0 && ( )} ) : ( From 2737b7bd3a788e242ee3ece437ac27d4218dd84d Mon Sep 17 00:00:00 2001 From: Kirill Zyusko Date: Thu, 19 Mar 2026 19:01:16 +0100 Subject: [PATCH 154/206] feat: migrate Button component (assets scope) (#27630) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Replace Button component with DSRN package. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/DSYS-445 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/1cbd0d59-883c-440c-91bd-914b307a655a ### **After** https://github.com/user-attachments/assets/6bc13f85-7f7b-4b64-a6b5-fcc47f4763b8 ## **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. ## **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** > Moderate risk because it swaps the `Button` implementation and prop API in user-facing asset add flows and the scam warning modal, which could cause subtle layout/disabled-state regressions. Logic changes are minimal and covered by updated tests. > > **Overview** > Migrates several assets-scope screens and tests from the legacy component-library `Button` to `@metamask/design-system-react-native` `Button`, updating the prop API (e.g., `ButtonVariants`/`label`/`width` → `ButtonVariant` + children + `isFullWidth`). > > Updates `ScamWarningModal`, `AddCustomToken`, and `SearchTokenAutocomplete` to use the new button props and removes now-unneeded styling, and adjusts related tests/mocks to assert disabled/enabled behavior via `toBeDisabled()`/`toBeEnabled()` and the new button shape. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 0077d623a122ecfd7dad128a58b33df37e9a7bf1. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../UI/AssetOverview/Price/Price.test.tsx | 23 ++++++++----------- .../ScamWarningModal.test.tsx | 19 +++++++++------ .../ScamWarningModal/ScamWarningModal.tsx | 19 ++++++++------- .../AddCustomToken/AddCustomToken.test.tsx | 2 +- .../AddCustomToken/AddCustomToken.tsx | 23 +++++++++++-------- .../SearchTokenAutocomplete.test.tsx | 14 ++++------- .../SearchTokenAutocomplete.tsx | 19 ++++++++------- 7 files changed, 58 insertions(+), 61 deletions(-) diff --git a/app/components/UI/AssetOverview/Price/Price.test.tsx b/app/components/UI/AssetOverview/Price/Price.test.tsx index 8b3a6b9ef59..8bf40a5a5dc 100644 --- a/app/components/UI/AssetOverview/Price/Price.test.tsx +++ b/app/components/UI/AssetOverview/Price/Price.test.tsx @@ -6,9 +6,7 @@ import { TokenPrice, } from '../../../../components/hooks/useTokenHistoricalPrices'; import PriceChart from '../PriceChart/PriceChart'; -import Button, { - ButtonVariants, -} from '../../../../component-library/components/Buttons/Button'; +import { Button, ButtonVariant } from '@metamask/design-system-react-native'; jest.mock('../PriceChart/PriceChart', () => ({ ...jest.requireActual('../PriceChart/PriceChart'), @@ -49,16 +47,15 @@ describe('Price Component', () => { }); it('renders price at selected date', async () => { - jest - .mocked(PriceChart) - .mockImplementation(({ onChartIndexChange }) => ( - + )); const { getByTestId } = render(); diff --git a/app/components/UI/Tokens/TokenList/ScamWarningModal/ScamWarningModal.test.tsx b/app/components/UI/Tokens/TokenList/ScamWarningModal/ScamWarningModal.test.tsx index 3eaa613597f..e73fe1e01df 100644 --- a/app/components/UI/Tokens/TokenList/ScamWarningModal/ScamWarningModal.test.tsx +++ b/app/components/UI/Tokens/TokenList/ScamWarningModal/ScamWarningModal.test.tsx @@ -50,22 +50,27 @@ jest.mock('../../../../../component-library/components/Texts/Text', () => { }; }); -jest.mock('../../../../../component-library/components/Buttons/Button', () => { +jest.mock('@metamask/design-system-react-native', () => { /* eslint-disable @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ const ReactMock = require('react'); + const actual = jest.requireActual('@metamask/design-system-react-native'); return { - __esModule: true, - default: ({ label, onPress, testID }: Record) => + ...actual, + Button: ({ children, onPress }: Record) => ReactMock.createElement( 'View', { - testID: testID ?? 'edit-network-button', + testID: 'edit-network-button', onPress, + accessibilityRole: 'button', + accessible: true, }, - ReactMock.createElement('Text', {}, label), + ReactMock.createElement( + 'Text', + { accessibilityRole: 'text' }, + children, + ), ), - ButtonVariants: { Secondary: 'Secondary' }, - ButtonSize: { Lg: 'Lg' }, }; }); diff --git a/app/components/UI/Tokens/TokenList/ScamWarningModal/ScamWarningModal.tsx b/app/components/UI/Tokens/TokenList/ScamWarningModal/ScamWarningModal.tsx index 664f4a5cf2d..17acbe5f275 100644 --- a/app/components/UI/Tokens/TokenList/ScamWarningModal/ScamWarningModal.tsx +++ b/app/components/UI/Tokens/TokenList/ScamWarningModal/ScamWarningModal.tsx @@ -6,10 +6,11 @@ import { StyleSheet, View } from 'react-native'; import SheetHeader from '../../../../../component-library/components/Sheet/SheetHeader'; import { strings } from '../../../../../../locales/i18n'; import Text from '../../../../../component-library/components/Texts/Text'; -import Button, { - ButtonVariants, +import { + Button, + ButtonVariant, ButtonSize, -} from '../../../../../component-library/components/Buttons/Button'; +} from '@metamask/design-system-react-native'; import { selectEvmNetworkConfigurationsByChainId } from '../../../../../selectors/networkController'; import { useSelector } from 'react-redux'; import { useNavigation } from '@react-navigation/native'; @@ -36,9 +37,6 @@ const createStyles = (colors: Colors) => paddingTop: 0, borderWidth: 0, }, - editNetworkButton: { - width: '100%', - }, notch: { width: 40, height: 4, @@ -114,12 +112,13 @@ export const ScamWarningModal = ({ diff --git a/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.test.tsx b/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.test.tsx index e5493627b4e..1b0ee47fa10 100644 --- a/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.test.tsx +++ b/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.test.tsx @@ -149,7 +149,7 @@ describe('AddCustomToken', () => { const { getByTestId } = renderComponent(); const nextButton = getByTestId(ImportTokenViewSelectorsIDs.NEXT_BUTTON); - expect(nextButton.props.disabled || nextButton.props.isDisabled).toBe(true); + expect(nextButton).toBeDisabled(); }); it('navigates to ConfirmAddAsset when form is valid and Next is pressed', async () => { diff --git a/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx b/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx index 086d1ffdb42..4ab6912e006 100644 --- a/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx +++ b/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx @@ -9,7 +9,14 @@ import { TextInput, Platform, ActivityIndicator } from 'react-native'; import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { Box, Text, TextVariant } from '@metamask/design-system-react-native'; +import { + Box, + Text, + TextVariant, + Button, + ButtonVariant, + ButtonSize, +} from '@metamask/design-system-react-native'; import type { Hex } from '@metamask/utils'; import { useNavigation, type ParamListBase } from '@react-navigation/native'; import { StackNavigationProp } from '@react-navigation/stack'; @@ -32,11 +39,6 @@ import { } from '../../../../../util/networks'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import { formatIconUrlWithProxy } from '@metamask/assets-controllers'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; import Icon, { IconName, IconSize, @@ -547,14 +549,15 @@ const AddCustomToken = ({ ); diff --git a/app/components/Views/AddAsset/components/SearchTokenAutoComplete/SearchTokenAutocomplete.test.tsx b/app/components/Views/AddAsset/components/SearchTokenAutoComplete/SearchTokenAutocomplete.test.tsx index 55481524ade..e6cf6dbd8c2 100644 --- a/app/components/Views/AddAsset/components/SearchTokenAutoComplete/SearchTokenAutocomplete.test.tsx +++ b/app/components/Views/AddAsset/components/SearchTokenAutoComplete/SearchTokenAutocomplete.test.tsx @@ -307,7 +307,7 @@ describe('SearchTokenAutocomplete', () => { it('next button is disabled when no tokens are selected', () => { const { getByTestId } = renderComponent(); const nextButton = getByTestId(ImportTokenViewSelectorsIDs.NEXT_BUTTON); - expect(nextButton).toHaveProp('disabled', true); + expect(nextButton).toBeDisabled(); }); it('enables Next button after selecting a token', () => { @@ -320,7 +320,7 @@ describe('SearchTokenAutocomplete', () => { fireEvent.press(tokenResult); const nextButton = getByTestId(ImportTokenViewSelectorsIDs.NEXT_BUTTON); - expect(nextButton).toHaveProp('disabled', false); + expect(nextButton).toBeEnabled(); }); it('shows clear button when search has text and clears on press', () => { @@ -354,16 +354,10 @@ describe('SearchTokenAutocomplete', () => { ); fireEvent.press(tokenResult); - expect(getByTestId(ImportTokenViewSelectorsIDs.NEXT_BUTTON)).toHaveProp( - 'disabled', - false, - ); + expect(getByTestId(ImportTokenViewSelectorsIDs.NEXT_BUTTON)).toBeEnabled(); fireEvent.press(tokenResult); - expect(getByTestId(ImportTokenViewSelectorsIDs.NEXT_BUTTON)).toHaveProp( - 'disabled', - true, - ); + expect(getByTestId(ImportTokenViewSelectorsIDs.NEXT_BUTTON)).toBeDisabled(); }); it('navigates to ConfirmAddAsset with correct params and tracks analytics', () => { diff --git a/app/components/Views/AddAsset/components/SearchTokenAutoComplete/SearchTokenAutocomplete.tsx b/app/components/Views/AddAsset/components/SearchTokenAutoComplete/SearchTokenAutocomplete.tsx index 2ea8d87a8e0..0e325625a7b 100644 --- a/app/components/Views/AddAsset/components/SearchTokenAutoComplete/SearchTokenAutocomplete.tsx +++ b/app/components/Views/AddAsset/components/SearchTokenAutoComplete/SearchTokenAutocomplete.tsx @@ -22,13 +22,10 @@ import { getDecimalChainId } from '../../../../../util/networks'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import Routes from '../../../../../constants/navigation/Routes'; import SearchTokenResults from '../SearchTokenResults/SearchTokenResults'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; -import { ImportTokenViewSelectorsIDs } from '../../ImportAssetView.testIds'; import { + Button, + ButtonVariant, + ButtonSize, Box, BoxFlexDirection, BoxAlignItems, @@ -38,6 +35,7 @@ import { IconSize, IconColor, } from '@metamask/design-system-react-native'; +import { ImportTokenViewSelectorsIDs } from '../../ImportAssetView.testIds'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import Logger from '../../../../../util/Logger'; import { CaipAssetType, Hex } from '@metamask/utils'; @@ -451,14 +449,15 @@ const SearchTokenAutocomplete = ({ navigation, selectedChainId }: Props) => { ); From 8287109c84f83f5491a5b83c2d415004436e94ac Mon Sep 17 00:00:00 2001 From: Davide Brocchetto Date: Thu, 19 Mar 2026 11:27:58 -0700 Subject: [PATCH 155/206] test: Fixed Swap and Bridge tests to run with SSE enabled (#27644) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Moderate risk because it changes the E2E runtime shim’s network patching (adds `expo/fetch` interception) and alters how swap/bridge quote mocks are served (SSE headers/stream format), which could affect test stability and request routing. > > **Overview** > Ensures E2E networking that uses `expo/fetch` is routed through the mock proxy by patching the `expo/src/winter/fetch/fetch` source export in `shim.js`. > > Adds `setupSSEMockRequest` to return `text/event-stream` responses via `/proxy`, and updates swap/bridge mocks (including the gasless swap smoke test) to mock `getQuoteStream` via SSE rather than JSON and to remove SSE-disabled feature-flag setups. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 40ff127fd16f105a51f897339038f82b4778f47a. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- shim.js | 25 +++ tests/api-mocking/helpers/mockHelpers.ts | 34 ++++ tests/helpers/swap/bridge-mocks.ts | 57 ++----- tests/helpers/swap/swap-mocks.ts | 196 ++++++----------------- tests/smoke/swap/gasless-swap.spec.ts | 37 ++--- 5 files changed, 134 insertions(+), 215 deletions(-) diff --git a/shim.js b/shim.js index 0b6deacea10..74afcf8b7aa 100644 --- a/shim.js +++ b/shim.js @@ -341,6 +341,31 @@ if (enableApiCallLogs || isTest) { // eslint-disable-next-line no-console console.log(`[WS Patch] Routes: ${JSON.stringify(wsRoutes)}`); } + + // Patch expo/fetch so its native networking routes through the mock proxy. + // The re-export in expo/src/winter/fetch/index.ts uses `export * from` + // which Babel compiles to a non-configurable getter. Patching the + // re-exporter's property silently fails. Instead we patch the SOURCE + // module (expo/src/winter/fetch/fetch) where `fetch` is a plain + // writable export. The re-export getter reads from the source, so + // all consumers (including bridge-controller) pick up the patched fn. + try { + const fetchSourceModule = require('expo/src/winter/fetch/fetch'); + const originalExpoFetch = fetchSourceModule.fetch; + fetchSourceModule.fetch = (url, options) => { + const proxyUrl = `${MOCKTTP_URL}/proxy?url=${encodeURIComponent(url)}`; + // eslint-disable-next-line no-console + console.log(`[E2E SHIM] expo/fetch: ${url} → ${proxyUrl}`); + return originalExpoFetch(proxyUrl, options); + }; + // eslint-disable-next-line no-console + console.log( + '[E2E SHIM] Patched expo/fetch source module to route through mock proxy', + ); + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[E2E SHIM] Failed to patch expo/fetch:', e.message); + } } })(); } diff --git a/tests/api-mocking/helpers/mockHelpers.ts b/tests/api-mocking/helpers/mockHelpers.ts index adbdac2feca..e1149352f89 100644 --- a/tests/api-mocking/helpers/mockHelpers.ts +++ b/tests/api-mocking/helpers/mockHelpers.ts @@ -219,6 +219,40 @@ export async function setupMockRequest( } } +/** + * Sets up a mock for Server-Sent Events (SSE) endpoints through the mobile proxy. + * Unlike {@link setupMockRequest}, this sends the response with + * `Content-Type: text/event-stream` so that SSE clients (e.g. expo/fetch) + * recognise the stream format. + * + * @param server - The mockttp server instance + * @param url - URL pattern to match (string or RegExp) + * @param sseBody - Pre-formatted SSE body (use `toSSEResponse()` to convert quote arrays) + * @param priority - Rule priority (default 999) + */ +export async function setupSSEMockRequest( + server: Mockttp, + url: string | RegExp, + sseBody: string, + priority = 999, +) { + await server + .forGet('/proxy') + .matching((request) => { + const decodedUrl = getDecodedProxiedURL(request.url); + if (url instanceof RegExp) { + return url.test(decodedUrl); + } + return decodedUrl.includes(String(url)); + }) + .asPriority(priority) + .thenReply(200, sseBody, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + connection: 'keep-alive', + }); +} + /** * Helper to mock a POST request with complex body matching through the mobile proxy pattern * diff --git a/tests/helpers/swap/bridge-mocks.ts b/tests/helpers/swap/bridge-mocks.ts index fd0cc18c72c..28b900f6ada 100644 --- a/tests/helpers/swap/bridge-mocks.ts +++ b/tests/helpers/swap/bridge-mocks.ts @@ -1,6 +1,9 @@ import { Mockttp } from 'mockttp'; import { TestSpecificMock } from '../../framework'; -import { setupMockRequest } from '../../api-mocking/helpers/mockHelpers'; +import { + setupMockRequest, + setupSSEMockRequest, +} from '../../api-mocking/helpers/mockHelpers'; import { setupRemoteFeatureFlagsMock } from '../../api-mocking/helpers/remoteFeatureFlagsHelper'; import { GET_TOKENS_MAINNET_RESPONSE, @@ -46,9 +49,6 @@ export const testSpecificMock: TestSpecificMock = async ( { chainId: 'eip155:59144', name: 'Linea' }, { chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', name: 'Solana' }, ], - sse: { - enabled: false, - }, }, }); // Mock Ethereum token list @@ -85,49 +85,26 @@ export const testSpecificMock: TestSpecificMock = async ( // ── SSE path (bridge-controller uses getQuoteStream for SSE streaming) ── // Catch-all for getQuoteStream — low priority fallback to prevent real network calls - await setupMockRequest( + await setupSSEMockRequest( mockServer, - { - requestMethod: 'GET', - url: /getQuoteStream/i, - response: toSSEResponse(GET_QUOTE_ETH_BASE_RESPONSE), - responseCode: 200, - }, + /getQuoteStream/i, + toSSEResponse(GET_QUOTE_ETH_BASE_RESPONSE), 1, // lower priority than specific mocks below (999) ); // Mock SSE quote response ETH(Ethereum)->SOL(Solana) - await setupMockRequest(mockServer, { - requestMethod: 'GET', - url: /getQuoteStream.*destChainId=1151111081099710/i, - response: toSSEResponse(GET_QUOTE_ETH_SOLANA_RESPONSE), - responseCode: 200, - }); + await setupSSEMockRequest( + mockServer, + /getQuoteStream.*destChainId=1151111081099710/i, + toSSEResponse(GET_QUOTE_ETH_SOLANA_RESPONSE), + ); // Mock SSE quote response ETH(Ethereum)->ETH(BASE) - await setupMockRequest(mockServer, { - requestMethod: 'GET', - url: /getQuoteStream.*destChainId=8453/i, - response: toSSEResponse(GET_QUOTE_ETH_BASE_RESPONSE), - responseCode: 200, - }); - - // ── JSON path (fallback for non-SSE quote fetching) ────────────────────── - // Mock quote response ETH(Ethereum)->SOL(Solana) - await setupMockRequest(mockServer, { - requestMethod: 'GET', - url: /\/getQuote\?.*destChainId=1151111081099710/i, - response: GET_QUOTE_ETH_SOLANA_RESPONSE, - responseCode: 200, - }); - - // Mock quote response ETH(Ethereum)->ETH(BASE) - await setupMockRequest(mockServer, { - requestMethod: 'GET', - url: /\/getQuote\?.*destChainId=8453/i, - response: GET_QUOTE_ETH_BASE_RESPONSE, - responseCode: 200, - }); + await setupSSEMockRequest( + mockServer, + /getQuoteStream.*destChainId=8453/i, + toSSEResponse(GET_QUOTE_ETH_BASE_RESPONSE), + ); // Mock popular tokens (POST - for token selector) // This combines responses from all networks as the API returns tokens for all requested chainIds diff --git a/tests/helpers/swap/swap-mocks.ts b/tests/helpers/swap/swap-mocks.ts index e4c45d6930e..e2d5e411d67 100644 --- a/tests/helpers/swap/swap-mocks.ts +++ b/tests/helpers/swap/swap-mocks.ts @@ -4,8 +4,8 @@ import { TestSpecificMock } from '../../framework'; import { interceptProxyUrl, setupMockRequest, + setupSSEMockRequest, } from '../../api-mocking/helpers/mockHelpers'; -import { setupRemoteFeatureFlagsMock } from '../../api-mocking/helpers/remoteFeatureFlagsHelper'; import { GET_QUOTE_ETH_USDC_RESPONSE, GET_QUOTE_ETH_USDC_RESPONSE_CUSTOM_SLIPPAGE, @@ -75,174 +75,73 @@ export const testSpecificMock: TestSpecificMock = async ( ) => { await setupSpotPricesMock(mockServer); - await setupRemoteFeatureFlagsMock(mockServer, { - bridgeConfigV2: { - sse: { enabled: false }, - }, - }); - - // ── SSE path (bridge-controller SSE feature flag ON) ────────────────────── // Catch-all for getQuoteStream with no slippage param (initial render before // useInitialSlippage fires). Registered first so specific mocks below at // priority 999 take precedence. Prevents real network calls that cause // Error: Aborted when the bridge controller aborts the in-flight request. - await setupMockRequest( + await setupSSEMockRequest( mockServer, - { - requestMethod: 'GET', - url: /getQuoteStream/i, - response: toSSEResponse(GET_QUOTE_ETH_USDC_RESPONSE), - responseCode: 200, - }, + /getQuoteStream/i, + toSSEResponse(GET_QUOTE_ETH_USDC_RESPONSE), 1, // lower priority than the specific mocks below (999) ); // Mock ETH->USDC with default 2% slippage (SSE) - await setupMockRequest(mockServer, { - requestMethod: 'GET', - url: /getQuoteStream.*destTokenAddress=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48.*slippage=2/i, - response: toSSEResponse(GET_QUOTE_ETH_USDC_RESPONSE), - responseCode: 200, - }); + await setupSSEMockRequest( + mockServer, + /getQuoteStream.*destTokenAddress=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48.*slippage=2/i, + toSSEResponse(GET_QUOTE_ETH_USDC_RESPONSE), + ); // Mock ETH->USDC with 3.5% custom slippage (SSE) - await setupMockRequest(mockServer, { - requestMethod: 'GET', - url: /getQuoteStream.*destTokenAddress=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48.*slippage=3\.5/i, - response: toSSEResponse(GET_QUOTE_ETH_USDC_RESPONSE_CUSTOM_SLIPPAGE), - responseCode: 200, - }); + await setupSSEMockRequest( + mockServer, + /getQuoteStream.*destTokenAddress=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48.*slippage=3\.5/i, + toSSEResponse(GET_QUOTE_ETH_USDC_RESPONSE_CUSTOM_SLIPPAGE), + ); // Mock ETH->DAI (SSE) - await setupMockRequest(mockServer, { - requestMethod: 'GET', - url: /getQuoteStream.*destTokenAddress=0x6B175474E89094C44Da98b954EedeAC495271d0F/i, - response: toSSEResponse(GET_QUOTE_ETH_DAI_RESPONSE), - responseCode: 200, - }); + await setupSSEMockRequest( + mockServer, + /getQuoteStream.*destTokenAddress=0x6B175474E89094C44Da98b954EedeAC495271d0F/i, + toSSEResponse(GET_QUOTE_ETH_DAI_RESPONSE), + ); // Mock USDC->USDT (SSE) - await setupMockRequest(mockServer, { - requestMethod: 'GET', - url: /getQuoteStream.*destTokenAddress=0xdAC17F958D2ee523a2206206994597C13D831ec7/i, - response: toSSEResponse(GET_QUOTE_USDC_USDT_RESPONSE), - responseCode: 200, - }); + await setupSSEMockRequest( + mockServer, + /getQuoteStream.*destTokenAddress=0xdAC17F958D2ee523a2206206994597C13D831ec7/i, + toSSEResponse(GET_QUOTE_USDC_USDT_RESPONSE), + ); // No quote when destination is mUSD (SSE) - await setupMockRequest(mockServer, { - requestMethod: 'GET', - url: /getQuoteStream.*destTokenAddress=0xacA92E438df0B2401fF60dA7E4337B687a2435DA/i, - response: '', - responseCode: 200, - }); + await setupSSEMockRequest( + mockServer, + /getQuoteStream.*destTokenAddress=0xacA92E438df0B2401fF60dA7E4337B687a2435DA/i, + '', + ); // Mock USDC->ETH (SSE) - await setupMockRequest(mockServer, { - requestMethod: 'GET', - url: /getQuoteStream.*srcTokenAddress=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48.*destTokenAddress=0x0000000000000000000000000000000000000000/i, - response: toSSEResponse(GET_QUOTE_USDC_ETH_RESPONSE), - responseCode: 200, - }); + await setupSSEMockRequest( + mockServer, + /getQuoteStream.*srcTokenAddress=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48.*destTokenAddress=0x0000000000000000000000000000000000000000/i, + toSSEResponse(GET_QUOTE_USDC_ETH_RESPONSE), + ); // Mock ETH->WETH (SSE) - await setupMockRequest(mockServer, { - requestMethod: 'GET', - url: /getQuoteStream.*destTokenAddress=0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/i, - response: toSSEResponse(GET_QUOTE_ETH_WETH_RESPONSE), - responseCode: 200, - }); + await setupSSEMockRequest( + mockServer, + /getQuoteStream.*destTokenAddress=0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/i, + toSSEResponse(GET_QUOTE_ETH_WETH_RESPONSE), + ); // Mock WETH->ETH (SSE) - await setupMockRequest(mockServer, { - requestMethod: 'GET', - url: /getQuoteStream.*srcTokenAddress=0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2.*destTokenAddress=0x0000000000000000000000000000000000000000/i, - response: toSSEResponse(GET_QUOTE_WETH_ETH_SAME_CHAIN_RESPONSE), - responseCode: 200, - }); - - // ── JSON path (bridge-controller SSE feature flag OFF) ───────────────────── - // The bridge controller falls back to fetchBridgeQuotes → /getQuote? (no - // "Stream" suffix) returning plain JSON when sse.enabled is false (e.g. local - // dev with BRIDGE_USE_DEV_APIS=true). Use /\/getQuote\?/i so the regex matches - // "getQuote?" but NOT "getQuoteStream?". - - // Catch-all for getQuote (no slippage / initial render) - await setupMockRequest( + await setupSSEMockRequest( mockServer, - { - requestMethod: 'GET', - url: /\/getQuote\?/i, - response: GET_QUOTE_ETH_USDC_RESPONSE, - responseCode: 200, - }, - 1, // lower priority than specific mocks below (999) + /getQuoteStream.*srcTokenAddress=0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2.*destTokenAddress=0x0000000000000000000000000000000000000000/i, + toSSEResponse(GET_QUOTE_WETH_ETH_SAME_CHAIN_RESPONSE), ); - // Mock ETH->USDC with default 2% slippage (JSON) - await setupMockRequest(mockServer, { - requestMethod: 'GET', - url: /\/getQuote\?.*destTokenAddress=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48.*slippage=2/i, - response: GET_QUOTE_ETH_USDC_RESPONSE, - responseCode: 200, - }); - - // Mock ETH->USDC with 3.5% custom slippage (JSON) - await setupMockRequest(mockServer, { - requestMethod: 'GET', - url: /\/getQuote\?.*destTokenAddress=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48.*slippage=3\.5/i, - response: GET_QUOTE_ETH_USDC_RESPONSE_CUSTOM_SLIPPAGE, - responseCode: 200, - }); - - // Mock ETH->DAI (JSON) - await setupMockRequest(mockServer, { - requestMethod: 'GET', - url: /\/getQuote\?.*destTokenAddress=0x6B175474E89094C44Da98b954EedeAC495271d0F/i, - response: GET_QUOTE_ETH_DAI_RESPONSE, - responseCode: 200, - }); - - // Mock USDC->USDT (JSON) - await setupMockRequest(mockServer, { - requestMethod: 'GET', - url: /\/getQuote\?.*destTokenAddress=0xdAC17F958D2ee523a2206206994597C13D831ec7/i, - response: GET_QUOTE_USDC_USDT_RESPONSE, - responseCode: 200, - }); - - // No quote when destination is mUSD (JSON) - await setupMockRequest(mockServer, { - requestMethod: 'GET', - url: /\/getQuote\?.*destTokenAddress=0xacA92E438df0B2401fF60dA7E4337B687a2435DA/i, - response: [], - responseCode: 200, - }); - - // Mock USDC->ETH (JSON) - await setupMockRequest(mockServer, { - requestMethod: 'GET', - url: /\/getQuote\?.*srcTokenAddress=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48.*destTokenAddress=0x0000000000000000000000000000000000000000/i, - response: GET_QUOTE_USDC_ETH_RESPONSE, - responseCode: 200, - }); - - // Mock ETH->WETH (JSON) - await setupMockRequest(mockServer, { - requestMethod: 'GET', - url: /\/getQuote\?.*destTokenAddress=0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/i, - response: GET_QUOTE_ETH_WETH_RESPONSE, - responseCode: 200, - }); - - // Mock WETH->ETH (JSON) - await setupMockRequest(mockServer, { - requestMethod: 'GET', - url: /\/getQuote\?.*srcTokenAddress=0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2.*destTokenAddress=0x0000000000000000000000000000000000000000/i, - response: GET_QUOTE_WETH_ETH_SAME_CHAIN_RESPONSE, - responseCode: 200, - }); - // Mock Ethereum token list await setupMockRequest(mockServer, { requestMethod: 'GET', @@ -276,12 +175,11 @@ export const testSpecificMock: TestSpecificMock = async ( }); // Mock USDC->GOOGLON (SSE) - await setupMockRequest(mockServer, { - requestMethod: 'GET', - url: /getQuoteStream.*destTokenAddress=0xba47214edd2bb43099611b208f75e4b42fdcfedc/i, - response: toSSEResponse(GET_QUOTE_USDC_GOOGLON_RESPONSE), - responseCode: 200, - }); + await setupSSEMockRequest( + mockServer, + /getQuoteStream.*destTokenAddress=0xba47214edd2bb43099611b208f75e4b42fdcfedc/i, + toSSEResponse(GET_QUOTE_USDC_GOOGLON_RESPONSE), + ); // Mock USDC->GOOGLON (JSON) await setupMockRequest(mockServer, { diff --git a/tests/smoke/swap/gasless-swap.spec.ts b/tests/smoke/swap/gasless-swap.spec.ts index 8271e72e37b..300e11dbe70 100644 --- a/tests/smoke/swap/gasless-swap.spec.ts +++ b/tests/smoke/swap/gasless-swap.spec.ts @@ -9,10 +9,12 @@ import { logger } from '../../framework/logger'; import { AnvilPort } from '../../framework/fixtures/FixtureUtils'; import { AnvilManager } from '../../seeder/anvil-manager'; import QuoteView from '../../page-objects/swaps/QuoteView'; -import { setupMockRequest } from '../../api-mocking/helpers/mockHelpers'; -import { GASLESS_SWAP_QUOTES_ETH_MUSD } from '../../helpers/swap/constants'; +import { setupSSEMockRequest } from '../../api-mocking/helpers/mockHelpers'; +import { + GASLESS_SWAP_QUOTES_ETH_MUSD, + toSSEResponse, +} from '../../helpers/swap/constants'; import { setupSpotPricesMock } from '../../helpers/swap/swap-mocks'; -import { setupRemoteFeatureFlagsMock } from '../../../tests/api-mocking/helpers/remoteFeatureFlagsHelper'; describe(SmokeTrade('Gasless Swap - '), (): void => { const chainId = '0x1'; @@ -52,29 +54,12 @@ describe(SmokeTrade('Gasless Swap - '), (): void => { ], testSpecificMock: async (mockServer) => { await setupSpotPricesMock(mockServer); - // Mock ETH->MUSD quote (gasless swap) - await setupMockRequest(mockServer, { - requestMethod: 'GET', - url: /getQuote.*destTokenAddress=0xacA92E438df0B2401fF60dA7E4337B687a2435DA/i, - response: GASLESS_SWAP_QUOTES_ETH_MUSD, - responseCode: 200, - }); - await setupRemoteFeatureFlagsMock(mockServer, { - bridgeConfigV2: { - sse: { enabled: false }, - }, - smartTransactionsNetworks: { - '0x1': { - mobileActiveIOS: true, - sentinelUrl: - 'https://tx-sentinel-ethereum-mainnet.api.cx.metamask.io', - expectedDeadline: 45, - maxDeadline: 160, - mobileActive: true, - mobileActiveAndroid: true, - }, - }, - }); + // Mock ETH->MUSD quote — SSE path (getQuoteStream) + await setupSSEMockRequest( + mockServer, + /getQuoteStream.*destTokenAddress=0xacA92E438df0B2401fF60dA7E4337B687a2435DA/i, + toSSEResponse(GASLESS_SWAP_QUOTES_ETH_MUSD), + ); }, restartDevice: true, endTestfn: async () => { From b38ce3b0811052d93fe2f01906518f29a417647e Mon Sep 17 00:00:00 2001 From: Alejandro Garcia Anglada Date: Thu, 19 Mar 2026 19:32:53 +0100 Subject: [PATCH 156/206] feat: compliance infrastructure (#26436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Integrates `@metamask/compliance-controller` into the mobile app to enable OFAC sanctions screening for wallet addresses. This PR sets up the full infrastructure so that any flow (Send, Swap, Bridge, Perps, etc.) can check whether a wallet address is blocked. **What this PR includes:** - **Engine registration**: Both `ComplianceService` (stateless HTTP client) and `ComplianceController` (stateful, caches blocklist) are registered in the Engine following the standard modular controller init pattern. - **Feature flag gating**: The controller is always instantiated (so its Redux state slot exists), but `init()` — which fetches the blocked wallets list from the Compliance API — only runs when the `complianceEnabled` remote feature flag is `true`. Default is `false`. - **Redux selectors**: `selectIsWalletBlocked(address)` performs a synchronous lookup against the cached blocklist. Also exposes `selectBlockedWallets`, `selectWalletComplianceStatusMap`, and `selectComplianceLastCheckedAt`. - **React hooks**: `useWalletCompliance(address)` for state + imperative API checks, and `useComplianceGate(address)` which combines the feature flag check with blocked status as a single guard for transaction flows. - **Unit tests**: 20 tests across 4 test suites covering controller init, service init, selectors, and hooks. - **Documentation**: `docs/compliance.md` with architecture overview, usage examples for all integration patterns, and testing guidance. **Architecture:** ``` RemoteFeatureFlagController (complianceEnabled) | v ComplianceController --messenger--> ComplianceService --HTTP--> Compliance API | v (state sync via Redux) Redux selectors (selectIsWalletBlocked) | v React hooks (useWalletCompliance / useComplianceGate) | v Consumer Flows (Send, Swap, Bridge, Perps, etc.) ``` **Note:** The `@metamask/compliance-controller` npm package (v1.0.0) is currently published without `dist/` build artifacts. A manual mock is included at `app/__mocks__/@metamask/compliance-controller.ts` so tests pass. TypeScript resolution errors for this package will clear once it is properly built and re-published. ## **Changelog** CHANGELOG entry: null ## **Related issues** Refs: https://github.com/MetaMask/core/tree/main/packages/compliance-controller ## **Manual testing steps** ```gherkin Feature: OFAC Compliance Infrastructure Scenario: Compliance controller initializes when feature flag is enabled Given the app is launched with complianceEnabled feature flag set to true When the Engine initializes Then the ComplianceController fetches the blocked wallets list from the Compliance API And the blocked wallets are cached in Redux state at engine.backgroundState.ComplianceController.blockedWallets Scenario: Compliance controller is dormant when feature flag is disabled Given the app is launched with complianceEnabled feature flag set to false (default) When the Engine initializes Then the ComplianceController is instantiated but does NOT call init() And no Compliance API requests are made Scenario: Blocked wallet is detected via selector Given the ComplianceController has fetched a blocklist containing address 0xBLOCKED When a component calls selectIsWalletBlocked('0xBLOCKED') Then it returns true Scenario: useComplianceGate respects feature flag Given the complianceEnabled feature flag is false When a component calls useComplianceGate('0xBLOCKED') Then isBlocked returns false regardless of cached blocklist data ``` ## **Screenshots/Recordings** Not applicable — this PR is infrastructure-only with no UI changes. ## **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. ## **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** > Adds new Engine controllers that can fetch and cache an OFAC blocklist and exposes hooks/selectors used to gate transaction flows; incorrect flag/config or init behavior could affect compliance enforcement or introduce unexpected network calls. > > **Overview** > Introduces OFAC compliance infrastructure by integrating `@metamask/compliance-controller` into the Engine as `ComplianceService` + `ComplianceController`, wiring new messengers, state change events, and including controller state in `Engine.state`. > > Adds a new remote feature flag `complianceEnabled` and gates `ComplianceController.init()` behind it (controller is still instantiated so the Redux state slot exists), plus new Redux selectors and React hooks (`useWalletCompliance`, `useComplianceGate`, `useAccountGroupCompliance`) to synchronously detect blocked addresses and optionally trigger on-demand checks. > > Updates fixtures/snapshots, feature-flag registry, CODEOWNERS, and adds unit tests + documentation (`docs/compliance.md`), along with a temporary manual mock for `@metamask/compliance-controller` to keep builds/tests passing. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit b0f3779c6915e902bc4f7cb04577382326a49e53. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Michal Szorad --- .github/CODEOWNERS | 2 + .../@metamask/compliance-controller.ts | 82 ++++++ .../hooks/useWalletCompliance.test.ts | 205 +++++++++++++++ app/components/hooks/useWalletCompliance.ts | 129 +++++++++ app/constants/featureFlags.ts | 1 + app/core/Engine/Engine.test.ts | 5 +- app/core/Engine/Engine.ts | 10 + app/core/Engine/constants.ts | 2 + .../compliance-controller-init.test.ts | 92 +++++++ .../compliance/compliance-controller-init.ts | 66 +++++ .../compliance-service-init.test.ts | 34 +++ .../compliance/compliance-service-init.ts | 25 ++ .../compliance-controller-messenger.ts | 71 +++++ .../compliance-service-messenger.ts | 27 ++ app/core/Engine/messengers/index.ts | 13 + app/core/Engine/types.ts | 22 +- app/selectors/complianceController.test.ts | 246 ++++++++++++++++++ app/selectors/complianceController.ts | 80 ++++++ .../featureFlagController/compliance.test.ts | 95 +++++++ .../featureFlagController/compliance.ts | 35 +++ .../logs/__snapshots__/index.test.ts.snap | 2 + app/util/test/initial-background-state.json | 5 +- docs/compliance.md | 229 ++++++++++++++++ package.json | 1 + tests/feature-flags/feature-flag-registry.ts | 8 + yarn.lock | 21 +- 26 files changed, 1500 insertions(+), 8 deletions(-) create mode 100644 app/__mocks__/@metamask/compliance-controller.ts create mode 100644 app/components/hooks/useWalletCompliance.test.ts create mode 100644 app/components/hooks/useWalletCompliance.ts create mode 100644 app/core/Engine/controllers/compliance/compliance-controller-init.test.ts create mode 100644 app/core/Engine/controllers/compliance/compliance-controller-init.ts create mode 100644 app/core/Engine/controllers/compliance/compliance-service-init.test.ts create mode 100644 app/core/Engine/controllers/compliance/compliance-service-init.ts create mode 100644 app/core/Engine/messengers/compliance/compliance-controller-messenger.ts create mode 100644 app/core/Engine/messengers/compliance/compliance-service-messenger.ts create mode 100644 app/selectors/complianceController.test.ts create mode 100644 app/selectors/complianceController.ts create mode 100644 app/selectors/featureFlagController/compliance.test.ts create mode 100644 app/selectors/featureFlagController/compliance.ts create mode 100644 docs/compliance.md diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d0e8b00428a..38f093a99ac 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -168,6 +168,8 @@ app/components/UI/Perps/ @MetaMask/perps app/components/UI/WalletAction/*perps* @MetaMask/perps app/core/Engine/controllers/perps-controller @MetaMask/perps app/core/Engine/messengers/perps-controller-messenger @MetaMask/perps +app/core/Engine/controllers/compliance/ @MetaMask/perps +app/core/Engine/messengers/compliance/ @MetaMask/perps app/core/DeeplinkManager/Handlers/handlePerpsUrl.ts @MetaMask/perps app/core/AgenticService/ @MetaMask/perps **/Perps/** @MetaMask/perps diff --git a/app/__mocks__/@metamask/compliance-controller.ts b/app/__mocks__/@metamask/compliance-controller.ts new file mode 100644 index 00000000000..9b8afdbd6ed --- /dev/null +++ b/app/__mocks__/@metamask/compliance-controller.ts @@ -0,0 +1,82 @@ +/** + * Manual mock for @metamask/compliance-controller. + * + * The npm package is published but dist/ artifacts may not yet be available. + * This mock provides the public API surface needed for tests and TypeScript + * compilation within the mobile repo. + */ + +export class ComplianceService { + readonly name = 'ComplianceService'; +} + +export class ComplianceController { + readonly name = 'ComplianceController'; + state: Record; + + constructor(args: Record) { + this.state = (args.state ?? {}) as Record; + } + + async init(): Promise { + // noop + } + + async checkWalletCompliance( + address: string, + ): Promise<{ address: string; blocked: boolean; checkedAt: string }> { + return { address, blocked: false, checkedAt: new Date().toISOString() }; + } + + async checkWalletsCompliance( + addresses: string[], + ): Promise<{ address: string; blocked: boolean; checkedAt: string }[]> { + return addresses.map((a) => ({ + address: a, + blocked: false, + checkedAt: new Date().toISOString(), + })); + } + + async updateBlockedWallets(): Promise<{ + addresses: string[]; + sources: { ofac: number; remote: number }; + lastUpdated: string; + fetchedAt: string; + }> { + return { + addresses: [], + sources: { ofac: 0, remote: 0 }, + lastUpdated: new Date().toISOString(), + fetchedAt: new Date().toISOString(), + }; + } + + clearComplianceState(): void { + // noop + } +} + +export function getDefaultComplianceControllerState() { + return { + walletComplianceStatusMap: {}, + blockedWallets: null, + blockedWalletsLastFetched: 0, + lastCheckedAt: null, + }; +} + +export function selectIsWalletBlocked(address: string) { + return (state: { + blockedWallets?: { addresses: string[] } | null; + walletComplianceStatusMap?: Record< + string, + { blocked: boolean } | undefined + >; + }): boolean => { + if (state.blockedWallets?.addresses.includes(address)) { + return true; + } + return state.walletComplianceStatusMap?.[address]?.blocked ?? false; + }; +} diff --git a/app/components/hooks/useWalletCompliance.test.ts b/app/components/hooks/useWalletCompliance.test.ts new file mode 100644 index 00000000000..5141fe6772f --- /dev/null +++ b/app/components/hooks/useWalletCompliance.test.ts @@ -0,0 +1,205 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useSelector } from 'react-redux'; +import { + useWalletCompliance, + useComplianceGate, + useAccountGroupCompliance, +} from './useWalletCompliance'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +const mockCheckWalletCompliance = jest.fn(); +const mockCheckWalletsCompliance = jest.fn(); + +jest.mock('../../core/Engine', () => ({ + context: { + ComplianceController: { + checkWalletCompliance: (...args: unknown[]) => + mockCheckWalletCompliance(...args), + checkWalletsCompliance: (...args: unknown[]) => + mockCheckWalletsCompliance(...args), + }, + }, +})); + +jest.mock('../../selectors/multichainAccounts/accountTreeController', () => ({ + selectSelectedAccountGroupWithInternalAccountsAddresses: jest.fn(), +})); + +const mockUseSelector = useSelector as jest.MockedFunction; + +const BLOCKED_ADDRESS = '0xBLOCKED'; +const SAFE_ADDRESS = '0xSAFE'; +const SAFE_ADDRESS_2 = '0xSAFE2'; + +describe('useWalletCompliance', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns isBlocked=true for a blocked single address', () => { + mockUseSelector.mockReturnValue(true); + + const { result } = renderHook(() => useWalletCompliance(BLOCKED_ADDRESS)); + + expect(result.current.isBlocked).toBe(true); + }); + + it('returns isBlocked=false for a safe single address', () => { + mockUseSelector.mockReturnValue(false); + + const { result } = renderHook(() => useWalletCompliance(SAFE_ADDRESS)); + + expect(result.current.isBlocked).toBe(false); + }); + + it('calls checkWalletCompliance for a single address', async () => { + mockUseSelector.mockReturnValue(false); + mockCheckWalletCompliance.mockResolvedValue({ + address: SAFE_ADDRESS, + blocked: false, + checkedAt: '2025-01-01T00:00:00Z', + }); + + const { result } = renderHook(() => useWalletCompliance(SAFE_ADDRESS)); + + await result.current.checkCompliance(); + expect(mockCheckWalletCompliance).toHaveBeenCalledWith(SAFE_ADDRESS); + expect(mockCheckWalletsCompliance).not.toHaveBeenCalled(); + }); + + it('returns isBlocked=true when any address in an array is blocked', () => { + // 1st call: selectIsWalletBlocked (single, for addresses[0]) -> false + // 2nd call: selectAreAnyWalletsBlocked (batch) -> true + mockUseSelector.mockReturnValueOnce(false).mockReturnValueOnce(true); + + const { result } = renderHook(() => + useWalletCompliance([SAFE_ADDRESS, BLOCKED_ADDRESS]), + ); + + expect(result.current.isBlocked).toBe(true); + }); + + it('returns isBlocked=false when no address in an array is blocked', () => { + mockUseSelector.mockReturnValueOnce(false).mockReturnValueOnce(false); + + const { result } = renderHook(() => + useWalletCompliance([SAFE_ADDRESS, SAFE_ADDRESS_2]), + ); + + expect(result.current.isBlocked).toBe(false); + }); + + it('calls checkWalletsCompliance for an array of addresses', async () => { + mockUseSelector.mockReturnValue(false); + mockCheckWalletsCompliance.mockResolvedValue([ + { + address: SAFE_ADDRESS, + blocked: false, + checkedAt: '2025-01-01T00:00:00Z', + }, + { + address: SAFE_ADDRESS_2, + blocked: false, + checkedAt: '2025-01-01T00:00:00Z', + }, + ]); + + const { result } = renderHook(() => + useWalletCompliance([SAFE_ADDRESS, SAFE_ADDRESS_2]), + ); + + await result.current.checkCompliance(); + expect(mockCheckWalletsCompliance).toHaveBeenCalledWith([ + SAFE_ADDRESS, + SAFE_ADDRESS_2, + ]); + expect(mockCheckWalletCompliance).not.toHaveBeenCalled(); + }); +}); + +describe('useComplianceGate', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns isBlocked=false when compliance is disabled even if address is blocked', () => { + // useComplianceGate calls useSelector: + // 1st: selectComplianceEnabled -> false + // 2nd: selectIsWalletBlocked -> true + // 3rd: selectAreAnyWalletsBlocked -> false (empty array for single) + mockUseSelector + .mockReturnValueOnce(false) // selectComplianceEnabled + .mockReturnValueOnce(true) // selectIsWalletBlocked + .mockReturnValueOnce(false); // selectAreAnyWalletsBlocked (empty) + + const { result } = renderHook(() => useComplianceGate(BLOCKED_ADDRESS)); + + expect(result.current.isComplianceEnabled).toBe(false); + expect(result.current.isBlocked).toBe(false); + }); + + it('returns isBlocked=true when compliance is enabled and address is blocked', () => { + mockUseSelector + .mockReturnValueOnce(true) // selectComplianceEnabled + .mockReturnValueOnce(true) // selectIsWalletBlocked + .mockReturnValueOnce(false); // selectAreAnyWalletsBlocked (empty) + + const { result } = renderHook(() => useComplianceGate(BLOCKED_ADDRESS)); + + expect(result.current.isComplianceEnabled).toBe(true); + expect(result.current.isBlocked).toBe(true); + }); + + it('works with array of addresses', () => { + mockUseSelector + .mockReturnValueOnce(true) // selectComplianceEnabled + .mockReturnValueOnce(false) // selectIsWalletBlocked (single for [0]) + .mockReturnValueOnce(true); // selectAreAnyWalletsBlocked (batch) + + const { result } = renderHook(() => + useComplianceGate([SAFE_ADDRESS, BLOCKED_ADDRESS]), + ); + + expect(result.current.isComplianceEnabled).toBe(true); + expect(result.current.isBlocked).toBe(true); + }); +}); + +describe('useAccountGroupCompliance', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('checks all addresses from the selected account group', () => { + // useAccountGroupCompliance calls useSelector: + // 1st: selectSelectedAccountGroupWithInternalAccountsAddresses + // 2nd: selectComplianceEnabled + // 3rd: selectIsWalletBlocked (single for [0]) + // 4th: selectAreAnyWalletsBlocked (batch) + mockUseSelector + .mockReturnValueOnce(['0xEVM', 'bc1qBTC', 'So1SOL']) // group addresses + .mockReturnValueOnce(true) // selectComplianceEnabled + .mockReturnValueOnce(false) // selectIsWalletBlocked + .mockReturnValueOnce(true); // selectAreAnyWalletsBlocked + + const { result } = renderHook(() => useAccountGroupCompliance()); + + expect(result.current.isComplianceEnabled).toBe(true); + expect(result.current.isBlocked).toBe(true); + }); + + it('returns isBlocked=false when account group has no addresses', () => { + mockUseSelector + .mockReturnValueOnce([]) // empty group + .mockReturnValueOnce(true) // selectComplianceEnabled + .mockReturnValueOnce(false) // selectIsWalletBlocked + .mockReturnValueOnce(false); // selectAreAnyWalletsBlocked + + const { result } = renderHook(() => useAccountGroupCompliance()); + + expect(result.current.isBlocked).toBe(false); + }); +}); diff --git a/app/components/hooks/useWalletCompliance.ts b/app/components/hooks/useWalletCompliance.ts new file mode 100644 index 00000000000..ba6082ae6c8 --- /dev/null +++ b/app/components/hooks/useWalletCompliance.ts @@ -0,0 +1,129 @@ +import { useSelector } from 'react-redux'; +import { useCallback, useMemo } from 'react'; +import Engine from '../../core/Engine'; +import { + selectIsWalletBlocked, + selectAreAnyWalletsBlocked, +} from '../../selectors/complianceController'; +import { selectComplianceEnabled } from '../../selectors/featureFlagController/compliance'; +import { selectSelectedAccountGroupWithInternalAccountsAddresses } from '../../selectors/multichainAccounts/accountTreeController'; + +type AddressInput = string | string[]; + +function normalizeAddresses(input: AddressInput): string[] { + return Array.isArray(input) ? input : [input]; +} + +/** + * Hook that provides compliance state and actions for one or more wallet addresses. + * + * Reads from the cached blocklist (synchronous) and exposes an imperative + * `checkCompliance` function for on-demand API checks. + * + * When given an array of addresses (e.g. from a multichain account group), + * `isBlocked` returns `true` if ANY address in the array is blocked. + * + * @param address - A single wallet address or array of addresses to check. + * @returns Object with `isBlocked` boolean and `checkCompliance` async function. + * + * @example + * ```tsx + * // Single address + * const { isBlocked } = useWalletCompliance(recipientAddress); + * + * // Multiple addresses (multichain account group) + * const { isBlocked } = useWalletCompliance(['0xEVM...', 'bc1q...', 'So1...']); + * ``` + */ +export function useWalletCompliance(address: AddressInput) { + const addressKey = Array.isArray(address) ? address.join(',') : address; + // eslint-disable-next-line react-hooks/exhaustive-deps -- addressKey is a stable scalar derived from address + const addresses = useMemo(() => normalizeAddresses(address), [addressKey]); + const isSingle = addresses.length === 1; + + const singleBlocked = useSelector(selectIsWalletBlocked(addresses[0] ?? '')); + const batchBlocked = useSelector( + selectAreAnyWalletsBlocked(isSingle ? [] : addresses), + ); + + const isBlocked = isSingle ? singleBlocked : batchBlocked; + + const checkCompliance = useCallback(async () => { + if (isSingle) { + return Engine.context.ComplianceController.checkWalletCompliance( + addresses[0], + ); + } + return Engine.context.ComplianceController.checkWalletsCompliance( + addresses, + ); + }, [addresses, isSingle]); + + return useMemo( + () => ({ isBlocked, checkCompliance }), + [isBlocked, checkCompliance], + ); +} + +/** + * Convenience hook that combines the compliance feature flag check with + * the wallet blocked status. Use this as a guard in transaction flows. + * + * When compliance is disabled via feature flag, `isBlocked` always returns + * `false` regardless of the cached blocklist. + * + * @param address - A single wallet address or array of addresses to check. + * @returns Object with `isComplianceEnabled`, `isBlocked`, and `checkCompliance`. + * + * @example + * ```tsx + * const { isComplianceEnabled, isBlocked } = useComplianceGate(recipientAddress); + * + * if (isComplianceEnabled && isBlocked) { + * // Block the transaction + * } + * ``` + */ +export function useComplianceGate(address: AddressInput) { + const isComplianceEnabled = useSelector(selectComplianceEnabled); + const { isBlocked: rawIsBlocked, checkCompliance } = + useWalletCompliance(address); + + const isBlocked = isComplianceEnabled && rawIsBlocked; + + return useMemo( + () => ({ isComplianceEnabled, isBlocked, checkCompliance }), + [isComplianceEnabled, isBlocked, checkCompliance], + ); +} + +/** + * Zero-config hook that checks compliance for all addresses in the + * currently selected account group. In multichain wallets, one group + * can contain EVM, Solana, Bitcoin, and other chain-specific addresses. + * + * Returns `isBlocked: true` if ANY address in the group is blocked. + * + * @returns Object with `isComplianceEnabled`, `isBlocked`, and `checkCompliance`. + * + * @example + * ```tsx + * const { isBlocked } = useAccountGroupCompliance(); + * + * if (isBlocked) { + * // Current account group contains a sanctioned address + * } + * ``` + */ +export function useAccountGroupCompliance() { + const addresses = useSelector( + selectSelectedAccountGroupWithInternalAccountsAddresses, + ); + const filteredAddresses = useMemo( + () => addresses.filter((addr): addr is string => addr != null), + [addresses], + ); + return useComplianceGate( + filteredAddresses.length > 0 ? filteredAddresses : [], + ); +} diff --git a/app/constants/featureFlags.ts b/app/constants/featureFlags.ts index 7cd64426b8e..c443b3f9fd6 100644 --- a/app/constants/featureFlags.ts +++ b/app/constants/featureFlags.ts @@ -14,6 +14,7 @@ export enum FeatureFlagNames { assetsDefiPositionsEnabled = 'assetsDefiPositionsEnabled', tokenDetailsV2Buttons = 'tokenDetailsV2Buttons', tokenDetailsV2ButtonLayout = 'tokenDetailsV2ButtonLayout', + complianceEnabled = 'complianceEnabled', } export const DEFAULT_FEATURE_FLAG_VALUES: Partial< diff --git a/app/core/Engine/Engine.test.ts b/app/core/Engine/Engine.test.ts index ccc39500783..5edecb14061 100644 --- a/app/core/Engine/Engine.test.ts +++ b/app/core/Engine/Engine.test.ts @@ -1221,10 +1221,11 @@ describe('Engine', () => { Engine.init(TEST_ANALYTICS_ID, {}); const controllersWithState = Object.entries(Engine.context) .filter( - ([_, controller]) => + ([controllerName, controller]) => 'state' in controller && Boolean(controller.state) && - !isEmpty(controller.state), + (!isEmpty(controller.state) || + controllerName === 'ComplianceController'), ) .map(([controllerName]) => controllerName); diff --git a/app/core/Engine/Engine.ts b/app/core/Engine/Engine.ts index eeac3b61b85..cef7f9339da 100644 --- a/app/core/Engine/Engine.ts +++ b/app/core/Engine/Engine.ts @@ -181,6 +181,8 @@ import { rampsControllerInit } from './controllers/ramps-controller/ramps-contro import { aiDigestControllerInit } from './controllers/ai-digest-controller-init'; import { cardControllerInit } from './controllers/card-controller'; import { transakServiceInit } from './controllers/ramps-controller/transak-service-init'; +import { complianceServiceInit } from './controllers/compliance/compliance-service-init'; +import { complianceControllerInit } from './controllers/compliance/compliance-controller-init'; // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -377,6 +379,8 @@ export class Engine { RampsController: rampsControllerInit, AiDigestController: aiDigestControllerInit, CardController: cardControllerInit, + ComplianceService: complianceServiceInit, + ComplianceController: complianceControllerInit, }, persistedState: initialState as EngineState, baseControllerMessenger: this.controllerMessenger, @@ -419,6 +423,8 @@ export class Engine { const rampsController = controllersByName.RampsController; const aiDigestController = controllersByName.AiDigestController; const cardController = controllersByName.CardController; + const complianceService = controllersByName.ComplianceService; + const complianceController = controllersByName.ComplianceController; // Backwards compatibility for existing references this.accountsController = accountsController; @@ -579,6 +585,8 @@ export class Engine { RampsController: rampsController, AiDigestController: aiDigestController, CardController: cardController, + ComplianceService: complianceService, + ComplianceController: complianceController, }; const childControllers = Object.assign({}, this.context); @@ -1341,6 +1349,7 @@ export default { TransactionPayController, RampsController, AiDigestController, + ComplianceController, ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) AuthenticationController, CronjobController, @@ -1412,6 +1421,7 @@ export default { RampsController: RampsController.state, AiDigestController: AiDigestController.state, CardController: CardController.state, + ComplianceController: ComplianceController.state, ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) AuthenticationController: AuthenticationController.state, CronjobController: CronjobController.state, diff --git a/app/core/Engine/constants.ts b/app/core/Engine/constants.ts index ce7a9cbdd3a..242f5afd475 100644 --- a/app/core/Engine/constants.ts +++ b/app/core/Engine/constants.ts @@ -19,6 +19,7 @@ export const STATELESS_NON_CONTROLLER_NAMES = [ 'ProfileMetricsService', 'RampsService', 'TransakService', + 'ComplianceService', ] as const; export const BACKGROUND_STATE_CHANGE_EVENT_NAMES = [ @@ -88,6 +89,7 @@ export const BACKGROUND_STATE_CHANGE_EVENT_NAMES = [ 'CardController:stateChange', 'DelegationController:stateChange', 'ProfileMetricsController:stateChange', + 'ComplianceController:stateChange', ] as const; export const swapsSupportedChainIds = [ diff --git a/app/core/Engine/controllers/compliance/compliance-controller-init.test.ts b/app/core/Engine/controllers/compliance/compliance-controller-init.test.ts new file mode 100644 index 00000000000..b0474864caf --- /dev/null +++ b/app/core/Engine/controllers/compliance/compliance-controller-init.test.ts @@ -0,0 +1,92 @@ +import { buildControllerInitRequestMock } from '../../utils/test-utils'; +import { ExtendedMessenger } from '../../../ExtendedMessenger'; +import { + getComplianceControllerMessenger, + getComplianceControllerInitMessenger, + ComplianceControllerInitMessenger, +} from '../../messengers/compliance/compliance-controller-messenger'; +import { ControllerInitRequest } from '../../types'; +import { complianceControllerInit } from './compliance-controller-init'; +import { + ComplianceController, + type ComplianceControllerMessenger, +} from '@metamask/compliance-controller'; +import { MOCK_ANY_NAMESPACE, MockAnyNamespace } from '@metamask/messenger'; + +jest.mock('react-native-device-info', () => ({ + getVersion: jest.fn().mockReturnValue('99.0.0'), +})); + +function buildComplianceFlag(enabled: boolean) { + return { enabled, minimumVersion: '0.0.0' }; +} + +function getInitRequestMock( + overrides: { + complianceEnabled?: boolean; + persistedState?: Record; + } = {}, +): jest.Mocked< + ControllerInitRequest< + ComplianceControllerMessenger, + ComplianceControllerInitMessenger + > +> { + const { complianceEnabled = false, persistedState = {} } = overrides; + + const baseMessenger = new ExtendedMessenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + baseMessenger.registerActionHandler( + // @ts-expect-error: Partial mock for feature flag state + 'RemoteFeatureFlagController:getState', + () => ({ + remoteFeatureFlags: { + complianceEnabled: buildComplianceFlag(complianceEnabled), + }, + }), + ); + + const requestMock = { + ...buildControllerInitRequestMock(baseMessenger), + controllerMessenger: getComplianceControllerMessenger(baseMessenger), + initMessenger: getComplianceControllerInitMessenger(baseMessenger), + persistedState, + }; + + return requestMock; +} + +describe('complianceControllerInit', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('instantiates the ComplianceController', () => { + const { controller } = complianceControllerInit(getInitRequestMock()); + expect(controller).toBeInstanceOf(ComplianceController); + }); + + it('calls init() when complianceEnabled feature flag is true', () => { + const initSpy = jest + .spyOn(ComplianceController.prototype, 'init') + .mockResolvedValue(undefined); + + complianceControllerInit(getInitRequestMock({ complianceEnabled: true })); + + expect(initSpy).toHaveBeenCalled(); + initSpy.mockRestore(); + }); + + it('does not call init() when complianceEnabled feature flag is false', () => { + const initSpy = jest + .spyOn(ComplianceController.prototype, 'init') + .mockResolvedValue(undefined); + + complianceControllerInit(getInitRequestMock({ complianceEnabled: false })); + + expect(initSpy).not.toHaveBeenCalled(); + initSpy.mockRestore(); + }); +}); diff --git a/app/core/Engine/controllers/compliance/compliance-controller-init.ts b/app/core/Engine/controllers/compliance/compliance-controller-init.ts new file mode 100644 index 00000000000..89667c5351c --- /dev/null +++ b/app/core/Engine/controllers/compliance/compliance-controller-init.ts @@ -0,0 +1,66 @@ +import { + ComplianceController, + type ComplianceControllerMessenger, +} from '@metamask/compliance-controller'; +import type { ControllerInitFunction } from '../../types'; +import type { ComplianceControllerInitMessenger } from '../../messengers/compliance/compliance-controller-messenger'; +import { FeatureFlagNames } from '../../../../constants/featureFlags'; +import Logger from '../../../../util/Logger'; +import { validatedVersionGatedFeatureFlag } from '../../../../util/remoteFeatureFlag'; + +/** + * Initialize the ComplianceController. + * + * The controller is always instantiated so its state slot exists, but + * `init()` (which fetches the blocked wallets list) only runs when the + * `complianceEnabled` feature flag is true. + * + * @param request - The request object. + * @param request.controllerMessenger - The messenger for the controller. + * @param request.initMessenger - The messenger for reading feature flags. + * @param request.persistedState - Persisted state to hydrate from. + * @returns The initialized ComplianceController. + */ +export const complianceControllerInit: ControllerInitFunction< + ComplianceController, + ComplianceControllerMessenger, + ComplianceControllerInitMessenger +> = ({ controllerMessenger, initMessenger, persistedState }) => { + const controller = new ComplianceController({ + messenger: controllerMessenger, + state: persistedState.ComplianceController, + }); + + const isComplianceEnabled = (): boolean => { + const remoteState = initMessenger.call( + 'RemoteFeatureFlagController:getState', + ); + const localOverride = + remoteState?.localOverrides?.[FeatureFlagNames.complianceEnabled]; + if (localOverride !== undefined) { + return Boolean(localOverride); + } + + const remoteFlag = + remoteState?.remoteFeatureFlags?.[FeatureFlagNames.complianceEnabled]; + return validatedVersionGatedFeatureFlag(remoteFlag) ?? false; + }; + + if (isComplianceEnabled()) { + controller + .init() + .then(() => { + Logger.log('ComplianceController initialized'); + }) + .catch((error: unknown) => + Logger.error( + error instanceof Error ? error : new Error(String(error)), + 'ComplianceController init failed — sanctions blocklist may be empty', + ), + ); + } else { + Logger.log('ComplianceController disabled via feature flag'); + } + + return { controller }; +}; diff --git a/app/core/Engine/controllers/compliance/compliance-service-init.test.ts b/app/core/Engine/controllers/compliance/compliance-service-init.test.ts new file mode 100644 index 00000000000..561d7a5c070 --- /dev/null +++ b/app/core/Engine/controllers/compliance/compliance-service-init.test.ts @@ -0,0 +1,34 @@ +import { buildControllerInitRequestMock } from '../../utils/test-utils'; +import { ExtendedMessenger } from '../../../ExtendedMessenger'; +import { getComplianceServiceMessenger } from '../../messengers/compliance/compliance-service-messenger'; +import { complianceServiceInit } from './compliance-service-init'; +import { + ComplianceService, + type ComplianceServiceMessenger, +} from '@metamask/compliance-controller'; +import { ControllerInitRequest } from '../../types'; +import { MOCK_ANY_NAMESPACE, MockAnyNamespace } from '@metamask/messenger'; + +function getInitRequestMock(): jest.Mocked< + ControllerInitRequest +> { + const baseMessenger = new ExtendedMessenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + return { + ...buildControllerInitRequestMock(baseMessenger), + controllerMessenger: getComplianceServiceMessenger(baseMessenger), + }; +} + +describe('complianceServiceInit', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('instantiates the ComplianceService', () => { + const { controller } = complianceServiceInit(getInitRequestMock()); + expect(controller).toBeInstanceOf(ComplianceService); + }); +}); diff --git a/app/core/Engine/controllers/compliance/compliance-service-init.ts b/app/core/Engine/controllers/compliance/compliance-service-init.ts new file mode 100644 index 00000000000..050ebf47c6d --- /dev/null +++ b/app/core/Engine/controllers/compliance/compliance-service-init.ts @@ -0,0 +1,25 @@ +import { + ComplianceService, + type ComplianceServiceMessenger, +} from '@metamask/compliance-controller'; +import type { ControllerInitFunction } from '../../types'; + +/** + * Initialize the ComplianceService. + * + * @param request - The request object. + * @param request.controllerMessenger - The messenger to use for the service. + * @returns The initialized ComplianceService. + */ +export const complianceServiceInit: ControllerInitFunction< + ComplianceService, + ComplianceServiceMessenger +> = ({ controllerMessenger }) => { + const controller = new ComplianceService({ + messenger: controllerMessenger, + fetch, + env: __DEV__ ? 'development' : 'production', + }); + + return { controller }; +}; diff --git a/app/core/Engine/messengers/compliance/compliance-controller-messenger.ts b/app/core/Engine/messengers/compliance/compliance-controller-messenger.ts new file mode 100644 index 00000000000..92a69a2b8fe --- /dev/null +++ b/app/core/Engine/messengers/compliance/compliance-controller-messenger.ts @@ -0,0 +1,71 @@ +import { + Messenger, + type MessengerActions, + type MessengerEvents, +} from '@metamask/messenger'; +import type { ComplianceControllerMessenger } from '@metamask/compliance-controller'; +import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; +import type { RootMessenger } from '../../types'; + +/** + * Get the messenger for the ComplianceController. + * + * Delegates ComplianceService actions so the controller can call + * the service through the messenger. + * + * @param rootMessenger - The root messenger. + * @returns The ComplianceControllerMessenger. + */ +export function getComplianceControllerMessenger( + rootMessenger: RootMessenger, +): ComplianceControllerMessenger { + const messenger = new Messenger< + 'ComplianceController', + MessengerActions, + MessengerEvents, + RootMessenger + >({ + namespace: 'ComplianceController', + parent: rootMessenger, + }); + rootMessenger.delegate({ + actions: [ + 'ComplianceService:checkWalletCompliance', + 'ComplianceService:checkWalletsCompliance', + 'ComplianceService:updateBlockedWallets', + ], + messenger, + }); + return messenger; +} + +export type ComplianceControllerInitMessenger = ReturnType< + typeof getComplianceControllerInitMessenger +>; + +/** + * Get the init messenger for the ComplianceController. + * + * Provides access to RemoteFeatureFlagController state for feature flag checks. + * + * @param rootMessenger - The root messenger. + * @returns The ComplianceControllerInitMessenger. + */ +export function getComplianceControllerInitMessenger( + rootMessenger: RootMessenger, +) { + const messenger = new Messenger< + 'ComplianceControllerInit', + RemoteFeatureFlagControllerGetStateAction, + never, + RootMessenger + >({ + namespace: 'ComplianceControllerInit', + parent: rootMessenger, + }); + rootMessenger.delegate({ + actions: ['RemoteFeatureFlagController:getState'], + messenger, + }); + return messenger; +} diff --git a/app/core/Engine/messengers/compliance/compliance-service-messenger.ts b/app/core/Engine/messengers/compliance/compliance-service-messenger.ts new file mode 100644 index 00000000000..b972a7b08de --- /dev/null +++ b/app/core/Engine/messengers/compliance/compliance-service-messenger.ts @@ -0,0 +1,27 @@ +import { + Messenger, + type MessengerActions, + type MessengerEvents, +} from '@metamask/messenger'; +import type { ComplianceServiceMessenger } from '@metamask/compliance-controller'; +import type { RootMessenger } from '../../types'; + +/** + * Get the messenger for the ComplianceService. + * + * @param rootMessenger - The root messenger. + * @returns The ComplianceServiceMessenger. + */ +export function getComplianceServiceMessenger( + rootMessenger: RootMessenger, +): ComplianceServiceMessenger { + return new Messenger< + 'ComplianceService', + MessengerActions, + MessengerEvents, + RootMessenger + >({ + namespace: 'ComplianceService', + parent: rootMessenger, + }); +} diff --git a/app/core/Engine/messengers/index.ts b/app/core/Engine/messengers/index.ts index 7a8ca246490..aec7a81818a 100644 --- a/app/core/Engine/messengers/index.ts +++ b/app/core/Engine/messengers/index.ts @@ -152,6 +152,11 @@ import { getProfileMetricsServiceMessenger } from './profile-metrics-service-mes import { getAnalyticsControllerMessenger } from './analytics-controller-messenger'; import { getAiDigestControllerMessenger } from './ai-digest-controller-messenger'; import { getCardControllerMessenger } from './card-controller-messenger'; +import { getComplianceServiceMessenger } from './compliance/compliance-service-messenger'; +import { + getComplianceControllerMessenger, + getComplianceControllerInitMessenger, +} from './compliance/compliance-controller-messenger'; /** * The messengers for the controllers that have been. @@ -475,4 +480,12 @@ export const CONTROLLER_MESSENGERS = { getMessenger: getCardControllerMessenger, getInitMessenger: noop, }, + ComplianceService: { + getMessenger: getComplianceServiceMessenger, + getInitMessenger: noop, + }, + ComplianceController: { + getMessenger: getComplianceControllerMessenger, + getInitMessenger: getComplianceControllerInitMessenger, + }, } as const; diff --git a/app/core/Engine/types.ts b/app/core/Engine/types.ts index 932e03518fe..2af99d23182 100644 --- a/app/core/Engine/types.ts +++ b/app/core/Engine/types.ts @@ -428,6 +428,15 @@ import { AiDigestControllerEvents, AiDigestControllerState, } from '@metamask/ai-controllers'; +import { + ComplianceController, + ComplianceControllerActions, + ComplianceControllerEvents, + ComplianceControllerState, + ComplianceService, + ComplianceServiceActions, + ComplianceServiceEvents, +} from '@metamask/compliance-controller'; /** * Controllers that area always instantiated @@ -440,6 +449,7 @@ type RequiredControllers = Omit< | 'RewardsDataService' | 'SnapKeyringBuilder' | 'StorageService' + | 'ComplianceService' >; /** @@ -453,6 +463,7 @@ type OptionalControllers = Pick< | 'RewardsDataService' | 'SnapKeyringBuilder' | 'StorageService' + | 'ComplianceService' >; type PermissionsByRpcMethod = ReturnType; @@ -552,6 +563,8 @@ type GlobalActions = | RampsControllerActions | RampsServiceActions | AiDigestControllerActions + | ComplianceControllerActions + | ComplianceServiceActions | TransakServiceActions; type GlobalEvents = @@ -631,6 +644,8 @@ type GlobalEvents = | RampsControllerEvents | RampsServiceEvents | AiDigestControllerEvents + | ComplianceControllerEvents + | ComplianceServiceEvents | TransakServiceEvents; /** @@ -752,6 +767,8 @@ export type Controllers = { ProfileMetricsService: ProfileMetricsService; RampsService: RampsService; AiDigestController: AiDigestController; + ComplianceService: ComplianceService; + ComplianceController: ComplianceController; TransakService: TransakService; }; @@ -834,6 +851,7 @@ export type EngineState = { DelegationController: DelegationControllerState; ProfileMetricsController: ProfileMetricsControllerState; AiDigestController: AiDigestControllerState; + ComplianceController: ComplianceControllerState; }; /** Controller names */ @@ -945,7 +963,9 @@ export type ControllersToInitialize = | 'ProfileMetricsController' | 'ProfileMetricsService' | 'AnalyticsController' - | 'AiDigestController'; + | 'AiDigestController' + | 'ComplianceService' + | 'ComplianceController'; /** * Callback that returns a controller messenger for a specific controller. diff --git a/app/selectors/complianceController.test.ts b/app/selectors/complianceController.test.ts new file mode 100644 index 00000000000..5404777952d --- /dev/null +++ b/app/selectors/complianceController.test.ts @@ -0,0 +1,246 @@ +import { + selectBlockedWallets, + selectIsWalletBlocked, + selectAreAnyWalletsBlocked, + selectWalletComplianceStatusMap, + selectComplianceLastCheckedAt, +} from './complianceController'; + +const BLOCKED_ADDRESS = '0xBLOCKED'; +const BLOCKED_ADDRESS_2 = '0xBLOCKED2'; +const SAFE_ADDRESS = '0xSAFE'; +const SAFE_ADDRESS_2 = '0xSAFE2'; + +function buildState(complianceState?: Record) { + return { + engine: { + backgroundState: { + ComplianceController: complianceState, + }, + }, + } as Parameters>[0]; +} + +describe('complianceController selectors', () => { + describe('selectBlockedWallets', () => { + it('returns null when blockedWallets is not set', () => { + const state = buildState({ + blockedWallets: null, + walletComplianceStatusMap: {}, + blockedWalletsLastFetched: 0, + lastCheckedAt: null, + }); + expect(selectBlockedWallets(state)).toBeNull(); + }); + + it('returns the blocked wallets info when populated', () => { + const blockedWallets = { + addresses: [BLOCKED_ADDRESS], + sources: { ofac: 1, remote: 0 }, + lastUpdated: '2025-01-01T00:00:00Z', + fetchedAt: '2025-01-01T00:00:00Z', + }; + const state = buildState({ + blockedWallets, + walletComplianceStatusMap: {}, + blockedWalletsLastFetched: 1000, + lastCheckedAt: null, + }); + expect(selectBlockedWallets(state)).toEqual(blockedWallets); + }); + }); + + describe('selectIsWalletBlocked', () => { + it('returns true when address is in the blocklist', () => { + const state = buildState({ + blockedWallets: { + addresses: [BLOCKED_ADDRESS], + sources: { ofac: 1, remote: 0 }, + lastUpdated: '2025-01-01T00:00:00Z', + fetchedAt: '2025-01-01T00:00:00Z', + }, + walletComplianceStatusMap: {}, + blockedWalletsLastFetched: 1000, + lastCheckedAt: null, + }); + + expect(selectIsWalletBlocked(BLOCKED_ADDRESS)(state)).toBe(true); + }); + + it('returns false when address is not blocked', () => { + const state = buildState({ + blockedWallets: { + addresses: [BLOCKED_ADDRESS], + sources: { ofac: 1, remote: 0 }, + lastUpdated: '2025-01-01T00:00:00Z', + fetchedAt: '2025-01-01T00:00:00Z', + }, + walletComplianceStatusMap: {}, + blockedWalletsLastFetched: 1000, + lastCheckedAt: null, + }); + + expect(selectIsWalletBlocked(SAFE_ADDRESS)(state)).toBe(false); + }); + + it('falls back to walletComplianceStatusMap when blocklist is null', () => { + const state = buildState({ + blockedWallets: null, + walletComplianceStatusMap: { + [BLOCKED_ADDRESS]: { + address: BLOCKED_ADDRESS, + blocked: true, + checkedAt: '2025-01-01T00:00:00Z', + }, + }, + blockedWalletsLastFetched: 0, + lastCheckedAt: '2025-01-01T00:00:00Z', + }); + + expect(selectIsWalletBlocked(BLOCKED_ADDRESS)(state)).toBe(true); + }); + + it('returns false when controller state is undefined', () => { + const state = buildState(undefined); + expect(selectIsWalletBlocked(BLOCKED_ADDRESS)(state)).toBe(false); + }); + + it('returns the same selector instance for the same address (memoized)', () => { + expect(selectIsWalletBlocked(BLOCKED_ADDRESS)).toBe( + selectIsWalletBlocked(BLOCKED_ADDRESS), + ); + expect(selectIsWalletBlocked(SAFE_ADDRESS)).not.toBe( + selectIsWalletBlocked(BLOCKED_ADDRESS), + ); + }); + }); + + describe('selectAreAnyWalletsBlocked', () => { + it('returns true when one of the addresses is blocked', () => { + const state = buildState({ + blockedWallets: { + addresses: [BLOCKED_ADDRESS], + sources: { ofac: 1, remote: 0 }, + lastUpdated: '2025-01-01T00:00:00Z', + fetchedAt: '2025-01-01T00:00:00Z', + }, + walletComplianceStatusMap: {}, + blockedWalletsLastFetched: 1000, + lastCheckedAt: null, + }); + + expect( + selectAreAnyWalletsBlocked([SAFE_ADDRESS, BLOCKED_ADDRESS])(state), + ).toBe(true); + }); + + it('returns false when none of the addresses is blocked', () => { + const state = buildState({ + blockedWallets: { + addresses: [BLOCKED_ADDRESS], + sources: { ofac: 1, remote: 0 }, + lastUpdated: '2025-01-01T00:00:00Z', + fetchedAt: '2025-01-01T00:00:00Z', + }, + walletComplianceStatusMap: {}, + blockedWalletsLastFetched: 1000, + lastCheckedAt: null, + }); + + expect( + selectAreAnyWalletsBlocked([SAFE_ADDRESS, SAFE_ADDRESS_2])(state), + ).toBe(false); + }); + + it('returns true when multiple addresses are blocked', () => { + const state = buildState({ + blockedWallets: { + addresses: [BLOCKED_ADDRESS, BLOCKED_ADDRESS_2], + sources: { ofac: 2, remote: 0 }, + lastUpdated: '2025-01-01T00:00:00Z', + fetchedAt: '2025-01-01T00:00:00Z', + }, + walletComplianceStatusMap: {}, + blockedWalletsLastFetched: 1000, + lastCheckedAt: null, + }); + + expect( + selectAreAnyWalletsBlocked([BLOCKED_ADDRESS, BLOCKED_ADDRESS_2])(state), + ).toBe(true); + }); + + it('returns false for empty addresses array', () => { + const state = buildState({ + blockedWallets: { + addresses: [BLOCKED_ADDRESS], + sources: { ofac: 1, remote: 0 }, + lastUpdated: '2025-01-01T00:00:00Z', + fetchedAt: '2025-01-01T00:00:00Z', + }, + walletComplianceStatusMap: {}, + blockedWalletsLastFetched: 1000, + lastCheckedAt: null, + }); + + expect(selectAreAnyWalletsBlocked([])(state)).toBe(false); + }); + + it('returns false when controller state is undefined', () => { + const state = buildState(undefined); + expect(selectAreAnyWalletsBlocked([BLOCKED_ADDRESS])(state)).toBe(false); + }); + + it('returns the same selector instance for the same address set (memoized, order-independent)', () => { + const sel1 = selectAreAnyWalletsBlocked([SAFE_ADDRESS, BLOCKED_ADDRESS]); + const sel2 = selectAreAnyWalletsBlocked([BLOCKED_ADDRESS, SAFE_ADDRESS]); + expect(sel1).toBe(sel2); + }); + }); + + describe('selectWalletComplianceStatusMap', () => { + it('returns the status map', () => { + const statusMap = { + [SAFE_ADDRESS]: { + address: SAFE_ADDRESS, + blocked: false, + checkedAt: '2025-01-01T00:00:00Z', + }, + }; + const state = buildState({ + blockedWallets: null, + walletComplianceStatusMap: statusMap, + blockedWalletsLastFetched: 0, + lastCheckedAt: null, + }); + expect(selectWalletComplianceStatusMap(state)).toEqual(statusMap); + }); + + it('returns empty object when state is undefined', () => { + const state = buildState(undefined); + expect(selectWalletComplianceStatusMap(state)).toEqual({}); + }); + }); + + describe('selectComplianceLastCheckedAt', () => { + it('returns the last checked timestamp', () => { + const state = buildState({ + blockedWallets: null, + walletComplianceStatusMap: {}, + blockedWalletsLastFetched: 0, + lastCheckedAt: '2025-06-15T10:00:00Z', + }); + expect(selectComplianceLastCheckedAt(state)).toBe('2025-06-15T10:00:00Z'); + }); + + it('returns null when not yet checked', () => { + const state = buildState({ + blockedWallets: null, + walletComplianceStatusMap: {}, + blockedWalletsLastFetched: 0, + lastCheckedAt: null, + }); + expect(selectComplianceLastCheckedAt(state)).toBeNull(); + }); + }); +}); diff --git a/app/selectors/complianceController.ts b/app/selectors/complianceController.ts new file mode 100644 index 00000000000..8cd554c6126 --- /dev/null +++ b/app/selectors/complianceController.ts @@ -0,0 +1,80 @@ +import { createSelector, type Selector } from 'reselect'; +import { memoize } from 'lodash'; +import { RootState } from '../reducers'; +import { selectIsWalletBlocked as coreSelectIsWalletBlocked } from '@metamask/compliance-controller'; + +const selectComplianceControllerState = (state: RootState) => + state.engine.backgroundState.ComplianceController; + +/** + * Select the full blocked wallets info object, or null if not yet fetched. + */ +export const selectBlockedWallets = createSelector( + selectComplianceControllerState, + (state) => state?.blockedWallets ?? null, +); + +/** + * Memoized factory: same (address) returns the same selector instance so reselect caching works. + */ +const getSelectIsWalletBlocked = memoize((address: string) => + createSelector(selectComplianceControllerState, (state) => + state ? coreSelectIsWalletBlocked(address)(state) : false, + ), +); + +/** + * Create a selector that returns whether a specific wallet address is blocked. + * + * Checks the proactively fetched blocklist first, then falls back to + * the per-address compliance status map. Selector instances are cached per address. + * + * @param address - The wallet address to check. + * @returns A selector returning `true` if blocked, `false` otherwise. + */ +export const selectIsWalletBlocked = ( + address: string, +): Selector => getSelectIsWalletBlocked(address); + +/** + * Memoized factory: same address set (order-independent) returns the same selector instance. + * Inner loop uses coreSelectIsWalletBlocked(addr)(state) because (state) here is the + * compliance controller slice, not root state. + */ +const getSelectAreAnyWalletsBlocked = memoize( + (addresses: string[]) => + createSelector(selectComplianceControllerState, (state) => { + if (!state || addresses.length === 0) return false; + return addresses.some((addr) => coreSelectIsWalletBlocked(addr)(state)); + }), + (addresses: string[]) => + [...addresses].sort((a, b) => a.localeCompare(b)).join(','), +); + +/** + * Create a selector that returns whether ANY of the given addresses is blocked. + * Useful for multichain account groups where one group has addresses across + * multiple chains (EVM, Solana, Bitcoin, etc.). Selector instances are cached per address set. + * + * @param addresses - The wallet addresses to check. + * @returns A selector returning `true` if any address is blocked. + */ +export const selectAreAnyWalletsBlocked = ( + addresses: string[], +): Selector => getSelectAreAnyWalletsBlocked(addresses); + +/** + * Select the per-address compliance status map. + */ +export const selectWalletComplianceStatusMap = createSelector( + selectComplianceControllerState, + (state) => state?.walletComplianceStatusMap ?? {}, +); + +/** + * Select the timestamp of the last compliance check. + */ +export const selectComplianceLastCheckedAt = createSelector( + selectComplianceControllerState, + (state) => state?.lastCheckedAt ?? null, +); diff --git a/app/selectors/featureFlagController/compliance.test.ts b/app/selectors/featureFlagController/compliance.test.ts new file mode 100644 index 00000000000..6a40788205e --- /dev/null +++ b/app/selectors/featureFlagController/compliance.test.ts @@ -0,0 +1,95 @@ +import { selectComplianceEnabled } from './compliance'; +import { FeatureFlagNames } from '../../constants/featureFlags'; +import { hasMinimumRequiredVersion } from '../../util/remoteFeatureFlag'; + +jest.mock('react-native-device-info', () => ({ + getVersion: jest.fn().mockReturnValue('1.0.0'), +})); + +jest.mock('../../util/remoteFeatureFlag', () => ({ + ...jest.requireActual('../../util/remoteFeatureFlag'), + hasMinimumRequiredVersion: jest.fn(), +})); + +describe('selectComplianceEnabled', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(hasMinimumRequiredVersion).mockReturnValue(true); + }); + + it('returns true when remote flag is valid and enabled', () => { + const result = selectComplianceEnabled.resultFunc({ + [FeatureFlagNames.complianceEnabled]: { + enabled: true, + minimumVersion: '1.0.0', + }, + }); + + expect(result).toBe(true); + }); + + it('returns false when remote flag is valid but disabled', () => { + const result = selectComplianceEnabled.resultFunc({ + [FeatureFlagNames.complianceEnabled]: { + enabled: false, + minimumVersion: '1.0.0', + }, + }); + + expect(result).toBe(false); + }); + + it('returns false when version check fails', () => { + jest.mocked(hasMinimumRequiredVersion).mockReturnValue(false); + + const result = selectComplianceEnabled.resultFunc({ + [FeatureFlagNames.complianceEnabled]: { + enabled: true, + minimumVersion: '99.0.0', + }, + }); + + expect(result).toBe(false); + }); + + it('returns false when remote flag is invalid', () => { + const result = selectComplianceEnabled.resultFunc({ + [FeatureFlagNames.complianceEnabled]: { + enabled: 'invalid', + minimumVersion: 123, + } as unknown as { enabled: boolean; minimumVersion: string }, + }); + + expect(result).toBe(false); + }); + + it('returns false when remote feature flags are empty', () => { + const result = selectComplianceEnabled.resultFunc({}); + + expect(result).toBe(false); + }); + + it('returns false when complianceEnabled property is missing', () => { + const result = selectComplianceEnabled.resultFunc({ + someOtherFlag: true, + }); + + expect(result).toBe(false); + }); + + it('returns true when boolean local override is true (aligns with init)', () => { + const result = selectComplianceEnabled.resultFunc({ + [FeatureFlagNames.complianceEnabled]: true, + }); + + expect(result).toBe(true); + }); + + it('returns false when boolean local override is false (aligns with init)', () => { + const result = selectComplianceEnabled.resultFunc({ + [FeatureFlagNames.complianceEnabled]: false, + }); + + expect(result).toBe(false); + }); +}); diff --git a/app/selectors/featureFlagController/compliance.ts b/app/selectors/featureFlagController/compliance.ts new file mode 100644 index 00000000000..7a7cc5c2d9d --- /dev/null +++ b/app/selectors/featureFlagController/compliance.ts @@ -0,0 +1,35 @@ +import { createSelector } from 'reselect'; +import { hasProperty } from '@metamask/utils'; +import { selectRemoteFeatureFlags } from './index'; +import { FeatureFlagNames } from '../../constants/featureFlags'; +import { + validatedVersionGatedFeatureFlag, + type VersionGatedFeatureFlag, +} from '../../util/remoteFeatureFlag'; + +const DEFAULT_COMPLIANCE_ENABLED = false; + +/** + * Select whether OFAC compliance checking is enabled via feature flag. + * Handles version-gated flag shape: `{ enabled: boolean, minimumVersion: string }` + * and boolean local overrides (same as complianceControllerInit) so init and UI stay in sync. + */ +export const selectComplianceEnabled = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => { + if (!hasProperty(remoteFeatureFlags, FeatureFlagNames.complianceEnabled)) { + return DEFAULT_COMPLIANCE_ENABLED; + } + const rawFlag = remoteFeatureFlags[FeatureFlagNames.complianceEnabled]; + + // Boolean local overrides (dev tools): align with complianceControllerInit + if (typeof rawFlag === 'boolean') { + return rawFlag; + } + + const remoteFlag = rawFlag as unknown as VersionGatedFeatureFlag; + return ( + validatedVersionGatedFeatureFlag(remoteFlag) ?? DEFAULT_COMPLIANCE_ENABLED + ); + }, +); diff --git a/app/util/logs/__snapshots__/index.test.ts.snap b/app/util/logs/__snapshots__/index.test.ts.snap index 5ff1130d0ca..a3d577c9449 100644 --- a/app/util/logs/__snapshots__/index.test.ts.snap +++ b/app/util/logs/__snapshots__/index.test.ts.snap @@ -76,6 +76,7 @@ exports[`logs :: generateStateLogs Sanitized SeedlessOnboardingController State "providerData": {}, "selectedCountry": null, }, + "ComplianceController": {}, "ConnectivityController": { "connectivityStatus": "online", }, @@ -941,6 +942,7 @@ exports[`logs :: generateStateLogs generates a valid json export 1`] = ` "providerData": {}, "selectedCountry": null, }, + "ComplianceController": {}, "ConnectivityController": { "connectivityStatus": "online", }, diff --git a/app/util/test/initial-background-state.json b/app/util/test/initial-background-state.json index 639bfd5eba5..f77d37a42e6 100644 --- a/app/util/test/initial-background-state.json +++ b/app/util/test/initial-background-state.json @@ -301,7 +301,6 @@ "0x1": true, "0x5": true, "0x38": true, - "0x3e7": true, "0x61": true, "0xa": true, "0xa869": true, @@ -320,7 +319,8 @@ "0x507": true, "0x505": true, "0x64": true, - "0x531": true + "0x531": true, + "0x3e7": true }, "isIpfsGatewayEnabled": true, "smartTransactionsOptInStatus": true, @@ -686,6 +686,7 @@ "cardholderAccounts": [], "providerData": {} }, + "ComplianceController": {}, "ConnectivityController": { "connectivityStatus": "online" }, diff --git a/docs/compliance.md b/docs/compliance.md new file mode 100644 index 00000000000..afaedf505b8 --- /dev/null +++ b/docs/compliance.md @@ -0,0 +1,229 @@ +# OFAC Compliance + +MetaMask Mobile integrates the `@metamask/compliance-controller` package to enforce OFAC sanctions screening on wallet addresses. This document explains the architecture, feature flag setup, and how to use compliance checks in any flow. + +## Architecture + +The compliance system is composed of two Engine-level modules: + +- **`ComplianceService`** -- Stateless HTTP client that communicates with the Compliance API (`compliance.api.cx.metamask.io` in production, `compliance.dev-api.cx.metamask.io` in development). +- **`ComplianceController`** -- Stateful controller that caches the blocked wallets list and per-address compliance results. It persists its state across app restarts. + +``` +┌──────────────────────────┐ +│ RemoteFeatureFlagCtrl │ +│ (complianceEnabled) │ +└──────────┬───────────────┘ + │ feature flag check + ▼ +┌──────────────────────────┐ messenger ┌──────────────────────────┐ +│ ComplianceController │ ───────────────► │ ComplianceService │ +│ (state + cache) │ │ (HTTP client) │ +└──────────┬───────────────┘ └──────────────────────────┘ + │ state sync via Redux + ▼ +┌──────────────────────────┐ +│ Redux selectors │ +│ selectIsWalletBlocked │ +└──────────┬───────────────┘ + │ + ▼ +┌──────────────────────────┐ +│ React hooks │ +│ useWalletCompliance │ +│ useComplianceGate │ +└──────────┬───────────────┘ + │ + ▼ + Consumer Flows + (Send, Swap, Bridge, Perps, etc.) +``` + +## Feature Flag + +Compliance is gated behind the `complianceEnabled` remote feature flag. When the flag is `false` (the default): + +- The `ComplianceController` is still instantiated (so its state slot exists in Redux), but `init()` is not called -- no API requests are made. +- `useComplianceGate` returns `isBlocked: false` regardless of cached data. + +To enable compliance: + +1. Set `complianceEnabled: true` in LaunchDarkly (or via the local feature flag override screen in dev builds). +2. The controller will fetch the blocked wallets list on next app launch. + +### Feature flag selector + +```typescript +import { selectComplianceEnabled } from 'app/selectors/featureFlagController/compliance'; + +const isEnabled = useSelector(selectComplianceEnabled); +``` + +## Usage in Flows + +### Option 1: `useComplianceGate` hook (recommended for flow guards) + +This is the simplest way to gate a flow. It combines the feature flag check with the blocked status: + +```tsx +import { useComplianceGate } from 'app/components/hooks/useWalletCompliance'; + +function SendConfirmation({ recipientAddress }: { recipientAddress: string }) { + const { isComplianceEnabled, isBlocked } = + useComplianceGate(recipientAddress); + + if (isComplianceEnabled && isBlocked) { + return ; + } + + return ; +} +``` + +### Option 2: `useWalletCompliance` hook (for detailed control) + +Use this when you need the imperative `checkCompliance` function for on-demand API checks: + +```tsx +import { useWalletCompliance } from 'app/components/hooks/useWalletCompliance'; + +function AddressInput({ address }: { address: string }) { + const { isBlocked, checkCompliance } = useWalletCompliance(address); + + const handleSubmit = async () => { + // Force a fresh API check + const result = await checkCompliance(); + if (result.blocked) { + // handle blocked + } + }; + + return ( + <> + {isBlocked && } + + + ); +} +``` + +### Option 3: Redux selectors (for non-component code) + +```typescript +import { selectIsWalletBlocked } from 'app/selectors/complianceController'; +import { store } from 'app/store'; + +const isBlocked = selectIsWalletBlocked('0x1234...')(store.getState()); +``` + +### Option 4: Direct Engine access (for controller-to-controller or middleware code) + +```typescript +import Engine from 'app/core/Engine'; + +// Check a single wallet +const status = + await Engine.context.ComplianceController.checkWalletCompliance('0x1234...'); + +// Check multiple wallets +const statuses = + await Engine.context.ComplianceController.checkWalletsCompliance([ + '0xaaaa...', + '0xbbbb...', + ]); + +// Force refresh the blocklist +await Engine.context.ComplianceController.updateBlockedWallets(); +``` + +## How the Blocklist Works + +1. On app launch (when compliance is enabled), `ComplianceController.init()` fetches the full blocked wallets list from the API if the cached list is stale (older than 1 hour by default). +2. The list is persisted to Redux state at `engine.backgroundState.ComplianceController.blockedWallets`. +3. `selectIsWalletBlocked(address)` performs a **synchronous** lookup against this cached list -- no API call at check time. +4. If the address is not in the cached blocklist, the selector falls back to the `walletComplianceStatusMap` which stores results from on-demand `checkWalletCompliance()` calls. + +## State Shape + +```typescript +type ComplianceControllerState = { + // Cached results from on-demand per-address checks + walletComplianceStatusMap: Record< + string, + { + address: string; + blocked: boolean; + checkedAt: string; // ISO-8601 + } + >; + + // Full blocked wallets list from the API (null if not fetched) + blockedWallets: { + addresses: string[]; + sources: { ofac: number; remote: number }; + lastUpdated: string; + fetchedAt: string; + } | null; + + // Timestamp of last blocklist fetch (ms since epoch) + blockedWalletsLastFetched: number; + + // ISO-8601 timestamp of last compliance check + lastCheckedAt: string | null; +}; +``` + +## Testing + +### Mocking compliance state in unit tests + +The package has a manual mock at `app/__mocks__/@metamask/compliance-controller.ts` that provides the full public API surface. + +To test components that use compliance hooks: + +```typescript +import { useSelector } from 'react-redux'; + +jest.mock('react-redux', () => ({ useSelector: jest.fn() })); + +const mockUseSelector = useSelector as jest.MockedFunction; + +// For useComplianceGate: +mockUseSelector + .mockReturnValueOnce(true) // selectComplianceEnabled + .mockReturnValueOnce(true); // selectIsWalletBlocked +``` + +### Mocking in fixture-based tests + +Add compliance state to your test fixture: + +```typescript +new FixtureBuilder() + .withComplianceController({ + walletComplianceStatusMap: {}, + blockedWallets: { + addresses: ['0xBLOCKED'], + sources: { ofac: 1, remote: 0 }, + lastUpdated: '2025-01-01T00:00:00Z', + fetchedAt: '2025-01-01T00:00:00Z', + }, + blockedWalletsLastFetched: Date.now(), + lastCheckedAt: new Date().toISOString(), + }) + .build(); +``` + +## File Reference + +| File | Purpose | +| -------------------------------------------------------------------------- | --------------------------------------------------- | +| `app/constants/featureFlags.ts` | `complianceEnabled` flag definition | +| `app/core/Engine/controllers/compliance/compliance-service-init.ts` | Service initialization | +| `app/core/Engine/controllers/compliance/compliance-controller-init.ts` | Controller initialization with feature flag gating | +| `app/core/Engine/messengers/compliance/compliance-service-messenger.ts` | Service messenger setup | +| `app/core/Engine/messengers/compliance/compliance-controller-messenger.ts` | Controller + init messenger setup | +| `app/selectors/complianceController.ts` | Redux selectors | +| `app/selectors/featureFlagController/compliance.ts` | Feature flag selector | +| `app/components/hooks/useWalletCompliance.ts` | `useWalletCompliance` and `useComplianceGate` hooks | +| `app/__mocks__/@metamask/compliance-controller.ts` | Manual mock for tests | diff --git a/package.json b/package.json index b18f96349f5..3b088af877e 100644 --- a/package.json +++ b/package.json @@ -217,6 +217,7 @@ "@metamask/bridge-controller": "^69.1.1", "@metamask/bridge-status-controller": "^68.1.0", "@metamask/chain-agnostic-permission": "^1.3.0", + "@metamask/compliance-controller": "^1.0.1", "@metamask/connectivity-controller": "^0.1.0", "@metamask/controller-utils": "^11.18.0", "@metamask/core-backend": "^5.0.0", diff --git a/tests/feature-flags/feature-flag-registry.ts b/tests/feature-flags/feature-flag-registry.ts index 4ec5c6f3312..f37172447ce 100644 --- a/tests/feature-flags/feature-flag-registry.ts +++ b/tests/feature-flags/feature-flag-registry.ts @@ -734,6 +734,14 @@ export const FEATURE_FLAG_REGISTRY: Record = { status: FeatureFlagStatus.Active, }, + complianceEnabled: { + name: 'complianceEnabled', + type: FeatureFlagType.Remote, + inProd: true, + productionDefault: false, + status: FeatureFlagStatus.Active, + }, + config_registry_api_enabled: { name: 'config_registry_api_enabled', type: FeatureFlagType.Remote, diff --git a/yarn.lock b/yarn.lock index f9bda4a5ec3..c37bb543ce0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7994,6 +7994,20 @@ __metadata: languageName: node linkType: hard +"@metamask/compliance-controller@npm:^1.0.1": + version: 1.0.1 + resolution: "@metamask/compliance-controller@npm:1.0.1" + dependencies: + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/controller-utils": "npm:^11.19.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/utils": "npm:^11.9.0" + reselect: "npm:^5.1.1" + checksum: 10/5847e60715cf6896d7dc496d9cd6a68b0b0e6a9515e732a682b4dbb8741a8d864bd78754f7ca00e040d220612177206afafd77d477986046be002867961516fe + languageName: node + linkType: hard + "@metamask/connectivity-controller@npm:^0.1.0": version: 0.1.0 resolution: "@metamask/connectivity-controller@npm:0.1.0" @@ -20489,9 +20503,9 @@ __metadata: linkType: hard "@wdio/protocols@npm:^9.24.0": - version: 9.24.0 - resolution: "@wdio/protocols@npm:9.24.0" - checksum: 10/b5478e70dbd0f5294115334c833455032433eff57b62c7355ec6ca01d15f56fb16bc0c5d62acb3c6f66b8360d33af71b8b66d7a9e2e76d684ec45dd0bb318f1b + version: 9.25.0 + resolution: "@wdio/protocols@npm:9.25.0" + checksum: 10/b794d6c7b9636a2ee07bf098fef5c4e5b9c3c7ccbd768afda3acc12bfc3edfc4a5281505e8fd20449036732d0df665c06d45facf72673fb7168f63cb26dd23c5 languageName: node linkType: hard @@ -35517,6 +35531,7 @@ __metadata: "@metamask/browser-playground": "npm:0.3.0" "@metamask/build-utils": "npm:^3.0.0" "@metamask/chain-agnostic-permission": "npm:^1.3.0" + "@metamask/compliance-controller": "npm:^1.0.1" "@metamask/connectivity-controller": "npm:^0.1.0" "@metamask/controller-utils": "npm:^11.18.0" "@metamask/core-backend": "npm:^5.0.0" From 7c278276719e0914fcae970d53638d31784c9fc5 Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Thu, 19 Mar 2026 21:22:35 +0000 Subject: [PATCH 157/206] [skip ci] Bump version number to 4105 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 97165e04944..1bc0963af64 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 3607 + versionCode 4105 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index c8f848cf5bd..53ee5e63a6d 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 3911 + VERSION_NUMBER: 4105 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 3911 + FLASK_VERSION_NUMBER: 4105 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 6726013ffa4..88b91c62638 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3911; + CURRENT_PROJECT_VERSION = 4105; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3911; + CURRENT_PROJECT_VERSION = 4105; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3911; + CURRENT_PROJECT_VERSION = 4105; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3911; + CURRENT_PROJECT_VERSION = 4105; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3911; + CURRENT_PROJECT_VERSION = 4105; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3911; + CURRENT_PROJECT_VERSION = 4105; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From d620dc5e032e30e29c6f463b5a54046cac352bb1 Mon Sep 17 00:00:00 2001 From: Bryan Fullam Date: Fri, 20 Mar 2026 09:24:59 +0100 Subject: [PATCH 158/206] cherry-pick of #27690: feat: Add A/B test for bridge token selector balance layout (#27714) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Cherry pick swaps a/b test that barely missed RC cutoff. This PR adds an A/B test for the bridge token selector balance layout. Control keeps the current presentation by showing fiat balance on the top row and keeping the ticker in the token balance text. Treatment moves the token balance to the top row, removes the duplicate ticker from the token balance text, and keeps the top and bottom rows aligned with the intended size and color hierarchy. The PR also passes the active experiment through the bridge page-view and submit analytics paths using `active_ab_tests` so the treatment can be evaluated against downstream conversion metrics. ## **Changelog** CHANGELOG entry: Added an experiment for the bridge token selector balance layout. ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Bridge token selector balance layout experiment Scenario: User sees the control layout in the bridge token selector Given the token selector balance layout experiment is in the control variant And the user opens the Bridge flow When the token selector list is shown Then the fiat balance is shown on the top row And the token balance is shown on the bottom row with the ticker included Scenario: User sees the treatment layout in the bridge token selector Given the token selector balance layout experiment is in the treatment variant And the user opens the Bridge flow When the token selector list is shown Then the token balance is shown on the top row without the duplicate ticker And the fiat balance is shown on the bottom row Scenario: Analytics include the active experiment Given the token selector balance layout experiment is active When the user opens the Bridge flow And submits a bridge quote Then the relevant page-view and submit analytics payloads include active_ab_tests for the active experiment ``` ## **Screenshots/Recordings** ### **Before** Control variant: Screenshot 2026-03-18 at 19 11 16 ### **After** Treatment variant: Screenshot 2026-03-19 at 18 24 35 ## **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. ## **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** > Medium risk because it changes bridge token list balance rendering and threads new `activeAbTests` metadata through bridge submission/page-view analytics, which could affect UI correctness and controller call signatures if mismatched. > > **Overview** > Adds a new A/B experiment (`swapsSWAPS4242AbtestTokenSelectorBalanceLayout`) that toggles the bridge token selector’s balance layout: *control* keeps fiat-on-top with token balance including ticker, while *treatment* shows token balance first and can omit the ticker. > > Updates bridge analytics and submission paths to include an `active_ab_tests`/`activeAbTests` array when experiments are active (now aggregating both the existing numpad quick actions test and the new token selector test), with new/updated unit tests covering the variant-driven UI ordering and the forwarded experiment metadata. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit fb210460eafefd04d0684425d031b24bc996c31c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). ## **Description** ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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. --- .../TokenSelectorItem.abTestConfig.ts | 26 ++++ .../components/TokenSelectorItem.test.tsx | 67 +++++++++ .../Bridge/components/TokenSelectorItem.tsx | 139 ++++++++++++++---- .../hooks/useTrackSwapPageViewed/index.ts | 51 +++++-- .../bridge/hooks/useSubmitBridgeTx.test.tsx | 101 +++++++++++++ app/util/bridge/hooks/useSubmitBridgeTx.ts | 45 ++++++ 6 files changed, 390 insertions(+), 39 deletions(-) create mode 100644 app/components/UI/Bridge/components/TokenSelectorItem.abTestConfig.ts diff --git a/app/components/UI/Bridge/components/TokenSelectorItem.abTestConfig.ts b/app/components/UI/Bridge/components/TokenSelectorItem.abTestConfig.ts new file mode 100644 index 00000000000..17b2db140d8 --- /dev/null +++ b/app/components/UI/Bridge/components/TokenSelectorItem.abTestConfig.ts @@ -0,0 +1,26 @@ +export const TOKEN_SELECTOR_BALANCE_LAYOUT_AB_KEY = + 'swapsSWAPS4242AbtestTokenSelectorBalanceLayout'; + +export enum TokenSelectorBalanceLayoutVariant { + Control = 'control', + Treatment = 'treatment', +} + +interface TokenSelectorBalanceLayoutConfig { + showTokenBalanceFirst: boolean; + removeTickerFromTokenBalance: boolean; +} + +export const TOKEN_SELECTOR_BALANCE_LAYOUT_VARIANTS: Record< + TokenSelectorBalanceLayoutVariant, + TokenSelectorBalanceLayoutConfig +> = { + [TokenSelectorBalanceLayoutVariant.Control]: { + showTokenBalanceFirst: false, + removeTickerFromTokenBalance: false, + }, + [TokenSelectorBalanceLayoutVariant.Treatment]: { + showTokenBalanceFirst: true, + removeTickerFromTokenBalance: true, + }, +}; diff --git a/app/components/UI/Bridge/components/TokenSelectorItem.test.tsx b/app/components/UI/Bridge/components/TokenSelectorItem.test.tsx index 5f03b70aaa4..53529bd157a 100644 --- a/app/components/UI/Bridge/components/TokenSelectorItem.test.tsx +++ b/app/components/UI/Bridge/components/TokenSelectorItem.test.tsx @@ -1,7 +1,9 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; +import { Text as RNText } from 'react-native'; import { TokenSelectorItem } from './TokenSelectorItem'; import { ethers } from 'ethers'; +import { useABTest } from '../../../../hooks'; import { createMockTokenWithBalance } from '../testUtils/fixtures'; import { TOKEN_BALANCE_LOADING, @@ -13,6 +15,10 @@ jest.mock('react-redux', () => ({ useSelector: jest.fn(() => []), })); +jest.mock('../../../../hooks', () => ({ + useABTest: jest.fn(), +})); + jest.mock('../../../../../locales/i18n', () => ({ strings: (key: string) => { const translations: Record = { @@ -91,9 +97,18 @@ jest.mock('../../../../component-library/components/Tags/Tag', () => { describe('TokenSelectorItem', () => { const mockOnPress = jest.fn(); + const mockUseABTest = jest.mocked(useABTest); beforeEach(() => { jest.clearAllMocks(); + mockUseABTest.mockReturnValue({ + variant: { + showTokenBalanceFirst: false, + removeTickerFromTokenBalance: false, + }, + variantName: 'control', + isActive: false, + }); }); describe('rendering', () => { @@ -382,4 +397,56 @@ describe('TokenSelectorItem', () => { expect(fiatBalanceElement.props.numberOfLines).toBe(1); }); }); + + describe('A/B variants', () => { + it('keeps fiat above token balance in the control layout', () => { + const token = createMockTokenWithBalance({ + balance: '50.0', + balanceFiat: '$500', + symbol: 'USDC', + }); + + const controlRender = render( + , + ); + expect(controlRender.getByText('50 USDC')).toBeOnTheScreen(); + + const controlTextOrder = controlRender + .UNSAFE_getAllByType(RNText) + .map((textNode) => String(textNode.props.children)); + expect(controlTextOrder.indexOf('$500')).toBeLessThan( + controlTextOrder.indexOf('50 USDC'), + ); + }); + + it('shows token balance first without the ticker in the treatment layout', () => { + mockUseABTest.mockReturnValue({ + variant: { + showTokenBalanceFirst: true, + removeTickerFromTokenBalance: true, + }, + variantName: 'treatment', + isActive: true, + }); + + const token = createMockTokenWithBalance({ + balance: '50.0', + balanceFiat: '$500', + symbol: 'USDC', + }); + + const treatmentRender = render( + , + ); + expect(treatmentRender.getByText('50')).toBeOnTheScreen(); + expect(treatmentRender.queryByText('50 USDC')).not.toBeOnTheScreen(); + + const treatmentTextOrder = treatmentRender + .UNSAFE_getAllByType(RNText) + .map((textNode) => String(textNode.props.children)); + expect(treatmentTextOrder.indexOf('50')).toBeLessThan( + treatmentTextOrder.indexOf('$500'), + ); + }); + }); }); diff --git a/app/components/UI/Bridge/components/TokenSelectorItem.tsx b/app/components/UI/Bridge/components/TokenSelectorItem.tsx index cf6f6881a44..be71f41ce6f 100644 --- a/app/components/UI/Bridge/components/TokenSelectorItem.tsx +++ b/app/components/UI/Bridge/components/TokenSelectorItem.tsx @@ -5,6 +5,8 @@ import { View, TouchableOpacity, Platform, + StyleProp, + TextStyle, } from 'react-native'; import { useSelector } from 'react-redux'; import { strings } from '../../../../../locales/i18n'; @@ -46,6 +48,12 @@ import { ACCOUNT_TYPE_LABELS } from '../../../../constants/account-type-labels'; import parseAmount from '../../../../util/parseAmount'; import { getTokenImageSource } from '../utils'; import { useRWAToken } from '../hooks/useRWAToken'; +import { useABTest } from '../../../../hooks'; +import { + TOKEN_SELECTOR_BALANCE_LAYOUT_AB_KEY, + TOKEN_SELECTOR_BALANCE_LAYOUT_VARIANTS, + TokenSelectorBalanceLayoutVariant, +} from './TokenSelectorItem.abTestConfig'; const createStyles = ({ theme, @@ -136,12 +144,22 @@ interface TokenSelectorItemProps { isNoFeeAsset?: boolean; } +const isLoadingBalance = (balance?: string) => + balance === TOKEN_BALANCE_LOADING || + balance === TOKEN_BALANCE_LOADING_UPPERCASE; + const FiatBalanceView = ({ balance, isSelected, + textStyle, + textVariant, + textColor, }: { balance?: string; isSelected: boolean; + textStyle?: StyleProp; + textVariant: TextVariant; + textColor: TextColor; }) => { const { styles } = useStyles(createStyles, { isSelected }); @@ -149,18 +167,51 @@ const FiatBalanceView = ({ return null; } - if ( - balance === TOKEN_BALANCE_LOADING || - balance === TOKEN_BALANCE_LOADING_UPPERCASE - ) { + if (isLoadingBalance(balance)) { + return ; + } + + return ( + + {balance} + + ); +}; + +const TokenBalanceView = ({ + balance, + isSelected, + textStyle, + textVariant, + textColor, +}: { + balance?: string; + isSelected: boolean; + textStyle?: StyleProp; + textVariant: TextVariant; + textColor: TextColor; +}) => { + const { styles } = useStyles(createStyles, { isSelected }); + + if (!balance) { + return null; + } + + if (isLoadingBalance(balance)) { return ; } return ( {balance} @@ -178,6 +229,10 @@ export const TokenSelectorItem: React.FC = ({ isNoFeeAsset = false, }) => { const { styles } = useStyles(createStyles, { isSelected }); + const { variant } = useABTest( + TOKEN_SELECTOR_BALANCE_LAYOUT_AB_KEY, + TOKEN_SELECTOR_BALANCE_LAYOUT_VARIANTS, + ); const noFeeAssets = useSelector((state: RootState) => selectNoFeeAssets(state, token.chainId), ); @@ -197,8 +252,18 @@ export const TokenSelectorItem: React.FC = ({ return parseAmount(balance, 5) || balance; }; - const cryptoBalance = token.balance - ? `${formatTokenBalance(token.balance)} ${token.symbol}` + const selectedVariant = + variant ?? + TOKEN_SELECTOR_BALANCE_LAYOUT_VARIANTS[ + TokenSelectorBalanceLayoutVariant.Control + ]; + const formattedTokenBalance = token.balance + ? formatTokenBalance(token.balance) + : undefined; + const cryptoBalance = formattedTokenBalance + ? selectedVariant.removeTickerFromTokenBalance + ? formattedTokenBalance + : `${formattedTokenBalance} ${token.symbol}` : undefined; const isNative = token.address === ethers.constants.AddressZero; @@ -206,8 +271,16 @@ export const TokenSelectorItem: React.FC = ({ // to check if the token is a stock by checking if the name includes 'ondo' or 'stock' const { isStockToken } = useRWAToken(); - const balance = shouldShowBalance ? fiatValue : undefined; - const secondaryBalance = shouldShowBalance ? cryptoBalance : undefined; + const fiatBalance = shouldShowBalance ? fiatValue : undefined; + const tokenBalance = shouldShowBalance ? cryptoBalance : undefined; + const topRowBalanceTextStyle = { + textVariant: TextVariant.BodyMDMedium, + textColor: TextColor.Default, + }; + const bottomRowBalanceTextStyle = { + textVariant: TextVariant.BodyMD, + textColor: TextColor.Alternative, + }; const label = token.accountType ? ACCOUNT_TYPE_LABELS[token.accountType] @@ -291,21 +364,21 @@ export const TokenSelectorItem: React.FC = ({ )} - {secondaryBalance ? ( - secondaryBalance === TOKEN_BALANCE_LOADING || - secondaryBalance === TOKEN_BALANCE_LOADING_UPPERCASE ? ( - - ) : ( - - {secondaryBalance} - - ) - ) : null} + {selectedVariant.showTokenBalanceFirst ? ( + + ) : ( + + )} = ({ {token.name} - + {selectedVariant.showTokenBalanceFirst ? ( + + ) : ( + + )} {isStockToken(token as BridgeToken) && } diff --git a/app/components/UI/Bridge/hooks/useTrackSwapPageViewed/index.ts b/app/components/UI/Bridge/hooks/useTrackSwapPageViewed/index.ts index 26fe449e1bb..758bf428b42 100644 --- a/app/components/UI/Bridge/hooks/useTrackSwapPageViewed/index.ts +++ b/app/components/UI/Bridge/hooks/useTrackSwapPageViewed/index.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { useSelector } from 'react-redux'; import { useABTest } from '../../../../../hooks'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; @@ -13,15 +13,46 @@ import { NUMPAD_QUICK_ACTIONS_AB_KEY, NUMPAD_QUICK_ACTIONS_VARIANTS, } from '../../components/GaslessQuickPickOptions/abTestConfig'; +import { + TOKEN_SELECTOR_BALANCE_LAYOUT_AB_KEY, + TOKEN_SELECTOR_BALANCE_LAYOUT_VARIANTS, +} from '../../components/TokenSelectorItem.abTestConfig'; export const useTrackSwapPageViewed = () => { const { trackEvent, createEventBuilder } = useAnalytics(); const sourceToken = useSelector(selectSourceToken); const destToken = useSelector(selectDestToken); const abTestContext = useSelector(selectAbTestContext); - const { variantName, isActive } = useABTest( - NUMPAD_QUICK_ACTIONS_AB_KEY, - NUMPAD_QUICK_ACTIONS_VARIANTS, + const { variantName: numpadVariantName, isActive: isNumpadAbActive } = + useABTest(NUMPAD_QUICK_ACTIONS_AB_KEY, NUMPAD_QUICK_ACTIONS_VARIANTS); + const { + variantName: tokenSelectorVariantName, + isActive: isTokenSelectorAbActive, + } = useABTest( + TOKEN_SELECTOR_BALANCE_LAYOUT_AB_KEY, + TOKEN_SELECTOR_BALANCE_LAYOUT_VARIANTS, + ); + + const activeABTests = useMemo( + () => [ + ...(isNumpadAbActive + ? [{ key: NUMPAD_QUICK_ACTIONS_AB_KEY, value: numpadVariantName }] + : []), + ...(isTokenSelectorAbActive + ? [ + { + key: TOKEN_SELECTOR_BALANCE_LAYOUT_AB_KEY, + value: tokenSelectorVariantName, + }, + ] + : []), + ], + [ + isNumpadAbActive, + numpadVariantName, + isTokenSelectorAbActive, + tokenSelectorVariantName, + ], ); const hasTrackedPageView = useRef(false); @@ -44,13 +75,8 @@ export const useTrackSwapPageViewed = () => { abTestContext.assetsASSETS2493AbtestTokenDetailsLayout, }, }), - ...(isActive && { - active_ab_tests: [ - { - key: NUMPAD_QUICK_ACTIONS_AB_KEY, - value: variantName, - }, - ], + ...(activeABTests.length > 0 && { + active_ab_tests: activeABTests, }), }; trackEvent( @@ -64,8 +90,7 @@ export const useTrackSwapPageViewed = () => { destToken, trackEvent, createEventBuilder, - isActive, - variantName, + activeABTests, abTestContext, ]); }; diff --git a/app/util/bridge/hooks/useSubmitBridgeTx.test.tsx b/app/util/bridge/hooks/useSubmitBridgeTx.test.tsx index 33eee482723..b17aa823315 100644 --- a/app/util/bridge/hooks/useSubmitBridgeTx.test.tsx +++ b/app/util/bridge/hooks/useSubmitBridgeTx.test.tsx @@ -12,8 +12,14 @@ import { QuoteMetadata, QuoteResponse } from '@metamask/bridge-controller'; import { backgroundState } from '../../test/initial-root-state'; import { TransactionMeta } from '@metamask/transaction-controller'; import { selectSourceWalletAddress } from '../../../selectors/bridge'; +import { useABTest } from '../../../hooks'; type BridgeQuoteResponse = QuoteResponse & QuoteMetadata; +interface MockABTestResult { + variant: unknown; + variantName: string; + isActive: boolean; +} let mockSubmitTx: jest.Mock< Promise, @@ -97,11 +103,37 @@ jest.mock('../../../selectors/bridge', () => ({ ), })); +jest.mock('../../../hooks', () => ({ + useABTest: jest.fn(), +})); + const mockStore = configureMockStore(); +const inactiveABTestResult: MockABTestResult = { + variant: undefined, + variantName: 'control', + isActive: false, +}; describe('useSubmitBridgeTx', () => { + const mockABTests = ({ + first = inactiveABTestResult, + second = inactiveABTestResult, + }: { + first?: MockABTestResult; + second?: MockABTestResult; + } = {}) => { + jest + .mocked(useABTest) + .mockReset() + .mockReturnValue(inactiveABTestResult) + .mockReturnValueOnce(first) + .mockReturnValueOnce(second); + }; + beforeEach(() => { jest.clearAllMocks(); + // Default every test to the non-experiment path unless it opts in. + mockABTests(); }); const createWrapper = (mockState = {}) => { @@ -179,6 +211,7 @@ describe('useSubmitBridgeTx', () => { undefined, undefined, undefined, + undefined, ); expect(txResult).toEqual({ chainId: '0x1', @@ -190,6 +223,46 @@ describe('useSubmitBridgeTx', () => { from: '0x1234567890123456789012345678901234567890', }, }); + + // Re-render with an active assignment to verify submitTx forwards activeAbTests. + mockABTests({ + second: { + variant: {}, + variantName: 'treatment', + isActive: true, + }, + }); + mockSubmitTx.mockResolvedValueOnce({ + chainId: '0x1', + id: '2', + networkClientId: '1', + status: 'submitted', + time: Date.now(), + txParams: { + from: '0x1234567890123456789012345678901234567890', + }, + } as TransactionMeta); + + const { result: activeResult } = renderHook(() => useSubmitBridgeTx(), { + wrapper: createWrapper(), + }); + + await activeResult.current.submitBridgeTx({ + quoteResponse: mockQuoteResponse as BridgeQuoteResponse, + }); + + expect(mockSubmitTx).toHaveBeenLastCalledWith( + '0x1234567890123456789012345678901234567890', + { + ...mockQuoteResponse, + approval: undefined, + }, + true, + undefined, + undefined, + undefined, + [{ key: expect.any(String), value: 'treatment' }], + ); }); it('should handle bridge transaction with approval', async () => { @@ -227,6 +300,7 @@ describe('useSubmitBridgeTx', () => { undefined, undefined, undefined, + undefined, ); expect(txResult).toEqual({ chainId: '0x1', @@ -427,6 +501,33 @@ describe('useSubmitBridgeTx', () => { accountAddress: '0x1234567890123456789012345678901234567890', location: undefined, abTests: undefined, + activeAbTests: undefined, + }); + + // Re-render with an active assignment to verify submitIntent forwards activeAbTests. + mockABTests({ + second: { + variant: {}, + variantName: 'treatment', + isActive: true, + }, + }); + mockSubmitIntent.mockResolvedValueOnce(mockIntentResult); + + const { result: activeResult } = renderHook(() => useSubmitBridgeTx(), { + wrapper: createWrapper(), + }); + + await activeResult.current.submitBridgeTx({ + quoteResponse: mockQuoteResponse, + }); + + expect(mockSubmitIntent).toHaveBeenLastCalledWith({ + quoteResponse: mockQuoteResponse, + accountAddress: '0x1234567890123456789012345678901234567890', + location: undefined, + abTests: undefined, + activeAbTests: [{ key: expect.any(String), value: 'treatment' }], }); expect(mockSubmitTx).not.toHaveBeenCalled(); expect(txResult).toEqual(mockIntentResult); diff --git a/app/util/bridge/hooks/useSubmitBridgeTx.ts b/app/util/bridge/hooks/useSubmitBridgeTx.ts index 735beaf5c1d..4a7c3d46577 100644 --- a/app/util/bridge/hooks/useSubmitBridgeTx.ts +++ b/app/util/bridge/hooks/useSubmitBridgeTx.ts @@ -8,11 +8,30 @@ import { useSelector } from 'react-redux'; import { selectShouldUseSmartTransaction } from '../../../selectors/smartTransactionsController'; import { selectSourceWalletAddress } from '../../../selectors/bridge'; import { selectAbTestContext } from '../../../core/redux/slices/bridge'; +import { useABTest } from '../../../hooks'; +import { + NUMPAD_QUICK_ACTIONS_AB_KEY, + NUMPAD_QUICK_ACTIONS_VARIANTS, +} from '../../../components/UI/Bridge/components/GaslessQuickPickOptions/abTestConfig'; +import { + TOKEN_SELECTOR_BALANCE_LAYOUT_AB_KEY, + TOKEN_SELECTOR_BALANCE_LAYOUT_VARIANTS, +} from '../../../components/UI/Bridge/components/TokenSelectorItem.abTestConfig'; +import { useMemo } from 'react'; export default function useSubmitBridgeTx() { const stxEnabled = useSelector(selectShouldUseSmartTransaction); const walletAddress = useSelector(selectSourceWalletAddress); const abTestContext = useSelector(selectAbTestContext); + const { variantName: numpadVariantName, isActive: isNumpadAbActive } = + useABTest(NUMPAD_QUICK_ACTIONS_AB_KEY, NUMPAD_QUICK_ACTIONS_VARIANTS); + const { + variantName: tokenSelectorVariantName, + isActive: isTokenSelectorAbActive, + } = useABTest( + TOKEN_SELECTOR_BALANCE_LAYOUT_AB_KEY, + TOKEN_SELECTOR_BALANCE_LAYOUT_VARIANTS, + ); const abTests = abTestContext?.assetsASSETS2493AbtestTokenDetailsLayout ? { @@ -20,6 +39,30 @@ export default function useSubmitBridgeTx() { abTestContext.assetsASSETS2493AbtestTokenDetailsLayout, } : undefined; + const activeAbTests = useMemo(() => { + const tests: { key: string; value: string }[] = []; + + if (isNumpadAbActive) { + tests.push({ + key: NUMPAD_QUICK_ACTIONS_AB_KEY, + value: numpadVariantName, + }); + } + + if (isTokenSelectorAbActive) { + tests.push({ + key: TOKEN_SELECTOR_BALANCE_LAYOUT_AB_KEY, + value: tokenSelectorVariantName, + }); + } + + return tests.length > 0 ? tests : undefined; + }, [ + isNumpadAbActive, + numpadVariantName, + isTokenSelectorAbActive, + tokenSelectorVariantName, + ]); const submitBridgeTx = async ({ quoteResponse, @@ -40,6 +83,7 @@ export default function useSubmitBridgeTx() { accountAddress: walletAddress, location, abTests, + activeAbTests, }); } return Engine.context.BridgeStatusController.submitTx( @@ -52,6 +96,7 @@ export default function useSubmitBridgeTx() { undefined, // quotesReceivedContext location, abTests, + activeAbTests, ); }; From 058316b9e103592873810844ebc4e2d3b9977f4a Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Fri, 20 Mar 2026 08:26:30 +0000 Subject: [PATCH 159/206] [skip ci] Bump version number to 4116 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 1bc0963af64..862cdc30894 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4105 + versionCode 4116 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 53ee5e63a6d..43ad0bfd14b 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4105 + VERSION_NUMBER: 4116 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4105 + FLASK_VERSION_NUMBER: 4116 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 88b91c62638..b2a1b2576fd 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4105; + CURRENT_PROJECT_VERSION = 4116; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4105; + CURRENT_PROJECT_VERSION = 4116; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4105; + CURRENT_PROJECT_VERSION = 4116; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4105; + CURRENT_PROJECT_VERSION = 4116; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4105; + CURRENT_PROJECT_VERSION = 4116; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4105; + CURRENT_PROJECT_VERSION = 4116; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 979ebf4dab9071a30fe576dd12fd6eaad12e7c87 Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 09:08:53 +0000 Subject: [PATCH 160/206] chore(runway): cherry-pick chore: Exempt `metamaskbotv2` from CLA check cp-7.71.0 (#27763) - chore: Exempt `metamaskbotv2` from CLA check cp-7.71.0 (#27758) ## **Description** The CLABot workflow has been updated to exempt `metamaskv2` (i.e. commits created by Patroll tokens) from the CLA check. We saw the CLA check fail recently on a release branch due to some commits appearing for the first time from Patroll (see https://github.com/MetaMask/metamask-mobile/pull/27708#issuecomment-4092630720). This change will fix that CI failure. ## **Changelog** CHANGELOG entry: null ## **Related issues** N/A ## **Manual testing steps** N/A ## **Screenshots/Recordings** 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. ## **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** > Low risk workflow change limited to expanding the CLA bot allowlist; main impact is potentially skipping CLA enforcement for this additional bot account. > > **Overview** > Updates the `CLA Signature Bot` GitHub Actions workflow to add `metamaskbotv2[bot]` to the CLA exemption allowlist, preventing CLA check failures on PRs/merge groups created by that bot. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit f1f4342f672c3170241c1afa8ad69771b3182c7f. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [0f0a400](https://github.com/MetaMask/metamask-mobile/commit/0f0a400b07fac335e2093237a75011051c60f65f) Co-authored-by: Mark Stacey --- .github/workflows/cla.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index b358b99881e..50fde16b047 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -6,7 +6,7 @@ on: types: [opened,closed,synchronize] merge_group: types: [checks_requested] - + jobs: CLABot: if: github.event_name == 'pull_request_target' || contains(github.event.comment.html_url, '/pull/') @@ -24,6 +24,6 @@ jobs: url-to-cladocument: 'https://metamask.io/cla.html' # This branch can't have protections, commits are made directly to the specified branch. branch: 'cla-signatures' - allowlist: 'dependabot[bot],metamaskbot,crowdin-bot,runway-github[bot],cursorbot,cursoragent' + allowlist: 'dependabot[bot],metamaskbot,metamaskbotv2[bot],crowdin-bot,runway-github[bot],cursorbot,cursoragent' allow-organization-members: true blockchain-storage-flag: false From b29c766eaafb94bb2fb8190869e3460dc04cee11 Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Mon, 23 Mar 2026 09:10:25 +0000 Subject: [PATCH 161/206] [skip ci] Bump version number to 4144 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 862cdc30894..78db3285bd4 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4116 + versionCode 4144 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 43ad0bfd14b..e4dca4d495c 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4116 + VERSION_NUMBER: 4144 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4116 + FLASK_VERSION_NUMBER: 4144 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index b2a1b2576fd..d8cc4f162dd 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4116; + CURRENT_PROJECT_VERSION = 4144; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4116; + CURRENT_PROJECT_VERSION = 4144; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4116; + CURRENT_PROJECT_VERSION = 4144; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4116; + CURRENT_PROJECT_VERSION = 4144; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4116; + CURRENT_PROJECT_VERSION = 4144; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4116; + CURRENT_PROJECT_VERSION = 4144; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 4246be96cbb770243cef39aa64e3c0520b10df9f Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 13:04:51 +0100 Subject: [PATCH 162/206] chore(runway): cherry-pick refactor: simplify rampsUnifiedBuyV2 feature flag to single selector cp-7.71.0 (#27762) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - refactor: simplify rampsUnifiedBuyV2 feature flag to single selector cp-7.71.0 (#27760) ## **Description** The `rampsUnifiedBuyV2` feature flag previously used three chained selectors (`selectRampsUnifiedBuyV2Config` → `selectRampsUnifiedBuyV2ActiveFlag` / `selectRampsUnifiedBuyV2MinimumVersionFlag`) and a custom 2-arg `hasMinimumRequiredVersion` utility. This was inconsistent with how other feature flags (e.g. homepage redesign) are handled in the codebase. This PR consolidates the three selectors into a single `selectRampsUnifiedBuyV2Enabled` selector that uses the shared `validatedVersionGatedFeatureFlag` utility from `app/util/remoteFeatureFlag`. The remote flag shape is updated from `{ active, minimumVersion }` to `{ enabled, minimumVersion }` to match the standard `VersionGatedFeatureFlag` type. **Key changes:** - **Selector file** (`rampsUnifiedBuyV2.ts`): Replaced 3 selectors + `RampsUnifiedBuyV2Config` interface with a single `selectRampsUnifiedBuyV2Enabled` selector - **Hook** (`useRampsUnifiedV2Enabled.ts`): Simplified from two `useSelector` calls + `hasMinimumRequiredVersion` to a single `useSelector` - **Utility** (`isRampsUnifiedV2Enabled.ts`): Simplified to delegate directly to the selector - **Controller init** (`ramps-controller-init.ts`): Replaced local interface + `hasMinimumRequiredVersion` with `validatedVersionGatedFeatureFlag`; imports shared flag key constant - **Flag key constant**: Exported `RAMPS_UNIFIED_BUY_V2_FLAG_KEY` from the selector file as single source of truth - **E2E mocks/fixtures**: Updated flag shape from `active` to `enabled` in `FixtureBuilder`, `feature-flags-mocks`, and `feature-flag-registry` ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** N/A — this is a pure refactor of internal selector structure. Behavior is unchanged. All unit tests have been updated and pass. ## **Screenshots/Recordings** ### **Before** N/A ### **After** https://github.com/user-attachments/assets/13ab83a0-f3b1-4862-95cc-ec02fc5b89b5 ## **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. ## **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** > Touches feature-flag gating that controls whether the ramps V2 flow and controller initialization run, and changes the expected remote flag shape from `active` to `enabled`. Main risk is misconfigured/older flag payloads causing the feature to be incorrectly disabled. > > **Overview** > Simplifies `rampsUnifiedBuyV2` enablement checks by replacing the chained config/active/min-version selectors and custom gating logic with a single `selectRampsUnifiedBuyV2Enabled` that delegates to `validatedVersionGatedFeatureFlag`. > > Updates the Ramp hook/utility and `ramps-controller-init` to consume this unified version-gated flag (keeping the build-flag override), and standardizes the remote flag payload from `{ active, minimumVersion }` to `{ enabled, minimumVersion }` across unit tests, E2E mocks/fixtures, and the feature-flag registry defaults. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 3edd562babe66c83b700fbbac49c57cb1ea522fd. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [73198ff](https://github.com/MetaMask/metamask-mobile/commit/73198ff6128dcb3f8d2b15c476042864a3a3cdb9) Co-authored-by: Pedro Pablo Aste Kompen Co-authored-by: chloeYue <105063779+chloeYue@users.noreply.github.com> --- .../hooks/useRampsUnifiedV2Enabled.test.ts | 56 ++--- .../UI/Ramp/hooks/useRampsUnifiedV2Enabled.ts | 32 +-- .../utils/isRampsUnifiedV2Enabled.test.ts | 24 +- .../UI/Ramp/utils/isRampsUnifiedV2Enabled.ts | 10 +- .../ramps-controller-init.test.ts | 18 +- .../ramps-controller/ramps-controller-init.ts | 21 +- .../ramps/rampsUnifiedBuyV2.test.ts | 210 +++++------------- .../ramps/rampsUnifiedBuyV2.ts | 36 +-- .../mock-responses/feature-flags-mocks.ts | 8 +- tests/feature-flags/feature-flag-registry.ts | 2 +- tests/framework/fixtures/FixtureBuilder.ts | 2 +- 11 files changed, 139 insertions(+), 280 deletions(-) diff --git a/app/components/UI/Ramp/hooks/useRampsUnifiedV2Enabled.test.ts b/app/components/UI/Ramp/hooks/useRampsUnifiedV2Enabled.test.ts index cab3107eea9..e6b4193e728 100644 --- a/app/components/UI/Ramp/hooks/useRampsUnifiedV2Enabled.test.ts +++ b/app/components/UI/Ramp/hooks/useRampsUnifiedV2Enabled.test.ts @@ -6,11 +6,11 @@ import useRampsUnifiedV2Enabled from './useRampsUnifiedV2Enabled'; import { getVersion } from 'react-native-device-info'; function mockInitialState({ - rampsUnifiedBuyV2ActiveFlag = true, - rampsUnifiedBuyV2MinimumVersionFlag, + enabled = true, + minimumVersion, }: { - rampsUnifiedBuyV2ActiveFlag?: boolean; - rampsUnifiedBuyV2MinimumVersionFlag?: string | null; + enabled?: boolean; + minimumVersion?: string | null; } = {}) { return { ...initialRootState, @@ -20,9 +20,9 @@ function mockInitialState({ RemoteFeatureFlagController: { remoteFeatureFlags: { rampsUnifiedBuyV2: { - active: rampsUnifiedBuyV2ActiveFlag, - ...(rampsUnifiedBuyV2MinimumVersionFlag !== undefined && { - minimumVersion: rampsUnifiedBuyV2MinimumVersionFlag, + enabled, + ...(minimumVersion !== undefined && { + minimumVersion, }), }, }, @@ -59,8 +59,8 @@ describe('useRampsUnifiedV2Enabled', () => { () => useRampsUnifiedV2Enabled(), { state: mockInitialState({ - rampsUnifiedBuyV2ActiveFlag: false, - rampsUnifiedBuyV2MinimumVersionFlag: '2.0.0', + enabled: false, + minimumVersion: '2.0.0', }), }, ); @@ -76,8 +76,8 @@ describe('useRampsUnifiedV2Enabled', () => { () => useRampsUnifiedV2Enabled(), { state: mockInitialState({ - rampsUnifiedBuyV2ActiveFlag: true, - rampsUnifiedBuyV2MinimumVersionFlag: '1.0.0', + enabled: true, + minimumVersion: '1.0.0', }), }, ); @@ -93,8 +93,8 @@ describe('useRampsUnifiedV2Enabled', () => { () => useRampsUnifiedV2Enabled(), { state: mockInitialState({ - rampsUnifiedBuyV2ActiveFlag: true, - rampsUnifiedBuyV2MinimumVersionFlag: '2.0.0', + enabled: true, + minimumVersion: '2.0.0', }), }, ); @@ -111,8 +111,8 @@ describe('useRampsUnifiedV2Enabled', () => { () => useRampsUnifiedV2Enabled(), { state: mockInitialState({ - rampsUnifiedBuyV2ActiveFlag: true, - rampsUnifiedBuyV2MinimumVersionFlag: '7.63.0', + enabled: true, + minimumVersion: '7.63.0', }), }, ); @@ -127,8 +127,8 @@ describe('useRampsUnifiedV2Enabled', () => { () => useRampsUnifiedV2Enabled(), { state: mockInitialState({ - rampsUnifiedBuyV2ActiveFlag: false, - rampsUnifiedBuyV2MinimumVersionFlag: '7.63.0', + enabled: false, + minimumVersion: '7.63.0', }), }, ); @@ -143,8 +143,8 @@ describe('useRampsUnifiedV2Enabled', () => { () => useRampsUnifiedV2Enabled(), { state: mockInitialState({ - rampsUnifiedBuyV2ActiveFlag: true, - rampsUnifiedBuyV2MinimumVersionFlag: '7.63.0', + enabled: true, + minimumVersion: '7.63.0', }), }, ); @@ -159,8 +159,8 @@ describe('useRampsUnifiedV2Enabled', () => { () => useRampsUnifiedV2Enabled(), { state: mockInitialState({ - rampsUnifiedBuyV2ActiveFlag: true, - rampsUnifiedBuyV2MinimumVersionFlag: null, + enabled: true, + minimumVersion: null, }), }, ); @@ -175,8 +175,8 @@ describe('useRampsUnifiedV2Enabled', () => { () => useRampsUnifiedV2Enabled(), { state: mockInitialState({ - rampsUnifiedBuyV2ActiveFlag: true, - rampsUnifiedBuyV2MinimumVersionFlag: undefined, + enabled: true, + minimumVersion: undefined, }), }, ); @@ -191,8 +191,8 @@ describe('useRampsUnifiedV2Enabled', () => { () => useRampsUnifiedV2Enabled(), { state: mockInitialState({ - rampsUnifiedBuyV2ActiveFlag: true, - rampsUnifiedBuyV2MinimumVersionFlag: '7.63.0', + enabled: true, + minimumVersion: '7.63.0', }), }, ); @@ -200,15 +200,15 @@ describe('useRampsUnifiedV2Enabled', () => { expect(result.current).toBe(true); }); - it('returns false when both active flag and minimum version are not set', () => { + it('returns false when both enabled flag and minimum version are not set', () => { mockGetVersion.mockReturnValue('8.0.0'); const { result } = renderHookWithProvider( () => useRampsUnifiedV2Enabled(), { state: mockInitialState({ - rampsUnifiedBuyV2ActiveFlag: false, - rampsUnifiedBuyV2MinimumVersionFlag: null, + enabled: false, + minimumVersion: null, }), }, ); diff --git a/app/components/UI/Ramp/hooks/useRampsUnifiedV2Enabled.ts b/app/components/UI/Ramp/hooks/useRampsUnifiedV2Enabled.ts index 2259af515b8..a586257ae1c 100644 --- a/app/components/UI/Ramp/hooks/useRampsUnifiedV2Enabled.ts +++ b/app/components/UI/Ramp/hooks/useRampsUnifiedV2Enabled.ts @@ -1,33 +1,13 @@ import { useSelector } from 'react-redux'; -import { - selectRampsUnifiedBuyV2ActiveFlag, - selectRampsUnifiedBuyV2MinimumVersionFlag, -} from '../../../../selectors/featureFlagController/ramps/rampsUnifiedBuyV2'; -import { hasMinimumRequiredVersion } from '../utils/hasMinimumRequiredVersion'; +import { selectRampsUnifiedBuyV2Enabled } from '../../../../selectors/featureFlagController/ramps/rampsUnifiedBuyV2'; export default function useRampsUnifiedV2Enabled() { - const rampsUnifiedBuyV2MinimumVersionFlag = useSelector( - selectRampsUnifiedBuyV2MinimumVersionFlag, - ); - const rampsUnifiedBuyV2ActiveFlag = useSelector( - selectRampsUnifiedBuyV2ActiveFlag, - ); + const isEnabled = useSelector(selectRampsUnifiedBuyV2Enabled); - const rampsUnifiedBuyV2BuildFlag = - process.env.MM_RAMPS_UNIFIED_BUY_V2_ENABLED; - - // if build flag is defined, it takes precedence over remote feature flag - if ( - rampsUnifiedBuyV2BuildFlag === 'true' || - rampsUnifiedBuyV2BuildFlag === 'false' - ) { - return rampsUnifiedBuyV2BuildFlag === 'true'; + const buildFlag = process.env.MM_RAMPS_UNIFIED_BUY_V2_ENABLED; + if (buildFlag === 'true' || buildFlag === 'false') { + return buildFlag === 'true'; } - const isRampsUnifiedV2Enabled = hasMinimumRequiredVersion( - rampsUnifiedBuyV2MinimumVersionFlag, - rampsUnifiedBuyV2ActiveFlag, - ); - - return isRampsUnifiedV2Enabled; + return isEnabled; } diff --git a/app/components/UI/Ramp/utils/isRampsUnifiedV2Enabled.test.ts b/app/components/UI/Ramp/utils/isRampsUnifiedV2Enabled.test.ts index fb04ee960b9..cc0c454ac77 100644 --- a/app/components/UI/Ramp/utils/isRampsUnifiedV2Enabled.test.ts +++ b/app/components/UI/Ramp/utils/isRampsUnifiedV2Enabled.test.ts @@ -9,10 +9,10 @@ jest.mock('react-native-device-info', () => ({ })); function buildState({ - active = true, + enabled = true, minimumVersion, }: { - active?: boolean; + enabled?: boolean; minimumVersion?: string | null; } = {}) { return { @@ -24,7 +24,7 @@ function buildState({ ...backgroundState.RemoteFeatureFlagController, remoteFeatureFlags: { rampsUnifiedBuyV2: { - active, + enabled, ...(minimumVersion !== undefined && { minimumVersion }), }, }, @@ -53,7 +53,7 @@ describe('isRampsUnifiedV2Enabled', () => { process.env.MM_RAMPS_UNIFIED_BUY_V2_ENABLED = 'true'; const result = isRampsUnifiedV2Enabled( - buildState({ active: false, minimumVersion: '99.0.0' }), + buildState({ enabled: false, minimumVersion: '99.0.0' }), ); expect(result).toBe(true); @@ -63,7 +63,7 @@ describe('isRampsUnifiedV2Enabled', () => { process.env.MM_RAMPS_UNIFIED_BUY_V2_ENABLED = 'false'; const result = isRampsUnifiedV2Enabled( - buildState({ active: true, minimumVersion: '1.0.0' }), + buildState({ enabled: true, minimumVersion: '1.0.0' }), ); expect(result).toBe(false); @@ -71,21 +71,21 @@ describe('isRampsUnifiedV2Enabled', () => { }); describe('remote feature flag behavior when build flag is not set', () => { - it('returns true when active and version meets minimum requirement', () => { + it('returns true when enabled and version meets minimum requirement', () => { mockGetVersion.mockReturnValue('8.0.0'); const result = isRampsUnifiedV2Enabled( - buildState({ active: true, minimumVersion: '7.63.0' }), + buildState({ enabled: true, minimumVersion: '7.63.0' }), ); expect(result).toBe(true); }); - it('returns false when active flag is false', () => { + it('returns false when enabled flag is false', () => { mockGetVersion.mockReturnValue('8.0.0'); const result = isRampsUnifiedV2Enabled( - buildState({ active: false, minimumVersion: '7.63.0' }), + buildState({ enabled: false, minimumVersion: '7.63.0' }), ); expect(result).toBe(false); @@ -95,7 +95,7 @@ describe('isRampsUnifiedV2Enabled', () => { mockGetVersion.mockReturnValue('7.0.0'); const result = isRampsUnifiedV2Enabled( - buildState({ active: true, minimumVersion: '7.63.0' }), + buildState({ enabled: true, minimumVersion: '7.63.0' }), ); expect(result).toBe(false); @@ -105,7 +105,7 @@ describe('isRampsUnifiedV2Enabled', () => { mockGetVersion.mockReturnValue('8.0.0'); const result = isRampsUnifiedV2Enabled( - buildState({ active: true, minimumVersion: null }), + buildState({ enabled: true, minimumVersion: null }), ); expect(result).toBe(false); @@ -115,7 +115,7 @@ describe('isRampsUnifiedV2Enabled', () => { mockGetVersion.mockReturnValue('7.63.0'); const result = isRampsUnifiedV2Enabled( - buildState({ active: true, minimumVersion: '7.63.0' }), + buildState({ enabled: true, minimumVersion: '7.63.0' }), ); expect(result).toBe(true); diff --git a/app/components/UI/Ramp/utils/isRampsUnifiedV2Enabled.ts b/app/components/UI/Ramp/utils/isRampsUnifiedV2Enabled.ts index 5065e228945..956ccd194b1 100644 --- a/app/components/UI/Ramp/utils/isRampsUnifiedV2Enabled.ts +++ b/app/components/UI/Ramp/utils/isRampsUnifiedV2Enabled.ts @@ -1,8 +1,4 @@ -import { - selectRampsUnifiedBuyV2ActiveFlag, - selectRampsUnifiedBuyV2MinimumVersionFlag, -} from '../../../../selectors/featureFlagController/ramps/rampsUnifiedBuyV2'; -import { hasMinimumRequiredVersion } from './hasMinimumRequiredVersion'; +import { selectRampsUnifiedBuyV2Enabled } from '../../../../selectors/featureFlagController/ramps/rampsUnifiedBuyV2'; import { RootState } from '../../../../reducers'; /** @@ -16,7 +12,5 @@ export function isRampsUnifiedV2Enabled(state: RootState): boolean { return buildFlag === 'true'; } - const activeFlag = selectRampsUnifiedBuyV2ActiveFlag(state); - const minimumVersion = selectRampsUnifiedBuyV2MinimumVersionFlag(state); - return hasMinimumRequiredVersion(minimumVersion, activeFlag); + return selectRampsUnifiedBuyV2Enabled(state); } diff --git a/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts b/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts index a47f66344cf..e96ce515ecb 100644 --- a/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts +++ b/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts @@ -67,17 +67,17 @@ jest.mock('react-native-device-info', () => ({ const createMockInitMessenger = ( overrides: { - active?: boolean; + enabled?: boolean; minimumVersion?: string | null; } = {}, ): RampsControllerInitMessenger => { - const { active = false, minimumVersion = null } = overrides; + const { enabled = false, minimumVersion = null } = overrides; return { call: jest.fn().mockReturnValue({ remoteFeatureFlags: { rampsUnifiedBuyV2: { - active, + enabled, minimumVersion, }, }, @@ -196,7 +196,7 @@ describe('ramps controller init', () => { describe('when V2 feature flag is enabled', () => { it('calls init at startup', async () => { initRequestMock.initMessenger = createMockInitMessenger({ - active: true, + enabled: true, minimumVersion: '1.0.0', }); @@ -209,7 +209,7 @@ describe('ramps controller init', () => { it('handles init failure gracefully', async () => { initRequestMock.initMessenger = createMockInitMessenger({ - active: true, + enabled: true, minimumVersion: '1.0.0', }); mockInit.mockRejectedValue(new Error('Network error')); @@ -225,7 +225,7 @@ describe('ramps controller init', () => { describe('when V2 feature flag is disabled', () => { it('does not call init at startup', async () => { initRequestMock.initMessenger = createMockInitMessenger({ - active: false, + enabled: false, }); rampsControllerInit(initRequestMock); @@ -235,9 +235,9 @@ describe('ramps controller init', () => { }); }); - it('does not call init when active is true but minimumVersion is missing', async () => { + it('does not call init when enabled is true but minimumVersion is missing', async () => { initRequestMock.initMessenger = createMockInitMessenger({ - active: true, + enabled: true, minimumVersion: null, }); @@ -265,7 +265,7 @@ describe('ramps controller init', () => { it('always returns the controller instance regardless of flag state', () => { initRequestMock.initMessenger = createMockInitMessenger({ - active: false, + enabled: false, }); const result = rampsControllerInit(initRequestMock); diff --git a/app/core/Engine/controllers/ramps-controller/ramps-controller-init.ts b/app/core/Engine/controllers/ramps-controller/ramps-controller-init.ts index a419253e8fb..f7a02ca5605 100644 --- a/app/core/Engine/controllers/ramps-controller/ramps-controller-init.ts +++ b/app/core/Engine/controllers/ramps-controller/ramps-controller-init.ts @@ -5,17 +5,11 @@ import { getDefaultRampsControllerState, } from '@metamask/ramps-controller'; import type { RampsControllerInitMessenger } from '../../messengers/ramps-controller-messenger'; -import { hasMinimumRequiredVersion } from '../../../../components/UI/Ramp/utils/hasMinimumRequiredVersion'; +import { validatedVersionGatedFeatureFlag } from '../../../../util/remoteFeatureFlag'; +import { RAMPS_UNIFIED_BUY_V2_FLAG_KEY } from '../../../../selectors/featureFlagController/ramps/rampsUnifiedBuyV2'; import { handleOrderStatusChangedForNotifications } from './event-handlers/notification'; import { handleOrderStatusChangedForMetrics } from './event-handlers/analytics'; -interface RampsUnifiedBuyV2Config { - active?: boolean; - minimumVersion?: string; -} - -const RAMPS_UNIFIED_BUY_V2_FLAG_KEY = 'rampsUnifiedBuyV2'; - /** * Determines whether the ramps unified buy V2 feature is enabled * by reading the remote feature flag state. @@ -30,14 +24,9 @@ function getIsRampsUnifiedBuyV2Enabled( const remoteState = initMessenger.call( 'RemoteFeatureFlagController:getState', ); - const config = (remoteState?.remoteFeatureFlags?.[ - RAMPS_UNIFIED_BUY_V2_FLAG_KEY - ] ?? {}) as RampsUnifiedBuyV2Config; - - return hasMinimumRequiredVersion( - config.minimumVersion, - config.active ?? false, - ); + const remoteFlag = + remoteState?.remoteFeatureFlags?.[RAMPS_UNIFIED_BUY_V2_FLAG_KEY]; + return validatedVersionGatedFeatureFlag(remoteFlag) ?? false; } catch { return false; } diff --git a/app/selectors/featureFlagController/ramps/rampsUnifiedBuyV2.test.ts b/app/selectors/featureFlagController/ramps/rampsUnifiedBuyV2.test.ts index bc93d1943e6..c8a9b757fd3 100644 --- a/app/selectors/featureFlagController/ramps/rampsUnifiedBuyV2.test.ts +++ b/app/selectors/featureFlagController/ramps/rampsUnifiedBuyV2.test.ts @@ -1,165 +1,77 @@ -import { - RampsUnifiedBuyV2Config, - selectRampsUnifiedBuyV2Config, - selectRampsUnifiedBuyV2ActiveFlag, - selectRampsUnifiedBuyV2MinimumVersionFlag, -} from './rampsUnifiedBuyV2'; -import { selectRemoteFeatureFlags } from '..'; -import { FeatureFlags } from '@metamask/remote-feature-flag-controller'; - -describe('RampsUnifiedBuyV2 selectors', () => { - const mockRemoteFeatureFlags: ReturnType & { - rampsUnifiedBuyV2: RampsUnifiedBuyV2Config; - } = { - rampsUnifiedBuyV2: { - active: true, - minimumVersion: '7.63.0', - }, - }; - - const mockEmptyRemoteFeatureFlags = {}; - - describe('selectRampsUnifiedBuyV2Config', () => { - it('returns the rampsUnifiedBuyV2Config when it exists', () => { - const result = selectRampsUnifiedBuyV2Config.resultFunc( - mockRemoteFeatureFlags, - ); - - expect(result).toEqual(mockRemoteFeatureFlags.rampsUnifiedBuyV2); - }); - - it('returns an empty object when rampsUnifiedBuyV2Config does not exist', () => { - const result = selectRampsUnifiedBuyV2Config.resultFunc( - mockEmptyRemoteFeatureFlags, - ); - - expect(result).toEqual({}); - }); - - it('returns an empty object when remoteFeatureFlags is null', () => { - const result = selectRampsUnifiedBuyV2Config.resultFunc( - null as unknown as FeatureFlags, - ); - - expect(result).toEqual({}); - }); - - it('returns an empty object when remoteFeatureFlags is undefined', () => { - const result = selectRampsUnifiedBuyV2Config.resultFunc( - undefined as unknown as FeatureFlags, - ); - - expect(result).toEqual({}); - }); +import { selectRampsUnifiedBuyV2Enabled } from './rampsUnifiedBuyV2'; +// eslint-disable-next-line import-x/no-namespace +import * as remoteFeatureFlagModule from '../../../util/remoteFeatureFlag'; + +jest.mock('react-native-device-info', () => ({ + getVersion: jest.fn().mockReturnValue('1.0.0'), +})); + +describe('selectRampsUnifiedBuyV2Enabled', () => { + let mockHasMinimumRequiredVersion: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + mockHasMinimumRequiredVersion = jest.spyOn( + remoteFeatureFlagModule, + 'hasMinimumRequiredVersion', + ); + mockHasMinimumRequiredVersion.mockReturnValue(true); }); - describe('selectRampsUnifiedBuyV2ActiveFlag', () => { - it('returns true when active is set to true', () => { - const result = selectRampsUnifiedBuyV2ActiveFlag.resultFunc( - mockRemoteFeatureFlags.rampsUnifiedBuyV2, - ); - - expect(result).toBe(true); - }); - - it('returns false when active is set to false', () => { - const mockConfigWithActiveFalse: RampsUnifiedBuyV2Config = { - active: false, - minimumVersion: '7.63.0', - }; - - const result = selectRampsUnifiedBuyV2ActiveFlag.resultFunc( - mockConfigWithActiveFalse, - ); - - expect(result).toBe(false); - }); - - it('returns false when active is not set', () => { - const result = selectRampsUnifiedBuyV2ActiveFlag.resultFunc({}); - - expect(result).toBe(false); - }); - - it('returns false when active is null', () => { - const mockConfigWithActiveNull: RampsUnifiedBuyV2Config = { - active: null as unknown as boolean, - minimumVersion: '7.63.0', - }; - - const result = selectRampsUnifiedBuyV2ActiveFlag.resultFunc( - mockConfigWithActiveNull, - ); - - expect(result).toBe(false); - }); - - it('returns false when active is undefined', () => { - const mockConfigWithActiveUndefined: RampsUnifiedBuyV2Config = { - active: undefined as unknown as boolean, - minimumVersion: '7.63.0', - }; - - const result = selectRampsUnifiedBuyV2ActiveFlag.resultFunc( - mockConfigWithActiveUndefined, - ); - - expect(result).toBe(false); - }); + afterEach(() => { + mockHasMinimumRequiredVersion?.mockRestore(); }); - describe('selectRampsUnifiedBuyV2MinimumVersionFlag', () => { - it('returns the minimumVersion when it exists', () => { - const result = selectRampsUnifiedBuyV2MinimumVersionFlag.resultFunc( - mockRemoteFeatureFlags.rampsUnifiedBuyV2, - ); - - expect(result).toBe('7.63.0'); + it('returns true when remote flag is valid and enabled', () => { + const result = selectRampsUnifiedBuyV2Enabled.resultFunc({ + rampsUnifiedBuyV2: { + enabled: true, + minimumVersion: '1.0.0', + }, }); + expect(result).toBe(true); + }); - it('returns null when minimumVersion is not set', () => { - const result = selectRampsUnifiedBuyV2MinimumVersionFlag.resultFunc({}); - - expect(result).toBeNull(); + it('returns false when remote flag is valid but disabled', () => { + const result = selectRampsUnifiedBuyV2Enabled.resultFunc({ + rampsUnifiedBuyV2: { + enabled: false, + minimumVersion: '1.0.0', + }, }); + expect(result).toBe(false); + }); - it('returns null when minimumVersion is null', () => { - const mockConfigWithVersionNull: RampsUnifiedBuyV2Config = { - active: true, - minimumVersion: null as unknown as string, - }; - - const result = selectRampsUnifiedBuyV2MinimumVersionFlag.resultFunc( - mockConfigWithVersionNull, - ); - - expect(result).toBeNull(); + it('returns false when version check fails', () => { + mockHasMinimumRequiredVersion.mockReturnValue(false); + const result = selectRampsUnifiedBuyV2Enabled.resultFunc({ + rampsUnifiedBuyV2: { + enabled: true, + minimumVersion: '99.0.0', + }, }); + expect(result).toBe(false); + }); - it('returns null when minimumVersion is undefined', () => { - const mockConfigWithVersionUndefined: RampsUnifiedBuyV2Config = { - active: true, - minimumVersion: undefined, - }; - - const result = selectRampsUnifiedBuyV2MinimumVersionFlag.resultFunc( - mockConfigWithVersionUndefined, - ); - - expect(result).toBeNull(); + it('returns false when remote flag is invalid', () => { + const result = selectRampsUnifiedBuyV2Enabled.resultFunc({ + rampsUnifiedBuyV2: { + enabled: 'invalid', + minimumVersion: 123, + }, }); + expect(result).toBe(false); + }); - it('returns the minimumVersion when it is an empty string', () => { - const mockConfigWithEmptyVersion: RampsUnifiedBuyV2Config = { - active: true, - minimumVersion: '', - }; - - const result = selectRampsUnifiedBuyV2MinimumVersionFlag.resultFunc( - mockConfigWithEmptyVersion, - ); + it('returns false when remote feature flags are empty', () => { + const result = selectRampsUnifiedBuyV2Enabled.resultFunc({}); + expect(result).toBe(false); + }); - expect(result).toBe(''); + it('returns false when rampsUnifiedBuyV2 is null', () => { + const result = selectRampsUnifiedBuyV2Enabled.resultFunc({ + rampsUnifiedBuyV2: null, }); + expect(result).toBe(false); }); }); diff --git a/app/selectors/featureFlagController/ramps/rampsUnifiedBuyV2.ts b/app/selectors/featureFlagController/ramps/rampsUnifiedBuyV2.ts index 2d537337695..da25e5058ab 100644 --- a/app/selectors/featureFlagController/ramps/rampsUnifiedBuyV2.ts +++ b/app/selectors/featureFlagController/ramps/rampsUnifiedBuyV2.ts @@ -1,34 +1,18 @@ import { createSelector } from 'reselect'; import { selectRemoteFeatureFlags } from '..'; +import { + validatedVersionGatedFeatureFlag, + VersionGatedFeatureFlag, +} from '../../../util/remoteFeatureFlag'; -export interface RampsUnifiedBuyV2Config { - active?: boolean; - minimumVersion?: string; -} +export const RAMPS_UNIFIED_BUY_V2_FLAG_KEY = 'rampsUnifiedBuyV2'; -const FLAG_KEY = 'rampsUnifiedBuyV2'; - -export const selectRampsUnifiedBuyV2Config = createSelector( +export const selectRampsUnifiedBuyV2Enabled = createSelector( selectRemoteFeatureFlags, (remoteFeatureFlags) => { - const rampsUnifiedBuyV2Config = remoteFeatureFlags?.[FLAG_KEY]; - return (rampsUnifiedBuyV2Config ?? {}) as RampsUnifiedBuyV2Config; - }, -); - -export const selectRampsUnifiedBuyV2ActiveFlag = createSelector( - selectRampsUnifiedBuyV2Config, - (rampsUnifiedBuyV2Config) => { - const rampsUnifiedBuyV2ActiveFlag = rampsUnifiedBuyV2Config?.active; - return rampsUnifiedBuyV2ActiveFlag ?? false; - }, -); - -export const selectRampsUnifiedBuyV2MinimumVersionFlag = createSelector( - selectRampsUnifiedBuyV2Config, - (rampsUnifiedBuyV2Config) => { - const rampsUnifiedBuyV2MinimumVersion = - rampsUnifiedBuyV2Config?.minimumVersion; - return rampsUnifiedBuyV2MinimumVersion ?? null; + const remoteFlag = remoteFeatureFlags[ + RAMPS_UNIFIED_BUY_V2_FLAG_KEY + ] as unknown as VersionGatedFeatureFlag; + return validatedVersionGatedFeatureFlag(remoteFlag) ?? false; }, ); diff --git a/tests/api-mocking/mock-responses/feature-flags-mocks.ts b/tests/api-mocking/mock-responses/feature-flags-mocks.ts index f607bca1ea3..fb7c485d8f8 100644 --- a/tests/api-mocking/mock-responses/feature-flags-mocks.ts +++ b/tests/api-mocking/mock-responses/feature-flags-mocks.ts @@ -135,9 +135,9 @@ export const remoteFeatureFlagRampsUnifiedV1Enabled = (active = true) => ({ }, }); -export const remoteFeatureFlagRampsUnifiedV2Enabled = (active = true) => ({ +export const remoteFeatureFlagRampsUnifiedV2Enabled = (enabled = true) => ({ rampsUnifiedBuyV2: { - active, + enabled, minimumVersion: '7.63.0', }, }); @@ -157,14 +157,14 @@ export const remoteFeatureFlagRampsUnifiedEnabled = (active = true) => ({ */ export const remoteFeatureFlagRampsUnifiedMatrixForE2E = ( rampsUnifiedBuyV1Active: boolean, - rampsUnifiedBuyV2Active: boolean, + rampsUnifiedBuyV2Enabled: boolean, ) => ({ rampsUnifiedBuyV1: { active: rampsUnifiedBuyV1Active, minimumVersion: '0.0.0', }, rampsUnifiedBuyV2: { - active: rampsUnifiedBuyV2Active, + enabled: rampsUnifiedBuyV2Enabled, minimumVersion: '0.0.0', }, }); diff --git a/tests/feature-flags/feature-flag-registry.ts b/tests/feature-flags/feature-flag-registry.ts index f37172447ce..bb05b3aa658 100644 --- a/tests/feature-flags/feature-flag-registry.ts +++ b/tests/feature-flags/feature-flag-registry.ts @@ -3240,7 +3240,7 @@ export const FEATURE_FLAG_REGISTRY: Record = { type: FeatureFlagType.Remote, inProd: true, productionDefault: { - active: false, + enabled: false, minimumVersion: '7.61.0', }, status: FeatureFlagStatus.Active, diff --git a/tests/framework/fixtures/FixtureBuilder.ts b/tests/framework/fixtures/FixtureBuilder.ts index b8cdf670961..01d30e82d21 100644 --- a/tests/framework/fixtures/FixtureBuilder.ts +++ b/tests/framework/fixtures/FixtureBuilder.ts @@ -517,7 +517,7 @@ class FixtureBuilder { minimumVersion: '0.0.0', }, rampsUnifiedBuyV2: { - active: rampsUnifiedBuyV2, + enabled: rampsUnifiedBuyV2, minimumVersion: '0.0.0', }, }, From 3ce6160893975ae467d125335dfee2b39203e6ce Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Mon, 23 Mar 2026 12:06:26 +0000 Subject: [PATCH 163/206] [skip ci] Bump version number to 4146 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 78db3285bd4..11266e9e0f9 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4144 + versionCode 4146 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index e4dca4d495c..8ca114a72d6 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4144 + VERSION_NUMBER: 4146 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4144 + FLASK_VERSION_NUMBER: 4146 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index d8cc4f162dd..5462a9c3e89 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4144; + CURRENT_PROJECT_VERSION = 4146; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4144; + CURRENT_PROJECT_VERSION = 4146; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4144; + CURRENT_PROJECT_VERSION = 4146; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4144; + CURRENT_PROJECT_VERSION = 4146; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4144; + CURRENT_PROJECT_VERSION = 4146; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4144; + CURRENT_PROJECT_VERSION = 4146; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 5834a304ea24311fb97d7b4d378198ca45325c7f Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 13:33:03 +0100 Subject: [PATCH 164/206] chore(runway): cherry-pick fix(ramps): Preserve user-entered amount during Transak navigation reset -> cp-7.71.0 (#27772) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(ramps): Preserve user-entered amount during Transak navigation reset -> cp-7.71.0 (#27742) ## **Description** Fixes the Buy flow amount reverting from the user-entered value back to the default $100 during the Transak native provider loading transition. When a user enters a custom amount (e.g. $30) and taps Continue, the Transak routing callbacks use `navigation.reset()` to rebuild the navigation stack with a fresh `BuildQuote` screen as the base route. This fresh instance initialized with `DEFAULT_AMOUNT = 100`, causing a visible flash of $100 during the transition to the checkout/KYC screen. The fix passes the current `quote.fiatAmount` as a route param (`amount`) to the `AMOUNT_INPUT` base route in every `navigation.reset()` call. `BuildQuote` now reads `params?.amount` as the initial state, preserving the user-entered amount through stack resets. ## **Changelog** CHANGELOG entry: Fixed Buy flow amount input reverting to $100 during Transak native provider checkout transition. ## **Related issues** Fixes: [TRAM-3348](https://consensyssoftware.atlassian.net/browse/TRAM-3348) ## **Manual testing steps** ```gherkin Feature: Amount persists through Transak native provider checkout transition Scenario: Custom amount does not revert to default during Continue loading Given the user is on the Buy screen with Transak Native provider And the default amount is $100 When the user changes the amount to $30 And the user taps Continue Then the displayed amount remains $30 during the loading transition And the amount does not flash back to $100 Scenario: Default amount is preserved when no custom amount is entered Given the user is on the Buy screen with Transak Native provider And the default amount is $100 When the user taps Continue without changing the amount Then the displayed amount remains $100 throughout the flow Scenario: Amount persists when navigating back from KYC/checkout screens Given the user entered $50 and proceeded through Continue When the user navigates back to the Buy screen Then the amount input shows $50 (not $100) ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/ba7fb42b-c43a-43f8-b438-0484090d7895 ### **After** https://github.com/user-attachments/assets/6d60c1ae-f0eb-448b-b7f3-297efbce85df https://github.com/user-attachments/assets/099ebf69-face-4bb0-8568-80357f59b35e Screenshot 2026-03-20 175035 ## **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 - [ ] 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. ## **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** > Updates Transak native-provider navigation/reset logic and initial screen state; mistakes could regress Buy/KYC routing or amount display during transitions, but changes are localized and covered by tests. > > **Overview** > Fixes the Transak native Buy flow so the user-entered fiat amount is preserved when the app uses `navigation.reset()` during KYC/checkout transitions. > > `BuildQuote` now supports an `amount` route param to initialize the amount state (and to prevent region defaults from overriding it), and `useTransakRouting` propagates this amount through all reset-based navigation paths (KYC approved → checkout, KYC forms, additional verification, verify identity, and KYC webview). Tests are updated/added to assert the amount is passed through routing callbacks and used as the initial displayed value. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit a8bdee0a10fab193fe58381e27316828206756ce. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [8a03f66](https://github.com/MetaMask/metamask-mobile/commit/8a03f66803964cbbe25b37cde717aeed03ec4bf7) Co-authored-by: Yu-Gi-Oh! <54774811+imyugioh@users.noreply.github.com> Co-authored-by: chloeYue <105063779+chloeYue@users.noreply.github.com> --- .../Ramp/Views/BuildQuote/BuildQuote.test.tsx | 37 ++++- .../UI/Ramp/Views/BuildQuote/BuildQuote.tsx | 16 +- .../AdditionalVerification.test.tsx | 1 + .../NativeFlow/AdditionalVerification.tsx | 6 +- .../UI/Ramp/hooks/useTransakRouting.test.ts | 143 ++++++++++++++---- .../UI/Ramp/hooks/useTransakRouting.ts | 58 +++++-- 6 files changed, 207 insertions(+), 54 deletions(-) diff --git a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx index db775cb0ccd..aea62bd39ff 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx +++ b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx @@ -402,6 +402,41 @@ describe('BuildQuote', () => { }); }); + describe('amount param initialization', () => { + it('uses DEFAULT_AMOUNT (100) when no amount param is provided', () => { + mockUseParams.mockReturnValue({}); + + const { getByTestId } = renderWithProvider(, { + state: initialRootState, + }); + + const amountInput = getByTestId(BuildQuoteSelectors.AMOUNT_INPUT); + expect(amountInput.props.children).toContain('100'); + }); + + it('uses amount param as initial value when provided via route params', () => { + mockUseParams.mockReturnValue({ amount: 30 }); + + const { getByTestId } = renderWithProvider(, { + state: initialRootState, + }); + + const amountInput = getByTestId(BuildQuoteSelectors.AMOUNT_INPUT); + expect(amountInput.props.children).toContain('30'); + }); + + it('does not override amount with region default when amount param is provided', () => { + mockUseParams.mockReturnValue({ amount: 50 }); + + const { getByTestId } = renderWithProvider(, { + state: initialRootState, + }); + + const amountInput = getByTestId(BuildQuoteSelectors.AMOUNT_INPUT); + expect(amountInput.props.children).toContain('50'); + }); + }); + describe('navigateAfterExternalBrowser', () => { it('resets to BuildQuote when returnDestination is buildQuote (Android external browser path)', async () => { mockDeviceIsAndroid.mockReturnValue(true); @@ -725,7 +760,7 @@ describe('BuildQuote', () => { '/payments/debit-credit-card', '100', ); - expect(mockRouteAfterAuth).toHaveBeenCalledWith(MOCK_TRANSAK_QUOTE); + expect(mockRouteAfterAuth).toHaveBeenCalledWith(MOCK_TRANSAK_QUOTE, 100); }); it('navigates to VerifyIdentity when user has no token', async () => { diff --git a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx index 88c6ed6d94f..407507a18b5 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx +++ b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx @@ -109,6 +109,8 @@ export interface BuildQuoteParams { nativeFlowError?: string; /** Which flow the user used to enter the Buy screen. */ buyFlowOrigin?: BuyFlowOrigin; + /** Pre-fill the amount input (e.g. when restoring state after a navigation reset). */ + amount?: number; } /** @@ -148,13 +150,17 @@ function BuildQuote() { const { formatCurrency } = useFormatters(); const cursorOpacity = useBlinkingCursor(); - const [amount, setAmount] = useState(() => String(DEFAULT_AMOUNT)); - const [amountAsNumber, setAmountAsNumber] = useState(DEFAULT_AMOUNT); - const [userHasEnteredAmount, setUserHasEnteredAmount] = useState(false); + const params = useParams(); + const initialAmount = params?.amount ?? DEFAULT_AMOUNT; + + const [amount, setAmount] = useState(() => String(initialAmount)); + const [amountAsNumber, setAmountAsNumber] = useState(initialAmount); + const [userHasEnteredAmount, setUserHasEnteredAmount] = useState( + params?.amount != null, + ); const [keyboardIsDirty, setKeyboardIsDirty] = useState(false); const [isContinueLoading, setIsContinueLoading] = useState(false); const [rampsError, setRampsError] = useState(null); - const params = useParams(); useEffect(() => { if (params?.nativeFlowError) { @@ -573,7 +579,7 @@ function BuildQuote() { if (!quote) { throw new Error(strings('deposit.buildQuote.unexpectedError')); } - await transakRouteAfterAuth(quote); + await transakRouteAfterAuth(quote, amountAsNumber); } else { navigation.navigate( ...createV2VerifyIdentityNavDetails({ diff --git a/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.test.tsx b/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.test.tsx index ce6144e1834..6375f3e7a3e 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.test.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.test.tsx @@ -71,6 +71,7 @@ describe('V2AdditionalVerification', () => { expect(mockNavigateToKycWebview).toHaveBeenCalledWith({ kycUrl: 'https://kyc.example.com', + amount: 100, }); }); diff --git a/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.tsx b/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.tsx index 3137b730f81..190666ee128 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.tsx @@ -27,7 +27,7 @@ interface V2AdditionalVerificationParams { const V2AdditionalVerification = () => { const navigation = useNavigation(); - const { kycUrl } = useParams(); + const { kycUrl, quote } = useParams(); const { styles, theme } = useStyles(styleSheet, {}); @@ -46,8 +46,8 @@ const V2AdditionalVerification = () => { }, [navigation, theme]); const handleContinuePress = useCallback(() => { - navigateToKycWebview({ kycUrl }); - }, [navigateToKycWebview, kycUrl]); + navigateToKycWebview({ kycUrl, amount: quote?.fiatAmount }); + }, [navigateToKycWebview, kycUrl, quote?.fiatAmount]); return ( diff --git a/app/components/UI/Ramp/hooks/useTransakRouting.test.ts b/app/components/UI/Ramp/hooks/useTransakRouting.test.ts index 96ea42b5452..bde067dce25 100644 --- a/app/components/UI/Ramp/hooks/useTransakRouting.test.ts +++ b/app/components/UI/Ramp/hooks/useTransakRouting.test.ts @@ -223,14 +223,20 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); await act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }); expect(mockReset).toHaveBeenCalledWith( expect.objectContaining({ index: 1, routes: [ - expect.objectContaining({ name: 'RampAmountInput' }), + expect.objectContaining({ + name: 'RampAmountInput', + params: { amount: mockQuote.fiatAmount }, + }), expect.objectContaining({ name: 'RampBasicInfo', params: expect.objectContaining({ quote: mockQuote }), @@ -267,7 +273,10 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); await act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }); expect(mockRequestOtt).toHaveBeenCalled(); @@ -281,7 +290,10 @@ describe('useTransakRouting', () => { expect.objectContaining({ index: 1, routes: [ - expect.objectContaining({ name: 'RampAmountInput' }), + expect.objectContaining({ + name: 'RampAmountInput', + params: { amount: mockQuote.fiatAmount }, + }), expect.objectContaining({ name: 'Checkout', params: expect.objectContaining({ @@ -328,7 +340,10 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); await act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }); expect(mockTransakCreateOrder).toHaveBeenCalledWith( @@ -361,14 +376,20 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); await act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }); expect(mockReset).toHaveBeenCalledWith( expect.objectContaining({ index: 1, routes: [ - expect.objectContaining({ name: 'RampAmountInput' }), + expect.objectContaining({ + name: 'RampAmountInput', + params: { amount: mockQuote.fiatAmount }, + }), expect.objectContaining({ name: 'RampKycProcessing', params: expect.objectContaining({ quote: mockQuote }), @@ -389,7 +410,10 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); await act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }); expect(mockLogoutFromProvider).toHaveBeenCalledWith(false); @@ -423,7 +447,10 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); await act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }); expect(mockSubmitPurposeOfUsageForm).toHaveBeenCalledWith([ @@ -455,14 +482,20 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); await act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }); expect(mockReset).toHaveBeenCalledWith( expect.objectContaining({ index: 1, routes: [ - expect.objectContaining({ name: 'RampAmountInput' }), + expect.objectContaining({ + name: 'RampAmountInput', + params: { amount: mockQuote.fiatAmount }, + }), expect.objectContaining({ name: 'RampAdditionalVerification', params: expect.objectContaining({ @@ -493,7 +526,10 @@ describe('useTransakRouting', () => { await expect( act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }), ).rejects.toThrow(); }); @@ -515,7 +551,10 @@ describe('useTransakRouting', () => { await expect( act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }), ).rejects.toThrow(); }); @@ -537,7 +576,10 @@ describe('useTransakRouting', () => { await expect( act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }), ).rejects.toThrow(); }); @@ -560,7 +602,10 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); await act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }); expect(mockRequestOtt).toHaveBeenCalled(); @@ -586,7 +631,10 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); await act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }); expect(mockRequestOtt).toHaveBeenCalled(); @@ -606,7 +654,10 @@ describe('useTransakRouting', () => { await expect( act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }), ).rejects.toThrow(); }); @@ -622,7 +673,10 @@ describe('useTransakRouting', () => { await expect( act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }), ).rejects.toThrow(); }); @@ -643,14 +697,20 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); await act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }); expect(mockReset).toHaveBeenCalledWith( expect.objectContaining({ index: 1, routes: [ - expect.objectContaining({ name: 'RampAmountInput' }), + expect.objectContaining({ + name: 'RampAmountInput', + params: { amount: mockQuote.fiatAmount }, + }), expect.objectContaining({ name: 'RampKycProcessing', }), @@ -670,7 +730,10 @@ describe('useTransakRouting', () => { await expect( act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }), ).rejects.toThrow(); }); @@ -693,7 +756,10 @@ describe('useTransakRouting', () => { await expect( act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }), ).rejects.toThrow(); }); @@ -717,7 +783,10 @@ describe('useTransakRouting', () => { await expect( act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }), ).rejects.toThrow(); }); @@ -740,7 +809,10 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); await act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }); expect(mockRequestOtt).toHaveBeenCalled(); @@ -752,14 +824,20 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); act(() => { - result.current.navigateToVerifyIdentity({ quote: mockQuote as never }); + result.current.navigateToVerifyIdentity({ + quote: mockQuote as never, + amount: 30, + }); }); expect(mockReset).toHaveBeenCalledWith( expect.objectContaining({ index: 1, routes: [ - expect.objectContaining({ name: 'RampAmountInput' }), + expect.objectContaining({ + name: 'RampAmountInput', + params: { amount: 30 }, + }), expect.objectContaining({ name: 'RampVerifyIdentity', params: expect.objectContaining({ quote: mockQuote }), @@ -771,12 +849,13 @@ describe('useTransakRouting', () => { }); describe('navigateToKycWebview', () => { - it('resets navigation stack to the KYC webview', () => { + it('resets navigation stack to the KYC webview with amount preserved', () => { const { result } = renderHook(() => useTransakRouting()); act(() => { result.current.navigateToKycWebview({ kycUrl: 'https://kyc.example.com', + amount: 30, }); }); @@ -784,7 +863,10 @@ describe('useTransakRouting', () => { expect.objectContaining({ index: 1, routes: [ - expect.objectContaining({ name: 'RampAmountInput' }), + expect.objectContaining({ + name: 'RampAmountInput', + params: { amount: 30 }, + }), expect.objectContaining({ name: 'Checkout', params: expect.objectContaining({ @@ -822,7 +904,10 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); await act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }); return capturedHandleNavigationStateChange; diff --git a/app/components/UI/Ramp/hooks/useTransakRouting.ts b/app/components/UI/Ramp/hooks/useTransakRouting.ts index e83dec59811..661863ddf24 100644 --- a/app/components/UI/Ramp/hooks/useTransakRouting.ts +++ b/app/components/UI/Ramp/hooks/useTransakRouting.ts @@ -159,11 +159,14 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { ); const navigateToVerifyIdentityCallback = useCallback( - ({ quote }: { quote: TransakBuyQuote }) => { + ({ quote, amount }: { quote: TransakBuyQuote; amount?: number }) => { navigation.reset({ index: 1, routes: [ - { name: Routes.RAMP.AMOUNT_INPUT }, + { + name: Routes.RAMP.AMOUNT_INPUT, + params: { amount }, + }, { name: Routes.RAMP.VERIFY_IDENTITY, params: { quote } }, ], }); @@ -175,14 +178,19 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { ({ quote, previousFormData, + amount, }: { quote: TransakBuyQuote; previousFormData?: BasicInfoFormData & AddressFormData; + amount?: number; }) => { navigation.reset({ index: 1, routes: [ - { name: Routes.RAMP.AMOUNT_INPUT }, + { + name: Routes.RAMP.AMOUNT_INPUT, + params: { amount }, + }, { name: Routes.RAMP.BASIC_INFO, params: { quote, previousFormData }, @@ -234,15 +242,20 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { quote, kycUrl, workFlowRunId, + amount, }: { quote: TransakBuyQuote; kycUrl: string; workFlowRunId: string; + amount?: number; }) => { navigation.reset({ index: 1, routes: [ - { name: Routes.RAMP.AMOUNT_INPUT }, + { + name: Routes.RAMP.AMOUNT_INPUT, + params: { amount }, + }, { name: Routes.RAMP.ADDITIONAL_VERIFICATION, params: { quote, kycUrl, workFlowRunId }, @@ -341,7 +354,7 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { ); const navigateToWebviewModalCallback = useCallback( - ({ paymentUrl }: { paymentUrl: string }) => { + ({ paymentUrl, amount }: { paymentUrl: string; amount?: number }) => { const callbackKey = registerCheckoutCallback(handleNavigationStateChange); const [routeName, routeParams] = createCheckoutNavDetails({ url: paymentUrl, @@ -351,7 +364,10 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { navigation.reset({ index: 1, routes: [ - { name: Routes.RAMP.AMOUNT_INPUT }, + { + name: Routes.RAMP.AMOUNT_INPUT, + params: { amount }, + }, { name: routeName, params: routeParams }, ], }); @@ -360,11 +376,14 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { ); const navigateToKycProcessingCallback = useCallback( - ({ quote }: { quote: TransakBuyQuote }) => { + ({ quote, amount }: { quote: TransakBuyQuote; amount?: number }) => { navigation.reset({ index: 1, routes: [ - { name: Routes.RAMP.AMOUNT_INPUT }, + { + name: Routes.RAMP.AMOUNT_INPUT, + params: { amount }, + }, { name: Routes.RAMP.KYC_PROCESSING, params: { quote } }, ], }); @@ -373,7 +392,7 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { ); const navigateToKycWebviewCallback = useCallback( - ({ kycUrl }: { kycUrl: string }) => { + ({ kycUrl, amount }: { kycUrl: string; amount?: number }) => { const [routeName, routeParams] = createCheckoutNavDetails({ url: kycUrl, providerName: 'Transak', @@ -381,7 +400,10 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { navigation.reset({ index: 1, routes: [ - { name: Routes.RAMP.AMOUNT_INPUT }, + { + name: Routes.RAMP.AMOUNT_INPUT, + params: { amount }, + }, { name: routeName, params: routeParams }, ], }); @@ -390,7 +412,7 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { ); const routeAfterAuthentication = useCallback( - async (quote: TransakBuyQuote, depth = 0) => { + async (quote: TransakBuyQuote, amount?: number, depth = 0) => { try { const userDetails = await getUserDetails(); const previousFormData = { @@ -473,7 +495,10 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { throw new Error('Failed to generate payment URL'); } - navigateToWebviewModalCallback({ paymentUrl }); + navigateToWebviewModalCallback({ + paymentUrl, + amount, + }); } return true; } catch (error) { @@ -493,7 +518,7 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { region: regionIsoCode, }); - navigateToBasicInfoCallback({ quote, previousFormData }); + navigateToBasicInfoCallback({ quote, previousFormData, amount }); return; case 'ADDITIONAL_FORMS_REQUIRED': { @@ -511,7 +536,7 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { await submitPurposeOfUsageForm([ 'Buying/selling crypto for investments', ]); - await routeAfterAuthentication(quote, depth + 1); + await routeAfterAuthentication(quote, amount, depth + 1); } else { Logger.error( new Error(`Submit of purpose depth exceeded: ${depth}`), @@ -538,16 +563,17 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { quote, kycUrl: metadata.kycUrl, workFlowRunId: metadata.workFlowRunId, + amount, }); return; } - navigateToKycProcessingCallback({ quote }); + navigateToKycProcessingCallback({ quote, amount }); return; } case 'SUBMITTED': { - navigateToKycProcessingCallback({ quote }); + navigateToKycProcessingCallback({ quote, amount }); return; } From 445b43065bc2151030ceaaf9b543dc58c3031708 Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Mon, 23 Mar 2026 12:34:42 +0000 Subject: [PATCH 165/206] [skip ci] Bump version number to 4147 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 11266e9e0f9..9b0eb3897d2 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4146 + versionCode 4147 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 8ca114a72d6..8b8ae3de325 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4146 + VERSION_NUMBER: 4147 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4146 + FLASK_VERSION_NUMBER: 4147 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 5462a9c3e89..f39563a32dd 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4146; + CURRENT_PROJECT_VERSION = 4147; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4146; + CURRENT_PROJECT_VERSION = 4147; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4146; + CURRENT_PROJECT_VERSION = 4147; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4146; + CURRENT_PROJECT_VERSION = 4147; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4146; + CURRENT_PROJECT_VERSION = 4147; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4146; + CURRENT_PROJECT_VERSION = 4147; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From cc3dfdc4175cce96400e5901e22ec6b49eded279 Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:40:51 +0100 Subject: [PATCH 166/206] chore(runway): cherry-pick fix(ramp): fixes order details bug cp-7.71.0 (#27801) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(ramp): fixes order details bug cp-7.71.0 (#27755) ## **Description** Fixes an order details UI bug where the title for bank transfer details was displaying for non bank-transfer orders. [TRAM 3359](https://consensyssoftware.atlassian.net/browse/TRAM-3359) ## **Changelog** CHANGELOG entry: fixes an order details UI bug ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### * Screenshot 2026-03-20 at 10 03 02 AM *Before** ### **After** Screenshot 2026-03-20 at 11 39 11 AM ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Low risk UI-only change that gates rendering of the bank details section; main risk is unintentionally hiding bank details if upstream field names/structure change. > > **Overview** > Fixes an order details UI bug where the bank-transfer section header could render for non-bank-transfer orders. > > `OrderContent` now returns `null` for `bankDetailFields` unless at least one expected bank detail field (e.g., amount, routing/account, IBAN/BIC) is present, and tests add coverage for absent/empty/non-matching `paymentDetails` vs. bank-transfer/SEPA scenarios. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 65f6cfc1b351e77d3b011a716ff0fb955001daae. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [7e9748e](https://github.com/MetaMask/metamask-mobile/commit/7e9748e60f6ffe74eb7296f745aa982e0fb105b3) Co-authored-by: George Weiler --- .../Views/OrderDetails/OrderContent.test.tsx | 88 ++++++++++++++++++- .../Ramp/Views/OrderDetails/OrderContent.tsx | 12 +++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/app/components/UI/Ramp/Views/OrderDetails/OrderContent.test.tsx b/app/components/UI/Ramp/Views/OrderDetails/OrderContent.test.tsx index d7083fb197a..f4c86a0f6fc 100644 --- a/app/components/UI/Ramp/Views/OrderDetails/OrderContent.test.tsx +++ b/app/components/UI/Ramp/Views/OrderDetails/OrderContent.test.tsx @@ -7,6 +7,14 @@ import { type RampsOrder, RampsOrderStatus } from '@metamask/ramps-controller'; import Clipboard from '@react-native-clipboard/clipboard'; import InAppBrowser from 'react-native-inappbrowser-reborn'; +type RampsOrderWithPaymentDetails = RampsOrder & { + paymentDetails: { + fiatCurrency: string; + paymentMethod: string; + fields: { name: string; id: string; value: string }[]; + }[]; +}; + const mockNavigate = jest.fn(); jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), @@ -177,6 +185,84 @@ describe('OrderContent', () => { ).toBeOnTheScreen(); }); + it('does not render bank details section when paymentDetails is absent', () => { + renderOrder(mockOrder); + + expect(screen.queryByText('To complete your order')).toBeNull(); + }); + + it('does not render bank details section when paymentDetails has no matching fields', () => { + const orderWithPaymentDetails: RampsOrderWithPaymentDetails = { + ...mockOrder, + paymentDetails: [ + { + fiatCurrency: 'USD', + paymentMethod: 'credit_debit_card', + fields: [], + }, + ], + }; + + renderOrder(orderWithPaymentDetails); + + expect(screen.queryByText('To complete your order')).toBeNull(); + }); + + it('renders bank details section when paymentDetails has bank transfer fields', () => { + const orderWithPaymentDetails: RampsOrderWithPaymentDetails = { + ...mockOrder, + paymentDetails: [ + { + fiatCurrency: 'USD', + paymentMethod: 'manual_bank_transfer', + fields: [ + { name: 'Amount', id: 'amount', value: '$100.00' }, + { + name: 'Routing Number', + id: 'routingNumber', + value: '021000021', + }, + { + name: 'Account Number', + id: 'accountNumber', + value: '1234567890', + }, + ], + }, + ], + }; + + renderOrder(orderWithPaymentDetails); + + expect(screen.getByText('To complete your order')).toBeOnTheScreen(); + expect(screen.getByText(/Routing number/i)).toBeOnTheScreen(); + expect(screen.getByText('021000021')).toBeOnTheScreen(); + }); + + it('renders bank details section when paymentDetails only includes SEPA fields', () => { + const orderWithPaymentDetails: RampsOrderWithPaymentDetails = { + ...mockOrder, + paymentDetails: [ + { + fiatCurrency: 'EUR', + paymentMethod: 'sepa_bank_transfer', + fields: [ + { name: 'IBAN', id: 'iban', value: 'DE89370400440532013000' }, + { name: 'BIC', id: 'bic', value: 'COBADEFFXXX' }, + ], + }, + ], + }; + + renderOrder(orderWithPaymentDetails); + + expect(screen.getByText('To complete your order')).toBeOnTheScreen(); + expect(screen.getByText(/^IBAN$/i)).toBeOnTheScreen(); + expect(screen.getByText('DE89370400440532013000')).toBeOnTheScreen(); + expect(screen.getByText(/^BIC$/i)).toBeOnTheScreen(); + expect(screen.getByText('COBADEFFXXX')).toBeOnTheScreen(); + }); + it('truncates long crypto amounts to 5 decimal places', () => { const longDecimalOrder: RampsOrder = { ...mockOrder, @@ -195,7 +281,7 @@ describe('OrderContent', () => { }; renderOrder(tinyAmountOrder); const tokenAmount = screen.getByTestId('ramps-order-details-token-amount'); - // 0.00000614 has 5 leading zeros → "0.0₅614" + // 0.00000614 has 5 leading zeros -> "0.0₅614" expect(tokenAmount).toHaveTextContent('0.0₅614 ETH'); }); diff --git a/app/components/UI/Ramp/Views/OrderDetails/OrderContent.tsx b/app/components/UI/Ramp/Views/OrderDetails/OrderContent.tsx index ed6c21707a0..2843e9d9712 100644 --- a/app/components/UI/Ramp/Views/OrderDetails/OrderContent.tsx +++ b/app/components/UI/Ramp/Views/OrderDetails/OrderContent.tsx @@ -280,6 +280,18 @@ const OrderContent: React.FC = ({ const iban = getFieldValue('IBAN'); const bic = getFieldValue('BIC'); + const hasAnyField = + amount || + accountName || + accountType || + bankName || + routingNumber || + accountNumber || + iban || + bic; + + if (!hasAnyField) return null; + return { amount, accountName, From 38b2cc9842b2de3c5b1229328723ae621eced009 Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Mon, 23 Mar 2026 14:42:39 +0000 Subject: [PATCH 167/206] [skip ci] Bump version number to 4148 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 9b0eb3897d2..365668f38d1 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4147 + versionCode 4148 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 8b8ae3de325..af6f803f5e3 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4147 + VERSION_NUMBER: 4148 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4147 + FLASK_VERSION_NUMBER: 4148 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index f39563a32dd..54d4427a731 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4147; + CURRENT_PROJECT_VERSION = 4148; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4147; + CURRENT_PROJECT_VERSION = 4148; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4147; + CURRENT_PROJECT_VERSION = 4148; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4147; + CURRENT_PROJECT_VERSION = 4148; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4147; + CURRENT_PROJECT_VERSION = 4148; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4147; + CURRENT_PROJECT_VERSION = 4148; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From ddf47210c1bfbc9dc55788880cfa5e33b2491422 Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:38:42 +0100 Subject: [PATCH 168/206] chore(runway): cherry-pick fix: start Ramps V2 init when remote feature flags hydrate cp-7.71.0 (#27807) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix: start Ramps V2 init when remote feature flags hydrate cp-7.71.0 (#27778) ## **Description** On a fresh install, `RemoteFeatureFlagController` loads flags asynchronously while Engine builds controllers. `rampsControllerInit` previously read the unified buy V2 flag only once; if flags were not in state yet, `RampsController.init()` never ran, so buy token lists stayed empty until a full app restart. This change subscribes to `RemoteFeatureFlagController:stateChange` (already delegated on `RampsControllerInitMessenger`) and re-runs the same V2 startup path when remote flag state updates. Order-status subscriptions are registered at most once. `RampsController.init()` remains idempotent for repeated calls. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TRAM-3350 ## **Manual testing steps** ```gherkin Feature: Unified buy V2 after fresh install Scenario: Buy token list loads without restarting the app Given a dev build with unified buy V2 enabled via remote flags And the app is installed fresh (or remote flag cache cleared) When the user completes onboarding and opens Buy / token selection Then tokens and providers load without requiring an app restart ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Adds a new subscription-driven initialization path triggered by `RemoteFeatureFlagController:stateChange`, which can change startup behavior and potentially cause repeated init/polling if underlying idempotency assumptions are wrong. > > **Overview** > Ensures Unified Buy V2 startup runs even when remote feature flags hydrate *after* Engine/controller initialization by subscribing to `RemoteFeatureFlagController:stateChange` and re-checking the V2 flag. > > Refactors V2 startup into a helper that conditionally calls `RampsController.init()`/`startOrderPolling()` and registers order-status subscriptions only once. Updates tests to cover the “flag off at startup then enabled on stateChange” scenario and to include `subscribe` in the thrown-state mock. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 78ff8000147e10ce2325b2dfa563dd0dd16b878c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [c6d96b6](https://github.com/MetaMask/metamask-mobile/commit/c6d96b6ff1e0efa6ad7172666f29e817a3e3ce9e) Co-authored-by: Amitabh Aggarwal --- .../ramps-controller-init.test.ts | 35 +++++++++++++++++++ .../ramps-controller/ramps-controller-init.ts | 35 ++++++++++++++----- 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts b/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts index e96ce515ecb..89918d75251 100644 --- a/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts +++ b/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts @@ -207,6 +207,40 @@ describe('ramps controller init', () => { }); }); + it('calls init when remote flags were off at startup then V2 enables on RemoteFeatureFlagController:stateChange', async () => { + let remoteEnabled = false; + const subscribeMock = jest.fn(); + const initMessenger = { + call: jest.fn(() => ({ + remoteFeatureFlags: { + rampsUnifiedBuyV2: remoteEnabled + ? { enabled: true, minimumVersion: '1.0.0' } + : { enabled: false }, + }, + })), + subscribe: subscribeMock, + } as unknown as RampsControllerInitMessenger; + + initRequestMock.initMessenger = initMessenger; + + rampsControllerInit(initRequestMock); + + expect(mockInit).not.toHaveBeenCalled(); + + const stateChangeHandler = subscribeMock.mock.calls.find( + (call) => call[0] === 'RemoteFeatureFlagController:stateChange', + )?.[1] as () => void; + + expect(stateChangeHandler).toBeDefined(); + + remoteEnabled = true; + stateChangeHandler(); + + await waitFor(() => { + expect(mockInit).toHaveBeenCalledTimes(1); + }); + }); + it('handles init failure gracefully', async () => { initRequestMock.initMessenger = createMockInitMessenger({ enabled: true, @@ -253,6 +287,7 @@ describe('ramps controller init', () => { call: jest.fn().mockImplementation(() => { throw new Error('Controller not ready'); }), + subscribe: jest.fn(), } as unknown as RampsControllerInitMessenger; rampsControllerInit(initRequestMock); diff --git a/app/core/Engine/controllers/ramps-controller/ramps-controller-init.ts b/app/core/Engine/controllers/ramps-controller/ramps-controller-init.ts index f7a02ca5605..09ebbe7a9f1 100644 --- a/app/core/Engine/controllers/ramps-controller/ramps-controller-init.ts +++ b/app/core/Engine/controllers/ramps-controller/ramps-controller-init.ts @@ -11,8 +11,7 @@ import { handleOrderStatusChangedForNotifications } from './event-handlers/notif import { handleOrderStatusChangedForMetrics } from './event-handlers/analytics'; /** - * Determines whether the ramps unified buy V2 feature is enabled - * by reading the remote feature flag state. + * Whether Unified Buy V2 is enabled per RemoteFeatureFlagController state. * * @param initMessenger - The init messenger to read RemoteFeatureFlagController state. * @returns Whether V2 is enabled. @@ -54,21 +53,28 @@ export const rampsControllerInit: ControllerInitFunction< state: rampsControllerState, }); - const isV2Enabled = getIsRampsUnifiedBuyV2Enabled(initMessenger); + let orderSubscriptionsRegistered = false; - if (isV2Enabled) { + const registerUnifiedBuyV2OrderSubscriptions = (): void => { + if (orderSubscriptionsRegistered) { + return; + } + orderSubscriptionsRegistered = true; initMessenger.subscribe( 'RampsController:orderStatusChanged', handleOrderStatusChangedForNotifications, ); - initMessenger.subscribe( 'RampsController:orderStatusChanged', handleOrderStatusChangedForMetrics, ); + }; - // Start init immediately so tokens (and providers) load on app start. - // init() is async and does not block controller creation. + const startUnifiedBuyV2IfEnabled = (): void => { + if (!getIsRampsUnifiedBuyV2Enabled(initMessenger)) { + return; + } + registerUnifiedBuyV2OrderSubscriptions(); controller .init() .then(() => { @@ -77,7 +83,20 @@ export const rampsControllerInit: ControllerInitFunction< .catch(() => { // Initialization failed - error state will be available via selectors }); - } + }; + + startUnifiedBuyV2IfEnabled(); + + // Remote flags can be empty on first Engine init and fill in once the + // controller has fetched; re-check so RampsController.init() runs then. + // + // This event fires for any RemoteFeatureFlagController state update — not + // only rampsUnifiedBuyV2. When V2 is off, startUnifiedBuyV2IfEnabled returns + // immediately. When V2 is on, order subscriptions register once; init() and + // startOrderPolling() are idempotent, so repeat invocations are safe. + initMessenger.subscribe('RemoteFeatureFlagController:stateChange', () => { + startUnifiedBuyV2IfEnabled(); + }); return { controller, From b1585639f33dd7d2caaedd79ce3c430df9e1bb4d Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Mon, 23 Mar 2026 15:40:26 +0000 Subject: [PATCH 169/206] [skip ci] Bump version number to 4149 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 365668f38d1..17bf777c9ba 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4148 + versionCode 4149 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index af6f803f5e3..a279f3d0f7a 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4148 + VERSION_NUMBER: 4149 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4148 + FLASK_VERSION_NUMBER: 4149 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 54d4427a731..5ef615ead93 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4148; + CURRENT_PROJECT_VERSION = 4149; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4148; + CURRENT_PROJECT_VERSION = 4149; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4148; + CURRENT_PROJECT_VERSION = 4149; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4148; + CURRENT_PROJECT_VERSION = 4149; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4148; + CURRENT_PROJECT_VERSION = 4149; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4148; + CURRENT_PROJECT_VERSION = 4149; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 0cc5d5e51a9effeab0afadebf4f11fe58aab03f3 Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:36:14 +0100 Subject: [PATCH 170/206] chore(runway): cherry-pick fix(ramps): fixes 0 ETH ramps issue when order data is not yet available cp-7.71.0 (#27812) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(ramps): fixes 0 ETH ramps issue when order data is not yet available cp-7.71.0 (#27756) ## **Description** "0 ETH" was displayed on some order pages when the order info was not yet available. This bug fixes by adding "..." placeholder until the info arrives. [TRAM-3360](https://consensyssoftware.atlassian.net/browse/TRAM-3360) ## **Changelog** CHANGELOG entry: Fixes small UI issue with ramps orders ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** Screenshot 2026-03-20 at 11 51 50 AM Screenshot 2026-03-20 at 11 53 40 AM ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Adjusts order-details loading/terminal-state logic and amount formatting, which could change what users see for certain pending/failed orders; impact is limited to UI display and snapshots. > > **Overview** > Prevents ramps order UIs from showing `0 ETH`/`0` amounts when data hasn’t arrived by treating `0`/missing `cryptoAmount` (and related fiat fields) as **unknown** and rendering an `...` placeholder in both the orders list (`displayOrder`) and order details (`OrderContent`). > > Order details now distinguishes *loading* vs *terminal* statuses (e.g., `Failed`, `Cancelled`) so terminal orders without amounts render placeholders instead of skeleton loaders, and fiat `fees`/`total` formatting is switched to `formatWithThreshold` currency formatting (snapshot updates included). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit a9bc07215c68095a9f9630fdbeb8b2b36fb35382. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Cursor Agent [174afa0](https://github.com/MetaMask/metamask-mobile/commit/174afa013f99a09a0552f87fb249dcfa8b5b65a1) Co-authored-by: George Weiler Co-authored-by: Cursor Agent --- .../__snapshots__/OrdersList.test.tsx.snap | 12 ++-- .../Views/OrderDetails/OrderContent.test.tsx | 27 ++++++-- .../Ramp/Views/OrderDetails/OrderContent.tsx | 66 +++++++++++++------ .../__snapshots__/OrderContent.test.tsx.snap | 18 ++--- .../__snapshots__/displayOrder.test.ts.snap | 2 +- .../UI/Ramp/utils/displayOrder.test.ts | 31 +++++++-- app/components/UI/Ramp/utils/displayOrder.ts | 12 +++- 7 files changed, 120 insertions(+), 48 deletions(-) diff --git a/app/components/UI/Ramp/Aggregator/Views/OrdersList/__snapshots__/OrdersList.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/OrdersList/__snapshots__/OrdersList.test.tsx.snap index 5091ad05fb1..7ea9855e1a1 100644 --- a/app/components/UI/Ramp/Aggregator/Views/OrdersList/__snapshots__/OrdersList.test.tsx.snap +++ b/app/components/UI/Ramp/Aggregator/Views/OrdersList/__snapshots__/OrdersList.test.tsx.snap @@ -127,7 +127,7 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` { "account": "0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A", "createdAt": 0, - "cryptoAmount": 0, + "cryptoAmount": "...", "cryptoCurrencySymbol": "ETH", "fiatAmount": undefined, "fiatCurrencyCode": "USD", @@ -1336,7 +1336,7 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` } testID="orders-list-crypto-amount-buy-6" > - 0 + ... ETH @@ -1505,7 +1505,7 @@ exports[`OrdersList renders correctly 1`] = ` { "account": "0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A", "createdAt": 0, - "cryptoAmount": 0, + "cryptoAmount": "...", "cryptoCurrencySymbol": "ETH", "fiatAmount": undefined, "fiatCurrencyCode": "USD", @@ -2864,7 +2864,7 @@ exports[`OrdersList renders correctly 1`] = ` } testID="orders-list-crypto-amount-buy-7" > - 0 + ... ETH @@ -4979,7 +4979,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` { "account": "0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A", "createdAt": 0, - "cryptoAmount": 0, + "cryptoAmount": "...", "cryptoCurrencySymbol": "ETH", "fiatAmount": undefined, "fiatCurrencyCode": "USD", @@ -6338,7 +6338,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` } testID="orders-list-crypto-amount-buy-7" > - 0 + ... ETH diff --git a/app/components/UI/Ramp/Views/OrderDetails/OrderContent.test.tsx b/app/components/UI/Ramp/Views/OrderDetails/OrderContent.test.tsx index f4c86a0f6fc..caabda17194 100644 --- a/app/components/UI/Ramp/Views/OrderDetails/OrderContent.test.tsx +++ b/app/components/UI/Ramp/Views/OrderDetails/OrderContent.test.tsx @@ -90,13 +90,14 @@ describe('OrderContent', () => { const pendingOrder: RampsOrder = { ...mockOrder, fiatAmount: 0, + cryptoAmount: 0, status: RampsOrderStatus.Pending, }; renderOrder(pendingOrder); expect(screen.toJSON()).toMatchSnapshot(); }); - it('shows ellipsis for token amount when cryptoAmount is 0 or missing', () => { + it('shows placeholder for token amount when cryptoAmount is 0 or missing', () => { const orderWithZeroCrypto: RampsOrder = { ...mockOrder, cryptoAmount: 0, @@ -285,7 +286,7 @@ describe('OrderContent', () => { expect(tokenAmount).toHaveTextContent('0.0₅614 ETH'); }); - it('shows "..." when cryptoAmount is missing', () => { + it('shows placeholder when cryptoAmount is missing', () => { const noAmountOrder: RampsOrder = { ...mockOrder, cryptoAmount: undefined as unknown as number, @@ -295,14 +296,32 @@ describe('OrderContent', () => { expect(tokenAmount).toHaveTextContent('... ETH'); }); - it('renders "0" when cryptoAmount is zero', () => { + it('shows placeholder when cryptoAmount is zero', () => { const zeroAmountOrder: RampsOrder = { ...mockOrder, cryptoAmount: 0, }; renderOrder(zeroAmountOrder); const tokenAmount = screen.getByTestId('ramps-order-details-token-amount'); - expect(tokenAmount).toHaveTextContent('0 ETH'); + expect(tokenAmount).toHaveTextContent('... ETH'); + }); + + it('shows placeholder amounts for terminal orders with no amounts', () => { + const failedOrder: RampsOrder = { + ...mockOrder, + cryptoAmount: 0, + fiatAmount: 0, + totalFeesFiat: 0, + status: RampsOrderStatus.Failed, + }; + + renderOrder(failedOrder); + + expect(screen.getByText('Failed')).toBeOnTheScreen(); + expect( + screen.getByTestId('ramps-order-details-token-amount'), + ).toHaveTextContent('... ETH'); + expect(screen.getAllByText('...')).toHaveLength(2); }); it('does not render info row when statusDescription is absent', () => { diff --git a/app/components/UI/Ramp/Views/OrderDetails/OrderContent.tsx b/app/components/UI/Ramp/Views/OrderDetails/OrderContent.tsx index 2843e9d9712..abb5fc9491d 100644 --- a/app/components/UI/Ramp/Views/OrderDetails/OrderContent.tsx +++ b/app/components/UI/Ramp/Views/OrderDetails/OrderContent.tsx @@ -26,7 +26,6 @@ import BadgeWrapper, { import { AvatarSize } from '../../../../../component-library/components/Avatars/Avatar'; import I18n, { strings } from '../../../../../../locales/i18n'; import { toDateFormat } from '../../../../../util/date'; -import { renderFiat } from '../../../../../util/number'; import { formatSubscriptNotation } from '../../../../../util/number/subscriptNotation'; import { formatWithThreshold } from '../../../../../util/assets'; import { getNetworkImageSource } from '../../../../../util/networks'; @@ -41,6 +40,14 @@ import BankDetailRow from '../../Deposit/components/BankDetailRow/BankDetailRow' import Routes from '../../../../../constants/navigation/Routes'; import { RampsOrderDetailsSelectorsIDs } from './OrderDetails.testIds'; +const AMOUNT_PLACEHOLDER = '...'; +const TERMINAL_STATUSES = new Set([ + RampsOrderStatus.Completed, + RampsOrderStatus.Failed, + RampsOrderStatus.Cancelled, + RampsOrderStatus.IdExpired, +]); + const localStyles = StyleSheet.create({ badgeWrapperCenter: { alignSelf: 'center', @@ -168,7 +175,16 @@ const OrderContent: React.FC = ({ } }; - const isLoading = !order.fiatAmount; + const fiatCurrencyCode = order.fiatCurrency?.symbol ?? ''; + const cryptoSymbol = order.cryptoCurrency?.symbol ?? ''; + + const hasAmounts = Boolean( + fiatCurrencyCode && + ((order.fiatAmount != null && Number(order.fiatAmount) > 0) || + (order.cryptoAmount != null && Number(order.cryptoAmount) > 0)), + ); + const isTerminal = TERMINAL_STATUSES.has(order.status); + const isLoading = !hasAmounts && !isTerminal; const handleClose = useCallback(() => { trackEvent( @@ -207,10 +223,6 @@ const OrderContent: React.FC = ({ trackEvent, ]); - const fiatDenomSymbol = order.fiatCurrency?.denomSymbol ?? ''; - const fiatCurrencyCode = order.fiatCurrency?.symbol ?? ''; - const cryptoSymbol = order.cryptoCurrency?.symbol ?? ''; - const normalizeChainIdForBadge = (chainId: string): string => { if (!chainId || chainId.includes(':') || chainId.startsWith('0x')) { return chainId; @@ -332,7 +344,7 @@ const OrderContent: React.FC = ({ fontWeight={FontWeight.Bold} twClassName="mt-6 text-center" > - {order.cryptoAmount != null + {order.cryptoAmount != null && Number(order.cryptoAmount) > 0 ? (formatSubscriptNotation( parseFloat(String(order.cryptoAmount)), ) ?? @@ -345,7 +357,7 @@ const OrderContent: React.FC = ({ maximumFractionDigits: 5, }, )) - : '...'}{' '} + : AMOUNT_PLACEHOLDER}{' '} {cryptoSymbol} @@ -475,12 +487,19 @@ const OrderContent: React.FC = ({ ) : ( - {fiatDenomSymbol} - {renderFiat( - Number(order.totalFeesFiat ?? 0), - fiatCurrencyCode, - fiatDecimals, - )} + {hasAmounts + ? formatWithThreshold( + Number(order.totalFeesFiat ?? 0), + 0, + I18n.locale, + { + style: 'currency', + currency: fiatCurrencyCode, + minimumFractionDigits: fiatDecimals, + maximumFractionDigits: fiatDecimals, + }, + ) + : AMOUNT_PLACEHOLDER} )} @@ -501,12 +520,19 @@ const OrderContent: React.FC = ({ ) : ( - {fiatDenomSymbol} - {renderFiat( - Number(order.fiatAmount ?? 0), - fiatCurrencyCode, - fiatDecimals, - )} + {hasAmounts + ? formatWithThreshold( + Number(order.fiatAmount ?? 0), + 0, + I18n.locale, + { + style: 'currency', + currency: fiatCurrencyCode, + minimumFractionDigits: fiatDecimals, + maximumFractionDigits: fiatDecimals, + }, + ) + : AMOUNT_PLACEHOLDER} )} diff --git a/app/components/UI/Ramp/Views/OrderDetails/__snapshots__/OrderContent.test.tsx.snap b/app/components/UI/Ramp/Views/OrderDetails/__snapshots__/OrderContent.test.tsx.snap index ddbffbb2581..2f20bd21c42 100644 --- a/app/components/UI/Ramp/Views/OrderDetails/__snapshots__/OrderContent.test.tsx.snap +++ b/app/components/UI/Ramp/Views/OrderDetails/__snapshots__/OrderContent.test.tsx.snap @@ -472,8 +472,7 @@ exports[`OrderContent renders completed state correctly 1`] = ` ] } > - $ - 2.5 USD + $2.50 - $ - 100 USD + $100.00 - 0.05 + ... ETH @@ -1095,7 +1093,7 @@ exports[`OrderContent renders loading state when order has no amount 1`] = ` `; -exports[`OrderContent shows ellipsis for token amount when cryptoAmount is 0 or missing 1`] = ` +exports[`OrderContent shows placeholder for token amount when cryptoAmount is 0 or missing 1`] = ` - 0 + ... ETH @@ -1567,8 +1565,7 @@ exports[`OrderContent shows ellipsis for token amount when cryptoAmount is 0 or ] } > - $ - 2.5 USD + $2.50 - $ - 100 USD + $100.00 { expect(result).toMatchSnapshot(); }); - it('defaults cryptoAmount to 0 when undefined', () => { - const fiatOrder = createMockFiatOrder({ cryptoAmount: undefined }); - const result = fiatOrderToDisplayOrder(fiatOrder); - expect(result.cryptoAmount).toBe(0); + it('uses placeholder when cryptoAmount is undefined or zero', () => { + expect( + fiatOrderToDisplayOrder( + createMockFiatOrder({ cryptoAmount: undefined }), + ).cryptoAmount, + ).toBe('...'); + expect( + fiatOrderToDisplayOrder(createMockFiatOrder({ cryptoAmount: 0 })) + .cryptoAmount, + ).toBe('...'); }); }); @@ -141,6 +147,23 @@ describe('displayOrder', () => { const result = rampsOrderToDisplayOrder(order); expect(result.createdAt).toBe(0); }); + + it('uses placeholder when cryptoAmount is 0 or missing', () => { + expect( + rampsOrderToDisplayOrder(createMockRampsOrder({ cryptoAmount: 0 })) + .cryptoAmount, + ).toBe('...'); + expect( + rampsOrderToDisplayOrder( + createMockRampsOrder({ cryptoAmount: undefined }), + ).cryptoAmount, + ).toBe('...'); + expect( + rampsOrderToDisplayOrder( + createMockRampsOrder({ cryptoAmount: null as unknown as string }), + ).cryptoAmount, + ).toBe('...'); + }); }); describe('mergeDisplayOrders', () => { diff --git a/app/components/UI/Ramp/utils/displayOrder.ts b/app/components/UI/Ramp/utils/displayOrder.ts index bf51aede63d..ad111e7b051 100644 --- a/app/components/UI/Ramp/utils/displayOrder.ts +++ b/app/components/UI/Ramp/utils/displayOrder.ts @@ -5,6 +5,8 @@ import { } from '../../../../reducers/fiatOrders'; import { FIAT_ORDER_PROVIDERS } from '../../../../constants/on-ramp'; +const AMOUNT_PLACEHOLDER = '...'; + export interface DisplayOrder { id: string; source: 'legacy' | 'v2'; @@ -37,7 +39,10 @@ export function fiatOrderToDisplayOrder(order: FiatOrder): DisplayOrder { createdAt: toEpochMs(order.createdAt), fiatAmount: order.amount, fiatCurrencyCode: order.currency, - cryptoAmount: order.cryptoAmount ?? 0, + cryptoAmount: + order.cryptoAmount != null && Number(order.cryptoAmount) > 0 + ? order.cryptoAmount + : AMOUNT_PLACEHOLDER, cryptoCurrencySymbol: order.cryptocurrency, network: order.network, status: order.state, @@ -65,7 +70,10 @@ export function rampsOrderToDisplayOrder(order: RampsOrder): DisplayOrder { createdAt: toEpochMs(order.createdAt), fiatAmount: order.fiatAmount, fiatCurrencyCode: order.fiatCurrency?.symbol ?? '', - cryptoAmount: order.cryptoAmount, + cryptoAmount: + order.cryptoAmount != null && Number(order.cryptoAmount) > 0 + ? order.cryptoAmount + : AMOUNT_PLACEHOLDER, cryptoCurrencySymbol: order.cryptoCurrency?.symbol ?? '', network: order.network?.chainId ?? '', status: RAMPS_STATUS_TO_DISPLAY[order.status] ?? 'PENDING', From 9035d5c9543099d9500c122b505716917360a49b Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Mon, 23 Mar 2026 16:38:29 +0000 Subject: [PATCH 171/206] [skip ci] Bump version number to 4150 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 17bf777c9ba..1641e9c34a8 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4149 + versionCode 4150 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index a279f3d0f7a..844fa82a20f 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4149 + VERSION_NUMBER: 4150 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4149 + FLASK_VERSION_NUMBER: 4150 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 5ef615ead93..68dd8095c2b 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4149; + CURRENT_PROJECT_VERSION = 4150; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4149; + CURRENT_PROJECT_VERSION = 4150; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4149; + CURRENT_PROJECT_VERSION = 4150; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4149; + CURRENT_PROJECT_VERSION = 4150; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4149; + CURRENT_PROJECT_VERSION = 4150; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4149; + CURRENT_PROJECT_VERSION = 4150; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 8d1b7c053da3766ccbc12372f17118838809a766 Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Mon, 23 Mar 2026 16:44:43 +0000 Subject: [PATCH 172/206] [skip ci] Bump version number to 4151 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 1641e9c34a8..942c5aa6490 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4150 + versionCode 4151 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 844fa82a20f..471161513ea 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4150 + VERSION_NUMBER: 4151 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4150 + FLASK_VERSION_NUMBER: 4151 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 68dd8095c2b..7ff6e0d55cf 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4150; + CURRENT_PROJECT_VERSION = 4151; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4150; + CURRENT_PROJECT_VERSION = 4151; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4150; + CURRENT_PROJECT_VERSION = 4151; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4150; + CURRENT_PROJECT_VERSION = 4151; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4150; + CURRENT_PROJECT_VERSION = 4151; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4150; + CURRENT_PROJECT_VERSION = 4151; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From c6a9f8783e64602b42734edd2e44d50e0dde1791 Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 20:21:43 +0000 Subject: [PATCH 173/206] chore(runway): cherry-pick chore(deps): ramps-controller preview for MetaMask/core#8251 -> cp-7.71.0 (#27823) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - chore(deps): ramps-controller preview for MetaMask/core#8251 -> cp-7.71.0 (#27709) ## **Description** Integration PR to validate **MetaMask/core** changes in mobile CI/E2E by resolving `@metamask/ramps-controller` from a **preview** npm package (`previewBuilds` in `package.json` + updated `yarn.lock`). No application code changes. **Core PR:** https://github.com/MetaMask/core/pull/8251 After core merges and a **released** version is published, this PR should be updated to remove `previewBuilds` and bump `dependencies` to the real version before merge. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: N/A (validation / dependency preview only) ## **Manual testing steps** ```gherkin Feature: ramps-controller preview validation Scenario: app resolves preview package Given a clean install from this branch When the app bundles and runs Then @metamask/ramps-controller resolves to the preview version from previewBuilds Scenario: ramps flows still work Given the app is built from this branch When user exercises on-ramp flows that use ramps-controller Then no regressions vs main (same UX; underlying package is preview) ``` ## **Screenshots/Recordings** N/A — dependency-only change. ## **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 - [ ] I've included tests if applicable - [ ] 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. ## **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** > Low risk because this is a dependency source/version switch for `@metamask/ramps-controller` with no application code changes; main risk is behavior changes introduced by the preview package at runtime. > > **Overview** > Switches `@metamask/ramps-controller` to a **preview build** via `previewBuilds` in `package.json` and `yarn.lock` (e.g. `@metamask-previews/ramps-controller@12.0.0-preview-434bd0c`). Update the preview version string if the bot publishes a newer build for core#8251. --- > [!NOTE] > **Low Risk** > Low risk patch-level dependency update; main risk is any runtime behavior changes in `@metamask/ramps-controller` affecting ramp flows, plus potential test fixture mismatches if state shape changes again. > > **Overview** > Updates `@metamask/ramps-controller` from `12.0.0` to `12.0.1` (including lockfile resolution changes). > > Aligns the default E2E/unit fixture (`default-fixture.json`) with the newer `RampsController` state shape by adding persisted sub-state for countries, providers/payment methods/tokens, native provider (Transak) auth/kyc/user details, requests, and orders. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 0b2fc2f72048eb5b41fbf6932072f9d063b9bf43. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [4a4f0ae](https://github.com/MetaMask/metamask-mobile/commit/4a4f0ae17c807e332bfa524c20efceb0cd3dc6e6) Co-authored-by: Yu-Gi-Oh! <54774811+imyugioh@users.noreply.github.com> --- package.json | 2 +- .../fixtures/json/default-fixture.json | 49 +++++++++++++++++++ yarn.lock | 10 ++-- 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 3b088af877e..53e998ccddc 100644 --- a/package.json +++ b/package.json @@ -277,7 +277,7 @@ "@metamask/preinstalled-example-snap": "^0.7.2", "@metamask/profile-metrics-controller": "^3.1.0", "@metamask/profile-sync-controller": "^28.0.0", - "@metamask/ramps-controller": "^12.0.0", + "@metamask/ramps-controller": "^12.0.1", "@metamask/react-native-acm": "1.2.0", "@metamask/react-native-actionsheet": "2.4.2", "@metamask/react-native-button": "^3.0.0", diff --git a/tests/framework/fixtures/json/default-fixture.json b/tests/framework/fixtures/json/default-fixture.json index ee5e110fd05..88ec07135f4 100644 --- a/tests/framework/fixtures/json/default-fixture.json +++ b/tests/framework/fixtures/json/default-fixture.json @@ -480,6 +480,55 @@ "useTransactionSimulations": true }, "RampsController": { + "countries": { + "data": [], + "error": null, + "isLoading": false, + "selected": null + }, + "nativeProviders": { + "transak": { + "buyQuote": { + "data": null, + "error": null, + "isLoading": false, + "selected": null + }, + "isAuthenticated": false, + "kycRequirement": { + "data": null, + "error": null, + "isLoading": false, + "selected": null + }, + "userDetails": { + "data": null, + "error": null, + "isLoading": false, + "selected": null + } + } + }, + "orders": [], + "paymentMethods": { + "data": [], + "error": null, + "isLoading": false, + "selected": null + }, + "providers": { + "data": [], + "error": null, + "isLoading": false, + "selected": null + }, + "requests": {}, + "tokens": { + "data": null, + "error": null, + "isLoading": false, + "selected": null + }, "userRegion": null }, "RemoteFeatureFlagController": { diff --git a/yarn.lock b/yarn.lock index c37bb543ce0..1bfe4fef13d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9387,14 +9387,14 @@ __metadata: languageName: node linkType: hard -"@metamask/ramps-controller@npm:^12.0.0": - version: 12.0.0 - resolution: "@metamask/ramps-controller@npm:12.0.0" +"@metamask/ramps-controller@npm:^12.0.1": + version: 12.0.1 + resolution: "@metamask/ramps-controller@npm:12.0.1" dependencies: "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.19.0" "@metamask/messenger": "npm:^0.3.0" - checksum: 10/fb22bbab95045b7c5d80d3fb24bd41b831e0516733cdf1a0159514cbd8b0aededd599818adecfbc86802299095a223251733c271a8d0b6db2d88b25c971da1ed + checksum: 10/a7f9428cb824bd0175ee1cc603d77c650fa7a23c7183e2cc0a0f21ee9b6378d80bbd1e496654e40d2edcfc840e60dd4a09d80feacb1746087a67b66761e1e6c7 languageName: node linkType: hard @@ -35597,7 +35597,7 @@ __metadata: "@metamask/profile-metrics-controller": "npm:^3.1.0" "@metamask/profile-sync-controller": "npm:^28.0.0" "@metamask/providers": "npm:^18.3.1" - "@metamask/ramps-controller": "npm:^12.0.0" + "@metamask/ramps-controller": "npm:^12.0.1" "@metamask/react-native-acm": "npm:1.2.0" "@metamask/react-native-actionsheet": "npm:2.4.2" "@metamask/react-native-button": "npm:^3.0.0" From b7b8475f4680ee7e8286b44a489d186a12a9dd7d Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Mon, 23 Mar 2026 20:23:20 +0000 Subject: [PATCH 174/206] [skip ci] Bump version number to 4155 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 942c5aa6490..8ae5c474c06 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4151 + versionCode 4155 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 471161513ea..9b6601a86c3 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4151 + VERSION_NUMBER: 4155 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4151 + FLASK_VERSION_NUMBER: 4155 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 7ff6e0d55cf..82deec54976 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4151; + CURRENT_PROJECT_VERSION = 4155; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4151; + CURRENT_PROJECT_VERSION = 4155; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4151; + CURRENT_PROJECT_VERSION = 4155; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4151; + CURRENT_PROJECT_VERSION = 4155; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4151; + CURRENT_PROJECT_VERSION = 4155; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4151; + CURRENT_PROJECT_VERSION = 4155; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 5ee4f82601e11c622fd17814fa85941831ec0ee2 Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 09:17:33 +0100 Subject: [PATCH 175/206] chore(runway): cherry-pick chore: adds market insights metric to Perps view entry point cp-7.71.0 (#27821) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - chore: adds market insights metric to Perps view entry point cp-7.71.0 (#27814) ## **Description** Adds two missing metric events to the Perps Market Details view to bring it to parity with the token details flow. `MARKET_INSIGHTS_OPENED` now fires whenever a user taps the Market Insights entry card, and `PERPS_SCREEN_VIEWED` now includes a `market_insights_displayed` boolean property that reflects whether a report was actually shown, with the event held until the insights fetch resolves so the value is fully accurate. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Low risk: changes are limited to analytics instrumentation and event timing gated on Market Insights loading, with added unit tests to validate tracking and navigation behavior. > > **Overview** > Adds missing Market Insights instrumentation to the Perps market details screen. > > `PerpsMarketDetailsView` now fires `MetaMetricsEvents.MARKET_INSIGHTS_OPENED` (with `perps_market`) when the Market Insights entry card is tapped, and delays `MetaMetricsEvents.PERPS_SCREEN_VIEWED` until insights loading completes so it can include an accurate `market_insights_displayed` boolean. > > Updates/extends `PerpsMarketDetailsView.test.tsx` with mocks for `useMarketInsights`/feature flags and new tests covering the new tracking payloads and navigation to `Routes.MARKET_INSIGHTS.VIEW` with `isPerps: true`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit dc97116e56c15cbf0514e47c2529982ba03b75b8. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [6e0f698](https://github.com/MetaMask/metamask-mobile/commit/6e0f698a7fc6d58c005e7c86f05f1288dfeb2607) Co-authored-by: António Regadas --- .../PerpsMarketDetailsView.test.tsx | 160 ++++++++++++++++++ .../PerpsMarketDetailsView.tsx | 17 +- 2 files changed, 174 insertions(+), 3 deletions(-) diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx index 0c4c30acf07..28bd6e07d45 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx @@ -10,6 +10,8 @@ import { import { PerpsConnectionProvider } from '../../providers/PerpsConnectionProvider'; import { useDefaultPayWithTokenWhenNoPerpsBalance } from '../../hooks/useDefaultPayWithTokenWhenNoPerpsBalance'; import { Linking } from 'react-native'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import Routes from '../../../../../constants/navigation/Routes'; // Mock Linking jest.mock('react-native/Libraries/Linking/Linking', () => ({ @@ -394,6 +396,34 @@ jest.mock('../../hooks/usePerpsEventTracking', () => ({ })), })); +const mockUseMarketInsights = jest.fn( + (_assetId?: string | null, _isEnabled?: boolean) => ({ + report: null as Record | null, + isLoading: false, + error: null, + timeAgo: '', + }), +); + +jest.mock('../../../MarketInsights', () => ({ + useMarketInsights: (assetId: string | null | undefined, isEnabled: boolean) => + mockUseMarketInsights(assetId, isEnabled), + MarketInsightsEntryCard: ({ onPress }: { onPress: () => void }) => { + const { TouchableOpacity } = jest.requireActual('react-native'); + return ( + + ); + }, + selectMarketInsightsEnabled: jest.fn(), +})); + +jest.mock( + '../../../../../selectors/featureFlagController/marketInsights', + () => ({ + selectMarketInsightsPerpsEnabled: jest.fn(), + }), +); + jest.mock('../../hooks/usePerpsPrices', () => ({ usePerpsPrices: jest.fn(() => ({})), })); @@ -3264,4 +3294,134 @@ describe('PerpsMarketDetailsView', () => { expect(queryByText('25x')).toBeNull(); }); }); + + describe('Market Insights analytics', () => { + const mockReport = { + summary: 'BTC momentum is building with increased buying pressure.', + sentiment: 'bullish', + generatedAt: new Date().toISOString(), + }; + + // Stable track mock reference set up in beforeEach via mockImplementation + const mockTrack = jest.fn(); + + beforeEach(() => { + // Override usePerpsEventTracking to expose a capturable track mock + const { usePerpsEventTracking: mockUsePerpsEventTrackingFn } = + jest.requireMock('../../hooks/usePerpsEventTracking'); + mockUsePerpsEventTrackingFn.mockImplementation(() => ({ + track: mockTrack, + })); + + // Enable perps market insights feature flag + const { useSelector } = jest.requireMock('react-redux'); + const { selectPerpsEligibility } = jest.requireMock( + '../../selectors/perpsController', + ); + const { selectMarketInsightsPerpsEnabled } = jest.requireMock( + '../../../../../selectors/featureFlagController/marketInsights', + ); + useSelector.mockImplementation((selector: unknown) => { + if (selector === selectPerpsEligibility) return true; + if (selector === selectMarketInsightsPerpsEnabled) return true; + return undefined; + }); + + // Default: a report is available and loading is complete + mockUseMarketInsights.mockReturnValue({ + report: mockReport, + isLoading: false, + error: null, + timeAgo: '5m ago', + }); + }); + + afterEach(() => { + mockTrack.mockClear(); + }); + + it('fires MARKET_INSIGHTS_OPENED with perps_market when entry card is pressed', () => { + const { getByTestId } = renderWithProvider( + + + , + { state: initialState }, + ); + + fireEvent.press(getByTestId('market-insights-entry-card')); + + expect(mockTrack).toHaveBeenCalledWith( + MetaMetricsEvents.MARKET_INSIGHTS_OPENED, + expect.objectContaining({ perps_market: 'BTC' }), + ); + }); + + it('navigates to MarketInsightsView with isPerps flag when entry card is pressed', () => { + const { getByTestId } = renderWithProvider( + + + , + { state: initialState }, + ); + + fireEvent.press(getByTestId('market-insights-entry-card')); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.MARKET_INSIGHTS.VIEW, + expect.objectContaining({ + assetIdentifier: 'BTC', + isPerps: true, + }), + ); + }); + + it('passes market_insights_displayed: true to PERPS_SCREEN_VIEWED when a report is available', () => { + renderWithProvider( + + + , + { state: initialState }, + ); + + const { usePerpsEventTracking: mockUsePerpsEventTrackingFn } = + jest.requireMock('../../hooks/usePerpsEventTracking'); + + expect(mockUsePerpsEventTrackingFn).toHaveBeenCalledWith( + expect.objectContaining({ + eventName: MetaMetricsEvents.PERPS_SCREEN_VIEWED, + properties: expect.objectContaining({ + market_insights_displayed: true, + }), + }), + ); + }); + + it('passes market_insights_displayed: false to PERPS_SCREEN_VIEWED when no report is returned', () => { + mockUseMarketInsights.mockReturnValue({ + report: null, + isLoading: false, + error: null, + timeAgo: '', + }); + + renderWithProvider( + + + , + { state: initialState }, + ); + + const { usePerpsEventTracking: mockUsePerpsEventTrackingFn } = + jest.requireMock('../../hooks/usePerpsEventTracking'); + + expect(mockUsePerpsEventTrackingFn).toHaveBeenCalledWith( + expect.objectContaining({ + eventName: MetaMetricsEvents.PERPS_SCREEN_VIEWED, + properties: expect.objectContaining({ + market_insights_displayed: false, + }), + }), + ); + }); + }); }); diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx index 8f879e8bfc9..8fccd94f0b9 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx @@ -229,8 +229,11 @@ const PerpsMarketDetailsView: React.FC = () => { // Feature flag for Market Insights in Perps const isPerpsInsightsEnabled = useSelector(selectMarketInsightsPerpsEnabled); - const { report: perpsInsightsReport, timeAgo: perpsInsightsTimeAgo } = - useMarketInsights(market?.symbol, isPerpsInsightsEnabled); + const { + report: perpsInsightsReport, + timeAgo: perpsInsightsTimeAgo, + isLoading: isPerpsInsightsLoading, + } = useMarketInsights(market?.symbol, isPerpsInsightsEnabled); // Check if current market is in watchlist const selectIsWatchlist = useMemo( @@ -542,6 +545,8 @@ const PerpsMarketDetailsView: React.FC = () => { }); // Track asset screen viewed event - declarative (main's event name) + // Waits for market insights to finish loading so market_insights_displayed + // reflects the actual display state rather than a loading-time snapshot. usePerpsEventTracking({ eventName: MetaMetricsEvents.PERPS_SCREEN_VIEWED, conditions: [ @@ -549,6 +554,7 @@ const PerpsMarketDetailsView: React.FC = () => { !!marketStats, !isLoadingHistory, !isLoadingPosition, + !isPerpsInsightsLoading, ], properties: { [PERPS_EVENT_PROPERTY.SCREEN_TYPE]: @@ -558,6 +564,8 @@ const PerpsMarketDetailsView: React.FC = () => { source || PERPS_EVENT_VALUE.SOURCE.PERP_MARKETS, [PERPS_EVENT_PROPERTY.OPEN_POSITION]: existingPosition ? 1 : 0, [PERPS_EVENT_PROPERTY.OPEN_ORDER]: openOrders.length, + market_insights_displayed: + isPerpsInsightsEnabled && Boolean(perpsInsightsReport), // A/B Test context (TAT-1937) - for baseline exposure tracking ...(isButtonColorTestEnabled && { [PERPS_EVENT_PROPERTY.AB_TEST_BUTTON_COLOR]: buttonColorVariant, @@ -1021,6 +1029,9 @@ const PerpsMarketDetailsView: React.FC = () => { // Handler for market insights card tap - navigates to full market insights view const handleMarketInsightsPress = useCallback(() => { if (!market?.symbol) return; + track(MetaMetricsEvents.MARKET_INSIGHTS_OPENED, { + perps_market: market.symbol, + }); trace({ name: TraceName.MarketInsightsViewLoad, op: TraceOperation.MarketInsightsLoad, @@ -1030,7 +1041,7 @@ const PerpsMarketDetailsView: React.FC = () => { assetIdentifier: market.symbol, isPerps: true, }); - }, [market?.symbol, navigation]); + }, [market?.symbol, navigation, track]); // Handler for order selection - navigates to order details const handleOrderSelect = useCallback( From 27fa10a3e169bd92c964c3676235da0bacecabad Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 09:19:18 +0100 Subject: [PATCH 176/206] chore(runway): cherry-pick fix(ramps): improve external-browser callback redirection cp-7.71.0 (#27829) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(ramps): improve external-browser callback redirection cp-7.71.0 (#27804) ## **Description** Fixes the external-browser return flow for unified ramps by moving callback resolution out of Build Quote and into Order Details. The bug was that external-browser returns were resolved too early in BuildQuote. If callback parsing or order lookup failed there, users could get bounced around or end up on a broken Order Details screen. This change fixes that by moving callback resolution into Order Details itself. BuildQuote now only hands off the callback context, and Order Details fetches the real order itself. That makes the flow more reliable: bailed callbacks return to Build Quote, and real fetch failures show a retryable error instead of a broken redirect. **What changed** - **Build Quote -> Order Details callback handoff** After a successful external-browser return, Build Quote now navigates to Order Details with the full `callbackUrl`, `providerCode`, and `walletAddress` instead of trying to resolve the order immediately. - **Order Details callback bootstrap** Order Details now supports loading from callback params, fetching the real order on first render, and updating route params once the order has been resolved. - **Bailed / invalid callback handling** If the callback resolves to a bailed order state or no usable order, the user is sent back to Build Quote instead of landing on a blank or broken Order Details screen. - **Retryable callback error state** If fetching the order from the callback URL fails, Order Details now shows a retryable error screen rather than silently resetting away. This makes transient backend/network failures recoverable. - **Navigation tests updated** Tests were updated to reflect the callback-based route shape and the new Order Details retry behavior. **What stays untouched** This PR does not change Order Content amount rendering, list display formatting, or duplicate placeholder cleanup. It is scoped only to fixing the external-browser redirection and callback-resolution path. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** Paypal Order going to build quote page first: Uploading Screen Recording 2026-03-23 at 1.00.39 PM.mov… ### **After** Native Transak Redirection https://github.com/user-attachments/assets/32d1a7f9-23c7-4df1-aba8-f639338d7a6f Bailed Paypal order (return to build quote page): https://github.com/user-attachments/assets/8ed07fa3-e7df-4b69-b2f0-9318799c8249 Paypal order going to order details page: https://github.com/user-attachments/assets/5a2e8489-a4b0-488d-8aca-7982df63c45c ## **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 - [ ] 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. ## **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 the unified ramps external-browser return flow and navigation params, which can impact users reaching the correct order state after checkout; failures may surface as new retry/error behaviors. > > **Overview** > Fixes unified ramps external-browser return handling by **moving callback URL resolution out of `BuildQuote` and into `OrderDetails`**. > > `BuildQuote` no longer calls `getOrderFromCallback`/`addOrder` on InAppBrowser success; it now resets navigation to `OrderDetails` with `callbackUrl`, `providerCode`, and `walletAddress`. `OrderDetails` bootstraps from these callback params, fetches the real order (bailing back to `BuildQuote` for invalid/bailed statuses), updates route params to the resolved `orderId`, and shows a retryable error state if the callback fetch fails. > > Updates `rampsNavigation` to support an `OrderDetails` route shaped around callback params (and makes `orderId` optional), and adjusts/adds tests to cover the new handoff and retry behavior. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 0eabfd6193462b1ac1eeeb1796b2a2f682a26a39. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [4efb704](https://github.com/MetaMask/metamask-mobile/commit/4efb70495c55584929c4271bb3576d35fc5a4681) Co-authored-by: George Weiler --- .../Ramp/Views/BuildQuote/BuildQuote.test.tsx | 14 +- .../UI/Ramp/Views/BuildQuote/BuildQuote.tsx | 42 ++---- .../Views/OrderDetails/OrderDetails.test.tsx | 48 ++++++- .../Ramp/Views/OrderDetails/OrderDetails.tsx | 124 ++++++++++++++++-- .../UI/Ramp/utils/rampsNavigation.test.ts | 20 +++ .../UI/Ramp/utils/rampsNavigation.ts | 21 ++- 6 files changed, 212 insertions(+), 57 deletions(-) diff --git a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx index aea62bd39ff..7108612c1d9 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx +++ b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx @@ -476,12 +476,6 @@ describe('BuildQuote', () => { type: 'success', url: 'metamask://on-ramp/providers/moonpay?orderId=ord-123', }); - mockGetOrderFromCallback.mockResolvedValue({ - providerOrderId: 'ord-123', - status: 'Pending', - cryptoAmount: '0.05', - cryptoCurrency: { symbol: 'ETH' }, - }); mockGetBuyWidgetData.mockResolvedValue({ url: 'https://widget.example.com/checkout', browser: 'IN_APP_OS_BROWSER', @@ -497,14 +491,18 @@ describe('BuildQuote', () => { }); await waitFor(() => { - expect(mockAddOrder).toHaveBeenCalled(); + expect(mockAddOrder).not.toHaveBeenCalled(); + expect(mockGetOrderFromCallback).not.toHaveBeenCalled(); expect(mockNavigationReset).toHaveBeenCalledWith({ index: 0, routes: [ { name: Routes.RAMP.RAMPS_ORDER_DETAILS, params: { - orderId: 'ord-123', + callbackUrl: + 'metamask://on-ramp/providers/moonpay?orderId=ord-123', + providerCode: 'moonpay', + walletAddress: '0x1234567890123456789012345678901234567890', showCloseButton: true, }, }, diff --git a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx index 407507a18b5..3f51b2f80f7 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx +++ b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx @@ -20,7 +20,6 @@ import { getWidgetRedirectConfig, } from '../../utils/buildQuoteWithRedirectUrl'; import { computeAmountUpdate } from '../../utils/computeAmountUpdate'; -import { extractOrderCode } from '../../utils/extractOrderCode'; import { getRampCallbackBaseUrl } from '../../utils/getRampCallbackBaseUrl'; import { getNavigateAfterExternalBrowserRoutes } from '../../utils/rampsNavigation'; import { reportRampsError } from '../../utils/reportRampsError'; @@ -176,8 +175,6 @@ function BuildQuote() { paymentMethods, getBuyWidgetData, addPrecreatedOrder, - addOrder, - getOrderFromCallback, paymentMethodsLoading, paymentMethodsFetching, paymentMethodsStatus, @@ -683,36 +680,17 @@ function BuildQuote() { return; } - try { - const order = await getOrderFromCallback( - providerCode, - result.url, - effectiveWallet, - ); - - if (!order || isBailedOrderStatus(order.status)) { - navigateAfterExternalBrowser({ returnDestination: 'buildQuote' }); - return; - } - - addOrder(order); - - const rawOrderId = order.providerOrderId ?? effectiveOrderId; - if (!rawOrderId) { - navigateAfterExternalBrowser({ returnDestination: 'buildQuote' }); - return; - } - - const orderCode = extractOrderCode(rawOrderId); - navigateAfterExternalBrowser({ - returnDestination: 'order', - orderCode, - providerCode, - walletAddress: effectiveWallet || undefined, - }); - } catch { + if (!effectiveWallet) { navigateAfterExternalBrowser({ returnDestination: 'buildQuote' }); + return; } + + navigateAfterExternalBrowser({ + returnDestination: 'order', + callbackUrl: result.url, + providerCode, + walletAddress: effectiveWallet, + }); } finally { InAppBrowser.closeAuth(); } @@ -757,8 +735,6 @@ function BuildQuote() { navigation, getBuyWidgetData, addPrecreatedOrder, - getOrderFromCallback, - addOrder, navigateAfterExternalBrowser, ]); diff --git a/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.test.tsx b/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.test.tsx index 1fb9a0b7c91..e9870faf295 100644 --- a/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.test.tsx +++ b/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { ActivityIndicator } from 'react-native'; -import { fireEvent, waitFor } from '@testing-library/react-native'; +import { fireEvent, waitFor, act } from '@testing-library/react-native'; import OrderDetails, { createRampsOrderDetailsNavDetails, } from './OrderDetails'; @@ -11,21 +11,29 @@ import { RampsOrderStatus } from '@metamask/ramps-controller'; const mockSetOptions = jest.fn(); const mockNavigate = jest.fn(); +const mockSetParams = jest.fn(); +const mockReset = jest.fn(); jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), useNavigation: () => ({ setOptions: mockSetOptions, navigate: mockNavigate, goBack: jest.fn(), + setParams: mockSetParams, + reset: mockReset, }), })); const mockGetOrderById = jest.fn(); const mockRefreshOrder = jest.fn(); +const mockGetOrderFromCallback = jest.fn(); +const mockAddOrder = jest.fn(); jest.mock('../../hooks/useRampsOrders', () => ({ useRampsOrders: () => ({ getOrderById: mockGetOrderById, refreshOrder: mockRefreshOrder, + getOrderFromCallback: mockGetOrderFromCallback, + addOrder: mockAddOrder, }), })); @@ -52,7 +60,9 @@ jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({ }), })); -const mockUseParams = jest.fn(() => ({ orderId: 'test-order-123' })); +const mockUseParams = jest.fn, []>(() => ({ + orderId: 'test-order-123', +})); jest.mock('../../../../../util/navigation/navUtils', () => ({ ...jest.requireActual('../../../../../util/navigation/navUtils'), useParams: () => mockUseParams(), @@ -167,7 +177,9 @@ describe('OrderDetails', () => { expect(getByText('ramps_order_details.try_again')).toBeOnTheScreen(); }); - fireEvent.press(getByText('ramps_order_details.try_again')); + await act(async () => { + fireEvent.press(getByText('ramps_order_details.try_again')); + }); expect(mockRefreshOrder).toHaveBeenCalled(); }); @@ -187,4 +199,34 @@ describe('OrderDetails', () => { const result = createRampsOrderDetailsNavDetails(); expect(result[0]).toBe(Routes.RAMP.RAMPS_ORDER_DETAILS); }); + + it('shows error state with retry when initial callback fetch fails', async () => { + mockUseParams.mockReturnValue({ + callbackUrl: 'metamask://on-ramp/providers/paypal?orderId=abc', + providerCode: 'paypal', + walletAddress: '0x123', + }); + mockGetOrderById.mockReturnValue(undefined); + mockGetOrderFromCallback.mockRejectedValue( + new Error('Network request failed'), + ); + + const { getByText } = render(); + + await waitFor(() => { + expect(getByText('Network request failed')).toBeOnTheScreen(); + }); + expect(getByText('ramps_order_details.try_again')).toBeOnTheScreen(); + + await act(async () => { + fireEvent.press(getByText('ramps_order_details.try_again')); + }); + expect(mockGetOrderFromCallback).toHaveBeenCalledTimes(2); + expect(mockGetOrderFromCallback).toHaveBeenNthCalledWith( + 2, + 'paypal', + 'metamask://on-ramp/providers/paypal?orderId=abc', + '0x123', + ); + }); }); diff --git a/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.tsx b/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.tsx index 051781bc99c..e903408e333 100644 --- a/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.tsx +++ b/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.tsx @@ -15,7 +15,12 @@ import { normalizeProviderCode, RampsOrderStatus, } from '@metamask/ramps-controller'; +import { isBailedOrderStatus } from '../BuildQuote/BuildQuote'; import { extractOrderCode } from '../../utils/extractOrderCode'; +import { + getNavigateAfterExternalBrowserRoutes, + type RampsOrderDetailsParams, +} from '../../utils/rampsNavigation'; import Button, { ButtonVariants, ButtonSize, @@ -36,10 +41,6 @@ import { useRampsOrders } from '../../hooks/useRampsOrders'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { RampsOrderDetailsSelectorsIDs } from './OrderDetails.testIds'; -interface RampsOrderDetailsParams { - orderId: string; - showCloseButton?: boolean; -} export const createRampsOrderDetailsNavDetails = createNavigationDetails( @@ -69,12 +70,16 @@ const styles = StyleSheet.create({ const OrderDetails = () => { const params = useParams(); - const { getOrderById, refreshOrder } = useRampsOrders(); + const { getOrderById, refreshOrder, getOrderFromCallback, addOrder } = + useRampsOrders(); const orderCode = params.orderId ? extractOrderCode(params.orderId) : ''; const order = getOrderById(orderCode); const isPending = order ? PENDING_STATUSES.has(order.status) : false; + const hasCallbackParams = Boolean( + params.callbackUrl && params.providerCode && params.walletAddress, + ); - const [isLoading, setIsLoading] = useState(isPending); + const [isLoading, setIsLoading] = useState(isPending || hasCallbackParams); const [error, setError] = useState(null); const theme = useTheme(); const { colors } = theme; @@ -82,6 +87,72 @@ const OrderDetails = () => { const { trackEvent, createEventBuilder } = useAnalytics(); const [isRefreshing, setIsRefreshing] = useState(false); + const hasFetchedFromCallback = useRef(false); + + const executeCallbackFetch = useCallback( + async ( + providerCode: string, + callbackUrl: string, + walletAddress: string, + logContext: string, + ) => { + try { + setError(null); + const fetchedOrder = await getOrderFromCallback( + providerCode, + callbackUrl, + walletAddress, + ); + if (!fetchedOrder || isBailedOrderStatus(fetchedOrder.status)) { + navigation.reset({ + index: 0, + routes: getNavigateAfterExternalBrowserRoutes({ + returnDestination: 'buildQuote', + }), + }); + return; + } + addOrder(fetchedOrder); + navigation.setParams({ + orderId: fetchedOrder.providerOrderId, + callbackUrl: undefined, + providerCode: undefined, + walletAddress: undefined, + }); + } catch (fetchError) { + Logger.error(fetchError as Error, { + message: `RampsOrderDetails: error fetching order from callback URL${logContext}`, + callbackUrl, + }); + setError( + fetchError instanceof Error && fetchError.message + ? fetchError.message + : strings('ramps_order_details.error_message'), + ); + } finally { + setIsLoading(false); + } + }, + [getOrderFromCallback, addOrder, navigation], + ); + + const handleRetryCallbackFetch = useCallback(async () => { + if (!params.callbackUrl || !params.providerCode || !params.walletAddress) { + return; + } + setIsLoading(true); + await executeCallbackFetch( + params.providerCode, + params.callbackUrl, + params.walletAddress, + ' (retry)', + ); + }, [ + params.callbackUrl, + params.providerCode, + params.walletAddress, + executeCallbackFetch, + ]); useEffect(() => { navigation.setOptions( @@ -148,12 +219,38 @@ const OrderDetails = () => { }, [order, refreshOrder]); useEffect(() => { - if (isPending) { + if (isPending && !hasCallbackParams) { handleOnRefresh(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + if ( + !hasCallbackParams || + hasFetchedFromCallback.current || + !params.callbackUrl || + !params.providerCode || + !params.walletAddress + ) { + return; + } + hasFetchedFromCallback.current = true; + + executeCallbackFetch( + params.providerCode, + params.callbackUrl, + params.walletAddress, + '', + ); + }, [ + hasCallbackParams, + params.callbackUrl, + params.providerCode, + params.walletAddress, + executeCallbackFetch, + ]); + if (isLoading) { return ( @@ -166,11 +263,10 @@ const OrderDetails = () => { ); } - if (!order) { - return ; - } - if (error) { + const onRetry = hasCallbackParams + ? handleRetryCallbackFetch + : handleOnRefresh; return ( @@ -198,7 +294,7 @@ const OrderDetails = () => { size={ButtonSize.Lg} width={ButtonWidthTypes.Full} label={strings('ramps_order_details.try_again')} - onPress={handleOnRefresh} + onPress={onRetry} /> @@ -206,6 +302,10 @@ const OrderDetails = () => { ); } + if (!order) { + return ; + } + return ( { }, }); }); + + it('returns order details route with callbackUrl when returning from external browser', () => { + const routes = getNavigateAfterExternalBrowserRoutes({ + returnDestination: 'order', + callbackUrl: 'metamask://on-ramp/providers/paypal?orderId=abc123', + providerCode: 'paypal-staging', + walletAddress: '0x1234', + }); + + expect(routes).toHaveLength(1); + expect(routes[0]).toEqual({ + name: Routes.RAMP.RAMPS_ORDER_DETAILS, + params: { + callbackUrl: 'metamask://on-ramp/providers/paypal?orderId=abc123', + providerCode: 'paypal-staging', + walletAddress: '0x1234', + showCloseButton: true, + }, + }); + }); }); }); diff --git a/app/components/UI/Ramp/utils/rampsNavigation.ts b/app/components/UI/Ramp/utils/rampsNavigation.ts index 8b9546ed755..844941fedae 100644 --- a/app/components/UI/Ramp/utils/rampsNavigation.ts +++ b/app/components/UI/Ramp/utils/rampsNavigation.ts @@ -1,8 +1,11 @@ import Routes from '../../../../constants/navigation/Routes'; export interface RampsOrderDetailsParams { - orderId: string; + orderId?: string; showCloseButton?: boolean; + callbackUrl?: string; + providerCode?: string; + walletAddress?: string; } export function createRampsOrderDetailsRoute(params: RampsOrderDetailsParams): { @@ -29,6 +32,12 @@ export type NavigateAfterExternalBrowserOpts = orderCode: string; providerCode: string; walletAddress?: string; + } + | { + returnDestination: 'order'; + callbackUrl: string; + providerCode: string; + walletAddress: string; }; /** @@ -43,6 +52,16 @@ export function getNavigateAfterExternalBrowserRoutes( | ReturnType )[] { if (opts.returnDestination === 'order') { + if ('callbackUrl' in opts) { + return [ + createRampsOrderDetailsRoute({ + callbackUrl: opts.callbackUrl, + providerCode: opts.providerCode, + walletAddress: opts.walletAddress, + showCloseButton: true, + }), + ]; + } return [ createRampsOrderDetailsRoute({ orderId: opts.orderCode, From aa8a7887125e842be184b35fbaf8dd5d59d028ff Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Tue, 24 Mar 2026 08:20:54 +0000 Subject: [PATCH 177/206] [skip ci] Bump version number to 4165 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 8ae5c474c06..83413c6695c 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4155 + versionCode 4165 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 9b6601a86c3..127d6b76c58 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4155 + VERSION_NUMBER: 4165 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4155 + FLASK_VERSION_NUMBER: 4165 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 82deec54976..fb921c6f29a 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4155; + CURRENT_PROJECT_VERSION = 4165; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4155; + CURRENT_PROJECT_VERSION = 4165; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4155; + CURRENT_PROJECT_VERSION = 4165; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4155; + CURRENT_PROJECT_VERSION = 4165; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4155; + CURRENT_PROJECT_VERSION = 4165; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4155; + CURRENT_PROJECT_VERSION = 4165; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 8b303c432339f6585436e54f78bc0eb857cb3b3f Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:12:18 +0100 Subject: [PATCH 178/206] chore(runway): cherry-pick fix: support webcredentials cp-7.71.0 (#27845) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix: support webcredentials cp-7.71.0 (#27741) ## **Description** This pr patch the expo-web-browser to support https redirect schema Taking reference from expo-web-browser sdk 55 https://github.com/expo/expo/blob/308031a6665f885811760aff7aebb68aea4a846a/packages/expo-web-browser/ios/WebAuthSession.swift#L36 ## **Changelog** CHANGELOG entry: expo-web-browser support https redirect scheme CHANGELOG entry: use webcredential for ios google login ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Moderate risk because it changes iOS `ASWebAuthenticationSession` callback configuration and entitlements, which can affect login/redirect flows and associated-domain behavior. > > **Overview** > Enables **HTTPS redirect-based auth callbacks** on iOS by patching `expo-web-browser`’s `WebAuthSession` to use iOS 17.4+/macOS 14.4+ `.https(host:path)` callbacks when the `redirectUrl` is `https`, falling back to the legacy `callbackURLScheme` behavior otherwise. > > Updates iOS entitlements (`MetaMask.entitlements` and `MetaMaskDebug.entitlements`) to include `webcredentials:link.metamask.io`, and wires the patch into the build via a Yarn `resolutions` entry plus corresponding `yarn.lock` changes. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 7730be370643b502854f27531eb6ccad29619946. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [a2f8164](https://github.com/MetaMask/metamask-mobile/commit/a2f8164fd22f439025b15d8780eccfd3223d57a8) Co-authored-by: ieow <4881057+ieow@users.noreply.github.com> --- ...po-web-browser-npm-14.0.2-98d00ce880.patch | 50 +++++++++++++++++++ ios/MetaMask/MetaMask.entitlements | 1 + ios/MetaMask/MetaMaskDebug.entitlements | 1 + package.json | 3 +- yarn.lock | 12 ++++- 5 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 .yarn/patches/expo-web-browser-npm-14.0.2-98d00ce880.patch diff --git a/.yarn/patches/expo-web-browser-npm-14.0.2-98d00ce880.patch b/.yarn/patches/expo-web-browser-npm-14.0.2-98d00ce880.patch new file mode 100644 index 00000000000..94024b5585b --- /dev/null +++ b/.yarn/patches/expo-web-browser-npm-14.0.2-98d00ce880.patch @@ -0,0 +1,50 @@ +diff --git a/ios/WebAuthSession.swift b/ios/WebAuthSession.swift +index 0d8101b01d7c6cd803acf6a359ceaa026993bdd0..c1beeabd962e561bf48392d58c084272247a95cc 100644 +--- a/ios/WebAuthSession.swift ++++ b/ios/WebAuthSession.swift +@@ -20,17 +20,34 @@ final internal class WebAuthSession { + private var presentationContextProvider = PresentationContextProvider() + + init(authUrl: URL, redirectUrl: URL?, options: AuthSessionOptions) { +- self.authSession = ASWebAuthenticationSession( +- url: authUrl, +- callbackURLScheme: redirectUrl?.scheme, +- completionHandler: { callbackUrl, error in +- self.finish(with: [ +- "type": callbackUrl != nil ? "success" : "cancel", +- "url": callbackUrl?.absoluteString, +- "error": error?.localizedDescription +- ]) +- } +- ) ++ let completionHandler: (URL?, Error?) -> Void = { callbackUrl, error in ++ self.finish(with: [ ++ "type": callbackUrl != nil ? "success" : "cancel", ++ "url": callbackUrl?.absoluteString, ++ "error": error?.localizedDescription ++ ]) ++ } ++ ++ // iOS 17.4+/macOS 14.4+ supports HTTPS callbacks with host/path matching ++ if #available(iOS 17.4, macOS 14.4, *), ++ let redirectUrl, ++ redirectUrl.scheme?.lowercased() == "https", ++ let host = redirectUrl.host(percentEncoded: false), ++ !host.isEmpty { ++ let rawPath = redirectUrl.path ++ let path = (rawPath.isEmpty || rawPath == "/") ? "" : rawPath ++ self.authSession = ASWebAuthenticationSession( ++ url: authUrl, ++ callback: .https(host: host, path: path), ++ completionHandler: completionHandler ++ ) ++ } else { ++ self.authSession = ASWebAuthenticationSession( ++ url: authUrl, ++ callbackURLScheme: redirectUrl?.scheme, ++ completionHandler: completionHandler ++ ) ++ } + self.authSession?.prefersEphemeralWebBrowserSession = options.preferEphemeralSession + } + diff --git a/ios/MetaMask/MetaMask.entitlements b/ios/MetaMask/MetaMask.entitlements index 8a7c420fb63..5b41008a05e 100644 --- a/ios/MetaMask/MetaMask.entitlements +++ b/ios/MetaMask/MetaMask.entitlements @@ -15,6 +15,7 @@ applinks:metamask-alternate.app.link applinks:link.metamask.io applinks:link-test.metamask.io + webcredentials:link.metamask.io com.apple.developer.in-app-payments diff --git a/ios/MetaMask/MetaMaskDebug.entitlements b/ios/MetaMask/MetaMaskDebug.entitlements index bb932ad1889..e4cafc45491 100644 --- a/ios/MetaMask/MetaMaskDebug.entitlements +++ b/ios/MetaMask/MetaMaskDebug.entitlements @@ -15,6 +15,7 @@ applinks:metamask-alternate.app.link applinks:link.metamask.io applinks:link-test.metamask.io + webcredentials:link.metamask.io com.apple.developer.in-app-payments diff --git a/package.json b/package.json index 53e998ccddc..1fd95d9ba02 100644 --- a/package.json +++ b/package.json @@ -185,7 +185,8 @@ "@metamask/accounts-controller": "37.0.0", "@metamask/core-backend": "^5.0.0", "bn.js@npm:4.11.6": "4.12.3", - "bn.js@npm:5.2.1": "5.2.3" + "bn.js@npm:5.2.1": "5.2.3", + "expo-web-browser@npm:~14.0.2": "patch:expo-web-browser@npm%3A14.0.2#~/.yarn/patches/expo-web-browser-npm-14.0.2-98d00ce880.patch" }, "dependencies": { "@config-plugins/detox": "^9.0.0", diff --git a/yarn.lock b/yarn.lock index 1bfe4fef13d..c98b0ac0f33 100644 --- a/yarn.lock +++ b/yarn.lock @@ -29212,7 +29212,7 @@ __metadata: languageName: node linkType: hard -"expo-web-browser@npm:~14.0.2": +"expo-web-browser@npm:14.0.2": version: 14.0.2 resolution: "expo-web-browser@npm:14.0.2" peerDependencies: @@ -29222,6 +29222,16 @@ __metadata: languageName: node linkType: hard +"expo-web-browser@patch:expo-web-browser@npm%3A14.0.2#~/.yarn/patches/expo-web-browser-npm-14.0.2-98d00ce880.patch": + version: 14.0.2 + resolution: "expo-web-browser@patch:expo-web-browser@npm%3A14.0.2#~/.yarn/patches/expo-web-browser-npm-14.0.2-98d00ce880.patch::version=14.0.2&hash=158d79" + peerDependencies: + expo: "*" + react-native: "*" + checksum: 10/68989f3d82afed74782e67aa9106df73c76a817cea8f7dbee54206177efb7176962f050b421699cebeb87a0cf2acad501e2dcf9d1e94d487b3fde07c8c20dc99 + languageName: node + linkType: hard + "expo@npm:~52.0.47": version: 52.0.47 resolution: "expo@npm:52.0.47" From b4f3655f11b3842afd39b8134dbac7ce75359371 Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Tue, 24 Mar 2026 11:13:55 +0000 Subject: [PATCH 179/206] [skip ci] Bump version number to 4168 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 83413c6695c..816ba0868ce 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4165 + versionCode 4168 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 127d6b76c58..e3b633d97c8 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4165 + VERSION_NUMBER: 4168 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4165 + FLASK_VERSION_NUMBER: 4168 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index fb921c6f29a..869a674492a 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4165; + CURRENT_PROJECT_VERSION = 4168; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4165; + CURRENT_PROJECT_VERSION = 4168; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4165; + CURRENT_PROJECT_VERSION = 4168; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4165; + CURRENT_PROJECT_VERSION = 4168; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4165; + CURRENT_PROJECT_VERSION = 4168; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4165; + CURRENT_PROJECT_VERSION = 4168; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 6db5885d546b9c4b5dffe38bfec845a127f240be Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:45:09 +0100 Subject: [PATCH 180/206] chore(runway): cherry-pick fix: add metrics opt In event cp-7.71.0 (#27868) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feat: add metrics opt In event (#27846) ## **Description** * Add Metrics Opt In event in Onboarding, Optinmetrics and MetaMetricsAndDataCollectionSection screen ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: METRICS_OPT_IN analytics on user opt-in Scenario: User opts in from onboarding MetaMetrics screen Given the user is on the onboarding MetaMetrics / data collection screen with basic usage enabled by default When the user continues without turning off basic usage Then the app completes onboarding as before and analytics pipelines receive a "Metrics Opt In" event with onboarding location and expected properties in addition to "Analytics Preference Selected" Scenario: User enables MetaMetrics from Settings Given the user is logged in and MetaMetrics is currently off When the user opens Settings > Security & privacy and turns the MetaMetrics switch on Then the app opts in successfully and emits "Metrics Opt In" with settings location and updated_after_onboarding before the preference-selected event Scenario: User enables marketing which requires MetaMetrics Given MetaMetrics is off and marketing data collection is off When the user turns marketing data collection on (which enables MetaMetrics) Then MetaMetrics turns on and "Metrics Opt In" is recorded before the subsequent preference events ``` ## **Screenshots/Recordings** ### **Before** ### **After** Screenshot 2026-03-24 at 3 35 19 PM Screenshot 2026-03-24 at 3 36 30 PM Screenshot 2026-03-24 at 3 40 10 PM ## **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. ## **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** > Low risk analytics-only change that adds an additional tracking call when users enable metrics (onboarding, social login, and settings). Main risk is event ordering/duplication affecting downstream dashboards rather than app behavior. > > **Overview** > Adds a new `MetaMetricsEvents.METRICS_OPT_IN` event and emits it whenever users enable metrics, including the onboarding opt-in screen (`location: onboarding_metametrics`), social login onboarding flow (`location: onboarding_social_login`), and the settings MetaMetrics toggle (`location: settings` / `onboarding_default_settings`). > > Updates tests to assert the new opt-in event is sent (and in settings/onboarding cases is sent *before* `ANALYTICS_PREFERENCE_SELECTED`), including verifying `updated_after_onboarding` and optional `account_type` properties. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 9968f73a4a81ca8b53ccb4dcfc581560fc4652bd. Configure [here](https://cursor.com/dashboard?tab=bugbot). [79b1aa8](https://github.com/MetaMask/metamask-mobile/commit/79b1aa88a8bc5618a2a54dc633c34aef844c6a8f) Co-authored-by: Gaurav Goel --- app/components/UI/OptinMetrics/index.test.tsx | 30 +++++++++++ app/components/UI/OptinMetrics/index.tsx | 28 ++++++----- .../Views/Onboarding/index.test.tsx | 8 +++ app/components/Views/Onboarding/index.tsx | 13 ++++- ...taMetricsAndDataCollectionSection.test.tsx | 50 +++++++++++++++++-- .../MetaMetricsAndDataCollectionSection.tsx | 11 ++++ app/core/Analytics/MetaMetrics.events.ts | 2 + 7 files changed, 124 insertions(+), 18 deletions(-) diff --git a/app/components/UI/OptinMetrics/index.test.tsx b/app/components/UI/OptinMetrics/index.test.tsx index f40b25cf32c..a8c5373eadc 100644 --- a/app/components/UI/OptinMetrics/index.test.tsx +++ b/app/components/UI/OptinMetrics/index.test.tsx @@ -146,6 +146,16 @@ describe('OptinMetrics', () => { await waitFor(() => { expect(mockAnalytics.trackEvent).toHaveBeenNthCalledWith( 1, + expect.objectContaining({ + name: MetaMetricsEvents.METRICS_OPT_IN.category, + properties: expect.objectContaining({ + location: 'onboarding_metametrics', + updated_after_onboarding: false, + }), + }), + ); + expect(mockAnalytics.trackEvent).toHaveBeenNthCalledWith( + 2, expect.objectContaining({ name: 'Analytics Preference Selected', properties: expect.objectContaining({ @@ -177,6 +187,16 @@ describe('OptinMetrics', () => { await waitFor(() => { expect(mockAnalytics.trackEvent).toHaveBeenNthCalledWith( 1, + expect.objectContaining({ + name: MetaMetricsEvents.METRICS_OPT_IN.category, + properties: expect.objectContaining({ + location: 'onboarding_metametrics', + updated_after_onboarding: false, + }), + }), + ); + expect(mockAnalytics.trackEvent).toHaveBeenNthCalledWith( + 2, expect.objectContaining({ name: 'Analytics Preference Selected', properties: expect.objectContaining({ @@ -212,6 +232,16 @@ describe('OptinMetrics', () => { ); await waitFor(() => { + expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: MetaMetricsEvents.METRICS_OPT_IN.category, + properties: expect.objectContaining({ + location: 'onboarding_metametrics', + updated_after_onboarding: false, + account_type: AccountType.Imported, + }), + }), + ); expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( expect.objectContaining({ name: 'Analytics Preference Selected', diff --git a/app/components/UI/OptinMetrics/index.tsx b/app/components/UI/OptinMetrics/index.tsx index 63871a546da..7485dff5b08 100644 --- a/app/components/UI/OptinMetrics/index.tsx +++ b/app/components/UI/OptinMetrics/index.tsx @@ -168,19 +168,21 @@ const OptinMetrics = () => { dispatch(setDataCollectionForMarketing(isMarketingChecked)); - // Track opt-out event if user opted out of metrics - if (!isBasicUsageChecked) { - metrics.trackEvent( - metrics - .createEventBuilder(MetaMetricsEvents.METRICS_OPT_OUT) - .addProperties({ - updated_after_onboarding: false, - location: 'onboarding_metametrics', - ...(accountType && { account_type: accountType }), - }) - .build(), - ); - } + // Track opt-in / opt-out for metrics + metrics.trackEvent( + metrics + .createEventBuilder( + isBasicUsageChecked + ? MetaMetricsEvents.METRICS_OPT_IN + : MetaMetricsEvents.METRICS_OPT_OUT, + ) + .addProperties({ + updated_after_onboarding: false, + location: 'onboarding_metametrics', + ...(accountType && { account_type: accountType }), + }) + .build(), + ); metrics.trackEvent( metrics diff --git a/app/components/Views/Onboarding/index.test.tsx b/app/components/Views/Onboarding/index.test.tsx index 4608707035d..771c308e15b 100644 --- a/app/components/Views/Onboarding/index.test.tsx +++ b/app/components/Views/Onboarding/index.test.tsx @@ -75,6 +75,7 @@ import { captureException } from '@sentry/react-native'; import Logger from '../../../util/Logger'; import { MIGRATION_ERROR_HAPPENED } from '../../../constants/storage'; import { AccountType } from '../../../constants/onboarding'; +import { MetaMetricsEvents } from '../../../core/Analytics'; // Mock netinfo - using existing mock jest.mock('@react-native-community/netinfo'); @@ -1990,6 +1991,13 @@ describe('Onboarding', () => { await waitFor(() => { expect(mockAnalytics.optIn).toHaveBeenCalled(); + expect( + mockCreateEventBuilder.mock.calls.some( + (call) => + (call[0] as { category: string }).category === + MetaMetricsEvents.METRICS_OPT_IN.category, + ), + ).toBe(true); }); }); }); diff --git a/app/components/Views/Onboarding/index.tsx b/app/components/Views/Onboarding/index.tsx index 353227bd74e..f2d715f0a34 100644 --- a/app/components/Views/Onboarding/index.tsx +++ b/app/components/Views/Onboarding/index.tsx @@ -733,6 +733,18 @@ const Onboarding = () => { discardBufferedTraces(); await setupSentry(); + const accountType = getSocialAccountType(provider, !createWallet); + metrics.trackEvent( + metrics + .createEventBuilder(MetaMetricsEvents.METRICS_OPT_IN) + .addProperties({ + updated_after_onboarding: false, + location: 'onboarding_social_login', + account_type: accountType, + }) + .build(), + ); + // use new trace instead of buffered trace for social login onboardingTraceCtx.current = trace({ name: TraceName.OnboardingJourneyOverall, @@ -740,7 +752,6 @@ const Onboarding = () => { tags: getTraceTags(store.getState()), }); - const accountType = getSocialAccountType(provider, !createWallet); if (createWallet) { track(MetaMetricsEvents.WALLET_SETUP_STARTED, { account_type: accountType, diff --git a/app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.test.tsx b/app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.test.tsx index c5ebee60e6d..545f466e354 100644 --- a/app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.test.tsx +++ b/app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.test.tsx @@ -377,7 +377,18 @@ describe('MetaMetricsAndDataCollectionSection', () => { deviceProp: 'Device value', userProp: 'User value', }); - expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( + expect(mockAnalytics.trackEvent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + name: MetaMetricsEvents.METRICS_OPT_IN.category, + properties: expect.objectContaining({ + updated_after_onboarding: true, + location: 'settings', + }), + }), + ); + expect(mockAnalytics.trackEvent).toHaveBeenNthCalledWith( + 2, expect.objectContaining({ name: MetaMetricsEvents.ANALYTICS_PREFERENCE_SELECTED.category, properties: expect.objectContaining({ @@ -407,7 +418,18 @@ describe('MetaMetricsAndDataCollectionSection', () => { fireEvent(metaMetricsSwitch, 'valueChange', true); await waitFor(() => { - expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( + expect(mockAnalytics.trackEvent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + name: MetaMetricsEvents.METRICS_OPT_IN.category, + properties: expect.objectContaining({ + updated_after_onboarding: true, + location: 'onboarding_default_settings', + }), + }), + ); + expect(mockAnalytics.trackEvent).toHaveBeenNthCalledWith( + 2, expect.objectContaining({ name: MetaMetricsEvents.ANALYTICS_PREFERENCE_SELECTED.category, properties: expect.objectContaining({ @@ -467,6 +489,16 @@ describe('MetaMetricsAndDataCollectionSection', () => { fireEvent(metaMetricsSwitch, 'valueChange', true); await waitFor(() => { + expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: MetaMetricsEvents.METRICS_OPT_IN.category, + properties: expect.objectContaining({ + updated_after_onboarding: true, + location: 'settings', + account_type: AccountType.MetamaskGoogle, + }), + }), + ); expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( expect.objectContaining({ name: MetaMetricsEvents.ANALYTICS_PREFERENCE_SELECTED.category, @@ -808,6 +840,16 @@ describe('MetaMetricsAndDataCollectionSection', () => { }); expect(mockAnalytics.trackEvent).toHaveBeenNthCalledWith( 1, + expect.objectContaining({ + name: MetaMetricsEvents.METRICS_OPT_IN.category, + properties: expect.objectContaining({ + location: 'settings', + updated_after_onboarding: true, + }), + }), + ); + expect(mockAnalytics.trackEvent).toHaveBeenNthCalledWith( + 2, expect.objectContaining({ name: MetaMetricsEvents.ANALYTICS_PREFERENCE_SELECTED.category, properties: expect.objectContaining({ @@ -827,8 +869,8 @@ describe('MetaMetricsAndDataCollectionSection', () => { }, ); expect(mockAnalytics.trackEvent).toHaveBeenNthCalledWith( - // if MetaMetrics is initially disabled, trackEvent is called twice and this is 2nd call - !metaMetricsInitiallyEnabled ? 2 : 1, + // if MetaMetrics is initially disabled, marketing consent is the 3rd trackEvent + !metaMetricsInitiallyEnabled ? 3 : 1, expect.objectContaining({ name: MetaMetricsEvents.ANALYTICS_PREFERENCE_SELECTED.category, properties: expect.objectContaining({ diff --git a/app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.tsx b/app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.tsx index c2940f9408b..59debfafdfb 100644 --- a/app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.tsx +++ b/app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.tsx @@ -114,6 +114,17 @@ const MetaMetricsAndDataCollectionSection: React.FC< setAnalyticsEnabled(true); analytics.identify(consolidatedTraits); + analytics.trackEvent( + AnalyticsEventBuilder.createEventBuilder( + MetaMetricsEvents.METRICS_OPT_IN, + ) + .addProperties({ + updated_after_onboarding: true, + location: analyticsLocation, + ...(accountType && { account_type: accountType }), + }) + .build(), + ); analytics.trackEvent( AnalyticsEventBuilder.createEventBuilder( MetaMetricsEvents.ANALYTICS_PREFERENCE_SELECTED, diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index 063eb7bc621..3e0d1d23446 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -124,6 +124,7 @@ enum EVENT_NAME { // Analytics ANALYTICS_PREFERENCE_SELECTED = 'Analytics Preference Selected', + METRICS_OPT_IN = 'Metrics Opt In', METRICS_OPT_OUT = 'Metrics Opt Out', ANALYTICS_REQUEST_DATA_DELETION = 'Delete MetaMetrics Data Request Submitted', EXPERIMENT_VIEWED = 'Experiment Viewed', @@ -829,6 +830,7 @@ const events = { ANALYTICS_PREFERENCE_SELECTED: generateOpt( EVENT_NAME.ANALYTICS_PREFERENCE_SELECTED, ), + METRICS_OPT_IN: generateOpt(EVENT_NAME.METRICS_OPT_IN), METRICS_OPT_OUT: generateOpt(EVENT_NAME.METRICS_OPT_OUT), ANALYTICS_REQUEST_DATA_DELETION: generateOpt( EVENT_NAME.ANALYTICS_REQUEST_DATA_DELETION, From 6ebe48251eb83aa24a05e954a80552dc102a8949 Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Tue, 24 Mar 2026 14:47:02 +0000 Subject: [PATCH 181/206] [skip ci] Bump version number to 4171 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 816ba0868ce..8f01fbabd04 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4168 + versionCode 4171 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index e3b633d97c8..8331c859bd7 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4168 + VERSION_NUMBER: 4171 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4168 + FLASK_VERSION_NUMBER: 4171 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 869a674492a..4fc1bf7cc4b 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4168; + CURRENT_PROJECT_VERSION = 4171; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4168; + CURRENT_PROJECT_VERSION = 4171; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4168; + CURRENT_PROJECT_VERSION = 4171; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4168; + CURRENT_PROJECT_VERSION = 4171; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4168; + CURRENT_PROJECT_VERSION = 4171; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4168; + CURRENT_PROJECT_VERSION = 4171; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 8f743b409bc613d1c998025200ba63fc3ed687d4 Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:50:24 +0100 Subject: [PATCH 182/206] chore(runway): cherry-pick fix(ramps): filter activity tab's transfer details for selected account -> cp-7.71.0 (#27865) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(ramps): filter activity tab's transfer details for selected account -> cp-7.71.0 (#27830) ## **Description** ### Activity — on-ramp orders scoped to the selected account The Activity **Orders** tab merges legacy fiat orders with V2 `RampsController` orders. Legacy rows were already limited to the **selected account group** via `getOrders`. V2 orders were not filtered, so purchase history from other wallets could appear. This change adds `selectRampsOrdersForSelectedAccountGroup`, which keeps only orders whose `walletAddress` matches any formatted address in the selected account group (same semantics as legacy, using `areAddressesEqual` for EVM vs non-EVM). Hook and modal consumers that should reflect “current wallet context” now use this selector instead of the raw controller list. ### Transak — preserve user-entered fiat amount on Build Quote (in-app) After **additional verification**, opening the KYC/payment webview called `navigateToKycWebview` with **`quote.fiatAmount`**, and the stack reset rewrote **RampAmountInput** params with that value. The quote total can differ from what the user typed (e.g. fees), so Build Quote could show **27.37** after the user entered **25**, and that value persisted after closing the sheet or going back. **Change:** `routeAfterAuthentication(..., amount)` already carried the typed fiat; `navigateToAdditionalVerificationCallback` now puts the same `amount` on **both** `RampAmountInput` and `RampAdditionalVerification` route params. **V2 Additional Verification** reads that param and passes **only** it into `navigateToKycWebview` (no `quote.fiatAmount` for this purpose). **Out of scope:** Order detail screens and stored order payloads are unchanged. The **Transak payment webview** URL is unchanged (no `fiatAmount` override in widget params); only in-app Build Quote / stack state matches the user’s input. ## **Changelog** CHANGELOG entry: Fixed Activity on-ramp (Orders) list showing V2 purchases from wallets other than the selected account group; fixed Transak unified buy flow so the fiat amount on Build Quote after additional verification matches the user-entered amount on the amount screen (in-app stack), without changing Transak’s payment widget totals. ### Tests - `selectRampsOrdersForSelectedAccountGroup` and ramp hook consumers (selector + hook unit tests). - `useTransakRouting`: IDPROOF / additional verification navigation with user `amount` set and omitted. - V2 `AdditionalVerification`: continue passes route `amount` into `navigateToKycWebview`. ## **Related issues** Refs: [TRAM-3361](https://consensyssoftware.atlassian.net/browse/TRAM-3361) ## **Manual testing steps** ```gherkin Feature: Activity on-ramp orders and Transak amount (TRAM-3361) Scenario: Orders tab shows only selected account group’s V2 on-ramp orders Given the user has two wallets (or account groups) with separate on-ramp purchase history And unified ramps V2 orders exist for more than one wallet address When the user selects account group A and opens Activity → Orders Then only on-ramp orders whose destination wallet belongs to account group A are listed When the user switches to account group B Then the Orders tab lists only orders for account group B Scenario: Custom fiat amount survives Transak additional verification on Build Quote Given the user is in unified Buy with Transak (native) as provider And the user enters a custom fiat amount (e.g. 25) on the amount screen When the user continues through flows that require additional verification And the user taps Continue on the additional verification screen Then Build Quote under the KYC/payment sheet still shows the entered amount (e.g. 25), not only the quote total When the user closes the webview or goes back from the flow Then the amount screen still shows the same entered amount (e.g. 25) ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/b26bb3cb-8219-48a3-8128-7c79026fdd18 ### **After** https://github.com/user-attachments/assets/3e1929e3-7e1a-42c9-98bd-ea8f7bc9b1eb https://github.com/user-attachments/assets/ef6d14ed-6533-4e04-a5a4-8cbd44477170 ## **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 - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Moderate UX/data-scoping change: filters which on-ramp orders appear based on multichain account selection and adjusts Transak navigation params, which could hide expected history or affect flow state if account/address resolution is off. > > **Overview** > Fixes unified ramp UI to **scope V2 `RampsController` orders to the selected account group**, preventing purchases from other wallets from showing up in Activity-derived surfaces. This introduces `selectRampsOrdersForSelectedAccountGroup` (address-matched via `areAddressesEqual`) and switches key consumers (`useRampsOrders`, `useRampsProviders`, `useRampsButtonClickData`, `ProviderSelectionModal`) from the unfiltered selector. > > Updates the Transak additional-verification flow to **preserve the user-entered fiat amount** through stack resets: `useTransakRouting` now carries `amount` into `RampAdditionalVerification` params, and `AdditionalVerification` uses that param (not `quote.fiatAmount`) when opening the KYC webview. Selector and routing behavior are covered with expanded unit tests. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 39d5861d79d45c9f742c809c6a95aa1ef2aff902. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [4661cdb](https://github.com/MetaMask/metamask-mobile/commit/4661cdba2d91fcd672861ab330f91f134451c111) --------- Co-authored-by: Yu-Gi-Oh! <54774811+imyugioh@users.noreply.github.com> --- .../ProviderSelectionModal.tsx | 6 +- .../AdditionalVerification.test.tsx | 5 +- .../NativeFlow/AdditionalVerification.tsx | 9 +- .../UI/Ramp/hooks/useRampsButtonClickData.ts | 6 +- .../UI/Ramp/hooks/useRampsOrders.test.ts | 70 ++++- .../UI/Ramp/hooks/useRampsOrders.ts | 4 +- .../UI/Ramp/hooks/useRampsProviders.ts | 6 +- .../UI/Ramp/hooks/useTransakRouting.test.ts | 59 +++- .../UI/Ramp/hooks/useTransakRouting.ts | 4 +- app/selectors/rampsController/index.test.ts | 256 ++++++++++++++++++ app/selectors/rampsController/index.ts | 34 ++- 11 files changed, 436 insertions(+), 23 deletions(-) diff --git a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelectionModal.tsx b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelectionModal.tsx index b6ef67590ed..8a1d0b81bb7 100644 --- a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelectionModal.tsx +++ b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelectionModal.tsx @@ -17,7 +17,7 @@ import { useRampsController } from '../../../hooks/useRampsController'; import { useRampsQuotes } from '../../../hooks/useRampsQuotes'; import useRampAccountAddress from '../../../hooks/useRampAccountAddress'; import { getOrdersProviders } from '../../../../../../reducers/fiatOrders'; -import { selectRampsOrders } from '../../../../../../selectors/rampsController'; +import { selectRampsOrdersForSelectedAccountGroup } from '../../../../../../selectors/rampsController'; import { completedOrdersFromRampsOrders } from '../../../utils/determinePreferredProvider'; import { useStyles } from '../../../../../hooks/useStyles'; import styleSheet from './ProviderSelectionModal.styles'; @@ -59,7 +59,9 @@ function ProviderSelectionModal() { } = useRampsController(); const legacyOrdersProviders = useSelector(getOrdersProviders); - const controllerOrders = useSelector(selectRampsOrders); + const controllerOrders = useSelector( + selectRampsOrdersForSelectedAccountGroup, + ); const ordersProviders = useMemo(() => { const v2ProviderIds = completedOrdersFromRampsOrders(controllerOrders).map( diff --git a/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.test.tsx b/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.test.tsx index 6375f3e7a3e..481c0cfa3c3 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.test.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.test.tsx @@ -36,9 +36,10 @@ jest.mock('../../../../../util/navigation/navUtils', () => ({ (..._args: unknown[]) => (params: unknown) => ['MockRoute', params], useParams: () => ({ - quote: { quoteId: 'test-quote-id', fiatAmount: 100 }, + quote: { quoteId: 'test-quote-id', fiatAmount: 127.37 }, kycUrl: 'https://kyc.example.com', workFlowRunId: 'wf-123', + amount: 25, }), })); @@ -71,7 +72,7 @@ describe('V2AdditionalVerification', () => { expect(mockNavigateToKycWebview).toHaveBeenCalledWith({ kycUrl: 'https://kyc.example.com', - amount: 100, + amount: 25, }); }); diff --git a/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.tsx b/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.tsx index 190666ee128..00a4377856f 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.tsx @@ -23,11 +23,14 @@ interface V2AdditionalVerificationParams { quote: TransakBuyQuote; kycUrl: string; workFlowRunId: string; + /** From BuildQuote route; keeps stack amount in sync when opening KYC webview. */ + amount?: number; } const V2AdditionalVerification = () => { const navigation = useNavigation(); - const { kycUrl, quote } = useParams(); + const { kycUrl, amount: userEnteredAmount } = + useParams(); const { styles, theme } = useStyles(styleSheet, {}); @@ -46,8 +49,8 @@ const V2AdditionalVerification = () => { }, [navigation, theme]); const handleContinuePress = useCallback(() => { - navigateToKycWebview({ kycUrl, amount: quote?.fiatAmount }); - }, [navigateToKycWebview, kycUrl, quote?.fiatAmount]); + navigateToKycWebview({ kycUrl, amount: userEnteredAmount }); + }, [navigateToKycWebview, kycUrl, userEnteredAmount]); return ( diff --git a/app/components/UI/Ramp/hooks/useRampsButtonClickData.ts b/app/components/UI/Ramp/hooks/useRampsButtonClickData.ts index 1868700aa6c..4b73ecb6ddf 100644 --- a/app/components/UI/Ramp/hooks/useRampsButtonClickData.ts +++ b/app/components/UI/Ramp/hooks/useRampsButtonClickData.ts @@ -5,7 +5,7 @@ import { getRampRoutingDecision, UnifiedRampRoutingType, } from '../../../../reducers/fiatOrders'; -import { selectRampsOrders } from '../../../../selectors/rampsController'; +import { selectRampsOrdersForSelectedAccountGroup } from '../../../../selectors/rampsController'; import { getProviderToken } from '../Deposit/utils/ProviderTokenVault'; import { completedOrdersFromFiatOrders, @@ -21,7 +21,9 @@ export interface RampsButtonClickData { export function useRampsButtonClickData(): RampsButtonClickData { const orders = useSelector(getOrders); - const controllerOrders = useSelector(selectRampsOrders); + const controllerOrders = useSelector( + selectRampsOrdersForSelectedAccountGroup, + ); const rampRoutingDecision = useSelector(getRampRoutingDecision); const [isAuthenticated, setIsAuthenticated] = useState(false); diff --git a/app/components/UI/Ramp/hooks/useRampsOrders.test.ts b/app/components/UI/Ramp/hooks/useRampsOrders.test.ts index 0c5048f6ba5..6f1b6557191 100644 --- a/app/components/UI/Ramp/hooks/useRampsOrders.test.ts +++ b/app/components/UI/Ramp/hooks/useRampsOrders.test.ts @@ -2,9 +2,23 @@ import { renderHook, act } from '@testing-library/react-native'; import { Provider } from 'react-redux'; import { configureStore } from '@reduxjs/toolkit'; import React from 'react'; +import { AccountGroupType } from '@metamask/account-api'; import { RampsOrderStatus, type RampsOrder } from '@metamask/ramps-controller'; +import { createMockInternalAccount } from '../../../../util/test/accountsControllerTestUtils'; import { useRampsOrders } from './useRampsOrders'; +const RAMP_HOOKS_TEST_WALLET_ID = 'keyring:use-ramps-orders-test' as const; +const RAMP_HOOKS_TEST_GROUP_ID = + `${RAMP_HOOKS_TEST_WALLET_ID}/ethereum` as const; +const RAMP_HOOKS_TEST_ACCOUNT_ID = 'account-rh-1'; +/** Must be a valid EVM address (20 bytes) so `areAddressesEqual` treats it as EVM. */ +const RAMP_HOOKS_TEST_ADDRESS = '0x2990079bcdee240329a520d2444386fc119da21a'; + +const rampHooksTestInternalAccount = { + ...createMockInternalAccount(RAMP_HOOKS_TEST_ADDRESS, 'Test'), + id: RAMP_HOOKS_TEST_ACCOUNT_ID, +}; + const mockAddOrder = jest.fn(); const mockAddPrecreatedOrder = jest.fn(); const mockRemoveOrder = jest.fn(); @@ -35,7 +49,7 @@ const createMockOrder = (overrides: Partial = {}): RampsOrder => ({ createdAt: Date.now(), totalFeesFiat: 5, txHash: '0xabc', - walletAddress: '0x123', + walletAddress: RAMP_HOOKS_TEST_ADDRESS, status: RampsOrderStatus.Completed, network: { name: 'Ethereum', chainId: 'eip155:1' }, canBeUpdated: false, @@ -54,6 +68,45 @@ const createMockStore = (orders: RampsOrder[] = []) => RampsController: { orders, }, + AccountTreeController: { + accountTree: { + wallets: { + [RAMP_HOOKS_TEST_WALLET_ID]: { + id: RAMP_HOOKS_TEST_WALLET_ID, + metadata: { name: 'Test wallet' }, + groups: { + [RAMP_HOOKS_TEST_GROUP_ID]: { + id: RAMP_HOOKS_TEST_GROUP_ID, + type: AccountGroupType.SingleAccount, + accounts: [RAMP_HOOKS_TEST_ACCOUNT_ID], + metadata: { name: 'Test Group' }, + }, + }, + }, + }, + selectedAccountGroup: RAMP_HOOKS_TEST_GROUP_ID, + }, + }, + RemoteFeatureFlagController: { + remoteFeatureFlags: { + enableMultichainAccounts: { + enabled: true, + featureVersion: '1', + minimumVersion: '1.0.0', + }, + }, + }, + AccountsController: { + internalAccounts: { + accounts: { + [RAMP_HOOKS_TEST_ACCOUNT_ID]: rampHooksTestInternalAccount, + }, + selectedAccount: RAMP_HOOKS_TEST_ACCOUNT_ID, + }, + }, + KeyringController: { + keyrings: [], + }, }, }), }, @@ -78,7 +131,7 @@ describe('useRampsOrders', () => { expect(result.current.orders).toEqual([]); }); - it('returns orders from the store', () => { + it('returns orders from the store when walletAddress matches the selected account group', () => { const order = createMockOrder(); const store = createMockStore([order]); const { result } = renderHook(() => useRampsOrders(), { @@ -88,6 +141,19 @@ describe('useRampsOrders', () => { expect(result.current.orders).toEqual([order]); }); + it('excludes orders whose walletAddress is not in the selected account group', () => { + const foreignOrder = createMockOrder({ + providerOrderId: 'foreign-order', + walletAddress: '0x0000000000000000000000000000000000000001', + }); + const store = createMockStore([foreignOrder]); + const { result } = renderHook(() => useRampsOrders(), { + wrapper: wrapper(store), + }); + + expect(result.current.orders).toEqual([]); + }); + it('finds an order by providerOrderId', () => { const order1 = createMockOrder({ providerOrderId: 'order-1' }); const order2 = createMockOrder({ providerOrderId: 'order-2' }); diff --git a/app/components/UI/Ramp/hooks/useRampsOrders.ts b/app/components/UI/Ramp/hooks/useRampsOrders.ts index 72f58dd8198..48a78cb0480 100644 --- a/app/components/UI/Ramp/hooks/useRampsOrders.ts +++ b/app/components/UI/Ramp/hooks/useRampsOrders.ts @@ -3,7 +3,7 @@ import { useSelector } from 'react-redux'; import type { RampsOrder } from '@metamask/ramps-controller'; import { extractOrderCode } from '../utils/extractOrderCode'; import Engine from '../../../../core/Engine'; -import { selectRampsOrders } from '../../../../selectors/rampsController'; +import { selectRampsOrdersForSelectedAccountGroup } from '../../../../selectors/rampsController'; export interface AddPrecreatedOrderParams { orderId: string; @@ -31,7 +31,7 @@ export interface UseRampsOrdersResult { } export function useRampsOrders(): UseRampsOrdersResult { - const orders = useSelector(selectRampsOrders); + const orders = useSelector(selectRampsOrdersForSelectedAccountGroup); const getOrderById = useCallback( (providerOrderId: string) => { diff --git a/app/components/UI/Ramp/hooks/useRampsProviders.ts b/app/components/UI/Ramp/hooks/useRampsProviders.ts index 255aeff8af3..017e6f626ec 100644 --- a/app/components/UI/Ramp/hooks/useRampsProviders.ts +++ b/app/components/UI/Ramp/hooks/useRampsProviders.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo } from 'react'; import { useSelector } from 'react-redux'; import { selectProviders, - selectRampsOrders, + selectRampsOrdersForSelectedAccountGroup, } from '../../../../selectors/rampsController'; import { type Provider } from '@metamask/ramps-controller'; import Engine from '../../../../core/Engine'; @@ -55,7 +55,9 @@ export function useRampsProviders(): UseRampsProvidersResult { } = useSelector(selectProviders); const legacyOrders = useSelector(getOrders); - const controllerOrders = useSelector(selectRampsOrders); + const controllerOrders = useSelector( + selectRampsOrdersForSelectedAccountGroup, + ); const completedOrders = useMemo( () => [ diff --git a/app/components/UI/Ramp/hooks/useTransakRouting.test.ts b/app/components/UI/Ramp/hooks/useTransakRouting.test.ts index bde067dce25..708fcd90549 100644 --- a/app/components/UI/Ramp/hooks/useTransakRouting.test.ts +++ b/app/components/UI/Ramp/hooks/useTransakRouting.test.ts @@ -284,7 +284,7 @@ describe('useTransakRouting', () => { 'test-ott', mockQuote, MOCK_WALLET_ADDRESS, - expect.any(Object), + { theme: 'light' }, ); expect(mockReset).toHaveBeenCalledWith( expect.objectContaining({ @@ -482,10 +482,7 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); await act(async () => { - await result.current.routeAfterAuthentication( - mockQuote as never, - mockQuote.fiatAmount, - ); + await result.current.routeAfterAuthentication(mockQuote as never, 25); }); expect(mockReset).toHaveBeenCalledWith( @@ -494,7 +491,56 @@ describe('useTransakRouting', () => { routes: [ expect.objectContaining({ name: 'RampAmountInput', - params: { amount: mockQuote.fiatAmount }, + params: { amount: 25 }, + }), + expect.objectContaining({ + name: 'RampAdditionalVerification', + params: expect.objectContaining({ + quote: mockQuote, + kycUrl: 'https://kyc.example.com', + workFlowRunId: 'wf-123', + amount: 25, + }), + }), + ], + }), + ); + }); + + it('handles ADDITIONAL_FORMS_REQUIRED with IDPROOF when user amount is omitted', async () => { + mockGetUserDetails.mockResolvedValue({ + firstName: 'John', + address: {}, + }); + mockGetKycRequirement.mockResolvedValue({ + status: 'ADDITIONAL_FORMS_REQUIRED', + kycType: 'STANDARD', + }); + mockGetAdditionalRequirements.mockResolvedValue({ + formsRequired: [ + { + type: 'IDPROOF', + metadata: { + kycUrl: 'https://kyc.example.com', + workFlowRunId: 'wf-123', + }, + }, + ], + }); + + const { result } = renderHook(() => useTransakRouting()); + + await act(async () => { + await result.current.routeAfterAuthentication(mockQuote as never); + }); + + expect(mockReset).toHaveBeenCalledWith( + expect.objectContaining({ + index: 1, + routes: [ + expect.objectContaining({ + name: 'RampAmountInput', + params: { amount: undefined }, }), expect.objectContaining({ name: 'RampAdditionalVerification', @@ -502,6 +548,7 @@ describe('useTransakRouting', () => { quote: mockQuote, kycUrl: 'https://kyc.example.com', workFlowRunId: 'wf-123', + amount: undefined, }), }), ], diff --git a/app/components/UI/Ramp/hooks/useTransakRouting.ts b/app/components/UI/Ramp/hooks/useTransakRouting.ts index 661863ddf24..342e15b0441 100644 --- a/app/components/UI/Ramp/hooks/useTransakRouting.ts +++ b/app/components/UI/Ramp/hooks/useTransakRouting.ts @@ -40,6 +40,8 @@ interface RampStackParamList { quote: TransakBuyQuote; kycUrl: string; workFlowRunId: string; + /** User-entered fiat from BuildQuote; used when resetting stack so amount screen keeps the typed value. */ + amount?: number; }; RampKycProcessing: { quote: TransakBuyQuote }; RampEnterEmail: undefined; @@ -258,7 +260,7 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { }, { name: Routes.RAMP.ADDITIONAL_VERIFICATION, - params: { quote, kycUrl, workFlowRunId }, + params: { quote, kycUrl, workFlowRunId, amount }, }, ], }); diff --git a/app/selectors/rampsController/index.test.ts b/app/selectors/rampsController/index.test.ts index dd17c1ed4ca..4d179e49b44 100644 --- a/app/selectors/rampsController/index.test.ts +++ b/app/selectors/rampsController/index.test.ts @@ -6,6 +6,13 @@ import { type Country, type PaymentMethod, } from '@metamask/ramps-controller'; +import { AccountGroupType } from '@metamask/account-api'; +import { AccountId } from '@metamask/accounts-controller'; +import { TrxAccountType } from '@metamask/keyring-api'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import { InternalAccount } from '@metamask/keyring-internal-api'; +import { createMockInternalAccount } from '../../util/test/accountsControllerTestUtils'; +import { mockSolanaAddress } from '../../util/test/keyringControllerTestUtils'; import { selectUserRegion, selectProviders, @@ -14,6 +21,7 @@ import { selectPaymentMethods, selectRampsControllerState, selectRampsOrders, + selectRampsOrdersForSelectedAccountGroup, selectTransak, } from './index'; @@ -31,6 +39,7 @@ type RampsControllerStateOverride = Partial; const createMockState = ( rampsController: RampsControllerStateOverride = {}, + extraBackgroundState: Record = {}, ): RootState => ({ engine: { @@ -58,10 +67,65 @@ const createMockState = ( }, ...rampsController, }, + KeyringController: { + keyrings: [], + }, + ...extraBackgroundState, }, }, }) as unknown as RootState; +const WALLET_ID = 'keyring:ramps-selector-test' as const; +const GROUP_ID = `${WALLET_ID}/ethereum` as const; + +function createStateWithSelectedAccountGroup( + rampsController: RampsControllerStateOverride, + internalAccount: InternalAccount, + accountId: string, +): RootState { + return createMockState(rampsController, { + AccountTreeController: { + accountTree: { + wallets: { + [WALLET_ID]: { + id: WALLET_ID, + metadata: { name: 'Test wallet' }, + groups: { + [GROUP_ID]: { + id: GROUP_ID, + type: AccountGroupType.SingleAccount, + accounts: [accountId], + metadata: { name: 'Test Group' }, + }, + }, + }, + }, + selectedAccountGroup: GROUP_ID, + }, + }, + RemoteFeatureFlagController: { + remoteFeatureFlags: { + enableMultichainAccounts: { + enabled: true, + featureVersion: '1', + minimumVersion: '1.0.0', + }, + }, + }, + AccountsController: { + internalAccounts: { + accounts: { + [accountId]: internalAccount, + }, + selectedAccount: accountId, + }, + }, + KeyringController: { + keyrings: [], + }, + }); +} + const mockUserRegion: UserRegion = { country: { isoCode: 'US', @@ -314,6 +378,198 @@ describe('RampsController Selectors', () => { }); }); + describe('selectRampsOrdersForSelectedAccountGroup', () => { + const accountId = 'account-ramps-1'; + const walletAddrLower = '0x2990079bcdee240329a520d2444386fc119da21a'; + const internalAccount = { + ...createMockInternalAccount(walletAddrLower, 'Account 1'), + id: accountId, + }; + + it('returns empty array when no selected account group addresses', () => { + const mockOrders = [ + { + providerOrderId: 'order-1', + walletAddress: walletAddrLower, + status: 'COMPLETED', + createdAt: 1000, + }, + ]; + const state = createMockState({ + orders: mockOrders, + } as never); + + expect(selectRampsOrdersForSelectedAccountGroup(state)).toEqual([]); + }); + + it('keeps orders whose walletAddress matches a selected group address (case-insensitive for EVM)', () => { + const mockOrders = [ + { + providerOrderId: 'order-match', + walletAddress: '0x2990079BCDEE240329A520D2444386FC119DA21A', + status: 'COMPLETED', + createdAt: 1000, + }, + { + providerOrderId: 'order-other', + walletAddress: '0x0000000000000000000000000000000000000001', + status: 'COMPLETED', + createdAt: 2000, + }, + ]; + const state = createStateWithSelectedAccountGroup( + { orders: mockOrders } as never, + internalAccount, + accountId, + ); + + const result = selectRampsOrdersForSelectedAccountGroup(state); + expect(result).toEqual([mockOrders[0]]); + }); + + it('excludes orders with missing walletAddress', () => { + const mockOrders = [ + { + providerOrderId: 'order-no-wallet', + status: 'COMPLETED', + createdAt: 1000, + }, + ]; + const state = createStateWithSelectedAccountGroup( + { orders: mockOrders } as never, + internalAccount, + accountId, + ); + + expect(selectRampsOrdersForSelectedAccountGroup(state)).toEqual([]); + }); + + it('keeps orders whose walletAddress matches a Solana account in the selected group', () => { + const solanaAccountId = 'account-ramps-solana' as AccountId; + const otherSolanaAddress = '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM'; + const solanaInternalAccount: InternalAccount = { + id: solanaAccountId, + address: mockSolanaAddress, + type: 'solana:dataAccount' as InternalAccount['type'], + options: {}, + methods: [], + scopes: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + metadata: { + name: 'Solana Account', + importTime: Date.now(), + keyring: { + type: 'Snap Keyring', + }, + }, + }; + const mockOrders = [ + { + providerOrderId: 'order-sol-match', + walletAddress: mockSolanaAddress, + status: 'COMPLETED', + createdAt: 1000, + }, + { + providerOrderId: 'order-sol-other', + walletAddress: otherSolanaAddress, + status: 'COMPLETED', + createdAt: 2000, + }, + ]; + const state = createStateWithSelectedAccountGroup( + { orders: mockOrders } as never, + solanaInternalAccount, + solanaAccountId, + ); + + expect(selectRampsOrdersForSelectedAccountGroup(state)).toEqual([ + mockOrders[0], + ]); + }); + + it('keeps orders whose walletAddress matches a Bitcoin account in the selected group', () => { + const bitcoinAccountId = 'account-ramps-bitcoin' as AccountId; + const bitcoinAddress = 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh'; + const otherBitcoinAddress = 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq'; + const bitcoinInternalAccount: InternalAccount = { + id: bitcoinAccountId, + address: bitcoinAddress, + type: 'bip122:p2wpkh' as InternalAccount['type'], + options: {}, + methods: [], + scopes: ['bip122:000000000019d6689c085ae165831e93'], + metadata: { + name: 'Bitcoin Account', + importTime: Date.now(), + keyring: { + type: 'Snap Keyring', + }, + }, + }; + const mockOrders = [ + { + providerOrderId: 'order-btc-match', + walletAddress: bitcoinAddress, + status: 'COMPLETED', + createdAt: 1000, + }, + { + providerOrderId: 'order-btc-other', + walletAddress: otherBitcoinAddress, + status: 'COMPLETED', + createdAt: 2000, + }, + ]; + const state = createStateWithSelectedAccountGroup( + { orders: mockOrders } as never, + bitcoinInternalAccount, + bitcoinAccountId, + ); + + expect(selectRampsOrdersForSelectedAccountGroup(state)).toEqual([ + mockOrders[0], + ]); + }); + + it('keeps orders whose walletAddress matches a Tron account in the selected group', () => { + const tronAccountId = 'account-ramps-tron' as AccountId; + const tronAddress = 'TXYZopYRdj2D9XRtbPoJZ1CuXLNaoEBgD'; + const otherTronAddress = 'TN3W4H6rK2ce4vX9YnFQHw8ENXNA9s8rPH'; + const tronInternalAccount: InternalAccount = { + ...createMockInternalAccount( + tronAddress, + 'Tron Account', + KeyringTypes.snap, + TrxAccountType.Eoa, + ), + id: tronAccountId, + }; + const mockOrders = [ + { + providerOrderId: 'order-tron-match', + walletAddress: tronAddress, + status: 'COMPLETED', + createdAt: 1000, + }, + { + providerOrderId: 'order-tron-other', + walletAddress: otherTronAddress, + status: 'COMPLETED', + createdAt: 2000, + }, + ]; + const state = createStateWithSelectedAccountGroup( + { orders: mockOrders } as never, + tronInternalAccount, + tronAccountId, + ); + + expect(selectRampsOrdersForSelectedAccountGroup(state)).toEqual([ + mockOrders[0], + ]); + }); + }); + describe('selectTransak', () => { it('returns transak state when nativeProviders.transak is set', () => { const mockTransakState = { diff --git a/app/selectors/rampsController/index.ts b/app/selectors/rampsController/index.ts index aa30fd4b8aa..1c86fea1d88 100644 --- a/app/selectors/rampsController/index.ts +++ b/app/selectors/rampsController/index.ts @@ -11,6 +11,9 @@ import { type RampsOrder, } from '@metamask/ramps-controller'; import { RootState } from '../../reducers'; +import { areAddressesEqual } from '../../util/address'; +import { createDeepEqualSelector } from '../util'; +import { selectSelectedAccountGroupWithInternalAccountsAddresses } from '../multichainAccounts/accountTreeController'; /** * Selects the RampsController state from Redux. @@ -90,13 +93,42 @@ export const selectPaymentMethods = createSelector( ); /** - * Selects V2 orders from RampsController state. + * Selects all V2 orders from RampsController state (unfiltered). + * For UI scoped to the selected account group, use + * `selectRampsOrdersForSelectedAccountGroup` instead. */ export const selectRampsOrders = createSelector( selectRampsControllerState, (rampsControllerState): RampsOrder[] => rampsControllerState?.orders ?? [], ); +/** + * V2 on-ramp orders whose `walletAddress` belongs to the selected account group. + * Matches legacy `getOrders` scoping for fiat orders. + */ +export const selectRampsOrdersForSelectedAccountGroup = createDeepEqualSelector( + [selectRampsOrders, selectSelectedAccountGroupWithInternalAccountsAddresses], + (orders, addresses): RampsOrder[] => { + if (addresses.length === 0) { + return []; + } + return orders.filter((order) => { + const walletAddress = order.walletAddress; + if (!walletAddress) { + return false; + } + return addresses.some( + (addr) => addr != null && areAddressesEqual(walletAddress, addr), + ); + }); + }, + { + devModeChecks: { + identityFunctionCheck: 'never', + }, + }, +); + /** * Selects the transak native provider state (isAuthenticated, userDetails, buyQuote, kycRequirement). */ From 94e6bcb0478d73c80c0c0b59871f557eea6e880f Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Tue, 24 Mar 2026 14:52:45 +0000 Subject: [PATCH 183/206] [skip ci] Bump version number to 4172 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 8f01fbabd04..cb4881fdc9b 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4171 + versionCode 4172 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 8331c859bd7..8bf6cc69f9c 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4171 + VERSION_NUMBER: 4172 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4171 + FLASK_VERSION_NUMBER: 4172 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 4fc1bf7cc4b..7d0636490fc 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4171; + CURRENT_PROJECT_VERSION = 4172; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4171; + CURRENT_PROJECT_VERSION = 4172; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4171; + CURRENT_PROJECT_VERSION = 4172; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4171; + CURRENT_PROJECT_VERSION = 4172; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4171; + CURRENT_PROJECT_VERSION = 4172; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4171; + CURRENT_PROJECT_VERSION = 4172; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 77eca9c5a2c8cc00ead352a163d6d7c3301b9f5e Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:18:20 +0100 Subject: [PATCH 184/206] chore(runway): cherry-pick feat: legacy-ios-feature-flag cp-7.71.0 (#27876) - feat: legacy-ios-feature-flag cp-7.71.0 (#27848) ## **Description** Support webcredential for ios google login Part 2/4 - Add feature flag This pr add feature flag for the ios google login PR list Part 1/ 4 - https://github.com/MetaMask/metamask-mobile/pull/27741 Part 2/ 4 - https://github.com/MetaMask/metamask-mobile/pull/27848 Part 3/ 4 - https://github.com/MetaMask/metamask-mobile/pull/27850 Part 4/ 4 - TBA ## **Changelog** CHANGELOG entry: added legacyIosGoogleConfigEnabled feature flag ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Low risk: adds a new remote feature flag and selector with env override, without changing authentication flow yet; main risk is misconfiguration since the selector defaults to enabled. > > **Overview** > Adds a new remote feature flag, `legacyIosGoogleConfigEnabled`, including registry metadata and a dedicated selector `selectLegacyIosGoogleConfigEnabled` (defaulting to `true`) that can be force-overridden via `MM_LEGACY_IOS_GOOGLE_CONFIG_ENABLED`. > > Includes unit tests covering default/remote/env override behavior, and updates `babel.config.tests.js` to avoid inlining env vars for the new selector and its tests. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit ca7e8132c44d06ba009029b1f83eb4412e10006f. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [6ae4c95](https://github.com/MetaMask/metamask-mobile/commit/6ae4c95e880b7f2f71eb4bd415b75347610aa236) Co-authored-by: ieow <4881057+ieow@users.noreply.github.com> --- app/constants/featureFlags.ts | 1 + .../legacyIosGoogleConfig/index.test.ts | 56 +++++++++++++++++++ .../legacyIosGoogleConfig/index.ts | 26 +++++++++ babel.config.tests.js | 2 + tests/feature-flags/feature-flag-registry.ts | 8 +++ 5 files changed, 93 insertions(+) create mode 100644 app/selectors/featureFlagController/legacyIosGoogleConfig/index.test.ts create mode 100644 app/selectors/featureFlagController/legacyIosGoogleConfig/index.ts diff --git a/app/constants/featureFlags.ts b/app/constants/featureFlags.ts index c443b3f9fd6..a439f406657 100644 --- a/app/constants/featureFlags.ts +++ b/app/constants/featureFlags.ts @@ -15,6 +15,7 @@ export enum FeatureFlagNames { tokenDetailsV2Buttons = 'tokenDetailsV2Buttons', tokenDetailsV2ButtonLayout = 'tokenDetailsV2ButtonLayout', complianceEnabled = 'complianceEnabled', + legacyIosGoogleConfigEnabled = 'legacyIosGoogleConfigEnabled', } export const DEFAULT_FEATURE_FLAG_VALUES: Partial< diff --git a/app/selectors/featureFlagController/legacyIosGoogleConfig/index.test.ts b/app/selectors/featureFlagController/legacyIosGoogleConfig/index.test.ts new file mode 100644 index 00000000000..3a507ff875a --- /dev/null +++ b/app/selectors/featureFlagController/legacyIosGoogleConfig/index.test.ts @@ -0,0 +1,56 @@ +import { + DEFAULT_LEGACY_IOS_GOOGLE_CONFIG_ENABLED, + selectLegacyIosGoogleConfigEnabled, +} from '.'; +import { FeatureFlagNames } from '../../../constants/featureFlags'; + +describe('Legacy iOS Google Config Feature Flag Selector', () => { + const originalEnv = process.env.MM_LEGACY_IOS_GOOGLE_CONFIG_ENABLED; + + beforeEach(() => { + delete process.env.MM_LEGACY_IOS_GOOGLE_CONFIG_ENABLED; + }); + + afterAll(() => { + if (originalEnv === undefined) { + delete process.env.MM_LEGACY_IOS_GOOGLE_CONFIG_ENABLED; + return; + } + + process.env.MM_LEGACY_IOS_GOOGLE_CONFIG_ENABLED = originalEnv; + }); + + it('returns the default value when the remote flag is missing', () => { + const result = selectLegacyIosGoogleConfigEnabled.resultFunc({}); + + expect(result).toBe(DEFAULT_LEGACY_IOS_GOOGLE_CONFIG_ENABLED); + }); + + it('returns the remote flag value when present', () => { + const result = selectLegacyIosGoogleConfigEnabled.resultFunc({ + [FeatureFlagNames.legacyIosGoogleConfigEnabled]: false, + }); + + expect(result).toBe(false); + }); + + it('allows the local env var to force enable the legacy config', () => { + process.env.MM_LEGACY_IOS_GOOGLE_CONFIG_ENABLED = 'true'; + + const result = selectLegacyIosGoogleConfigEnabled.resultFunc({ + [FeatureFlagNames.legacyIosGoogleConfigEnabled]: false, + }); + + expect(result).toBe(true); + }); + + it('allows the local env var to force disable the legacy config', () => { + process.env.MM_LEGACY_IOS_GOOGLE_CONFIG_ENABLED = 'false'; + + const result = selectLegacyIosGoogleConfigEnabled.resultFunc({ + [FeatureFlagNames.legacyIosGoogleConfigEnabled]: true, + }); + + expect(result).toBe(false); + }); +}); diff --git a/app/selectors/featureFlagController/legacyIosGoogleConfig/index.ts b/app/selectors/featureFlagController/legacyIosGoogleConfig/index.ts new file mode 100644 index 00000000000..be844dc3bd6 --- /dev/null +++ b/app/selectors/featureFlagController/legacyIosGoogleConfig/index.ts @@ -0,0 +1,26 @@ +import { hasProperty } from '@metamask/utils'; +import { createSelector } from 'reselect'; +import { FeatureFlagNames } from '../../../constants/featureFlags'; +import { getFeatureFlagValue } from '../env'; +import { selectRemoteFeatureFlags } from '..'; + +export const DEFAULT_LEGACY_IOS_GOOGLE_CONFIG_ENABLED = true; + +export const selectLegacyIosGoogleConfigEnabled = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => { + const remoteValue = hasProperty( + remoteFeatureFlags, + FeatureFlagNames.legacyIosGoogleConfigEnabled, + ) + ? Boolean( + remoteFeatureFlags[FeatureFlagNames.legacyIosGoogleConfigEnabled], + ) + : DEFAULT_LEGACY_IOS_GOOGLE_CONFIG_ENABLED; + return getFeatureFlagValue( + // Use direct env access so Babel can inline this value in app builds. + process.env.MM_LEGACY_IOS_GOOGLE_CONFIG_ENABLED, + remoteValue, + ); + }, +); diff --git a/babel.config.tests.js b/babel.config.tests.js index bf4dfe1bd1f..292e3151ff1 100644 --- a/babel.config.tests.js +++ b/babel.config.tests.js @@ -37,6 +37,8 @@ const newOverrides = [ 'app/components/UI/Ramp/hooks/useRampTokens.test.ts', 'app/components/Views/confirmations/hooks/pay/useTransactionPayWithdraw.ts', 'app/components/Views/confirmations/hooks/pay/useTransactionPayWithdraw.test.ts', + 'app/selectors/featureFlagController/legacyIosGoogleConfig/index.ts', + 'app/selectors/featureFlagController/legacyIosGoogleConfig/index.test.ts', 'app/util/environment.ts', 'app/util/environment.test.ts', 'app/core/Engine/controllers/rewards-controller/utils/rewards-api-url.ts', diff --git a/tests/feature-flags/feature-flag-registry.ts b/tests/feature-flags/feature-flag-registry.ts index bb05b3aa658..2c696a8020c 100644 --- a/tests/feature-flags/feature-flag-registry.ts +++ b/tests/feature-flags/feature-flag-registry.ts @@ -2829,6 +2829,14 @@ export const FEATURE_FLAG_REGISTRY: Record = { status: FeatureFlagStatus.Active, }, + legacyIosGoogleConfigEnabled: { + name: 'legacyIosGoogleConfigEnabled', + type: FeatureFlagType.Remote, + inProd: true, + productionDefault: true, + status: FeatureFlagStatus.Active, + }, + metalCardCheckoutEnabled: { name: 'metalCardCheckoutEnabled', type: FeatureFlagType.Remote, From b22241e6f6d22afcabb065641dce5b5c6e7a795a Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Tue, 24 Mar 2026 17:20:17 +0000 Subject: [PATCH 185/206] [skip ci] Bump version number to 4173 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index cb4881fdc9b..d91fbcd5d44 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4172 + versionCode 4173 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 8bf6cc69f9c..a82f6f06ee2 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4172 + VERSION_NUMBER: 4173 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4172 + FLASK_VERSION_NUMBER: 4173 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 7d0636490fc..6b9e475e392 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4172; + CURRENT_PROJECT_VERSION = 4173; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4172; + CURRENT_PROJECT_VERSION = 4173; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4172; + CURRENT_PROJECT_VERSION = 4173; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4172; + CURRENT_PROJECT_VERSION = 4173; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4172; + CURRENT_PROJECT_VERSION = 4173; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4172; + CURRENT_PROJECT_VERSION = 4173; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 3bfa916203555dedf92448b4d96ce017bdaf2e7c Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 09:19:52 +0100 Subject: [PATCH 186/206] chore(runway): cherry-pick fix(perps): validate TP/SL prices, clear stale config, and block invalid orders cp-7.71.0 (#27874) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(perps): validate TP/SL prices, clear stale config, and block invalid orders cp-7.71.0 (#27791) ## **Description** Fixes three related bugs in the Perps order form involving Take Profit (TP) and Stop Loss (SL) prices that were restored from a previous session's pending trade configuration: 1. **Stale TP/SL persisted after order submission**: The `pendingTradeConfiguration` was not cleared after a successful order, causing previously set TP/SL values to reappear on the next order form visit — even auto-submitting a stop loss the user never intended. 2. **TP/SL displayed as "off" despite being set**: When the RoE calculation clamped to zero (e.g., the TP/SL price was on the wrong side of the current market price), the "Auto close" summary row showed "off" instead of the actual price. The TP/SL edit form, however, showed the correct value — a confusing inconsistency. 3. **No validation or blocking for invalid TP/SL direction**: A restored TP/SL price that ended up on the wrong side of the market (e.g., take profit below entry for a long) was silently accepted and could be submitted, leading to immediate execution or unexpected behavior. ### Changes - Call `clearPendingTradeConfiguration` after successful order execution to prevent stale TP/SL restoration. - Display the formatted price in the "Auto close" row when RoE rounds to zero, instead of showing "off". - Validate TP/SL prices against current market price and trade direction using existing `isValidTakeProfitPrice` / `isValidStopLossPrice` utilities. - Show inline error warnings when TP or SL is on the wrong side of the current price. - Disable the "Place order" button while TP/SL is invalid. ## **Changelog** CHANGELOG entry: Fixed a bug where stale Take Profit and Stop Loss prices could persist across orders and display incorrectly in the Perps order form ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/27793 ## **Manual testing steps** ```gherkin Feature: Perps order TP/SL validation Scenario: stale TP/SL is cleared after placing an order Given the user has a Perps position open with TP and SL set And the user navigates to the order form When the user places the order successfully And the user returns to the order form for the same asset Then the TP and SL fields should be empty (not restored from previous order) Scenario: TP/SL on the wrong side shows warning and blocks submission Given the user is on the Perps order form for a Long position And the user sets a Take Profit price below the current market price When the order form validates the TP price Then a warning is displayed: "Take profit must be above current price. Update or clear it to place the order." And the "Place order" button is disabled Scenario: TP/SL on the wrong side for Short position Given the user is on the Perps order form for a Short position And the user sets a Stop Loss price below the current market price When the order form validates the SL price Then a warning is displayed: "Stop loss must be above current price. Update or clear it to place the order." And the "Place order" button is disabled Scenario: TP/SL with zero RoE displays price instead of "off" Given the user is on the Perps order form with a TP or SL set And the TP/SL price results in an RoE that rounds to 0% When the "Auto close" summary row renders Then it displays the formatted price value instead of "off" ``` ## **Screenshots/Recordings** N/A — validation logic and text changes only; no layout or visual design changes. ### **Before** N/A ### **After** Simulator Screenshot - iPhone 17
Pro Max - 2026-03-23 at 11 46 16 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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 the Perps order submission/validation path to block orders when TP/SL is on the wrong side and clears stored pending trade config after submission, which could affect order placement behavior. Risk is mitigated by added unit coverage for market vs limit reference price and button-disabled states. > > **Overview** > Prevents Perps orders from being submitted with **invalid TP/SL trigger prices** by validating TP/SL against the appropriate reference price (*current* for market orders, *entry/limit* for limit orders), showing inline warnings, and disabling the **Place order** button when TP/SL is wrong-side. > > Fixes TP/SL display inconsistencies by showing the formatted TP/SL *price* in the summary row when computed RoE clamps to `0%` instead of rendering `off`, and clears `PerpsController.clearPendingTradeConfiguration(asset)` after successful submission to avoid restoring stale TP/SL on subsequent visits. > > Adds new i18n strings for the wrong-side TP/SL warnings and expands `PerpsOrderView` tests to cover wrong-side validation, limit-order reference pricing, and the monochrome A/B button variant disablement. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 7c10dc8724422403fe1505f7b22ef7caff833648. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [ef3a973](https://github.com/MetaMask/metamask-mobile/commit/ef3a973c798e5132e860ec4063f54b0dc3fa3441) Co-authored-by: Michal Szorad --- .../PerpsOrderView/PerpsOrderView.test.tsx | 435 ++++++++++++++++++ .../Views/PerpsOrderView/PerpsOrderView.tsx | 78 +++- locales/languages/en.json | 2 + 3 files changed, 513 insertions(+), 2 deletions(-) diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx index 81b8e3a67c1..87536d54e85 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx @@ -113,6 +113,10 @@ jest.mock('../../../../../../locales/i18n', () => ({ 'Size must be a positive number', 'perps.tpsl.stop_loss_order_view_warning': 'Stop loss is {{direction}} liquidation price', + 'perps.tpsl.take_profit_wrong_side_warning': + 'Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.', + 'perps.tpsl.stop_loss_wrong_side_warning': + 'Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.', 'perps.tpsl.below': 'below', 'perps.tpsl.above': 'above', 'perps.points': 'Points', @@ -535,6 +539,16 @@ jest.mock('../../../../../core/Engine', () => ({ }, })); +// Mock usePerpsABTest hook (controllable per-test) +const mockUsePerpsABTest = jest.fn(() => ({ + variantName: 'control', + variant: { long: 'green', short: 'red' }, + isEnabled: false, +})); +jest.mock('../../utils/abTesting/usePerpsABTest', () => ({ + usePerpsABTest: () => mockUsePerpsABTest(), +})); + // Mock useTooltipModal hook jest.mock('../../../../hooks/useTooltipModal', () => ({ __esModule: true, @@ -1970,6 +1984,427 @@ describe('PerpsOrderView', () => { }); }); + describe('TP/SL wrong-side price validation', () => { + const orderContextWithTPSL = (overrides: { + direction: 'long' | 'short'; + takeProfitPrice?: string; + stopLossPrice?: string; + }) => ({ + orderForm: { + asset: 'ETH', + amount: '100', + leverage: 10, + direction: overrides.direction, + type: 'market' as const, + limitPrice: undefined, + takeProfitPrice: overrides.takeProfitPrice, + stopLossPrice: overrides.stopLossPrice, + balancePercent: 10, + }, + setAmount: jest.fn(), + setLeverage: jest.fn(), + setTakeProfitPrice: jest.fn(), + setStopLossPrice: jest.fn(), + setLimitPrice: jest.fn(), + setOrderType: jest.fn(), + handlePercentageAmount: jest.fn(), + handleMaxAmount: jest.fn(), + handleMinAmount: jest.fn(), + optimizeOrderAmount: jest.fn(), + maxPossibleAmount: 1000, + balanceForValidation: 1000, + calculations: { + marginRequired: '10', + positionSize: '0.033', + }, + }); + + it('shows warning and disables button for long position with TP below current price', async () => { + // Arrange: TP at 2000 is below current price 3000 → invalid for long + (usePerpsOrderContext as jest.Mock).mockReturnValue( + orderContextWithTPSL({ direction: 'long', takeProfitPrice: '2000' }), + ); + (usePerpsOrderValidation as jest.Mock).mockReturnValue({ + isValid: true, + errors: [], + isValidating: false, + }); + (usePerpsOrderExecution as jest.Mock).mockReturnValue({ + placeOrder: jest.fn(), + isPlacing: false, + }); + + // Act + render(, { wrapper: TestWrapper }); + + // Assert: warning visible + await waitFor(() => { + expect( + screen.getByText( + 'Take profit must be above current price. Update or clear it to place the order.', + ), + ).toBeDefined(); + }); + + // Assert: button disabled + const placeOrderButton = await screen.findByTestId( + PerpsOrderViewSelectorsIDs.PLACE_ORDER_BUTTON, + ); + expect(placeOrderButton.props.accessibilityState?.disabled).toBeTruthy(); + }); + + it('shows warning and disables button for long position with SL above current price', async () => { + // Arrange: SL at 3500 is above current price 3000 → invalid for long + (usePerpsOrderContext as jest.Mock).mockReturnValue( + orderContextWithTPSL({ direction: 'long', stopLossPrice: '3500' }), + ); + (usePerpsOrderValidation as jest.Mock).mockReturnValue({ + isValid: true, + errors: [], + isValidating: false, + }); + (usePerpsOrderExecution as jest.Mock).mockReturnValue({ + placeOrder: jest.fn(), + isPlacing: false, + }); + + // Act + render(, { wrapper: TestWrapper }); + + // Assert: warning visible + await waitFor(() => { + expect( + screen.getByText( + 'Stop loss must be below current price. Update or clear it to place the order.', + ), + ).toBeDefined(); + }); + + // Assert: button disabled + const placeOrderButton = await screen.findByTestId( + PerpsOrderViewSelectorsIDs.PLACE_ORDER_BUTTON, + ); + expect(placeOrderButton.props.accessibilityState?.disabled).toBeTruthy(); + }); + + it('shows warning for short position with TP above current price', async () => { + // Arrange: TP at 3500 is above current price 3000 → invalid for short + (usePerpsOrderContext as jest.Mock).mockReturnValue( + orderContextWithTPSL({ direction: 'short', takeProfitPrice: '3500' }), + ); + + // Act + render(, { wrapper: TestWrapper }); + + // Assert: warning uses "below" for short + await waitFor(() => { + expect( + screen.getByText( + 'Take profit must be below current price. Update or clear it to place the order.', + ), + ).toBeDefined(); + }); + }); + + it('shows warning for short position with SL below current price', async () => { + // Arrange: SL at 2000 is below current price 3000 → invalid for short + (usePerpsOrderContext as jest.Mock).mockReturnValue( + orderContextWithTPSL({ direction: 'short', stopLossPrice: '2000' }), + ); + + // Act + render(, { wrapper: TestWrapper }); + + // Assert: warning uses "above" for short + await waitFor(() => { + expect( + screen.getByText( + 'Stop loss must be above current price. Update or clear it to place the order.', + ), + ).toBeDefined(); + }); + }); + + it('does not show wrong-side warnings when TP/SL prices are valid', async () => { + // Arrange: valid TP above and SL below current price for long + (usePerpsOrderContext as jest.Mock).mockReturnValue( + orderContextWithTPSL({ + direction: 'long', + takeProfitPrice: '3500', + stopLossPrice: '2500', + }), + ); + + // Act + render(, { wrapper: TestWrapper }); + + // Assert: component renders (TP/SL summary with valid percentages) + await waitFor(() => { + expect(screen.getByText('Leverage')).toBeDefined(); + }); + + // Assert: no wrong-side warnings + expect(screen.queryByText(/Take profit must be/)).toBeNull(); + expect(screen.queryByText(/Stop loss must be.*current price/)).toBeNull(); + }); + + it('disables button when both TP and SL are on wrong side', async () => { + // Arrange: both invalid for long (TP below, SL above current price) + (usePerpsOrderContext as jest.Mock).mockReturnValue( + orderContextWithTPSL({ + direction: 'long', + takeProfitPrice: '2000', + stopLossPrice: '3500', + }), + ); + (usePerpsOrderValidation as jest.Mock).mockReturnValue({ + isValid: true, + errors: [], + isValidating: false, + }); + (usePerpsOrderExecution as jest.Mock).mockReturnValue({ + placeOrder: jest.fn(), + isPlacing: false, + }); + + // Act + render(, { wrapper: TestWrapper }); + + // Assert: both warnings shown + await waitFor(() => { + expect(screen.getByText(/Take profit must be above/)).toBeDefined(); + expect(screen.getByText(/Stop loss must be below/)).toBeDefined(); + }); + + // Assert: button disabled + const placeOrderButton = await screen.findByTestId( + PerpsOrderViewSelectorsIDs.PLACE_ORDER_BUTTON, + ); + expect(placeOrderButton.props.accessibilityState?.disabled).toBeTruthy(); + }); + + it('disables monochrome button variant when TP/SL is invalid', async () => { + // Arrange: monochrome A/B test variant + invalid TP + mockUsePerpsABTest.mockReturnValue({ + variantName: 'monochrome', + variant: { long: 'white', short: 'white' }, + isEnabled: true, + }); + (usePerpsOrderContext as jest.Mock).mockReturnValue( + orderContextWithTPSL({ direction: 'long', takeProfitPrice: '2000' }), + ); + (usePerpsOrderValidation as jest.Mock).mockReturnValue({ + isValid: true, + errors: [], + isValidating: false, + }); + (usePerpsOrderExecution as jest.Mock).mockReturnValue({ + placeOrder: jest.fn(), + isPlacing: false, + }); + + // Act + render(, { wrapper: TestWrapper }); + + // Assert: warning visible (proves hasInvalidTPSL is true in monochrome path) + await waitFor(() => { + expect(screen.getByText(/Take profit must be above/)).toBeDefined(); + }); + + // Assert: monochrome button rendered and receives isDisabled prop + const placeOrderButton = await screen.findByTestId( + PerpsOrderViewSelectorsIDs.PLACE_ORDER_BUTTON, + ); + expect(placeOrderButton).toBeDefined(); + }); + + describe('limit order TP/SL validates against entry price, not market price', () => { + const orderContextForLimitOrder = (overrides: { + direction: 'long' | 'short'; + limitPrice: string; + takeProfitPrice?: string; + stopLossPrice?: string; + }) => ({ + orderForm: { + asset: 'ETH', + amount: '100', + leverage: 10, + direction: overrides.direction, + type: 'limit' as const, + limitPrice: overrides.limitPrice, + takeProfitPrice: overrides.takeProfitPrice, + stopLossPrice: overrides.stopLossPrice, + balancePercent: 10, + }, + setAmount: jest.fn(), + setLeverage: jest.fn(), + setTakeProfitPrice: jest.fn(), + setStopLossPrice: jest.fn(), + setLimitPrice: jest.fn(), + setOrderType: jest.fn(), + handlePercentageAmount: jest.fn(), + handleMaxAmount: jest.fn(), + handleMinAmount: jest.fn(), + optimizeOrderAmount: jest.fn(), + maxPossibleAmount: 1000, + balanceForValidation: 1000, + calculations: { + marginRequired: '10', + positionSize: '0.04', + }, + }); + + it('accepts TP above limit price for long limit order even when TP is below market price', async () => { + // Scenario: market at $3000, long limit buy at $2500, TP at $2700 + // TP $2700 is valid relative to $2500 entry but below $3000 market price + (usePerpsOrderContext as jest.Mock).mockReturnValue( + orderContextForLimitOrder({ + direction: 'long', + limitPrice: '2500', + takeProfitPrice: '2700', + }), + ); + (usePerpsOrderValidation as jest.Mock).mockReturnValue({ + isValid: true, + errors: [], + isValidating: false, + }); + (usePerpsOrderExecution as jest.Mock).mockReturnValue({ + placeOrder: jest.fn(), + isPlacing: false, + }); + + render(, { wrapper: TestWrapper }); + + await waitFor(() => { + expect(screen.getByText('Leverage')).toBeDefined(); + }); + + // TP at $2700 is above the $2500 limit (entry) price, so no warning should appear + expect(screen.queryByText(/Take profit must be/)).toBeNull(); + }); + + it('accepts SL below limit price for long limit order even when SL is below market price', async () => { + // Scenario: market at $3000, long limit buy at $2500, SL at $2300 + // SL $2300 is valid relative to $2500 entry + (usePerpsOrderContext as jest.Mock).mockReturnValue( + orderContextForLimitOrder({ + direction: 'long', + limitPrice: '2500', + stopLossPrice: '2300', + }), + ); + (usePerpsOrderValidation as jest.Mock).mockReturnValue({ + isValid: true, + errors: [], + isValidating: false, + }); + (usePerpsOrderExecution as jest.Mock).mockReturnValue({ + placeOrder: jest.fn(), + isPlacing: false, + }); + + render(, { wrapper: TestWrapper }); + + await waitFor(() => { + expect(screen.getByText('Leverage')).toBeDefined(); + }); + + // SL at $2300 is below the $2500 limit (entry) price, so no warning should appear + expect(screen.queryByText(/Stop loss must be/)).toBeNull(); + }); + + it('rejects TP below limit price for long limit order', async () => { + // Scenario: market at $3000, long limit buy at $2500, TP at $2400 + // TP $2400 is below the $2500 entry → invalid + (usePerpsOrderContext as jest.Mock).mockReturnValue( + orderContextForLimitOrder({ + direction: 'long', + limitPrice: '2500', + takeProfitPrice: '2400', + }), + ); + (usePerpsOrderValidation as jest.Mock).mockReturnValue({ + isValid: true, + errors: [], + isValidating: false, + }); + (usePerpsOrderExecution as jest.Mock).mockReturnValue({ + placeOrder: jest.fn(), + isPlacing: false, + }); + + render(, { wrapper: TestWrapper }); + + await waitFor(() => { + expect( + screen.getByText(/Take profit must be above entry price/), + ).toBeDefined(); + }); + }); + + it('accepts TP below limit price for short limit order even when TP is above market price', async () => { + // Scenario: market at $3000, short limit sell at $3500, TP at $3200 + // TP $3200 is below $3500 entry (valid for short) but above $3000 market + (usePerpsOrderContext as jest.Mock).mockReturnValue( + orderContextForLimitOrder({ + direction: 'short', + limitPrice: '3500', + takeProfitPrice: '3200', + }), + ); + (usePerpsOrderValidation as jest.Mock).mockReturnValue({ + isValid: true, + errors: [], + isValidating: false, + }); + (usePerpsOrderExecution as jest.Mock).mockReturnValue({ + placeOrder: jest.fn(), + isPlacing: false, + }); + + render(, { wrapper: TestWrapper }); + + await waitFor(() => { + expect(screen.getByText('Leverage')).toBeDefined(); + }); + + // TP at $3200 is below $3500 limit entry, valid for short + expect(screen.queryByText(/Take profit must be/)).toBeNull(); + }); + + it('accepts SL above limit price for short limit order', async () => { + // Scenario: market at $3000, short limit sell at $3500, SL at $3700 + // SL $3700 is above $3500 entry (valid for short) + (usePerpsOrderContext as jest.Mock).mockReturnValue( + orderContextForLimitOrder({ + direction: 'short', + limitPrice: '3500', + stopLossPrice: '3700', + }), + ); + (usePerpsOrderValidation as jest.Mock).mockReturnValue({ + isValid: true, + errors: [], + isValidating: false, + }); + (usePerpsOrderExecution as jest.Mock).mockReturnValue({ + placeOrder: jest.fn(), + isPlacing: false, + }); + + render(, { wrapper: TestWrapper }); + + await waitFor(() => { + expect(screen.getByText('Leverage')).toBeDefined(); + }); + + // SL at $3700 is above $3500 limit entry, valid for short + expect(screen.queryByText(/Stop loss must be/)).toBeNull(); + }); + }); + }); + describe('TP/SL limit price validation', () => { it('shows toast and prevents TP/SL bottom sheet from opening on limit order without limit price', async () => { // Clear all mocks to ensure clean state diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx index a57a9f81cd8..94830b2df9d 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -47,6 +47,7 @@ import Text, { } from '../../../../../component-library/components/Texts/Text'; import useTooltipModal from '../../../../../components/hooks/useTooltipModal'; import Routes from '../../../../../constants/navigation/Routes'; +import Engine from '../../../../../core/Engine'; import DevLogger from '../../../../../core/SDKConnect/utils/DevLogger'; import { useTheme } from '../../../../../util/theme'; import { TraceName } from '../../../../../util/trace'; @@ -139,6 +140,8 @@ import { willFlipPosition } from '../../utils/orderUtils'; import { calculateRoEForPrice, isStopLossSafeFromLiquidation, + isValidStopLossPrice, + isValidTakeProfitPrice, } from '../../utils/tpslValidation'; import createStyles from './PerpsOrderView.styles'; import { PerpsPayRow } from './PerpsPayRow'; @@ -672,7 +675,11 @@ const PerpsOrderViewContentBase: React.FC = ({ ); const absRoE = Math.abs(parseFloat(tpRoE || '0')); tpDisplay = - absRoE > 0 ? `${absRoE.toFixed(0)}%` : strings('perps.order.off'); + absRoE > 0 + ? `${absRoE.toFixed(0)}%` + : formatPerpsFiat(orderForm.takeProfitPrice, { + ranges: PRICE_RANGES_UNIVERSAL, + }); } if (orderForm.stopLossPrice && price > 0 && orderForm.leverage) { @@ -689,7 +696,11 @@ const PerpsOrderViewContentBase: React.FC = ({ ); const absRoE = Math.abs(parseFloat(slRoE || '0')); slDisplay = - absRoE > 0 ? `${absRoE.toFixed(0)}%` : strings('perps.order.off'); + absRoE > 0 + ? `${absRoE.toFixed(0)}%` + : formatPerpsFiat(orderForm.stopLossPrice, { + ranges: PRICE_RANGES_UNIVERSAL, + }); } return `${strings('perps.order.tp')} ${tpDisplay}, ${strings( @@ -1079,6 +1090,12 @@ const PerpsOrderViewContentBase: React.FC = ({ } else { await executeOrder(orderParams); } + + // Clear pending trade config after successful submission to prevent + // stale TP/SL values from being restored on the next order form visit + Engine.context.PerpsController?.clearPendingTradeConfiguration( + orderForm.asset, + ); } finally { // Always reset submission flag isSubmittingRef.current = false; @@ -1204,6 +1221,35 @@ const PerpsOrderViewContentBase: React.FC = ({ ), ); + const isLimitWithPrice = + orderForm.type === 'limit' && Boolean(orderForm.limitPrice); + + const validationReferencePrice = isLimitWithPrice + ? parseFloat(String(orderForm.limitPrice)) + : assetData.price; + + const tpslPriceType = isLimitWithPrice ? 'entry' : 'current'; + + const isTakeProfitPriceInvalid = Boolean( + orderForm.takeProfitPrice?.trim() && + validationReferencePrice > 0 && + !isValidTakeProfitPrice(orderForm.takeProfitPrice, { + currentPrice: validationReferencePrice, + direction: orderForm.direction, + }), + ); + + const isStopLossPriceInvalid = Boolean( + orderForm.stopLossPrice?.trim() && + validationReferencePrice > 0 && + !isValidStopLossPrice(orderForm.stopLossPrice, { + currentPrice: validationReferencePrice, + direction: orderForm.direction, + }), + ); + + const hasInvalidTPSL = isTakeProfitPriceInvalid || isStopLossPriceInvalid; + let rewardAnimationState = RewardAnimationState.Idle; if (rewardsState.isLoading) { rewardAnimationState = RewardAnimationState.Loading; @@ -1421,6 +1467,32 @@ const PerpsOrderViewContentBase: React.FC = ({ )} + {!hideTPSL && isTakeProfitPriceInvalid && ( + + + {strings('perps.tpsl.take_profit_wrong_side_warning', { + direction: + orderForm.direction === 'long' + ? strings('perps.tpsl.above') + : strings('perps.tpsl.below'), + priceType: tpslPriceType, + })} + + + )} + {!hideTPSL && isStopLossPriceInvalid && ( + + + {strings('perps.tpsl.stop_loss_wrong_side_warning', { + direction: + orderForm.direction === 'long' + ? strings('perps.tpsl.below') + : strings('perps.tpsl.above'), + priceType: tpslPriceType, + })} + + + )} )} @@ -1652,6 +1724,7 @@ const PerpsOrderViewContentBase: React.FC = ({ !orderValidation.isValid || isPlacingOrder || doesStopLossRiskLiquidation || + hasInvalidTPSL || isAtOICap || shouldBlockBecauseOfFeesLoading } @@ -1672,6 +1745,7 @@ const PerpsOrderViewContentBase: React.FC = ({ !orderValidation.isValid || isPlacingOrder || doesStopLossRiskLiquidation || + hasInvalidTPSL || isAtOICap || shouldBlockBecauseOfFeesLoading } diff --git a/locales/languages/en.json b/locales/languages/en.json index 830f8db5ce7..a30519785e2 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1471,6 +1471,8 @@ "stop_loss_invalid_price": "Stop loss must be {{direction}} {{priceType}} price", "stop_loss_beyond_liquidation_error": "Stop loss must be {{direction}} liquidation price", "stop_loss_order_view_warning": "Stop loss is {{direction}} liquidation price", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "above", "below": "below", "done": "Done", From 39889c8a4f7bb16406ef30d03a17603542909ee4 Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Wed, 25 Mar 2026 08:21:31 +0000 Subject: [PATCH 187/206] [skip ci] Bump version number to 4179 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index d91fbcd5d44..afaaa7fdfc9 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4173 + versionCode 4179 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index a82f6f06ee2..0b2537e8a05 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4173 + VERSION_NUMBER: 4179 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4173 + FLASK_VERSION_NUMBER: 4179 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 6b9e475e392..8d4ef29b463 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4173; + CURRENT_PROJECT_VERSION = 4179; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4173; + CURRENT_PROJECT_VERSION = 4179; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4173; + CURRENT_PROJECT_VERSION = 4179; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4173; + CURRENT_PROJECT_VERSION = 4179; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4173; + CURRENT_PROJECT_VERSION = 4179; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4173; + CURRENT_PROJECT_VERSION = 4179; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 0e4683f2726eaac590a2fd0a1e835ed71069220b Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 10:06:45 +0100 Subject: [PATCH 188/206] chore(runway): cherry-pick fix: disable Branch test instance and debug mode in branch.json cp-7.71.0 (#27889) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix: disable Branch test instance and debug mode in branch.json cp-7.71.0 (#27879) ## **Description** `branch.json` had `useTestInstance: true` and `debugMode: true`, which forced **all builds** — including production RC — to initialize the Branch SDK against the **test environment**. Branch short links (e.g. `metamask.app.link/1WkF6GmE40b`) are created in the **live** Branch dashboard, so the test-instance SDK could never resolve them: the test and live environments are separate databases. This was the root cause of the "This page doesn't exist" error when opening Branch deepview short links (e.g. from Twitter/X). The SDK returned `+clicked_branch_link: false` and `+non_branch_link` with the raw URL because the test environment had no record of live links. The fix sets both values to `false` so the SDK uses the live key from the native configuration (`Info.plist` / `AndroidManifest.xml`), matching the environment where links are actually created. **Note:** The same `branch.json` content is also present at `android/app/src/main/assets/branch.json` — both platforms are affected. This PR fixes the root config; the Android copy should also be verified/updated. ## **Changelog** CHANGELOG entry: Fixed Branch.io deep links not resolving by switching SDK from test to live environment ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Branch short link deep linking Background: Given I have a production RC build installed And the app is using the live Branch key Scenario: user opens a Branch short link from cold start Given the app is not running When user taps a Branch deepview link (e.g. https://metamask.app.link/1WkF6GmE40b) Then the app should open And the user should be navigated to the intended destination (e.g. Trending page) And the "This page doesn't exist" modal should NOT appear Scenario: user opens a Branch short link from warm start (app backgrounded) Given the app is running in the background When user taps a Branch deepview link Then the app should come to foreground And the user should be navigated to the intended destination Scenario: user opens a direct universal link (non-Branch) Given the app is installed When user taps https://link.metamask.io/trending Then the Trending page should open normally And behavior should be unchanged from before this PR ``` ## **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. ## **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** > Low risk config-only change, but it affects deep link behavior across builds by switching Branch initialization away from the test environment. > > **Overview** > Disables `debugMode` and `useTestInstance` in `branch.json`, ensuring the Branch SDK initializes against the **live** environment rather than the test instance. > > This should restore proper resolution of production Branch short links/deepviews that previously failed when the app was forced to use the test Branch database. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 33fcef144d583a1c9368a13cf57d8e03d887dd98. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [75bef55](https://github.com/MetaMask/metamask-mobile/commit/75bef55fcaa5061fcc0178d2348a48babd483914) Co-authored-by: tommasini <46944231+tommasini@users.noreply.github.com> --- branch.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/branch.json b/branch.json index d64e8345b90..ecceb6254d5 100644 --- a/branch.json +++ b/branch.json @@ -1,6 +1,6 @@ { - "debugMode": true, - "useTestInstance": true, + "debugMode": false, + "useTestInstance": false, "delayInitToCheckForSearchAds": false, "appleSearchAdsDebugMode": false } From 6c47666bbb67ac1d606a8e9ce11cca2251df86ec Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Wed, 25 Mar 2026 09:08:27 +0000 Subject: [PATCH 189/206] [skip ci] Bump version number to 4181 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index afaaa7fdfc9..7dd8cbe46af 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4179 + versionCode 4181 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 0b2537e8a05..6c8ae8f3fc6 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4179 + VERSION_NUMBER: 4181 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4179 + FLASK_VERSION_NUMBER: 4181 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 8d4ef29b463..6ad772a2681 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4179; + CURRENT_PROJECT_VERSION = 4181; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4179; + CURRENT_PROJECT_VERSION = 4181; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4179; + CURRENT_PROJECT_VERSION = 4181; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4179; + CURRENT_PROJECT_VERSION = 4181; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4179; + CURRENT_PROJECT_VERSION = 4181; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4179; + CURRENT_PROJECT_VERSION = 4181; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 74b93996fdb49881d87a4502c88be05b1e4d9576 Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:25:35 +0100 Subject: [PATCH 190/206] chore(runway): cherry-pick fix: hardware wallet eip 7702 issue () (#27892) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix: hardware wallet eip 7702 issue (cp-7.71.0) (#27615) ## **Description** This PR will provide a fix for hardware wallet to gas free network like Monad and Sei. Due to currently Hardware wallet is not supported for EIP 7702 gas sponsorship, and Swap feature is not working for hardware wallet user. This fix will fall back the Gasless transaction to User pay gas previous model so that user can still do the swap and sign transaction like bfore. This is temporately fix for current version of extensions, and we will do a proper support in the future. Similar to extension PR: https://github.com/MetaMask/metamask-extension/pull/40915 Ticket: https://consensyssoftware.atlassian.net/jira/software/c/projects/NEB/boards/3738/backlog?selectedIssue=NEB-767 ## **Changelog** CHANGELOG entry: Hardware wallet user will fall back to use `User pay gas` for those Gasless network due to hardware wallet not supported in Gasless network like Sei and Monad. ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Gas sponsorship disabled for hardware wallet accounts Scenario: Hardware wallet user does not use gas sponsorship on sponsored network Given the user has added a hardware wallet account (Ledger or QR-based) And the hardware wallet account is selected as the active account And the user has added a gas-sponsored network (e.g. Monad) When the user attempts to perform a swap a dapp interaction or send a transaction on the sponsored network Then the transaction should not use gas sponsorship And the UI should not display any gas sponsorship labels (e.g. "No network fee", "Paid by MetaMask") And the user should see the normal network gas fee And the transaction should follow the standard user-pays-gas flow ``` ## **Screenshots/Recordings** ### **Before** > With HW account: Network list: Screenshot 2026-03-18 at 16 05 48 Tx flow: Screenshot 2026-03-18 at 15 53 04 Screenshot 2026-03-18 at 15 55 20 Screenshot 2026-03-18 at 15 55 47 ### **After** > With HW account: Network list: Screenshot 2026-03-18 at 16 06 19 Tx flow: Screenshot 2026-03-18 at 15 49 55 Screenshot 2026-03-18 at 15 50 29 ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Touches gasless sponsorship and transaction publishing paths (including 7702 delegation), which can affect whether transactions are sponsored vs user-paid and could change behavior on supported chains. Changes are scoped to hardware-wallet detection gates with added tests, reducing regression risk. > > **Overview** > Hardware wallet accounts now **opt out of gasless / EIP-7702 sponsorship**, forcing swaps/bridge and confirmations to use the normal *user-pays-gas* path. > > This adds an `accountSupports7702` gate to `TransactionControllerInit` so `Delegation7702PublishHook` and `isEIP7702GasFeeTokensEnabled` only activate for keyrings that support 7702, and updates `useIsGaslessSupported`/`useIsGasIncluded7702Supported` (via new `useIsHardwareWalletForBridge`) to report unsupported for hardware signers. > > Network selection UI (`NetworkSelector`, `NetworkMultiSelectorList`, `CustomNetwork`) now hides the “No network fee” sponsored label for hardware wallets, and a patched `@metamask/bridge-status-controller` waits for approval tx confirmation when required. Tests were added/updated to cover the new hardware-wallet gating behavior. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 83f66fc1de28d1f8f4be8a455844a9bbf39fba4c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: metamaskbot Co-authored-by: Julien Fontanel Co-authored-by: Frederic HENG Co-authored-by: Arafet (CN - Hong Kong) <52028926+arafetbenmakhlouf@users.noreply.github.com> Co-authored-by: tommasini <46944231+tommasini@users.noreply.github.com> [c4b93de](https://github.com/MetaMask/metamask-mobile/commit/c4b93deca90a841b826848947600d8e4a8f4867f) --------- Co-authored-by: khanti42 Co-authored-by: metamaskbot Co-authored-by: Julien Fontanel Co-authored-by: Frederic HENG Co-authored-by: Arafet (CN - Hong Kong) <52028926+arafetbenmakhlouf@users.noreply.github.com> Co-authored-by: tommasini <46944231+tommasini@users.noreply.github.com> --- ...tus-controller-npm-68.1.0-8a2c809398.patch | 38 ++++++ ...tus-controller-npm-69.0.0-ec19aeeecf.patch | 38 ++++++ .../useBridgeQuoteRequest.test.ts | 44 +++++++ .../useIsGasIncluded7702Supported/index.ts | 8 +- .../useIsGasIncluded7702Supported.test.ts | 37 ++++++ .../index.test.ts | 56 +++++++++ .../useIsHardwareWalletForBridge/index.ts | 18 +++ app/components/UI/Bridge/utils/transaction.ts | 4 +- .../NetworkMultiSelectorList.test.tsx | 21 ++++ .../NetworkMultiSelectorList.tsx | 26 +++- .../Views/NetworkSelector/NetworkSelector.tsx | 15 ++- .../CustomNetworkView/CustomNetwork.tsx | 11 +- .../gas-fee-details-row.tsx | 3 +- .../hooks/gas/useIsGaslessSupported.ts | 10 +- .../transaction-controller-init.test.ts | 21 ++++ .../transaction-controller-init.ts | 23 +++- .../account-supports-7702.test.ts | 115 ++++++++++++++++++ .../transactions/account-supports-7702.ts | 51 ++++++++ package.json | 4 +- yarn.lock | 52 +++++++- 20 files changed, 575 insertions(+), 20 deletions(-) create mode 100644 .yarn/patches/@metamask-bridge-status-controller-npm-68.1.0-8a2c809398.patch create mode 100644 .yarn/patches/@metamask-bridge-status-controller-npm-69.0.0-ec19aeeecf.patch create mode 100644 app/components/UI/Bridge/hooks/useIsHardwareWalletForBridge/index.test.ts create mode 100644 app/components/UI/Bridge/hooks/useIsHardwareWalletForBridge/index.ts create mode 100644 app/util/transactions/account-supports-7702.test.ts create mode 100644 app/util/transactions/account-supports-7702.ts diff --git a/.yarn/patches/@metamask-bridge-status-controller-npm-68.1.0-8a2c809398.patch b/.yarn/patches/@metamask-bridge-status-controller-npm-68.1.0-8a2c809398.patch new file mode 100644 index 00000000000..d841b6a6b85 --- /dev/null +++ b/.yarn/patches/@metamask-bridge-status-controller-npm-68.1.0-8a2c809398.patch @@ -0,0 +1,38 @@ +diff --git a/dist/bridge-status-controller.cjs b/dist/bridge-status-controller.cjs +index b787174f2c8c448ed1ad9c8884204c5c8b6858be..af058623871badb4891564c003693ea19d0aa676 100644 +--- a/dist/bridge-status-controller.cjs ++++ b/dist/bridge-status-controller.cjs +@@ -834,7 +834,13 @@ class BridgeStatusController extends (0, polling_controller_1.StaticIntervalPoll + ? quoteResponse.approval + : undefined, quoteResponse.resetApproval, requireApproval); + approvalTxId = approvalTxMeta?.id; +- await (0, transaction_1.handleMobileHardwareWalletDelay)(requireApproval); ++ if (requireApproval && approvalTxMeta) { ++ await (0, transaction_1.handleMobileHardwareWalletDelay)(requireApproval); ++ await __classPrivateFieldGet(this, _BridgeStatusController_waitForTxConfirmation, "f").call(this, approvalTxMeta.id); ++ } ++ else { ++ await (0, transaction_1.handleMobileHardwareWalletDelay)(requireApproval); ++ } + // Generate actionId for pre-submission history (non-batch EVM only) + const actionId = (0, transaction_1.generateActionId)().toString(); + // Add pre-submission history keyed by actionId +diff --git a/dist/bridge-status-controller.mjs b/dist/bridge-status-controller.mjs +index 2fe71bdd2caf4d62f7946e9466b31367d360cd7c..fb9c0bc45abf88873452667b85ef2ea0cdfd929c 100644 +--- a/dist/bridge-status-controller.mjs ++++ b/dist/bridge-status-controller.mjs +@@ -831,7 +831,13 @@ export class BridgeStatusController extends StaticIntervalPollingController() { + ? quoteResponse.approval + : undefined, quoteResponse.resetApproval, requireApproval); + approvalTxId = approvalTxMeta?.id; +- await handleMobileHardwareWalletDelay(requireApproval); ++ if (requireApproval && approvalTxMeta) { ++ await handleMobileHardwareWalletDelay(requireApproval); ++ await __classPrivateFieldGet(this, _BridgeStatusController_waitForTxConfirmation, "f").call(this, approvalTxMeta.id); ++ } ++ else { ++ await handleMobileHardwareWalletDelay(requireApproval); ++ } + // Generate actionId for pre-submission history (non-batch EVM only) + const actionId = generateActionId().toString(); + // Add pre-submission history keyed by actionId diff --git a/.yarn/patches/@metamask-bridge-status-controller-npm-69.0.0-ec19aeeecf.patch b/.yarn/patches/@metamask-bridge-status-controller-npm-69.0.0-ec19aeeecf.patch new file mode 100644 index 00000000000..d308173b6b2 --- /dev/null +++ b/.yarn/patches/@metamask-bridge-status-controller-npm-69.0.0-ec19aeeecf.patch @@ -0,0 +1,38 @@ +diff --git a/dist/bridge-status-controller.cjs b/dist/bridge-status-controller.cjs +index ec19aeeecfa32a3cdf955ccc1152829ee4ddfd8f..d9b427f9f0f4b05238d79c731fc81566634a7c25 100644 +--- a/dist/bridge-status-controller.cjs ++++ b/dist/bridge-status-controller.cjs +@@ -855,7 +855,13 @@ + ? quoteResponse.approval + : undefined, quoteResponse.resetApproval, requireApproval); + approvalTxId = approvalTxMeta?.id; +- await (0, transaction_1.handleMobileHardwareWalletDelay)(requireApproval); ++ if (requireApproval && approvalTxMeta) { ++ await (0, transaction_1.handleMobileHardwareWalletDelay)(requireApproval); ++ await __classPrivateFieldGet(this, _BridgeStatusController_waitForTxConfirmation, "f").call(this, approvalTxMeta.id); ++ } ++ else { ++ await (0, transaction_1.handleMobileHardwareWalletDelay)(requireApproval); ++ } + // Generate actionId for pre-submission history (non-batch EVM only) + const actionId = (0, transaction_1.generateActionId)().toString(); + // Add pre-submission history keyed by actionId +diff --git a/dist/bridge-status-controller.mjs b/dist/bridge-status-controller.mjs +index a5661d63c35b5ad3526c1804936dc0e189c90c29..86efc019968599662466e643dae7002ebf5f5014 100644 +--- a/dist/bridge-status-controller.mjs ++++ b/dist/bridge-status-controller.mjs +@@ -852,7 +852,13 @@ + ? quoteResponse.approval + : undefined, quoteResponse.resetApproval, requireApproval); + approvalTxId = approvalTxMeta?.id; +- await handleMobileHardwareWalletDelay(requireApproval); ++ if (requireApproval && approvalTxMeta) { ++ await handleMobileHardwareWalletDelay(requireApproval); ++ await __classPrivateFieldGet(this, _BridgeStatusController_waitForTxConfirmation, "f").call(this, approvalTxMeta.id); ++ } ++ else { ++ await handleMobileHardwareWalletDelay(requireApproval); ++ } + // Generate actionId for pre-submission history (non-batch EVM only) + const actionId = generateActionId().toString(); + // Add pre-submission history keyed by actionId diff --git a/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/useBridgeQuoteRequest.test.ts b/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/useBridgeQuoteRequest.test.ts index ecf661c8351..eb9c0b82cfd 100644 --- a/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/useBridgeQuoteRequest.test.ts +++ b/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/useBridgeQuoteRequest.test.ts @@ -484,6 +484,50 @@ describe('useBridgeQuoteRequest', () => { }); }); + describe('hardware wallet accounts', () => { + it('sends gasIncluded and gasIncluded7702 false when useIsGasIncluded7702Supported dispatches false for hardware wallet', async () => { + // useIsGasIncluded7702Supported now incorporates the HW wallet check and + // dispatches isGasIncluded7702Supported=false for hardware wallets. + // useIsGasIncludedSTXSendBundleSupported already dispatches false for HW + // wallets via selectShouldUseSmartTransaction. + const testState = createBridgeTestState({ + bridgeReducerOverrides: { + isGasIncludedSTXSendBundleSupported: false, + isGasIncluded7702Supported: false, + sourceToken: { + address: '0xSourceToken', + chainId: '0x1', + decimals: 18, + symbol: 'SRC', + }, + destToken: { + address: '0xDestToken', + chainId: '0x1', + decimals: 18, + symbol: 'DEST', + }, + }, + }); + + const { result } = renderHookWithProvider(() => useBridgeQuoteRequest(), { + state: testState, + }); + + await act(async () => { + await result.current(); + jest.advanceTimersByTime(DEBOUNCE_WAIT); + }); + + expect(spyUpdateBridgeQuoteRequestParams).toHaveBeenCalledWith( + expect.objectContaining({ + gasIncluded: false, + gasIncluded7702: false, + }), + undefined, + ); + }); + }); + describe('insufficientBal parameter', () => { it('includes insufficientBal false when balance is sufficient', async () => { mockUseIsInsufficientBalance.mockReturnValue(false); diff --git a/app/components/UI/Bridge/hooks/useIsGasIncluded7702Supported/index.ts b/app/components/UI/Bridge/hooks/useIsGasIncluded7702Supported/index.ts index bafe1d3eac6..c3488f1cea1 100644 --- a/app/components/UI/Bridge/hooks/useIsGasIncluded7702Supported/index.ts +++ b/app/components/UI/Bridge/hooks/useIsGasIncluded7702Supported/index.ts @@ -8,13 +8,15 @@ import { formatChainIdToHex, isNonEvmChainId, } from '@metamask/bridge-controller'; +import { useIsHardwareWalletForBridge } from '../useIsHardwareWalletForBridge'; /** * Hook that determines if 7702 gasless support is available for bridge/swap. * Should be used at the page level (e.g., BridgeView) to avoid repeated calculations. * - * Requirement for 7702: + * Requirements for 7702: * - Relay must be supported (for 7702 delegation) + * - Source wallet must not be a hardware wallet * * @param chainId - The chain ID to check (can be Hex, CAIP, or other format) - only EVM chains are supported */ @@ -40,9 +42,11 @@ export const useIsGasIncluded7702Supported = ( return isRelaySupported(evmChainId as Hex); }, [evmChainId]); + const isHardwareWallet = useIsHardwareWalletForBridge(); + // 7702 is available when ALL conditions are met const isGasIncluded7702Supported = Boolean( - evmChainId && !!isRelaySupportedForChain, + evmChainId && !!isRelaySupportedForChain && !isHardwareWallet, ); useEffect(() => { diff --git a/app/components/UI/Bridge/hooks/useIsGasIncluded7702Supported/useIsGasIncluded7702Supported.test.ts b/app/components/UI/Bridge/hooks/useIsGasIncluded7702Supported/useIsGasIncluded7702Supported.test.ts index 7c31e0e6dd7..1a3d7c3a8de 100644 --- a/app/components/UI/Bridge/hooks/useIsGasIncluded7702Supported/useIsGasIncluded7702Supported.test.ts +++ b/app/components/UI/Bridge/hooks/useIsGasIncluded7702Supported/useIsGasIncluded7702Supported.test.ts @@ -7,8 +7,18 @@ import configureStore from '../../../../../util/test/configureStore'; // Mock dependencies jest.mock('../../../../../util/transactions/transaction-relay'); +jest.mock('../useIsHardwareWalletForBridge', () => ({ + useIsHardwareWalletForBridge: jest.fn().mockReturnValue(false), +})); const mockIsRelaySupported = jest.mocked(isRelaySupported); +const { useIsHardwareWalletForBridge } = jest.requireMock( + '../useIsHardwareWalletForBridge', +); +const mockUseIsHardwareWalletForBridge = + useIsHardwareWalletForBridge as jest.MockedFunction< + typeof useIsHardwareWalletForBridge + >; describe('useIsGasIncluded7702Supported', () => { const MAINNET_CHAIN_ID = '0x1' as Hex; @@ -28,6 +38,7 @@ describe('useIsGasIncluded7702Supported', () => { beforeEach(() => { jest.clearAllMocks(); mockIsRelaySupported.mockResolvedValue(false); + mockUseIsHardwareWalletForBridge.mockReturnValue(false); }); afterEach(() => { @@ -147,6 +158,32 @@ describe('useIsGasIncluded7702Supported', () => { }); }); + describe('when source wallet is a hardware account', () => { + it('updates isGasIncluded7702Supported to false even when relay is supported', async () => { + mockIsRelaySupported.mockResolvedValue(true); + mockUseIsHardwareWalletForBridge.mockReturnValue(true); + + const { store } = renderHookWithProvider( + () => useIsGasIncluded7702Supported(MAINNET_CHAIN_ID), + { state: {} }, + ); + + await expectGasIncluded7702State(store, false); + }); + + it('updates isGasIncluded7702Supported to false for hardware wallet regardless of chain', async () => { + mockIsRelaySupported.mockResolvedValue(true); + mockUseIsHardwareWalletForBridge.mockReturnValue(true); + + const { store } = renderHookWithProvider( + () => useIsGasIncluded7702Supported('eip155:59144'), // Linea + { state: {} }, + ); + + await expectGasIncluded7702State(store, false); + }); + }); + describe('edge cases', () => { it('handles case-insensitive chainId matching', async () => { mockIsRelaySupported.mockResolvedValue(true); diff --git a/app/components/UI/Bridge/hooks/useIsHardwareWalletForBridge/index.test.ts b/app/components/UI/Bridge/hooks/useIsHardwareWalletForBridge/index.test.ts new file mode 100644 index 00000000000..f1082ff5e73 --- /dev/null +++ b/app/components/UI/Bridge/hooks/useIsHardwareWalletForBridge/index.test.ts @@ -0,0 +1,56 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useSelector } from 'react-redux'; +import { useIsHardwareWalletForBridge } from './index'; +import { isHardwareAccount } from '../../../../../util/address'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../../../../../util/address', () => ({ + isHardwareAccount: jest.fn(), +})); + +const mockUseSelector = useSelector as jest.MockedFunction; +const mockIsHardwareAccount = isHardwareAccount as jest.MockedFunction< + typeof isHardwareAccount +>; + +describe('useIsHardwareWalletForBridge', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseSelector.mockReturnValue(undefined); + mockIsHardwareAccount.mockReturnValue(false); + }); + + it('returns false when source wallet address is undefined', () => { + mockUseSelector.mockReturnValue(undefined); + + const { result } = renderHook(() => useIsHardwareWalletForBridge()); + + expect(result.current).toBe(false); + expect(mockIsHardwareAccount).not.toHaveBeenCalled(); + }); + + it('returns true when source wallet is a hardware account', () => { + const address = '0x1234567890123456789012345678901234567890'; + mockUseSelector.mockReturnValue(address); + mockIsHardwareAccount.mockReturnValue(true); + + const { result } = renderHook(() => useIsHardwareWalletForBridge()); + + expect(result.current).toBe(true); + expect(mockIsHardwareAccount).toHaveBeenCalledWith(address); + }); + + it('returns false when source wallet is not a hardware account', () => { + const address = '0x1234567890123456789012345678901234567890'; + mockUseSelector.mockReturnValue(address); + mockIsHardwareAccount.mockReturnValue(false); + + const { result } = renderHook(() => useIsHardwareWalletForBridge()); + + expect(result.current).toBe(false); + expect(mockIsHardwareAccount).toHaveBeenCalledWith(address); + }); +}); diff --git a/app/components/UI/Bridge/hooks/useIsHardwareWalletForBridge/index.ts b/app/components/UI/Bridge/hooks/useIsHardwareWalletForBridge/index.ts new file mode 100644 index 00000000000..c4ba9df5dee --- /dev/null +++ b/app/components/UI/Bridge/hooks/useIsHardwareWalletForBridge/index.ts @@ -0,0 +1,18 @@ +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { selectSourceWalletAddress } from '../../../../../selectors/bridge'; +import { isHardwareAccount } from '../../../../../util/address'; + +/** + * Returns whether the current bridge source account is a hardware wallet. + * Used to omit gas-included / 7702 params from bridge quote requests so responses + * are non-sponsored for hardware signers. + */ +export function useIsHardwareWalletForBridge(): boolean { + const walletAddress = useSelector(selectSourceWalletAddress); + + return useMemo( + () => Boolean(walletAddress && isHardwareAccount(walletAddress)), + [walletAddress], + ); +} diff --git a/app/components/UI/Bridge/utils/transaction.ts b/app/components/UI/Bridge/utils/transaction.ts index 77f981e8156..300a6fefb56 100644 --- a/app/components/UI/Bridge/utils/transaction.ts +++ b/app/components/UI/Bridge/utils/transaction.ts @@ -11,7 +11,9 @@ export const getIsBridgeTransaction = (txMeta: TransactionMeta) => { return ( origin === ORIGIN_METAMASK && (txMeta.type === TransactionType.bridgeApproval || - txMeta.type === TransactionType.bridge) + txMeta.type === TransactionType.bridge || + txMeta.type === TransactionType.swap || + txMeta.type === TransactionType.swapApproval) ); }; diff --git a/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.test.tsx b/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.test.tsx index 8d97ae24468..329ec5984d1 100644 --- a/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.test.tsx +++ b/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.test.tsx @@ -31,6 +31,27 @@ jest.mock('react-redux', () => ({ useSelector: jest.fn(), })); +// Avoid loading keyring-utils, keyring-api, and the network/Engine chain in this test +jest.mock('../../../selectors/accountsController', () => ({ + selectSelectedInternalAccountFormattedAddress: jest.fn(), +})); + +jest.mock('../../../util/address', () => ({ + isHardwareAccount: jest.fn(() => false), +})); + +jest.mock('@metamask/keyring-api', () => ({ + EntropySourceId: {}, + BtcMethod: {}, + EthMethod: {}, + SolAccountType: {}, + SolMethod: {}, + TrxMethod: {}, + isEvmAccountType: jest.fn(), + KeyringAccountType: {}, + EthScope: {}, +})); + jest.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: jest.fn(), })); diff --git a/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.tsx b/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.tsx index 9a3afef91ee..0ef4472b59f 100644 --- a/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.tsx +++ b/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.tsx @@ -59,6 +59,8 @@ import { selectEvmChainId } from '../../../selectors/networkController'; import { formatChainIdToCaip } from '@metamask/bridge-controller'; import { NETWORK_MULTI_SELECTOR_TEST_IDS } from '../NetworkMultiSelector/NetworkMultiSelector.constants'; import { getGasFeesSponsoredNetworkEnabled } from '../../../selectors/featureFlagController/gasFeesSponsored/index.ts'; +import { selectSelectedInternalAccountFormattedAddress } from '../../../selectors/accountsController'; +import { isHardwareAccount } from '../../../util/address'; import { strings } from '../../../../locales/i18n'; import TagColored, { TagColor, @@ -106,6 +108,12 @@ const NetworkMultiSelectList = ({ const isGasFeesSponsoredNetworkEnabled = useSelector( getGasFeesSponsoredNetworkEnabled, ); + const selectedAddress = useSelector( + selectSelectedInternalAccountFormattedAddress, + ); + const isHardwareWallet = Boolean( + selectedAddress && isHardwareAccount(selectedAddress), + ); const { styles } = useStyles(styleSheet, {}); @@ -269,7 +277,8 @@ const NetworkMultiSelectList = ({ const isDisabled = isLoading || isSelectionDisabled; const showButtonIcon = Boolean(networkTypeOrRpcUrl); - const isGasSponsored = isGasFeesSponsoredNetworkEnabled(chainId); + const isGasSponsored = + !isHardwareWallet && isGasFeesSponsoredNetworkEnabled(chainId); return ( @@ -342,6 +351,7 @@ const NetworkMultiSelectList = ({ isSelectAllNetworksSection, openRpcModal, isGasFeesSponsoredNetworkEnabled, + isHardwareWallet, styles.centeredNetworkCell, styles.noNetworkFeeContainer, ], @@ -351,11 +361,17 @@ const NetworkMultiSelectList = ({ if (!networks.length || !isAutoScrollEnabled) return; if (networksLengthRef.current !== networks.length) { const selectedNetwork = networks.find(({ isSelected }) => isSelected); - networkListRef?.current?.scrollToOffset({ - offset: selectedNetwork?.yOffset ?? 0, - animated: false, - }); + const offset = selectedNetwork?.yOffset ?? 0; networksLengthRef.current = networks.length; + // Defer scroll so FlashList has time to lay out items and avoid "index out of bounds" + requestAnimationFrame(() => { + if (networkListRef?.current?.scrollToOffset) { + networkListRef.current.scrollToOffset({ + offset, + animated: false, + }); + } + }); } }, [networks, isAutoScrollEnabled]); diff --git a/app/components/Views/NetworkSelector/NetworkSelector.tsx b/app/components/Views/NetworkSelector/NetworkSelector.tsx index f1a34a6fed8..12351d22ead 100644 --- a/app/components/Views/NetworkSelector/NetworkSelector.tsx +++ b/app/components/Views/NetworkSelector/NetworkSelector.tsx @@ -107,6 +107,8 @@ import { removeItemFromChainIdList } from '../../../util/metrics/MultichainAPI/n import { analytics } from '../../../util/analytics/analytics'; import { NETWORK_SELECTOR_SOURCES } from '../../../constants/networkSelector'; import { getGasFeesSponsoredNetworkEnabled } from '../../../selectors/featureFlagController/gasFeesSponsored'; +import { selectSelectedInternalAccountFormattedAddress } from '../../../selectors/accountsController'; +import { isHardwareAccount } from '../../../util/address'; import TagColored, { TagColor, } from '../../../component-library/components-temp/TagColored'; @@ -137,6 +139,12 @@ const NetworkSelector = ({ route }: NetworkSelectorProps) => { const isGasFeesSponsoredNetworkEnabled = useSelector( getGasFeesSponsoredNetworkEnabled, ); + const selectedAddress = useSelector( + selectSelectedInternalAccountFormattedAddress, + ); + const isHardwareWallet = Boolean( + selectedAddress && isHardwareAccount(selectedAddress), + ); const networkConfigurations = useSelector( selectEvmNetworkConfigurationsByChainId, @@ -559,7 +567,8 @@ const NetworkSelector = ({ route }: NetworkSelectorProps) => { {name} - {isGasFeesSponsoredNetworkEnabled(chainId) ? ( + {!isHardwareWallet && + isGasFeesSponsoredNetworkEnabled(chainId) ? ( { ) } tertiaryText={ - isSendFlow && isGasFeesSponsoredNetworkEnabled(chainId) + isSendFlow && + !isHardwareWallet && + isGasFeesSponsoredNetworkEnabled(chainId) ? strings('networks.no_network_fee') : undefined } diff --git a/app/components/Views/Settings/NetworksSettings/NetworkSettings/CustomNetworkView/CustomNetwork.tsx b/app/components/Views/Settings/NetworksSettings/NetworkSettings/CustomNetworkView/CustomNetwork.tsx index 16aca39e32b..1edd8ab10f8 100644 --- a/app/components/Views/Settings/NetworksSettings/NetworkSettings/CustomNetworkView/CustomNetwork.tsx +++ b/app/components/Views/Settings/NetworksSettings/NetworkSettings/CustomNetworkView/CustomNetwork.tsx @@ -34,6 +34,8 @@ import Icon, { } from '../../../../../../component-library/components/Icons/Icon'; import { selectAdditionalNetworksBlacklistFeatureFlag } from '../../../../../../selectors/featureFlagController/networkBlacklist'; import { getGasFeesSponsoredNetworkEnabled } from '../../../../../../selectors/featureFlagController/gasFeesSponsored'; +import { selectSelectedInternalAccountFormattedAddress } from '../../../../../../selectors/accountsController'; +import { isHardwareAccount } from '../../../../../../util/address'; import TagColored, { TagColor, } from '../../../../../../component-library/components-temp/TagColored'; @@ -65,6 +67,12 @@ const CustomNetwork = ({ const isGasFeesSponsoredNetworkEnabled = useSelector( getGasFeesSponsoredNetworkEnabled, ); + const selectedAddress = useSelector( + selectSelectedInternalAccountFormattedAddress, + ); + const isHardwareWallet = Boolean( + selectedAddress && isHardwareAccount(selectedAddress), + ); const { safeChains } = useSafeChains(); const blacklistedChainIds = useSelector( selectAdditionalNetworksBlacklistFeatureFlag, @@ -181,7 +189,8 @@ const CustomNetwork = ({ {networkConfiguration.nickname} - {isGasFeesSponsoredNetworkEnabled( + {!isHardwareWallet && + isGasFeesSponsoredNetworkEnabled( networkConfiguration.chainId, ) ? ( { const handleTransactionAddedEventForMetricsMock = jest.mocked( handleTransactionAddedEventForMetrics, ); + const accountSupports7702Mock = jest.mocked(accountSupports7702); const isSendBundleSupportedMock = jest.mocked(isSendBundleSupported); const selectMetaMaskPayFlagsMock = jest.mocked(selectMetaMaskPayFlags); const payHookClassMock = jest.mocked(TransactionPayPublishHook); @@ -422,6 +425,7 @@ describe('Transaction Controller Init', () => { let mockDelegation7702Hook: jest.MockedFn; beforeEach(() => { + accountSupports7702Mock.mockResolvedValue(true); payHookMock.mockResolvedValue({ transactionHash: undefined }); mockDelegation7702Hook = jest .fn() @@ -434,6 +438,20 @@ describe('Transaction Controller Init', () => { ); }); + it('skips Delegation7702PublishHook for hardware wallet accounts', async () => { + accountSupports7702Mock.mockResolvedValue(false); + selectShouldUseSmartTransactionMock.mockReturnValue(false); + + const hooks = testConstructorOption('hooks'); + await hooks?.publish?.({ + ...MOCK_TRANSACTION_META, + chainId: '0x13', + }); + + expect(Delegation7702PublishHookMock).not.toHaveBeenCalled(); + expect(mockDelegation7702Hook).not.toHaveBeenCalled(); + }); + it('falls back to Delegation7702PublishHook when smart transactions are disabled', async () => { selectShouldUseSmartTransactionMock.mockReturnValue(false); const hooks = testConstructorOption('hooks'); @@ -718,6 +736,7 @@ describe('Transaction Controller Init', () => { }); it('returns true if isExternalSign', async () => { + accountSupports7702Mock.mockResolvedValue(true); const mockTransactionMeta = { id: '123', status: 'approved', @@ -732,6 +751,7 @@ describe('Transaction Controller Init', () => { }); it('calls getNonceLock and releaseLock via Delegation7702PublishHook getNextNonce', async () => { + accountSupports7702Mock.mockResolvedValue(true); const releaseLockMock = jest.fn(); const getNonceLockMock = jest.fn().mockResolvedValue({ nextNonce: 99, @@ -773,6 +793,7 @@ describe('Transaction Controller Init', () => { }); it('calls 7702 publish hook if isExternalSign', async () => { + accountSupports7702Mock.mockResolvedValue(true); const delegation7702Mock: jest.MockedFn = jest.fn(); jest.mocked(Delegation7702PublishHook).mockImplementation( diff --git a/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts b/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts index b61e47c745c..a758e7066b4 100644 --- a/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts +++ b/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts @@ -52,6 +52,7 @@ import { } from '@metamask/transaction-pay-controller'; import { selectMetaMaskPayFlags } from '../../../../selectors/featureFlagController/confirmations'; import { trace } from '../../../../util/trace'; +import { accountSupports7702 } from '../../../../util/transactions/account-supports-7702'; import { Delegation7702PublishHook } from '../../../../util/transactions/hooks/delegation-7702-publish'; import { isSendBundleSupported } from '../../../../util/transactions/sentinel-api'; import { NetworkClientId } from '@metamask/network-controller'; @@ -110,6 +111,7 @@ export const TransactionControllerInit: ControllerInitFunction< publishHook({ transactionMeta, getState, + keyringController, transactionController, smartTransactionsController, initMessenger, @@ -134,6 +136,15 @@ export const TransactionControllerInit: ControllerInitFunction< isFirstTimeInteractionEnabled: () => isFirstTimeInteractionEnabled(preferencesController), isEIP7702GasFeeTokensEnabled: async (transactionMeta) => { + if ( + !(await accountSupports7702( + transactionMeta.txParams?.from, + keyringController as Parameters[1], + )) + ) { + return false; + } + const { chainId, isExternalSign } = transactionMeta; const state = getState(); @@ -191,6 +202,7 @@ async function getNextNonce( async function publishHook({ transactionMeta, getState, + keyringController, transactionController, smartTransactionsController, initMessenger, @@ -198,6 +210,7 @@ async function publishHook({ }: { transactionMeta: TransactionMeta; getState: () => RootState; + keyringController: Parameters[1]; transactionController: TransactionController; smartTransactionsController: SmartTransactionsController; initMessenger: TransactionControllerInitMessenger; @@ -224,7 +237,15 @@ async function publishHook({ const { isExternalSign } = transactionMeta; - if (!shouldUseSmartTransaction || !sendBundleSupport || isExternalSign) { + const keyringSupports7702 = await accountSupports7702( + transactionMeta.txParams?.from, + keyringController, + ); + + if ( + keyringSupports7702 && + (!shouldUseSmartTransaction || !sendBundleSupport || isExternalSign) + ) { const hook = new Delegation7702PublishHook({ isAtomicBatchSupported: transactionController.isAtomicBatchSupported.bind( transactionController, diff --git a/app/util/transactions/account-supports-7702.test.ts b/app/util/transactions/account-supports-7702.test.ts new file mode 100644 index 00000000000..cb9402df523 --- /dev/null +++ b/app/util/transactions/account-supports-7702.test.ts @@ -0,0 +1,115 @@ +import ExtendedKeyringTypes from '../../constants/keyringTypes'; +import { accountSupports7702 } from './account-supports-7702'; + +const SAMPLE_ADDRESS = '0x0000000000000000000000000000000000000001'; + +function createMockKeyringController(keyring: unknown): { + getKeyringForAccount: jest.Mock; +} { + return { + getKeyringForAccount: jest.fn().mockResolvedValue(keyring), + }; +} + +describe('accountSupports7702', () => { + it('returns true when address is undefined', async () => { + const controller = createMockKeyringController({ + type: ExtendedKeyringTypes.ledger, + }); + await expect(accountSupports7702(undefined, controller)).resolves.toBe( + true, + ); + expect(controller.getKeyringForAccount).not.toHaveBeenCalled(); + }); + + it('returns true when address is empty', async () => { + const controller = createMockKeyringController({ + type: ExtendedKeyringTypes.ledger, + }); + await expect(accountSupports7702('', controller)).resolves.toBe(true); + expect(controller.getKeyringForAccount).not.toHaveBeenCalled(); + }); + + it('returns true for HD Key Tree keyring', async () => { + const controller = createMockKeyringController({ + type: ExtendedKeyringTypes.hd, + }); + await expect(accountSupports7702(SAMPLE_ADDRESS, controller)).resolves.toBe( + true, + ); + expect(controller.getKeyringForAccount).toHaveBeenCalledWith( + SAMPLE_ADDRESS, + ); + }); + + it('returns true for Simple Key Pair keyring', async () => { + const controller = createMockKeyringController({ + type: ExtendedKeyringTypes.simple, + }); + await expect(accountSupports7702(SAMPLE_ADDRESS, controller)).resolves.toBe( + true, + ); + }); + + it('returns false for Ledger hardware keyring', async () => { + const controller = createMockKeyringController({ + type: ExtendedKeyringTypes.ledger, + }); + await expect(accountSupports7702(SAMPLE_ADDRESS, controller)).resolves.toBe( + false, + ); + }); + + it('returns false for QR hardware keyring', async () => { + const controller = createMockKeyringController({ + type: ExtendedKeyringTypes.qr, + }); + await expect(accountSupports7702(SAMPLE_ADDRESS, controller)).resolves.toBe( + false, + ); + }); + + it('returns false when keyring type is not in the allowlist', async () => { + const controller = createMockKeyringController({ + type: 'Snap Keyring', + }); + await expect(accountSupports7702(SAMPLE_ADDRESS, controller)).resolves.toBe( + false, + ); + }); + + it('returns false when keyring has no string type', async () => { + const controller = createMockKeyringController({ type: 123 }); + await expect(accountSupports7702(SAMPLE_ADDRESS, controller)).resolves.toBe( + false, + ); + }); + + it('returns false when keyring is null', async () => { + const controller = createMockKeyringController(null); + await expect(accountSupports7702(SAMPLE_ADDRESS, controller)).resolves.toBe( + false, + ); + }); + + it('returns true when getKeyringForAccount throws', async () => { + const controller = { + getKeyringForAccount: jest.fn().mockRejectedValue(new Error('not found')), + }; + await expect(accountSupports7702(SAMPLE_ADDRESS, controller)).resolves.toBe( + true, + ); + }); + + it('resolves the controller from a getter when a function is passed', async () => { + const controller = createMockKeyringController({ + type: ExtendedKeyringTypes.hd, + }); + await expect( + accountSupports7702(SAMPLE_ADDRESS, () => controller), + ).resolves.toBe(true); + expect(controller.getKeyringForAccount).toHaveBeenCalledWith( + SAMPLE_ADDRESS, + ); + }); +}); diff --git a/app/util/transactions/account-supports-7702.ts b/app/util/transactions/account-supports-7702.ts new file mode 100644 index 00000000000..e04243fd8b9 --- /dev/null +++ b/app/util/transactions/account-supports-7702.ts @@ -0,0 +1,51 @@ +import ExtendedKeyringTypes from '../../constants/keyringTypes'; + +/** Minimal shape; KeyringController.getKeyringForAccount is typed as Promise. */ +interface KeyringControllerLike { + getKeyringForAccount: (address: string) => Promise; +} + +/** + * Keyring types that support EIP-7702 (Setup Smart Account). + * Only HD (entropy) and simple (private key) accounts support this; hardware and snap do not. + */ +const KEYRING_TYPES_SUPPORTING_7702: string[] = [ + ExtendedKeyringTypes.hd, + ExtendedKeyringTypes.simple, +]; + +/** + * Returns whether the given account's keyring supports EIP-7702 gas fee tokens. + * Used to avoid requesting 7702 from sentinel for hardware and other unsupported keyrings. + * + * @param address - Account address (e.g. request.from or transactionMeta.txParams?.from). + * @param keyringControllerOrGetter - KeyringController instance or a function that returns it. + * @returns True if the account supports 7702 (or address is missing / lookup fails; assume supported). + */ +export async function accountSupports7702( + address: string | undefined, + keyringControllerOrGetter: + | KeyringControllerLike + | (() => KeyringControllerLike), +): Promise { + if (!address) { + return true; + } + const keyringController = + typeof keyringControllerOrGetter === 'function' + ? keyringControllerOrGetter() + : keyringControllerOrGetter; + try { + const keyring = await keyringController.getKeyringForAccount(address); + const keyringType = + keyring && + typeof keyring === 'object' && + 'type' in keyring && + typeof (keyring as { type: unknown }).type === 'string' + ? (keyring as { type: string }).type + : ''; + return KEYRING_TYPES_SUPPORTING_7702.includes(keyringType); + } catch { + return true; + } +} diff --git a/package.json b/package.json index 1fd95d9ba02..cda2f712c30 100644 --- a/package.json +++ b/package.json @@ -184,8 +184,10 @@ "viem": "2.31.3", "@metamask/accounts-controller": "37.0.0", "@metamask/core-backend": "^5.0.0", + "@metamask/bridge-status-controller@npm:^69.0.0": "patch:@metamask/bridge-status-controller@npm%3A69.0.0#~/.yarn/patches/@metamask-bridge-status-controller-npm-69.0.0-ec19aeeecf.patch", "bn.js@npm:4.11.6": "4.12.3", "bn.js@npm:5.2.1": "5.2.3", + "@metamask/bridge-status-controller@npm:^68.1.0": "patch:@metamask/bridge-status-controller@npm%3A68.1.0#~/.yarn/patches/@metamask-bridge-status-controller-npm-68.1.0-8a2c809398.patch", "expo-web-browser@npm:~14.0.2": "patch:expo-web-browser@npm%3A14.0.2#~/.yarn/patches/expo-web-browser-npm-14.0.2-98d00ce880.patch" }, "dependencies": { @@ -216,7 +218,7 @@ "@metamask/base-controller": "^9.0.0", "@metamask/bitcoin-wallet-snap": "^1.10.0", "@metamask/bridge-controller": "^69.1.1", - "@metamask/bridge-status-controller": "^68.1.0", + "@metamask/bridge-status-controller": "patch:@metamask/bridge-status-controller@npm%3A68.1.0#~/.yarn/patches/@metamask-bridge-status-controller-npm-68.1.0-8a2c809398.patch", "@metamask/chain-agnostic-permission": "^1.3.0", "@metamask/compliance-controller": "^1.0.1", "@metamask/connectivity-controller": "^0.1.0", diff --git a/yarn.lock b/yarn.lock index c98b0ac0f33..3a709443f69 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7888,7 +7888,7 @@ __metadata: languageName: node linkType: hard -"@metamask/bridge-status-controller@npm:^68.1.0": +"@metamask/bridge-status-controller@npm:68.1.0": version: 68.1.0 resolution: "@metamask/bridge-status-controller@npm:68.1.0" dependencies: @@ -7911,7 +7911,7 @@ __metadata: languageName: node linkType: hard -"@metamask/bridge-status-controller@npm:^69.0.0": +"@metamask/bridge-status-controller@npm:69.0.0": version: 69.0.0 resolution: "@metamask/bridge-status-controller@npm:69.0.0" dependencies: @@ -7934,6 +7934,52 @@ __metadata: languageName: node linkType: hard +"@metamask/bridge-status-controller@patch:@metamask/bridge-status-controller@npm%3A68.1.0#~/.yarn/patches/@metamask-bridge-status-controller-npm-68.1.0-8a2c809398.patch": + version: 68.1.0 + resolution: "@metamask/bridge-status-controller@patch:@metamask/bridge-status-controller@npm%3A68.1.0#~/.yarn/patches/@metamask-bridge-status-controller-npm-68.1.0-8a2c809398.patch::version=68.1.0&hash=358095" + dependencies: + "@metamask/accounts-controller": "npm:^37.0.0" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/bridge-controller": "npm:^69.1.0" + "@metamask/controller-utils": "npm:^11.19.0" + "@metamask/gas-fee-controller": "npm:^26.0.3" + "@metamask/keyring-controller": "npm:^25.1.0" + "@metamask/network-controller": "npm:^30.0.0" + "@metamask/polling-controller": "npm:^16.0.3" + "@metamask/profile-sync-controller": "npm:^28.0.0" + "@metamask/snaps-controllers": "npm:^17.2.0" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/transaction-controller": "npm:^62.21.0" + "@metamask/utils": "npm:^11.9.0" + bignumber.js: "npm:^9.1.2" + uuid: "npm:^8.3.2" + checksum: 10/e2fb1a7667030e5d486e0c70c5f673223a1f48b488490ee4fbcf662116111936596c447bddbcc75b166d58d3c726c5e5892578213db8b9206891e78a4f03c136 + languageName: node + linkType: hard + +"@metamask/bridge-status-controller@patch:@metamask/bridge-status-controller@npm%3A69.0.0#~/.yarn/patches/@metamask-bridge-status-controller-npm-69.0.0-ec19aeeecf.patch": + version: 69.0.0 + resolution: "@metamask/bridge-status-controller@patch:@metamask/bridge-status-controller@npm%3A69.0.0#~/.yarn/patches/@metamask-bridge-status-controller-npm-69.0.0-ec19aeeecf.patch::version=69.0.0&hash=41006d" + dependencies: + "@metamask/accounts-controller": "npm:^37.0.0" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/bridge-controller": "npm:^69.1.1" + "@metamask/controller-utils": "npm:^11.19.0" + "@metamask/gas-fee-controller": "npm:^26.1.0" + "@metamask/keyring-controller": "npm:^25.1.0" + "@metamask/network-controller": "npm:^30.0.0" + "@metamask/polling-controller": "npm:^16.0.3" + "@metamask/profile-sync-controller": "npm:^28.0.0" + "@metamask/snaps-controllers": "npm:^17.2.0" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/transaction-controller": "npm:^63.0.0" + "@metamask/utils": "npm:^11.9.0" + bignumber.js: "npm:^9.1.2" + uuid: "npm:^8.3.2" + checksum: 10/da79a48e1fbae222f682aedfe008f6c0ef1213c78ff0faa85e57d59258e517477456a2cab3ef09a68b44cefa68bd515c0b4b46a06294998bebeec269b40920d7 + languageName: node + linkType: hard + "@metamask/browser-passworder@npm:^5.0.0": version: 5.0.0 resolution: "@metamask/browser-passworder@npm:5.0.0" @@ -35536,7 +35582,7 @@ __metadata: "@metamask/base-controller": "npm:^9.0.0" "@metamask/bitcoin-wallet-snap": "npm:^1.10.0" "@metamask/bridge-controller": "npm:^69.1.1" - "@metamask/bridge-status-controller": "npm:^68.1.0" + "@metamask/bridge-status-controller": "patch:@metamask/bridge-status-controller@npm%3A68.1.0#~/.yarn/patches/@metamask-bridge-status-controller-npm-68.1.0-8a2c809398.patch" "@metamask/browser-passworder": "npm:^5.0.0" "@metamask/browser-playground": "npm:0.3.0" "@metamask/build-utils": "npm:^3.0.0" From 9f24f30385eaac9384f8b159fb4fe550e3d028bf Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Wed, 25 Mar 2026 11:27:13 +0000 Subject: [PATCH 191/206] [skip ci] Bump version number to 4182 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 7dd8cbe46af..aa3754c6484 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4181 + versionCode 4182 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 6c8ae8f3fc6..f6eefc3c406 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4181 + VERSION_NUMBER: 4182 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4181 + FLASK_VERSION_NUMBER: 4182 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 6ad772a2681..a68c253dcf2 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4181; + CURRENT_PROJECT_VERSION = 4182; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4181; + CURRENT_PROJECT_VERSION = 4182; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4181; + CURRENT_PROJECT_VERSION = 4182; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4181; + CURRENT_PROJECT_VERSION = 4182; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4181; + CURRENT_PROJECT_VERSION = 4182; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4181; + CURRENT_PROJECT_VERSION = 4182; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 901b9ae509c9a3cf4dea656583fa0cd8d75cc05a Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:49:45 +0000 Subject: [PATCH 192/206] chore(runway): cherry-pick fix(perps): fix HIP-3 asset ID lookup failure from dual-cache desync cp-7.70.1 (#27912) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(perps): fix HIP-3 asset ID lookup failure from dual-cache desync cp-7.70.1 (#27854) ## **Description** Fix HIP-3 asset ID lookup failure (`"Asset ID not found for xyz:BRENTOIL"`) that blocked trading on HIP-3 markets when navigating via the old Perps tab layout. **Root cause**: Dual-cache desync between `#cachedValidatedDexs` (string DEX names) and `#cachedAllPerpDexs` (raw API objects for `perpDexIndex` computation). The standalone preload path (`#getStandaloneValidatedDexs`) populated one cache but not the other. When `#buildAssetMapping` later ran, it found "xyz" in `dexsToMap` but couldn't compute its `perpDexIndex` because `#cachedAllPerpDexs` was null. **Why old Perps tab vs new Homepage Sections**: Both layouts sit inside `Wallet/index.tsx`, which calls `startMarketDataPreload()` on mount. This fires standalone HTTP calls that populate `#cachedValidatedDexs` but not `#cachedAllPerpDexs`. - **New homepage sections**: `PerpsSectionWithProvider` mounts immediately. Stream hooks fire `ensureReady()` before or concurrently with the standalone preload. Since `#cachedValidatedDexs` is often still null, `fetchValidatedDexsInternal` runs fresh and sets **both** caches correctly. - **Old tab layout**: The Perps tab doesn't mount until the user taps it. By that time, `startMarketDataPreload()` has already completed → `#cachedValidatedDexs` is populated by standalone. When the tab mounts → `getValidatedDexs()` → **cache hit** → `fetchValidatedDexsInternal` is never called → `#cachedAllPerpDexs` stays null → `buildAssetMapping` can't find "xyz". **Changes (1 file, 3 sites)**: 1. **Root cause fix**: `#getStandaloneValidatedDexs` now sets `this.#cachedAllPerpDexs = allDexs` after a successful `perpDexs()` call, keeping both caches in sync. 2. **Cache poisoning fix**: Removed `this.#cachedAllPerpDexs = this.#cachedAllPerpDexs ?? [null]` from the catch block in `#buildAssetMapping`. 3. **Cache poisoning fix**: Replaced persistent `if (!cache) { cache = [null] }` with local `const allPerpDexs = cache ?? [null]` — consumers read the cache, only the owner writes it. ## **Changelog** CHANGELOG entry: Fixed a bug where closing positions on HIP-3 markets (e.g., xyz:BRENTOIL) failed with "Asset ID not found" when navigating via the Perps tab ## **Related issues** Fixes: HIP-3 asset ID lookup failure on old Perps tab layout ## **Manual testing steps** ```gherkin Feature: HIP-3 position management via Perps tab Scenario: user closes a HIP-3 position from the old Perps tab Given user has an open position on a HIP-3 market (e.g., xyz:BRENTOIL) And user is using the old tab layout (homepage redesign v1 disabled) When user navigates to the Perps tab And user taps close on the xyz:BRENTOIL position Then the position closes successfully without "Asset ID not found" error Scenario: user opens a HIP-3 position from the old Perps tab Given user is on the Perps tab (old layout) When user navigates to xyz:BRENTOIL market and places a market order Then the order executes successfully with correct asset ID routing ``` ## **Screenshots/Recordings** ### **Before** Metro logs show the desync: ``` getValidatedDexs CACHE HIT {"cachedAllNull": true, "dexs": [null, "xyz"]} buildAssetMapping state {"allPerpDexsLen": 1, "cachedAllNull": true} Could not find perpDexIndex for DEX xyz Asset ID not found for xyz:BRENTOIL ``` ### **After** Metro logs show both caches in sync: ``` buildAssetMapping state {"allPerpDexsLen": 8, "cachedAllNull": false, "dexsToMap": [null, "xyz"]} Asset map state at order time {"assetExistsInMap": true, "hip3AssetsCount": 54, "totalAssetsInMap": 283} Resolved DEX-specific asset ID {"assetId": 110049, "coin": "xyz:BRENTOIL"} usePerpsClosePosition: Close result {"success": true, "orderId": "359617825254"} ``` ## **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. ## **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** > Touches HIP-3 market routing/asset-ID mapping in `HyperLiquidProvider`, so a mistake could break trading on some perps markets; scope is small and localized to cache population/fallback behavior. > > **Overview** > Fixes a HIP-3 asset mapping failure where `#cachedValidatedDexs` could be populated via the standalone preload path while `#cachedAllPerpDexs` stayed `null`, leading to missing `perpDexIndex` during `#buildAssetMapping`. > > `#getStandaloneValidatedDexs()` now also populates `#cachedAllPerpDexs` after a successful `perpDexs()` call, and `#buildAssetMapping()` no longer “poisons” the shared cache with a persistent `[null]` fallback (it uses a local fallback instead). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit c925609ab6e324afaf50556d96abf4acca2460ee. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [2898ec8](https://github.com/MetaMask/metamask-mobile/commit/2898ec839aa04320d74dc6bc4ea9b7ec24668d17) Co-authored-by: abretonc7s <107169956+abretonc7s@users.noreply.github.com> --- .../perps/providers/HyperLiquidProvider.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/app/controllers/perps/providers/HyperLiquidProvider.ts b/app/controllers/perps/providers/HyperLiquidProvider.ts index 764f4e42ea1..480ac72df40 100644 --- a/app/controllers/perps/providers/HyperLiquidProvider.ts +++ b/app/controllers/perps/providers/HyperLiquidProvider.ts @@ -2119,16 +2119,13 @@ export class HyperLiquidProvider implements PerpsProvider { '[buildAssetMapping] getValidatedDexs failed, falling back to main DEX', { error: String(dexError) }, ); - this.#cachedAllPerpDexs = this.#cachedAllPerpDexs ?? [null]; dexsToMap = [null]; } - // Use cached perpDexs array (populated by getValidatedDexs) - // Defensive: ensure non-null even if getValidatedDexs had an unexpected issue - if (!this.#cachedAllPerpDexs) { - this.#cachedAllPerpDexs = [null]; - } - const allPerpDexs = this.#cachedAllPerpDexs; + // Local fallback only — never write [null] into #cachedAllPerpDexs here. + // That cache is owned exclusively by #fetchValidatedDexsInternal; writing a + // fallback here would prevent subsequent callers from retrying perpDexs(). + const allPerpDexs = this.#cachedAllPerpDexs ?? [null]; this.#deps.debugLogger.log( 'HyperLiquidProvider: Starting asset mapping rebuild', @@ -4772,6 +4769,12 @@ export class HyperLiquidProvider implements PerpsProvider { return [null]; } + // Populate #cachedAllPerpDexs so buildAssetMapping can compute perpDexIndex. + // Without this, getValidatedDexs returns from #cachedValidatedDexs (string names) + // but #cachedAllPerpDexs (raw objects for index computation) stays null, + // causing "Could not find perpDexIndex for DEX xyz" failures. + this.#cachedAllPerpDexs = allDexs; + // Extract HIP-3 DEX names (filter out null which represents main DEX) const availableHip3Dexs: string[] = []; allDexs.forEach((dex) => { From 56b23328395123b7c2fa745c6a91511573b6429b Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Wed, 25 Mar 2026 13:51:32 +0000 Subject: [PATCH 193/206] [skip ci] Bump version number to 4183 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index aa3754c6484..69dff97f8e7 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4182 + versionCode 4183 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index f6eefc3c406..95d7f6c7568 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4182 + VERSION_NUMBER: 4183 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4182 + FLASK_VERSION_NUMBER: 4183 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index a68c253dcf2..5430c17781f 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4182; + CURRENT_PROJECT_VERSION = 4183; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4182; + CURRENT_PROJECT_VERSION = 4183; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4182; + CURRENT_PROJECT_VERSION = 4183; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4182; + CURRENT_PROJECT_VERSION = 4183; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4182; + CURRENT_PROJECT_VERSION = 4183; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4182; + CURRENT_PROJECT_VERSION = 4183; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 57b7cfa384998a838ec03a7f94564f6f1e71a075 Mon Sep 17 00:00:00 2001 From: chloeYue <105063779+chloeYue@users.noreply.github.com> Date: Wed, 25 Mar 2026 15:52:18 +0100 Subject: [PATCH 194/206] chore: Stable sync release 7.71.0 (7.70.1) (#27915) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Sync `stable` into `release/7.71.0` so the release branch includes everything merged to stable through **7.70.1** (hotfixes and changelog from [#27824](https://github.com/MetaMask/metamask-mobile/pull/27824)) ## Changelog CHANGELOG entry: null --- Made with [Cursor](https://cursor.com) --- > [!NOTE] > **Low Risk** > Low risk: only updates release metadata (CHANGELOG links/entries and `OTA_VERSION`) with no functional code changes beyond versioning. > > **Overview** > Adds a `7.70.1` section to `CHANGELOG.md` (including two perps-related fixes) and updates the compare links so *Unreleased* starts from `v7.70.1`. > > Bumps `OTA_VERSION` in `app/constants/ota.ts` from `v7.65.1` to `v7.70.1` to match the hotfix release. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit b8fba18be76a80a37e87f5049592090bcabb684d. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: metamaskbot Co-authored-by: João Loureiro <175489935+joaoloureirop@users.noreply.github.com> Co-authored-by: runway-github[bot] <73448015+runway-github[bot]@users.noreply.github.com> Co-authored-by: abretonc7s <107169956+abretonc7s@users.noreply.github.com> --- CHANGELOG.md | 10 +++++++++- app/constants/ota.ts | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc62e6a36e9..29a67c3c648 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [7.70.1] + +### Fixed + +- Fixed stale perpetuals data and missing 24h price change after returning from background (#27530) +- Fixed a bug where closing positions on HIP-3 markets (e.g., xyz:BRENTOIL) failed with "Asset ID not found" when navigating via the Perps tab (#27854) + ## [7.70.0] ### Added @@ -11008,7 +11015,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#957](https://github.com/MetaMask/metamask-mobile/pull/957): fix timeouts (#957) - [#954](https://github.com/MetaMask/metamask-mobile/pull/954): Bugfix: onboarding navigation (#954) -[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.70.0...HEAD +[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.70.1...HEAD +[7.70.1]: https://github.com/MetaMask/metamask-mobile/compare/v7.70.0...v7.70.1 [7.70.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.69.1...v7.70.0 [7.69.1]: https://github.com/MetaMask/metamask-mobile/compare/v7.69.0...v7.69.1 [7.69.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.68.3...v7.69.0 diff --git a/app/constants/ota.ts b/app/constants/ota.ts index 70e0dd691f3..ec21ae8bdf2 100644 --- a/app/constants/ota.ts +++ b/app/constants/ota.ts @@ -6,7 +6,7 @@ import otaConfig from '../../ota.config.js'; * Reset to v0 when releasing a new native build * We keep this OTA_VERSION here to because changes in ota.config.js will affect the fingerprint and break the workflow in Github Actions */ -export const OTA_VERSION: string = 'v7.65.1'; +export const OTA_VERSION: string = 'v7.70.1'; export const RUNTIME_VERSION = otaConfig.RUNTIME_VERSION; export const PROJECT_ID = otaConfig.PROJECT_ID; export const UPDATE_URL = otaConfig.UPDATE_URL; From 6599c4ad5fb8f69026d901869f1a86143b5fb756 Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Wed, 25 Mar 2026 14:53:55 +0000 Subject: [PATCH 195/206] [skip ci] Bump version number to 4184 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 69dff97f8e7..fa91bcdba45 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4183 + versionCode 4184 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 95d7f6c7568..81122210301 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4183 + VERSION_NUMBER: 4184 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4183 + FLASK_VERSION_NUMBER: 4184 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 5430c17781f..93003ea86e0 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4183; + CURRENT_PROJECT_VERSION = 4184; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4183; + CURRENT_PROJECT_VERSION = 4184; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4183; + CURRENT_PROJECT_VERSION = 4184; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4183; + CURRENT_PROJECT_VERSION = 4184; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4183; + CURRENT_PROJECT_VERSION = 4184; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4183; + CURRENT_PROJECT_VERSION = 4184; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 4876bad56a2ad8684446eef9a6a3d24a04e30862 Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 21:18:07 +0100 Subject: [PATCH 196/206] chore(runway): cherry-pick chore: New Crowdin translations by Github Action cp-7.71.0 (#27934) - chore: New Crowdin translations by Github Action cp-7.71.0 (#27496) Co-authored-by: metamaskbot [0b1f7be](https://github.com/MetaMask/metamask-mobile/commit/0b1f7befc4eedeb9cedc5d6a179151b469e6e552) Co-authored-by: MetaMask Bot <37885440+metamaskbot@users.noreply.github.com> Co-authored-by: metamaskbot --- locales/languages/de.json | 299 +++++++++++++++++++++++++++++--------- locales/languages/el.json | 297 ++++++++++++++++++++++++++++--------- locales/languages/es.json | 297 ++++++++++++++++++++++++++++--------- locales/languages/fr.json | 297 ++++++++++++++++++++++++++++--------- locales/languages/hi.json | 299 +++++++++++++++++++++++++++++--------- locales/languages/id.json | 297 ++++++++++++++++++++++++++++--------- locales/languages/ja.json | 299 +++++++++++++++++++++++++++++--------- locales/languages/ko.json | 299 +++++++++++++++++++++++++++++--------- locales/languages/pt.json | 297 ++++++++++++++++++++++++++++--------- locales/languages/ru.json | 299 +++++++++++++++++++++++++++++--------- locales/languages/tl.json | 293 +++++++++++++++++++++++++++++-------- locales/languages/tr.json | 299 +++++++++++++++++++++++++++++--------- locales/languages/vi.json | 297 ++++++++++++++++++++++++++++--------- locales/languages/zh.json | 297 ++++++++++++++++++++++++++++--------- 14 files changed, 3252 insertions(+), 914 deletions(-) diff --git a/locales/languages/de.json b/locales/languages/de.json index 4fc909b68e6..9310e98c947 100644 --- a/locales/languages/de.json +++ b/locales/languages/de.json @@ -20,6 +20,12 @@ "update": "Update" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "Benachrichtigung", @@ -120,8 +126,8 @@ "title": "Senden von Assets an die Burn-Adresse" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "Token-Contract-Warnung", + "message": "Die Empfängeradresse unterstützt womöglich keine direkten Token-Übertragungen, was zu Geldverlusten führen kann. Fahren Sie nur fort, wenn Sie sicher sind, dass dieser Contract Ihre Übertragung empfangen kann." }, "gas_sponsorship_reserve_balance": { "message": "Gas-Sponsoring ist für diese Transaktion nicht verfügbar. Sie benötigen mindestens %{minBalance} %{nativeTokenSymbol} auf Ihrem Konto.", @@ -694,8 +700,8 @@ "could_not_resolve_name": "Name konnte nicht aufgelöst werden", "invalid_address": "Ungültige Adresse", "contractAddressError": "Sie senden Tokens an die Kontraktadresse des Tokens. Dies kann zum Verlust dieser Tokens führen.", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "Smart-Contract-Adresse", + "smart_contract_address_warning": "Die Empfängeradresse unterstützt womöglich keine direkten Token-Übertragungen, was zu Geldverlusten führen kann. Fahren Sie nur fort, wenn Sie sicher sind, dass dieser Contract Ihre Übertragung empfangen kann.", "i_understand": "Ich verstehe", "cancel": "Stornieren" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "Stop-Loss muss {{direction}} {{priceType}} Preis sein", "stop_loss_beyond_liquidation_error": "Stop-Loss muss {{direction}} Liquidationspreis sein", "stop_loss_order_view_warning": "Stop-Loss ist {{direction}} Liquidationspreis", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "Über", "below": "Unter", "done": "Fertig", @@ -2086,14 +2094,15 @@ "a_closer_look": "Ein genauerer Blick", "whats_being_said": "Was gesagt wird", "footer_disclaimer": "KI-Zusammenfassung nur zu Informationszwecken", - "trade_button": "Trade", + "swap_button": "Tauschen", + "buy_button": "Kaufen", "sources_count": "+{{count}} Quellen", "sources_title": "Nachrichtenquellen", "feedback_submitted": "Feedback eingereicht", "helpful_prompt": "War dies hilfreich?", "feedback": { "title": "Feedback", - "description": "Helfen Sie uns, unsere KI-generierten Markteinblicke zu erweitern.", + "description": "Ihre Antwort trägt zur Verbesserung unserer KI-Zusammenfassungen bei.", "not_relevant": "Nicht relevant", "not_accurate": "Nicht genau", "hard_to_understand": "Schwer zu verstehen", @@ -2162,7 +2171,7 @@ "sell_position": "Position verkaufen", "cash_out": "Auszahlung", "cash_out_info": "Gelder werden Ihrem verfügbaren Guthaben hinzugefügt", - "buy_preview_outcome_at_price": "{{outcome}} at {{price}}", + "buy_preview_outcome_at_price": "{{outcome}} zum {{price}}", "at_price_per_share": "Verkauf von {{size}}-Aktien zu {{price}}", "cashout_info": "{{amount}} bei {{outcome}} zu {{initialPrice}}", "cashout_info_multiple": "{{amount}} bei {{outcomeGroupTitle}} • {{outcome}} zu {{initialPrice}}", @@ -2206,7 +2215,7 @@ "available_balance": "Verfügbares Guthaben", "claim_amount_text": "{{amount}} $ einfordern", "claim_winnings_text": "Gewinne einfordern", - "claiming_text": "Claiming...", + "claiming_text": "Einfordern ...", "unrealized_pnl_label": "Nicht realisierte GuV", "unrealized_pnl_value": "{{amount}} ({{percent}})", "unrealized_pnl_error": "Laden nicht möglich", @@ -2287,7 +2296,7 @@ "try_again": "Erneut versuchen" }, "in_progress": { - "title": "Claim already in progress" + "title": "Einforderung bereits in Bearbeitung" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "An die Börse oder den Markt entrichtete Gebühr", "total_incl_fees": "inkl. Gebühren", "close": "Schließen", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "Die angegebenen Preise setzen voraus, dass Ihre Order vollständig ausgeführt wird. Die tatsächlichen Beträge können abweichen, wenn die Order nur teilweise ausgeführt wird.", + "deposit_fee": "Einzahlungsgebühr", + "deposit_fee_description": "Gebühr für die Einzahlung von Geldern auf Ihr Prognosesaldo" }, "error": { "title": "Verbindung zu Prognosen nicht möglich", @@ -3059,6 +3068,7 @@ "networks_no_results": "Keine Netzwerke gefunden", "network_name_label": "Netzwerkname", "network_name_placeholder": "Netzwerkname (optional)", + "required": "Benötigt", "network_rpc_url_label": "RPC-URL", "network_rpc_name_label": "RPC-Name", "network_rpc_placeholder": "Neues RPC-Netzwerk", @@ -3298,6 +3308,8 @@ "blockaid_desc": "Diese Funktion warnt Sie durch die aktive Überprüfung von Transaktions- und Signaturanfragen vor bösartigen Aktivitäten.", "security_alerts": "Sicherheitsbenachrichtigungen", "security_alerts_desc": "Diese Funktion warnt Sie vor böswilligen Aktivitäten, indem sie Ihre Transaktions- und Signaturanfragen lokal überprüft. Führen Sie immer Ihre eigene Prüfung durch, bevor Sie eine Anfrage genehmigen. Es gibt keine Garantie dafür, dass diese Funktion alle bösartigen Aktivitäten erkennt. Mit der Aktivierung dieser Funktion erklären Sie sich mit den Nutzungsbedingungen des Anbieters einverstanden.", + "smart_account_dapp_requests_heading": "Anfragen für Smart-Konten von Dapps", + "smart_account_dapp_requests_desc": "Lassen Sie Dapps die Smart-Konto-Funktionen für Standardkonten anfordern. Dies wirkt sich nicht auf bereits bestehende Smart-Konten aus.", "smart_transactions_opt_in_heading": "Smart Transactions", "smart_transactions_opt_in_desc_supported_networks": "Schalten Sie Smart Transactions für zuverlässigere und sicherere Transaktionen auf unterstützten Netzwerken ein.", "smart_transactions_learn_more": "Mehr erfahren", @@ -3566,6 +3578,53 @@ "activity": "{{symbol}}-Aktivität", "disclaimer": "Die Marktdaten werden von Drittquellen wie CoinGecko bereitgestellt. Diese Daten dienen lediglich zu Informationszwecken. MetaMask übernimmt keinerlei Verantwortung für deren Richtigkeit." }, + "security_trust": { + "title": "Sicherheit und Vertrauen", + "malicious": "Bösartig", + "risky": "Riskant", + "malicious_token_title": "Bösartiges Token", + "malicious_token_description": "{{symbol}} ist ein bösartiger Token. Vermeiden Sie die Interaktion mit diesem Token oder dessen Handel.", + "verified_token_title": "Verifizierter Token", + "verified_token_description": "{{symbol}} wird aktiv gehandelt und ist weitgehend anerkannt. Die Verifizierung ist keine Empfehlung von MetaMask.", + "risky_token_title": "Riskanter Token", + "risky_token_description": "Warnsignale für {{symbol}} erkannt. Recherchieren Sie sorgfältig, bevor Sie diesen Token handeln.", + "malicious_token_sheet_description": "Ernsthafte Risikosignale für {{symbol}} erkannt. Wir empfehlen, diesen Token nicht zu handeln.", + "got_it": "Verstanden", + "proceed": "Fortfahren", + "cancel": "Stornieren", + "data_unavailable": "Sicherheitsdaten nicht verfügbar", + "subtitle_known": "Keine Risikosignale erkannt. Recherchieren Sie vor dem Handel stets jedes Asset.", + "subtitle_no_issues": "Keine Risikosignale erkannt. Recherchieren Sie vor dem Handel stets jedes Asset.", + "subtitle_suspicious": "Prüfen Sie die gekennzeichneten Punkte sorgfältig, bevor Sie dieses Asset handeln.", + "subtitle_malicious": "Ernsthafte Risikosignale erkannt. Wir empfehlen, dieses Asset zu meiden.", + "subtitle_unavailable": "Die Sicherheitsanalyse konnte für dieses Token nicht geladen werden.", + "token_distribution": "Verteilung von Token", + "total_supply": "Gesamtvorrat", + "top_10_holders": "Top 10 Inhaber", + "other": "Sonstiges", + "no_hidden_fees_detected": "Keine versteckten Gebühren erkannt", + "buy_sell_tax": "Kauf-/Verkaufssteuer", + "buy_tax": "Kaufsteuer", + "sell_tax": "Verkaufssteuer", + "transfer": "Übertragung", + "token_info": "Token-Infos", + "created": "Erstellt", + "token_age": "Token-Alter", + "network": "Netzwerk", + "type": "Geben Sie ein,", + "official_links": "Offizielle Links", + "website": "Website", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "N. z.", + "verified": "Verifiziert", + "no_issues": "Keine Probleme", + "suspicious": "Verdächtig", + "malicious_label": "Bösartig", + "more": "Mehr", + "evaluation_disclaimer": "Diese Sicherheitsüberprüfung dient nur der Bewertung und stellt keine Empfehlung zum Handel dar." + }, "account_details": { "title": "Kontodetails", "share_account": "Teilen", @@ -5934,6 +5993,10 @@ "claimable_bonus": "Anspruchsberechtigter Bonus", "claim_bonus": "Bonus einfordern", "claim_bonus_subtitle": "Der Bonus wird auf {{networkName}} ausgezahlt.", + "percentage_bonus_on_linea": "{{percentage}} % Bonus auf Linea", + "claim": "Einfordern", + "sounds_good": "Klingt gut", + "claimable_bonus_tooltip_with_percentage": "{{percentage}} % jährlicher Bonus, den Sie für das Halten von mUSD verdient haben. Ihr Bonus kann täglich auf Linea eingefordert werden.", "empty_state_cta": { "heading": "Verleihen Sie {{tokenSymbol}} und verdienen Sie", "body": "Verleihen Sie Ihre {{tokenSymbol}} mit {{protocol}} und verdienen Sie", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "Ihre Stablecoins" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Stake", "earn": "Verdienen", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "Sie haben nicht genügend Ressourcen, um diese Aktion durchzuführen." }, - "trx_unstaking_in_progress": "Unstaken {{amount}} TRX in Bearbeitung. Das Unstaken erfordert 14 Tage.", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "Unstaken von {{amount}} TRX in Bearbeitung", + "description": "Das Unstaken erfordert 14 Tage" + }, + "unstaked_banner": { + "title": "Unstaken von {{amount}} TRX abgeschlossen", + "description": "Ihr unstaken TRX kann jetzt ausgezahlt werden", + "button": "Auszahlen", + "error": "Auszahlung fehlgeschlagen" + } }, "stake_eth": "ETH staken", "unstake_eth": "ETH unstaken", @@ -6376,7 +6498,8 @@ "approve": "Anfrage genehmigen", "perps_deposit": "Gelder hinzufügen", "predict_deposit": "Prognosegelder hinzufügen", - "predict_withdraw": "Auszahlen" + "predict_withdraw": "Auszahlen", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "Diese Website möchte die Genehmigung, Ihre Tokens auszugeben.", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "Transaktion {{index}}", "transaction": "Transaktion", "available_balance": "Verfügbares Guthaben: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "Fortfahren", "deposit_edit_amount_done": "Gelder hinzufügen", "deposit_edit_amount_predict_withdraw": "Auszahlen", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "Hardware-Wallets werden noch nicht unterstützt. Verwenden Sie eine Hot Wallet, um fortzufahren.", "hardware_wallet_not_supported_solana": "Hardware-Wallets werden für Solana noch nicht unterstützt. Verwenden Sie ein Hot Wallet, um fortzufahren.", "price_impact_info_title": "Preiseinfluss", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "Auf diese Weise verändert Ihr Handel den Marktpreis eines Tokens. Dies ist abhängig von der Handelsgröße, der verfügbaren Liquidität und den Gebühren des Anbieters. MetaMask hat keinen Einfluss auf den Preis.", "price_impact_info_gasless_description": "Die Preisauswirkung spiegelt wider, wie Ihre Swap-Order den Marktpreis des Assets beeinflusst. Falls Sie nicht über genügend Gelder für Gas halten, wird ein Teil Ihres Quelltokens automatisch zur Deckung der Gebühren verwendet, was die Preisauswirkung erhöht. MetaMask hat keinerlei Kontrolle über die Preisauswirkung.", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "Aufgrund Ihrer Handelsgröße und der verfügbaren Liquidität erhalten Sie etwa {{priceImpact}} unter dem Marktpreis. Dies ist bereits in Ihrem Angebot berücksichtigt.", "price_impact_high": "Hohe Preisauswirkungen", "price_impact_execution_description": "Sie verlieren bei diesem Swap etwa {{priceImpact}} des Wertes Ihres Tokens. Versuchen Sie, den Betrag zu senken oder eine liquidere Route zu wählen.", "proceed": "Fortfahren", @@ -6627,8 +6751,8 @@ "total_cost": "Gesamtkosten", "got_it": "Verstanden", "price_impact_warning_title": "Hohe Preisauswirkungen", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "Sehr hohe Preisauswirkungen", + "price_impact_error_description": "Sie verlieren bei diesem Swap etwa {{priceImpact}} den Marktpreis Ihres Tokens. Versuchen Sie einen kleineren Handel oder eine liquidere Route, um Ihren Kurs zu verbessern." }, "quote_expired_modal": { "title": "Neue Angebote sind verfügbar", @@ -6940,7 +7064,7 @@ "upgrade_title": "Upgrade auf Metall", "continue_button": "Fortfahren", "virtual_card": { - "name": "Virtual Card", + "name": "Virtuelle Karte", "price": "Kostenlos", "feature_1": "Virtuelle Karte für Apple Pay und Google Pay", "feature_2": "Bezahlen Sie mit Kryptowährung (USDC, USDT, WETH und mehr)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "Metallkarte", "price": "199 $/Jahr", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "Alles in virtuell, plus:", + "feature_1": "Erstklassige gravierte Metallkarte", + "feature_2": "3 % Cashback auf die ersten 10.000 $/Jahr", "feature_3": "Keine Auslandstransaktionsgebühren" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "Verdienen Sie jährlich bis zu 300 $ an Cashback", + "upgrade_to_metal_label": "Oder upgraden Sie auf Metall für das 3-fache an Belohnungen" }, "review_order": { "title": "Prüfen Sie Ihre Bestellung", @@ -7104,7 +7228,7 @@ "ssn_description": "Vom Kartenaussteller gefordert. Es wird keine Bonitätsprüfung durchgeführt.", "invalid_ssn": "Ungültige SSN", "invalid_date_of_birth": "Ungültiges Geburtsdatum. Sie müssen mindestens 18 Jahre alt sein.", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "Vor- und Nachname müssen mit Ihrer verifizierten Identität übereinstimmen" }, "physical_address": { "title": "Fügen Sie Ihre Adresse hinzu", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "Sie sind Ihrem Ausgabenlimit nahe", "description": "Aktualisieren Sie, um Ablehnungen zu vermeiden", - "confirm_button_label": "Neues Limit festlegen" + "confirm_button_label": "Neues Limit festlegen", + "dismiss_button_label": "Verwerfen" }, "need_delegation": { "title": "Sie müssen Ihre Karte aktivieren", @@ -7301,7 +7426,6 @@ "dismiss": "Verwerfen", "update_success": "Ausgabenlimit erfolgreich aktualisiert", "update_error": "Aktualisierung des Ausgabenlimits fehlgeschlagen", - "solana_not_supported": "Aktivieren Sie Solana-Token auf card.metamask.io", "select_token": "Token auswählen", "loading": "Verfügbare Tokens werden geladen ...", "load_error": "Tokens können nicht geladen werden. Bitte versuchen Sie es erneut.", @@ -7343,9 +7467,7 @@ "limited": "Limitiert", "not_enabled": "Nicht aktiviert", "update_success": "Ausgabenpriorität erfolgreich aktualisiert", - "update_error": "Aktualisierung der Ausgabenpriorität fehlgeschlagen", - "solana_not_supported_button_title": "Andere Token auf Solana", - "solana_not_supported_button_description": "Auf card.metamask.io aktivieren" + "update_error": "Aktualisierung der Ausgabenpriorität fehlgeschlagen" }, "card_authentication": { "title": "Melden Sie sich bei Ihrem Kartenkonto an", @@ -7443,6 +7565,11 @@ "title": "Anmeldung fehlgeschlagen", "description": "Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut." }, + "version_guard": { + "title": "Aktualisierung erforderlich", + "description": "Für die Nutzung von Belohnungen ist eine neuere Version von MetaMask erforderlich. Bitte aktualisieren Sie, um fortzufahren.", + "update_button": "MetaMask aktualisieren" + }, "season_error": { "error_fetching_title": "Saison konnten nicht geladen werden", "error_fetching_description": "Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut.", @@ -7525,7 +7652,6 @@ "main_title": "Belohnungen", "referral_title": "Empfehlungen", "tab_overview_title": "Übersicht", - "tab_snapshots_title": "Schnappschüsse", "tab_activity_title": "Aktivität", "referral_stats_earned_from_referrals": "Durch Empfehlungen verdient", "referral_stats_referrals": "Empfehlungen", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "Sie haben in dieser Saison keine Belohnungen erhalten, aber es gibt ja immer ein nächstes Mal.", "verifying_rewards": "Wir stellen sicher, dass alles korrekt ist, bevor Sie Ihre Belohnungen einfordern." }, + "previous_season_view": { + "title": "Vorherige Saison" + }, "season_status": { "points_earned": "Verdiente Punkte" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "Aktive Boosts", "season_1": "Saison 1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "mUSD-Bonus-Rechner", + "description": "Finden Sie heraus, wie viel Sie durch die Konvertierung Ihrer Stablecoins in mUSD verdienen können.", + "amount_label": "Konvertierter Betrag", + "estimated_bonus": "Geschätzter jährlicher Bonus: bis zu 3 %", + "initial_amount": "Anfangsbetrag", + "daily_bonus": "Täglich einforderbarer Bonus", + "annualized_bonus": "Jährlicher Bonus", + "disclaimer": "Dies ist nur eine Schätzung. Der Bonus kann sich noch ändern.", "buy_button": "mUSD kaufen", - "swap_button": "Swap to mUSD" + "swap_button": "Swap zu mUSD" }, "upcoming_rewards": { "title": "Gesperrte Belohnungen", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "Laden fehlgeschlagen" }, - "snapshot": { + "campaign": { "starts_date": "Beginnt am {{date}}", "ends_date": "Endet am {{date}}", - "results_coming_soon": "Ergebnisse folgen in Kürze", - "tokens_on_the_way": "Tokens sind unterwegs", + "ended_date": "Ended {{date}}", "pill_up_next": "Als Nächstes", - "pill_live_now": "Jetzt live", - "pill_calculating": "Berechnungsvorgang", - "pill_results_ready": "Ergebnisse bereit", - "pill_complete": "Abgeschlossen" - }, - "snapshots_section": { - "title": "Schnappschüsse", - "error_title": "Schnappschüsse konnten nicht geladen werden", - "error_description": "Die Schnappschüsse konnten nicht geladen werden. Bitte versuchen Sie es erneut.", - "retry_button": "Erneut versuchen" - }, - "snapshots_tab": { + "pill_active": "Live", + "pill_complete": "Abgeschlossen", + "enter_now": "Jetzt eingeben", + "entered": "Eingegeben", + "participant_count": "Nr. {{count}}", + "opt_in_cta": "Anmelden", + "opt_in_sheet_title": "An der Kampagne teilnehmen", + "opt_in_sheet_description_pre_link": "Indem Sie auf „Anmelden“ klicken, stimmen Sie MetaMask-Belohnungen zu", + "opt_in_sheet_link_text": "Ergänzende Nutzungsbedingungen und Datenschutzhinweis", + "opt_in_sheet_description_post_link": "Wir verfolgen die Onchain-Aktivitäten und belohnen Sie automatisch.", + "geo_restriction_banner_title": "In Ihrer Region nicht verfügbar", + "geo_restriction_banner_description": "Diese Kampagne ist in Ihrer Region aufgrund lokaler Bestimmungen nicht verfügbar." + }, + "campaign_mechanics": { + "title": "Mechaniken" + }, + "campaign_details": { + "start_date": "Beginnt am: {{date}}", + "end_date": "Endet am: {{date}}", + "opt_in": "Anmelden", + "opting_in": "Anmeldevorgang ...", + "opted_in": "Sie haben sich für diese Kampagne angemeldet", + "opt_in_error": "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut.", + "join_campaign": "An der Kampagne teilnehmen", + "checking_opt_in_status": "Überprüfung des Anmeldestatus", + "swap": "Tauschen", + "how_it_works": "Wie es funktioniert" + }, + "campaigns_preview": { + "title": "Kampagnen", + "coming_soon": "Demnächst verfügbar", + "notify_me": "Mich benachrichtigen" + }, + "earn_rewards": { + "title": "Belohnungen verdienen", + "musd_title": "Bis zu 3 % Bonus auf Stablecoins", + "musd_subtitle": "Berechnen Sie Ihren mUSD-Bonus", + "card_title": "Bis zu 3 % Cashback", + "card_subtitle": "Sichern Sie sich jetzt Ihre MetaMask Card", + "card_subtitle_cardholder": "Nutzen Sie die Vorteile Ihrer MetaMask Card" + }, + "campaigns_view": { + "title": "Kampagnen", "active_title": "Aktiv", "upcoming_title": "Bevorstehend", "previous_title": "Vorherige", - "empty_state": "Keine Schnappschüsse verfügbar", - "error_title": "Schnappschüsse konnten nicht geladen werden", - "error_description": "Die Schnappschüsse konnten nicht geladen werden. Bitte versuchen Sie es erneut.", + "empty_state": "Keine Kampagnen verfügbar", + "error_title": "Kampagnen konnten nicht geladen werden", + "error_description": "Kampagnen konnten nicht geladen werden. Bitte versuchen Sie es erneut.", "retry_button": "Erneut versuchen", "refreshing": "Aktualisierung ..." } @@ -7953,13 +8112,12 @@ "continue": "Fortfahren" }, "connecting": { - "title": "Verbinden Sie Ihr {{device}}", + "title": "Ihr {{device}} wird verbunden ...", "searching": "Suche nach {{device}} ...", - "tips_header": "Um fortzufahren, stellen Sie Folgendes sicher:", + "tips_header": "Stellen Sie Folgendes sicher:", "tip_unlock": "Ihr {{device}} ist freigeschaltet", "tip_open_app": "Die Ethereum-App ist geöffnet", "tip_enable_bluetooth": "Bluetooth ist eingeschaltet", - "tip_dnd_off": "Nicht stören ist ausgeschaltet", "tip_bluetooth_permission": "Standort- und Bluetooth-Berechtigung werden erteilt", "tip_bluetooth_permission_v12": "Berechtigung für Geräte in der Nähe wird erteilt", "tip_stay_close": "Ihr Gerät bleibt in der Nähe Ihres Telefons" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "Berechtigung für Geräte in der Nähe ist erforderlich", "bluetooth_off": "Bitte schalten Sie Bluetooth ein, um eine Verbindung mit Ihrem Gerät herzustellen", "bluetooth_scan_failed": "Scannen nach Geräten fehlgeschlagen. Bitte versuchen Sie es erneut", - "bluetooth_connection_failed": "Aktivieren Sie Bluetooth auf Ihrem Gerät, um fortzufahren", + "bluetooth_connection_failed": "Die Verbindung zu Ihrem Gerät ist fehlgeschlagen. Bitte versuchen Sie es erneut", "not_supported": "Dieser Vorgang wird nicht unterstützt", "unknown_error": "Stellen Sie sicher, dass Ihr {{device}} mit der geheimen Wiederherstellungsphrase oder Passphrase für dieses Konto eingerichtet ist" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "Bargeld", + "cash_empty_description": "Sie haben noch keine mUSD. Konvertieren Sie Stablecoins in mUSD im Bereich „Bargeld“ auf der Startseite.", + "cash_empty_description_network_filter": "Kein mUSD in diesem Netzwerk. Wechseln Sie das Netzwerk, um Ihre mUSD einzusehen.", "tokens": "Token", "perpetuals": "Perpetuals", "predictions": "Prognosen", + "whats_happening": "Was ist passiert?", + "whats_happening_categories": { + "geopolitical": "Geopolitisch", + "macro": "Makro", + "regulatory": "Regulatorisch", + "technical": "Technisch", + "social": "Sozial", + "other": "Sonstiges" + }, "defi": "DeFi", "nfts": "NFTs", "import_nfts": "NFTs importieren", diff --git a/locales/languages/el.json b/locales/languages/el.json index 0c241834aac..9fc2fc4e2cc 100644 --- a/locales/languages/el.json +++ b/locales/languages/el.json @@ -20,6 +20,12 @@ "update": "Ενημέρωση" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "Ειδοποίηση", @@ -120,8 +126,8 @@ "title": "Αποστολή περιουσιακών στοιχείων σε διεύθυνση καύσης" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "Προειδοποίηση συμβολαίου token", + "message": "Η διεύθυνση παραλήπτη ενδέχεται να μην υποστηρίζει άμεσες μεταφορές tokens, γεγονός που μπορεί να οδηγήσει σε απώλεια κεφαλαίων. Συνεχίστε μόνο εάν είστε βέβαιοι ότι το συγκεκριμένο συμβόλαιο μπορεί να λάβει τη μεταφορά." }, "gas_sponsorship_reserve_balance": { "message": "Η κάλυψη των τελών δεν είναι διαθέσιμη για αυτή τη συναλλαγή. Θα χρειαστεί να διατηρείτε τουλάχιστον %{minBalance} %{nativeTokenSymbol} στον λογαριασμό σας.", @@ -694,8 +700,8 @@ "could_not_resolve_name": "Δεν ήταν δυνατή η επίλυση του ονόματος", "invalid_address": "Μη έγκυρη διεύθυνση", "contractAddressError": "Πρόκειται να στείλετε tokens στη διεύθυνση συμβολαίου του token. Αυτό μπορεί να οδηγήσει σε απώλεια των token.", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "Διεύθυνση έξυπνου συμβολαίου", + "smart_contract_address_warning": "Η διεύθυνση παραλήπτη ενδέχεται να μην υποστηρίζει άμεσες μεταφορές tokens, γεγονός που μπορεί να οδηγήσει σε απώλεια κεφαλαίων. Συνεχίστε μόνο εάν είστε βέβαιοι ότι το συγκεκριμένο συμβόλαιο μπορεί να λάβει τη μεταφορά.", "i_understand": "Κατανοώ", "cancel": "Άκυρο" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "Η τιμή περιορισμού ζημιάς πρέπει να είναι {{direction}} από την τιμή {{priceType}}", "stop_loss_beyond_liquidation_error": "Η τιμή περιορισμού ζημιάς πρέπει να είναι {{direction}} από την τιμή ρευστοποίησης", "stop_loss_order_view_warning": "Η τιμή περιορισμού ζημιάς είναι {{direction}} από την τιμή ρευστοποίησης", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "πάνω", "below": "κάτω", "done": "Τέλος", @@ -2086,14 +2094,15 @@ "a_closer_look": "Μια πιο προσεκτική ματιά", "whats_being_said": "Τι συζητιέται", "footer_disclaimer": "Περίληψη από ΤΝ, μόνο για ενημερωτικούς σκοπούς", - "trade_button": "Συναλλαγές", + "swap_button": "Ανταλλαγή", + "buy_button": "Αγορά", "sources_count": "+{{count}} πηγές", "sources_title": "Πηγές ειδήσεων", "feedback_submitted": "Το σχόλιό σας υποβλήθηκε", "helpful_prompt": "Ήταν χρήσιμο;", "feedback": { "title": "Ανατροφοδότηση", - "description": "Βοηθήστε μας να βελτιώσουμε τις αναλύσεις αγοράς που δημιουργεί η τεχνητή νοημοσύνη.", + "description": "Οι απαντήσεις σας βοηθούν στη βελτίωση των περιλήψεών μας με AI.", "not_relevant": "Δεν είναι σχετικό", "not_accurate": "Δεν είναι ακριβές", "hard_to_understand": "Δύσκολο στην κατανόηση", @@ -2206,7 +2215,7 @@ "available_balance": "Διαθέσιμο υπόλοιπο", "claim_amount_text": "Εξαργυρώστε ${{amount}}", "claim_winnings_text": "Εξαργύρωση κερδών", - "claiming_text": "Claiming...", + "claiming_text": "Λήψη…", "unrealized_pnl_label": "Μη οριστικοποιημένα κέρδη & ζημίες", "unrealized_pnl_value": "{{amount}} ({{percent}})", "unrealized_pnl_error": "Δεν ήταν δυνατή η φόρτωση", @@ -2287,7 +2296,7 @@ "try_again": "Προσπαθήστε ξανά" }, "in_progress": { - "title": "Claim already in progress" + "title": "Η διαδικασία λήψης βρίσκεται ήδη σε εξέλιξη" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "Τέλη που καταβάλλονται στην πλατφόρμα συναλλαγών ή στην αγορά", "total_incl_fees": "συμπερ. τέλη", "close": "Κλείσιμο", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "Οι τιμές που εμφανίζονται προϋποθέτουν ότι η εντολή σας θα εκτελεστεί πλήρως. Τα πραγματικά ποσά ενδέχεται να διαφέρουν εάν η εντολή εκτελεστεί μόνο μερικώς.", + "deposit_fee": "Τέλη κατάθεσης", + "deposit_fee_description": "Τέλη που επιβάλλονται για την κατάθεση χρημάτων στο υπόλοιπο ππροβλέψεών σας" }, "error": { "title": "Δεν ήταν δυνατή η σύνδεση με την πλατφόρμα προβλέψεων", @@ -3059,6 +3068,7 @@ "networks_no_results": "Δεν βρέθηκαν δίκτυα", "network_name_label": "Όνομα δικτύου", "network_name_placeholder": "Όνομα δικτύου (προαιρετικά)", + "required": "Απαιτείται", "network_rpc_url_label": "Διεύθυνση URL του RPC", "network_rpc_name_label": "Όνομα RPC", "network_rpc_placeholder": "Νέο δίκτυο RPC", @@ -3298,6 +3308,8 @@ "blockaid_desc": "Αυτή η λειτουργία σας προειδοποιεί για κακόβουλη δραστηριότητα, καθώς ελέγχει ενεργά τα αιτήματα συναλλαγών και υπογραφών.", "security_alerts": "Ειδοποιήσεις ασφαλείας", "security_alerts_desc": "Αυτή η λειτουργία σας ειδοποιεί για κακόβουλη δραστηριότητα, ελέγχοντας τοπικά τα αιτήματα συναλλαγών και υπογραφών σας. Κάνετε πάντα τη δική σας ενδελεχή έρευνα πριν εγκρίνετε οποιαδήποτε αιτήματα. Δεν υπάρχει καμία εγγύηση ότι αυτή η λειτουργία θα ανιχνεύσει όλες τις κακόβουλες δραστηριότητες. Ενεργοποιώντας αυτή τη λειτουργία, συμφωνείτε με τους όρους χρήσης του παρόχου.", + "smart_account_dapp_requests_heading": "Αιτήματα έξυπνου λογαριασμού από αποκεντρωμένες εφαρμογές (dapps)", + "smart_account_dapp_requests_desc": "Επιτρέψτε στις αποκεντρωμένες εφαρμογές (dapps) να ζητούν λειτουργίες έξυπνου λογαριασμού για τυπικούς λογαριασμούς. Δεν θα επηρεαστούν οι λογαριασμοί που είναι ήδη έξυπνοι λογαριασμοί.", "smart_transactions_opt_in_heading": "Έξυπνες Συναλλαγές", "smart_transactions_opt_in_desc_supported_networks": "Ενεργοποιήστε τις Έξυπνες Συναλλαγές για πιο αξιόπιστες και ασφαλείς συναλλαγές σε υποστηριζόμενα δίκτυα.", "smart_transactions_learn_more": "Μάθετε περισσότερα", @@ -3566,6 +3578,53 @@ "activity": "{{symbol}} δραστηριότητα", "disclaimer": "Τα δεδομένα αγοράς παρέχονται από τρίτους, όπως το CoinGecko. Τα δεδομένα προσφέρονται μόνο για ενημερωτικούς σκοπούς. Το MetaMask δεν φέρει ευθύνη για την ακρίβειά τους." }, + "security_trust": { + "title": "Ασφάλεια και εμπιστοσύνη", + "malicious": "Κακόβουλο", + "risky": "Επικίνδυνο", + "malicious_token_title": "Κακόβουλο token", + "malicious_token_description": "Το {{symbol}} είναι κακόβουλο token. Αποφύγετε την αλληλεπίδραση μαζί του ή να κάνετε συναλλαγές.", + "verified_token_title": "Επαληθευμένο token", + "verified_token_description": "Το {{symbol}} έχει υψηλή αναγνωρισιμότητα και είναι ευρέως γνωστό. Η επαλήθευση δεν αποτελεί έγκριση από το MetaMask.", + "risky_token_title": "Επικίνδυνο token", + "risky_token_description": "Εντοπίστηκαν προειδοποιητικές ενδείξεις για το {{symbol}}. Κάντε προσεκτική έρευνα πριν προχωρήσετε σε συναλλαγές με αυτό το token.", + "malicious_token_sheet_description": "Εντοπίστηκαν σοβαρές ενδείξεις κινδύνου για το {{symbol}}. Συνιστούμε να μην κάνετε συναλλαγές με αυτό το token.", + "got_it": "Κατανοητό", + "proceed": "Συνεχίστε", + "cancel": "Ακύρωση", + "data_unavailable": "Τα δεδομένα ασφαλείας δεν είναι διαθέσιμα", + "subtitle_known": "Δεν εντοπίστηκαν ενδείξεις κινδύνου. Να ερευνάτε πάντα κάθε ψηφιακό περιουσιακό στοιχείο πριν κάνετε συναλλαγές.", + "subtitle_no_issues": "Δεν εντοπίστηκαν ενδείξεις κινδύνου. Να ερευνάτε πάντα κάθε ψηφιακό περιουσιακό στοιχείο πριν κάνετε συναλλαγές.", + "subtitle_suspicious": "Εντοπίστηκαν προειδοποιητικές ενδείξεις. Ελέγξτε προσεκτικά τα ζητήματα που έχουν επισημανθεί πριν κάνετε συναλλαγές με αυτό το ψηφιακό περιουσιακό στοιχείο.", + "subtitle_malicious": "Εντοπίστηκαν σοβαρές ενδείξεις κινδύνου. Συνιστούμε να αποφύγετε αυτό το ψηφιακό περιουσιακό στοιχείο.", + "subtitle_unavailable": "Δεν ήταν δυνατή η φόρτωση της ανάλυσης ασφαλείας για αυτό το token.", + "token_distribution": "Κατανομή του token", + "total_supply": "Συνολική προσφορά", + "top_10_holders": "Κορυφαίοι 10 χρήστες", + "other": "Άλλο", + "no_hidden_fees_detected": "Δεν εντοπίστηκαν κρυφές χρεώσεις", + "buy_sell_tax": "Φόρος αγοράς/πώλησης", + "buy_tax": "Φόρος αγοράς", + "sell_tax": "Φόρος πώλησης", + "transfer": "Μεταφορά", + "token_info": "Πληροφορίες για το token", + "created": "Δημιουργήθηκε", + "token_age": "Ηλικία του token", + "network": "Δίκτυο", + "type": "Πληκτρολογήστε", + "official_links": "Επίσημοι σύνδεσμοι", + "website": "Ιστότοπος", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "Μη διαθέσιμη", + "verified": "Επαληθευμένο", + "no_issues": "Δεν υπάρχουν ζητήματα", + "suspicious": "Ύποπτο", + "malicious_label": "Κακόβουλο", + "more": "περισσότερα", + "evaluation_disclaimer": "Αυτή η αξιολόγηση ασφαλείας παρέχεται αποκλειστικά για ενημερωτικούς σκοπούς και δεν αποτελεί έγκριση ή σύσταση για συναλλαγές." + }, "account_details": { "title": "Στοιχεία λογαριασμού", "share_account": "Κοινοποίηση", @@ -5934,6 +5993,10 @@ "claimable_bonus": "Μπόνους προς εξαργύρωση", "claim_bonus": "Εξαργύρωση του μπόνους", "claim_bonus_subtitle": "Το μπόνους θα καταβληθεί στο δίκτυο {{networkName}}.", + "percentage_bonus_on_linea": "{{percentage}}% μπόνους στο δίκτυο Linea", + "claim": "Εξαργύρωση", + "sounds_good": "Εντάξει", + "claimable_bonus_tooltip_with_percentage": "Έχετε κερδίσει {{percentage}}% ετήσιο μπόνους επειδή έχετε mUSD. Το μπόνους σας μπορείτε να το εξαργυρώσετε καθημερινά στο δίκτυο Linea.", "empty_state_cta": { "heading": "Δανείστε {{tokenSymbol}} και κερδίστε", "body": "Δανείστε τα {{tokenSymbol}} μέσω του {{protocol}} και κερδίστε", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "Τα stablecoins σας" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Stake", "earn": "Κερδίστε", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "Δεν έχετε αρκετό υπόλοιπο πόρων για να εκτελέσετε αυτή την ενέργεια." }, - "trx_unstaking_in_progress": "Η αποδέσμευση {{amount}} TRX βρίσκεται σε εξέλιξη. Η διαδικασία διαρκεί 14 ημέρες.", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "Αποδέσμευση {{amount}} TRX σε εξέλιξη", + "description": "Η διαδικασία αποδέσμευσης διαρκεί 14 ημέρες" + }, + "unstaked_banner": { + "title": "Η αποδέσμευση {{amount}} TRX ολοκληρώθηκε", + "description": "Τα TRX που αποδεσμεύσατε είναι πλέον διαθέσιμα για ανάληψη", + "button": "Ανάληψη", + "error": "Η ανάληψη απέτυχε" + } }, "stake_eth": "Ποντάρισμα σε ETH", "unstake_eth": "Ακύρωση πονταρίσματος σε ETH", @@ -6376,7 +6498,8 @@ "approve": "Έγκριση αιτήματος", "perps_deposit": "Προσθήκη κεφαλαίων", "predict_deposit": "Προσθήκη κεφαλαίων για Προβλέψεις", - "predict_withdraw": "Ανάληψη" + "predict_withdraw": "Ανάληψη", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "Αυτός ο ιστότοπος θέλει άδεια για να δαπανήσει τα tokens σας.", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "Συναλλαγή {{index}}", "transaction": "Προστασία", "available_balance": "Διαθέσιμο υπόλοιπο: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "Συνεχίστε", "deposit_edit_amount_done": "Προσθήκη κεφαλαίων", "deposit_edit_amount_predict_withdraw": "Ανάληψη", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "Τα πορτοφόλια υλικού δεν υποστηρίζονται ακόμη. Χρησιμοποιήστε ένα θερμό πορτοφόλι για να συνεχίσετε.", "hardware_wallet_not_supported_solana": "Τα πορτοφόλια υλικού δεν υποστηρίζουν ακόμα την Solana. Χρησιμοποιήστε ένα θερμό πορτοφόλι για να συνεχίσετε.", "price_impact_info_title": "Αντίκτυπος στην τιμή", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "Έτσι επηρεάζει η συναλλαγή σας την τιμή αγοράς ενός token. Εξαρτάται από το μέγεθος της συναλλαγής, τη διαθέσιμη ρευστότητα και τις χρεώσεις των παρόχων. Το MetaMask δεν ελέγχει την επίδραση στην τιμή.", "price_impact_info_gasless_description": "Η επίδραση στην τιμή αντικατοπτρίζει το πώς η εντολή ανταλλαγής σας επηρεάζει την τιμή αγοράς του περιουσιακού στοιχείου. Αν δεν διαθέτετε αρκετά κεφάλαια για τα τέλη συναλλαγής, μέρος του αρχικού σας token κατανέμεται αυτόματα για την κάλυψη των χρεώσεων, γεγονός που αυξάνει την επίδραση στην τιμή. Το MetaMask δεν επηρεάζει ούτε ελέγχει την επίδραση στην τιμή.", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "Λόγω του μεγέθους της συναλλαγής σας και της διαθέσιμης ρευστότητας, θα λάβετε περίπου {{priceImpact}} λιγότερο από την τιμή αγοράς. Αυτό έχει ήδη ληφθεί υπόψη στην προσφορά σας.", "price_impact_high": "Υψηλή επίδραση στην τιμή", "price_impact_execution_description": "Θα χάσετε περίπου {{priceImpact}} από την αξία του token σας σε αυτή την ανταλλαγή. Προσπαθήστε να μειώσετε το ποσό ή να επιλέξετε ένα κανάλι με μεγαλύτερη ρευστότητα.", "proceed": "Συνεχίστε", @@ -6627,8 +6751,8 @@ "total_cost": "Συνολικό κόστος", "got_it": "Κατανοητό", "price_impact_warning_title": "Υψηλή επίδραση στην τιμή", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "Πολύ μεγάλη επίδραση στην τιμή", + "price_impact_error_description": "Θα χάσετε περίπου {{priceImpact}} από την τιμή αγοράς του token σε αυτή την ανταλλαγή. Δοκιμάστε με μικρότερο ποσό ή ένα κανάλι με μεγαλύτερη ρευστότητα για να βελτιώσετε την τιμή." }, "quote_expired_modal": { "title": "Υπάρχουν διαθέσιμες νέες προσφορές", @@ -6940,7 +7064,7 @@ "upgrade_title": "Αναβάθμιση σε Metal", "continue_button": "Συνεχίστε", "virtual_card": { - "name": "Virtual Card", + "name": "Εικονική κάρτα", "price": "Δωρεάν", "feature_1": "Εικονική κάρτα για Apple Pay και Google Pay", "feature_2": "Πληρώστε με κρυπτονομίσματα (USDC, USDT, WETH και άλλα)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "Metal Card", "price": "$199/έτος", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "Όλα όσα περιλαμβάνει η εικονική κάρτα, καθώς και:", + "feature_1": "Μεταλλική κάρτα υψηλής ποιότητας με χάραξη", + "feature_2": "3% επιστροφή χρημάτων στα πρώτα $10.000/έτος", "feature_3": "Χωρίς χρεώσεις για διεθνείς συναλλαγές" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "Κερδίστε έως και $300 επιστροφή χρημάτων κάθε χρόνο", + "upgrade_to_metal_label": "Ή κάντε αναβάθμιση σε Μεταλλική κάρτα για 3× ανταμοιβές" }, "review_order": { "title": "Έλεγχος εντολής", @@ -7104,7 +7228,7 @@ "ssn_description": "Απαιτείται από τον εκδότη της κάρτας. Δεν θα πραγματοποιηθεί έλεγχος πιστοληπτικής ικανότητας.", "invalid_ssn": "Μη έγκυρος αριθμός κοινωνικής ασφάλισης (ΑΜΚΑ)", "invalid_date_of_birth": "Μη έγκυρη ημερομηνία γέννησης. Πρέπει να είστε τουλάχιστον 18 ετών", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "Το όνομα και το επώνυμό σας πρέπει να ταιριάζουν με την επαληθευμένη ταυτότητά σας" }, "physical_address": { "title": "Προσθέστε τη διεύθυνσή σας", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "Έχετε σχεδόν φτάσει το όριο δαπανών σας", "description": "Ενημερώστε για να αποφύγετε απορρίψεις\t", - "confirm_button_label": "Ορισμός νέου ορίου" + "confirm_button_label": "Ορισμός νέου ορίου", + "dismiss_button_label": "Απόρριψη" }, "need_delegation": { "title": "Πρέπει να ενεργοποιήσετε την κάρτα σας", @@ -7301,7 +7426,6 @@ "dismiss": "Απόρριψη", "update_success": "Το όριο δαπανών ενημερώθηκε με επιτυχία", "update_error": "Απέτυχε η ενημέρωση του ορίου δαπανών", - "solana_not_supported": "Ενεργοποιήστε τα tokens στη Solana στο card.metamask.io", "select_token": "Επιλέξτε token", "loading": "Φόρτωση διαθέσιμων tokens...", "load_error": "Δεν ήταν δυνατή η φόρτωση των tokens. Παρακαλούμε δοκιμάστε ξανά.", @@ -7343,9 +7467,7 @@ "limited": "Περιορισμένο", "not_enabled": "Δεν είναι ενεργοποιημένο", "update_success": "Η προτεραιότητα δαπανών ενημερώθηκε με επιτυχία", - "update_error": "Απέτυχε η ενημέρωση της προτεραιότητας δαπανών", - "solana_not_supported_button_title": "Άλλα tokens στη Solana", - "solana_not_supported_button_description": "Ενεργοποίηση στο card.metamask.io" + "update_error": "Απέτυχε η ενημέρωση της προτεραιότητας δαπανών" }, "card_authentication": { "title": "Συνδεθείτε στον λογαριασμό της κάρτας σας", @@ -7443,6 +7565,11 @@ "title": "Αποτυχία συμμετοχής", "description": "Ελέγξτε τη σύνδεσή σας και προσπαθήστε ξανά." }, + "version_guard": { + "title": "Απαιτείται ενημέρωση", + "description": "Απαιτείται νεότερη έκδοση του MetaMask για να χρησιμοποιήσετε τις Ανταμοιβές. Παρακαλούμε ενημερώστε την εφαρμογή για να συνεχίσετε.", + "update_button": "Ενημερώστε το MetaMask" + }, "season_error": { "error_fetching_title": "Αδυναμία φόρτωσης της περιόδου", "error_fetching_description": "Ελέγξτε τη σύνδεσή σας και προσπαθήστε ξανά.", @@ -7525,7 +7652,6 @@ "main_title": "Ανταμοιβές", "referral_title": "Συστάσεις", "tab_overview_title": "Επισκόπηση", - "tab_snapshots_title": "Στιγμιότυπα", "tab_activity_title": "Δραστηριότητα", "referral_stats_earned_from_referrals": "Κερδίσατε από συστάσεις", "referral_stats_referrals": "Συστάσεις", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "Δεν κερδίσατε ανταμοιβές αυτή την περίοδο, αλλά υπάρχει πάντα η επόμενη φορά.", "verifying_rewards": "Βεβαιωνόμαστε ότι όλα είναι σωστά πριν διεκδικήσετε τις ανταμοιβές σας." }, + "previous_season_view": { + "title": "Προηγούμενη περίοδος" + }, "season_status": { "points_earned": "Πόντοι που κερδίσατε" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "Ενεργές ενισχύσεις", "season_1": "Περίοδος 1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "Εκτίμηση του μπόνους σε mUSD", + "description": "Δείτε πόσα μπορείτε να κερδίσετε μετατρέποντας τα stablecoins σας σε mUSD.", + "amount_label": "Το ποσό μετατράπηκε", + "estimated_bonus": "Εκτιμώμενο ετήσιο μπόνους: έως 3%", + "initial_amount": "Αρχικό ποσό", + "daily_bonus": "Ημερήσιο διαθέσιμο μπόνους", + "annualized_bonus": "Ετήσιο μπόνους", + "disclaimer": "Πρόκειται μόνο για εκτίμηση. Το μπόνους ενδέχεται να αλλάξει.", "buy_button": "Αγοράστε mUSD", - "swap_button": "Swap to mUSD" + "swap_button": "Ανταλλαγή σε mUSD" }, "upcoming_rewards": { "title": "Κλειδωμένες ανταμοιβές", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "Δεν ήταν δυνατή η φόρτωση" }, - "snapshot": { + "campaign": { "starts_date": "Αρχίζει {{date}}", "ends_date": "Λήγει {{date}}", - "results_coming_soon": "Τα αποτελέσματα έρχονται σύντομα", - "tokens_on_the_way": "Token καθ' οδόν", + "ended_date": "Ended {{date}}", "pill_up_next": "Επόμενο", - "pill_live_now": "Ζωντανά τώρα", - "pill_calculating": "Υπολογισμός", - "pill_results_ready": "Τα αποτελέσματα είναι έτοιμα", - "pill_complete": "Ολοκληρώθηκε" - }, - "snapshots_section": { - "title": "Στιγμιότυπα", - "error_title": "Δεν είναι δυνατή η φόρτωση στιγμιότυπων", - "error_description": "Δεν ήταν δυνατή η φόρτωση των στιγμιότυπων. Παρακαλούμε δοκιμάστε ξανά.", - "retry_button": "Επανάληψη" - }, - "snapshots_tab": { + "pill_active": "Ζωντανά", + "pill_complete": "Ολοκληρώθηκε", + "enter_now": "Συμμετάσχετε τώρα", + "entered": "Έχετε συμμετάσχει", + "participant_count": "#{{count}}", + "opt_in_cta": "Εγγραφή", + "opt_in_sheet_title": "Συμμετάσχετε στην καμπάνια", + "opt_in_sheet_description_pre_link": "Κάνοντας κλικ στο ‘Εγγραφή’, αποδέχεστε τις Ανταμοιβές του MetaMask", + "opt_in_sheet_link_text": "Συμπληρωματικοί Όροι Χρήσης και Δήλωση Απορρήτου", + "opt_in_sheet_description_post_link": "Θα παρακολουθούμε τη δραστηριότητά σας στο blockchain ώστε να σας ανταμείβουμε αυτόματα.", + "geo_restriction_banner_title": "Δεν είναι διαθέσιμη στην περιοχή σας", + "geo_restriction_banner_description": "Αυτή η καμπάνια δεν είναι διαθέσιμη στην περιοχή σας λόγω τοπικών κανονισμών." + }, + "campaign_mechanics": { + "title": "Κανόνες" + }, + "campaign_details": { + "start_date": "Ξεκινά: {{date}}", + "end_date": "Λήγει: {{date}}", + "opt_in": "Εγγραφή", + "opting_in": "Εγγραφή...", + "opted_in": "Έχετε εγγραφεί σε αυτή την καμπάνια", + "opt_in_error": "Η εγγραφή απέτυχε. Προσπαθήστε ξανά.", + "join_campaign": "Συμμετάσχετε στην καμπάνια", + "checking_opt_in_status": "Έλεγχος κατάστασης εγγραφής", + "swap": "Ανταλλαγή", + "how_it_works": "Πώς λειτουργεί" + }, + "campaigns_preview": { + "title": "Καμπάνιες", + "coming_soon": "Προσεχώς", + "notify_me": "Ειδοποιήστε με" + }, + "earn_rewards": { + "title": "Κερδίστε ανταμοιβές", + "musd_title": "Έως 3% μπόνους σε stables", + "musd_subtitle": "Υπολογίστε το μπόνους σας σε mUSD", + "card_title": "Έως 3% επιστροφή μετρητών", + "card_subtitle": "Αποκτήστε τώρα την MetaMask Card", + "card_subtitle_cardholder": "Δείτε τα προνόμια της MetaMask Card" + }, + "campaigns_view": { + "title": "Καμπάνιες", "active_title": "Ενεργό", "upcoming_title": "Επερχόμενο", "previous_title": "Προηγούμενο", - "empty_state": "Δεν υπάρχουν διαθέσιμα στιγμιότυπα", - "error_title": "Δεν είναι δυνατή η φόρτωση στιγμιότυπων", - "error_description": "Δεν ήταν δυνατή η φόρτωση των στιγμιότυπων. Παρακαλούμε δοκιμάστε ξανά.", + "empty_state": "Δεν υπάρχουν διαθέσιμες καμπάνιες", + "error_title": "Δεν ήταν δυνατή η φόρτωση των καμπανιών", + "error_description": "Δεν μπορέσαμε να φορτώσουμε τις καμπάνιες. Προσπαθήστε ξανά.", "retry_button": "Επανάληψη", "refreshing": "Ανανεώνεται..." } @@ -7953,13 +8112,12 @@ "continue": "Συνεχίστε" }, "connecting": { - "title": "Συνδέστε τη συσκευή σας {{device}}", + "title": "Σύνδεση με το {{device}}...", "searching": "Αναζήτηση για το {{device}}...", - "tips_header": "Για να συνεχίσετε, βεβαιωθείτε ότι:", + "tips_header": "Βεβαιωθείτε:", "tip_unlock": "Η συσκευή σας {{device}} είναι ξεκλείδωτη", "tip_open_app": "Η εφαρμογή Ethereum είναι ανοιχτή", "tip_enable_bluetooth": "Το Bluetooth είναι ενεργοποιημένο", - "tip_dnd_off": "Η λειτουργία Μην Ενοχλείτε είναι απενεργοποιημένη", "tip_bluetooth_permission": "Η άδεια τοποθεσίας και Bluetooth έχει δοθεί", "tip_bluetooth_permission_v12": "Η άδεια για κοντινές συσκευές έχει δοθεί", "tip_stay_close": "Η συσκευή σας πρέπει να βρίσκεται κοντά στο τηλέφωνό σας" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "Απαιτείται άδεια πρόσβασης σε κοντινές συσκευές", "bluetooth_off": "Ενεργοποιήστε το Bluetooth για να συνδεθεί με τη συσκευή σας", "bluetooth_scan_failed": "Δεν ήταν δυνατή η σάρωση συσκευών. Προσπαθήστε ξανά", - "bluetooth_connection_failed": "Ενεργοποιήστε το Bluetooth στη συσκευή σας για να συνεχίσετε", + "bluetooth_connection_failed": "Η σύνδεση με τη συσκευή σας απέτυχε. Προσπαθήστε ξανά", "not_supported": "Αυτή η λειτουργία δεν υποστηρίζεται", "unknown_error": "Βεβαιωθείτε ότι το {{device}} σας έχει ρυθμιστεί με τη Μυστική Φράση Ανάκτησης ή τη φράση πρόσβασης για αυτόν τον λογαριασμό" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "Μετρητά", + "cash_empty_description": "Δεν έχετε ακόμη mUSD. Μετατρέψτε τα stablecoins σας σε mUSD από την ενότητα Μετρητά στην αρχική σελίδα.", + "cash_empty_description_network_filter": "Δεν έχετε mUSD σε αυτό το δίκτυο. Αλλάξτε δίκτυο για να δείτε τα mUSD σας.", "tokens": "Token", "perpetuals": "Συμβόλαια αορίστου διάρκειας", "predictions": "Προβλέψεις", + "whats_happening": "Τι συμβαίνει", + "whats_happening_categories": { + "geopolitical": "Γεωπολιτικά", + "macro": "Μακροοικονομικά", + "regulatory": "Κανονισμοί", + "technical": "Τεχνικά", + "social": "Κοινωνικά", + "other": "Άλλο" + }, "defi": "DeFi", "nfts": "NFT", "import_nfts": "Εισαγωγή NFT", diff --git a/locales/languages/es.json b/locales/languages/es.json index 0aed1f163aa..3e30408f7e2 100644 --- a/locales/languages/es.json +++ b/locales/languages/es.json @@ -20,6 +20,12 @@ "update": "Actualizar" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "Alerta", @@ -120,8 +126,8 @@ "title": "Envío de activos a la dirección de quema" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "Advertencia sobre el contrato de tokens", + "message": "Es posible que la dirección del destinatario no admita transferencias directas de tokens, lo que podría provocar la pérdida de fondos. Continúa solo si estás seguro de que este contrato puede recibir tu transferencia." }, "gas_sponsorship_reserve_balance": { "message": "El patrocinio de gas no está disponible para esta transacción. Deberás mantener al menos %{minBalance} %{nativeTokenSymbol} en tu cuenta.", @@ -694,8 +700,8 @@ "could_not_resolve_name": "No se pudo resolver el nombre", "invalid_address": "Dirección no válida", "contractAddressError": "Estás enviando tokens a la dirección del contrato del token. Esto podría resultar en la pérdida de estos tokens.", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "Dirección del contrato inteligente", + "smart_contract_address_warning": "Es posible que la dirección del destinatario no admita transferencias directas de tokens, lo que podría provocar la pérdida de fondos. Continúa solo si estás seguro de que este contrato puede recibir tu transferencia.", "i_understand": "Comprendo", "cancel": "Cancelar" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "El límite de pérdidas debe estar por {{direction}} del precio {{priceType}}", "stop_loss_beyond_liquidation_error": "El límite de pérdidas debe estar por {{direction}} del precio de liquidación", "stop_loss_order_view_warning": "El límite de pérdidas está por {{direction}} del precio de liquidación", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "por encima", "below": "por debajo", "done": "Hecho", @@ -2086,14 +2094,15 @@ "a_closer_look": "Un vistazo más de cerca", "whats_being_said": "Qué se dice", "footer_disclaimer": "Resumen generado por IA solo con fines informativos", - "trade_button": "Operar", + "swap_button": "Canjear", + "buy_button": "Comprar", "sources_count": "+{{count}} fuentes", "sources_title": "Fuentes de noticias", "feedback_submitted": "Comentarios enviados", "helpful_prompt": "¿Te resultó útil?", "feedback": { "title": "Comentarios", - "description": "Ayuda a mejorar nuestra información de mercado generada por IA.", + "description": "Tu respuesta ayuda a mejorar nuestros resúmenes generados por IA.", "not_relevant": "No es relevante", "not_accurate": "No es precisa", "hard_to_understand": "Difícil de entender", @@ -2206,7 +2215,7 @@ "available_balance": "Saldo disponible", "claim_amount_text": "Reclamar ${{amount}}", "claim_winnings_text": "Reclama tus ganancias", - "claiming_text": "Claiming...", + "claiming_text": "Reclamando...", "unrealized_pnl_label": "P&L no realizado", "unrealized_pnl_value": "{{amount}} ({{percent}})", "unrealized_pnl_error": "No se puede cargar", @@ -2287,7 +2296,7 @@ "try_again": "Inténtalo de nuevo" }, "in_progress": { - "title": "Claim already in progress" + "title": "Reclamación ya en curso" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "Tarifa pagada al cambio o al mercado", "total_incl_fees": "tarifas incl.", "close": "Cerrar", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "Los precios indicados se basan en que tu orden se ejecute en su totalidad. Los montos reales pueden variar si la orden solo se ejecuta parcialmente.", + "deposit_fee": "Tarifa de depósito", + "deposit_fee_description": "Tarifa que se cobra por depositar fondos en tu saldo de predicción" }, "error": { "title": "No se puede conectar a las predicciones", @@ -3059,6 +3068,7 @@ "networks_no_results": "No se encontraron redes", "network_name_label": "Nombre de la red", "network_name_placeholder": "Nombre de la red (opcional)", + "required": "Requerido", "network_rpc_url_label": "URL de RPC", "network_rpc_name_label": "Nombre de RPC", "network_rpc_placeholder": "Nueva red RPC", @@ -3298,6 +3308,8 @@ "blockaid_desc": "Esta función le alerta sobre actividades maliciosas al revisar activamente las solicitudes de transacciones y firmas.", "security_alerts": "Alertas de seguridad", "security_alerts_desc": "Esta función le alerta sobre actividad maliciosa al revisar localmente sus solicitudes de transacción y firma. Haga siempre su propia diligencia debida antes de aprobar cualquier solicitud. No hay garantía de que esta función detecte toda la actividad maliciosa. Al activar esta función, acepta los términos de uso del proveedor.", + "smart_account_dapp_requests_heading": "Solicitudes de cuentas inteligentes desde dapps", + "smart_account_dapp_requests_desc": "Permite que las dapps soliciten funciones de cuentas inteligentes para cuentas estándar. Esto no afectará a las cuentas que ya son cuentas inteligentes.", "smart_transactions_opt_in_heading": "Transacciones inteligentes", "smart_transactions_opt_in_desc_supported_networks": "Active las transacciones inteligentes para realizar transacciones más confiables y seguras en las redes compatibles.", "smart_transactions_learn_more": "Conozca más", @@ -3566,6 +3578,53 @@ "activity": "Actividad de {{symbol}}", "disclaimer": "Los datos de mercado provienen de fuentes externas como CoinGecko. Su uso es meramente informativo. MetaMask no se responsabiliza de su exactitud." }, + "security_trust": { + "title": "Seguridad y confianza", + "malicious": "Malicioso", + "risky": "Riesgoso", + "malicious_token_title": "Token malicioso", + "malicious_token_description": "{{symbol}} es un token malicioso. Evita interactuar o realizar operaciones con él.", + "verified_token_title": "Token verificado", + "verified_token_description": "{{symbol}} opera activamente y goza de amplio reconocimiento. La verificación no implica el respaldo de MetaMask.", + "risky_token_title": "Token riesgoso", + "risky_token_description": "Se detectaron señales de precaución para {{symbol}}. Investiga bien antes de operar con este token.", + "malicious_token_sheet_description": "Se detectaron señales de riesgo grave para {{symbol}}. Te recomendamos no operar con este token.", + "got_it": "Entendido", + "proceed": "Continuar", + "cancel": "Cancelar", + "data_unavailable": "Datos de seguridad no disponibles", + "subtitle_known": "No se detectaron señales de riesgo. Siempre investiga cualquier activo antes de operar.", + "subtitle_no_issues": "No se detectaron señales de riesgo. Siempre investiga cualquier activo antes de operar.", + "subtitle_suspicious": "Se detectaron señales de precaución. Revisa cuidadosamente los problemas señalados antes de operar con este activo.", + "subtitle_malicious": "Se detectaron señales de riesgo grave. Te recomendamos evitar este activo.", + "subtitle_unavailable": "No se pudo cargar el análisis de seguridad de este token.", + "token_distribution": "Distribución de tokens", + "total_supply": "Suministro total", + "top_10_holders": "Los 10 principales titulares", + "other": "Otro", + "no_hidden_fees_detected": "No se detectaron tarifas ocultas", + "buy_sell_tax": "Impuesto de compra/venta", + "buy_tax": "Impuesto de compra", + "sell_tax": "Impuesto de venta", + "transfer": "Transferir", + "token_info": "Información del token", + "created": "Creado", + "token_age": "Antigüedad del token", + "network": "Red", + "type": "Tipo", + "official_links": "Enlaces oficiales", + "website": "Sitio web", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "N/A", + "verified": "Verificado", + "no_issues": "Sin problemas", + "suspicious": "Sospechoso", + "malicious_label": "Malicioso", + "more": "más", + "evaluation_disclaimer": "Esta revisión de seguridad es solo para fines de evaluación y no constituye un respaldo ni una recomendación para operar." + }, "account_details": { "title": "Detalles de la cuenta", "share_account": "Compartir", @@ -5934,6 +5993,10 @@ "claimable_bonus": "Bonificación reclamable", "claim_bonus": "Reclamar bono", "claim_bonus_subtitle": "El bono se pagará en {{networkName}}.", + "percentage_bonus_on_linea": "Bono del {{percentage}} % en Linea", + "claim": "Reclamar", + "sounds_good": "Me parece bien", + "claimable_bonus_tooltip_with_percentage": "Bono anualizado del {{percentage}} % que has ganado por mantener mUSD. Puedes reclamar tu bono diariamente en Linea.", "empty_state_cta": { "heading": "Presta {{tokenSymbol}} y gana", "body": "Presta tu {{tokenSymbol}} con {{protocol}} y gana", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "Tus monedas estables" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Stake", "earn": "Ganar", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "No tienes suficiente saldo de recursos para realizar esta acción." }, - "trx_unstaking_in_progress": "Unstaking de {{amount}} n curso. El unstaking tarda 14 días.", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "Unstaking de {{amount}} TRX en curso", + "description": "El unstaking tarda 14 días" + }, + "unstaked_banner": { + "title": "Unstaking de {{amount}} TRX finalizado", + "description": "Ya puedes retirar tus TRX sin staking", + "button": "Retirar", + "error": "Retiro fallido" + } }, "stake_eth": "Hacer staking de ETH", "unstake_eth": "Dejar de hacer staking de ETH", @@ -6376,7 +6498,8 @@ "approve": "Aprobar la solicitud", "perps_deposit": "Agregar fondos", "predict_deposit": "Añadir fondos de Predicciones", - "predict_withdraw": "Retirar" + "predict_withdraw": "Retirar", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "Este sitio necesita permiso para gastar sus tokens.", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "{{index}} de transacción", "transaction": "Transacción", "available_balance": "Saldo disponible: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "Continuar", "deposit_edit_amount_done": "Agregar fondos", "deposit_edit_amount_predict_withdraw": "Retirar", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "Las billeteras físicas aún no son compatibles. Usa una billetera en caliente para continuar.", "hardware_wallet_not_supported_solana": "Las billeteras físicas aún no son compatibles con Solana. Usa una billetera en caliente para continuar.", "price_impact_info_title": "Impacto sobre el precio", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "Así es como tu operación modifica el precio de mercado de un token. Depende del volumen de la operación, la liquidez disponible y las tarifas del proveedor. MetaMask no controla el impacto en el precio.", "price_impact_info_gasless_description": "El impacto en el precio refleja cómo tu orden de canje afecta el precio de mercado del activo. Si no tienes suficientes fondos para gas, parte de tu token de origen se asigna automáticamente para cubrir las tarifas, lo que aumenta el impacto en el precio. MetaMask no influye ni controla el impacto en el precio.", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "Debido al volumen de tu operación y a la liquidez disponible, obtendrás aproximadamente {{priceImpact}} menos que el precio de mercado. Esto ya está incluido en tu cotización.", "price_impact_high": "Alto impacto en el precio", "price_impact_execution_description": "Perderás aproximadamente {{priceImpact}} del valor de tu token en este canje. Intenta reducir el monto o elegir una ruta más líquida.", "proceed": "Continuar", @@ -6627,8 +6751,8 @@ "total_cost": "Costo total", "got_it": "Entendido", "price_impact_warning_title": "Alto impacto en el precio", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "Impacto muy alto en el precio", + "price_impact_error_description": "Perderás aproximadamente {{priceImpact}} del precio de mercado de tu token en este canje. Prueba con una operación más pequeña o una ruta con mayor liquidez para mejorar tu tasa." }, "quote_expired_modal": { "title": "Hay nuevas cotizaciones disponibles", @@ -6940,7 +7064,7 @@ "upgrade_title": "Actualiza a Metal", "continue_button": "Continuar", "virtual_card": { - "name": "Virtual Card", + "name": "Tarjeta Virtual", "price": "Gratis", "feature_1": "Tarjeta virtual para Apple Pay y Google Pay", "feature_2": "Paga con criptomonedas (USDC, USDT, WETH y más)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "Tarjeta Metal", "price": "$199/año", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "Todo lo que ofrece la versión virtual, más:", + "feature_1": "Tarjeta metálica grabada premium", + "feature_2": "3 % de cashback en los primeros $10.000/año", "feature_3": "Sin tarifas por transacciones internacionales" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "Gana hasta $300 en cashback al año", + "upgrade_to_metal_label": "O actualiza a Metal para obtener 3 veces más recompensas" }, "review_order": { "title": "Revisa tu orden", @@ -7104,7 +7228,7 @@ "ssn_description": "Requerido por el emisor de la tarjeta. No se realizará ninguna verificación de crédito.", "invalid_ssn": "SSN no válido", "invalid_date_of_birth": "Fecha de nacimiento no válida. Debes tener al menos 18 años", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "El nombre y apellido deben coincidir con tu identidad verificada" }, "physical_address": { "title": "Agrega tu dirección", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "Estás cerca de tu límite de gasto", "description": "Actualiza para evitar rechazos", - "confirm_button_label": "Establecer nuevo límite" + "confirm_button_label": "Establecer nuevo límite", + "dismiss_button_label": "Ignorar" }, "need_delegation": { "title": "Debes habilitar tu tarjeta", @@ -7301,7 +7426,6 @@ "dismiss": "Ignorar", "update_success": "Límite de gasto actualizado correctamente", "update_error": "No se pudo actualizar el límite de gasto", - "solana_not_supported": "Habilita los tokens de Solana en card.metamask.io", "select_token": "Selecciona un token", "loading": "Cargando tokens disponibles...", "load_error": "No se pueden cargar los tokens. Inténtalo de nuevo.", @@ -7343,9 +7467,7 @@ "limited": "Limitado", "not_enabled": "No habilitado", "update_success": "Prioridad de gasto actualizada correctamente", - "update_error": "No se pudo actualizar la prioridad de gasto", - "solana_not_supported_button_title": "Otros tokens en Solana", - "solana_not_supported_button_description": "Habilitar en card.metamask.io" + "update_error": "No se pudo actualizar la prioridad de gasto" }, "card_authentication": { "title": "Inicia sesión en tu cuenta de tarjeta", @@ -7443,6 +7565,11 @@ "title": "Error al registrarte", "description": "Comprueba tu conexión y vuelve a intentarlo." }, + "version_guard": { + "title": "Se requiere una actualización", + "description": "Se requiere una versión más reciente de MetaMask para usar Recompensas. Actualiza para continuar.", + "update_button": "Actualizar MetaMask" + }, "season_error": { "error_fetching_title": "No se pudo cargar la temporada", "error_fetching_description": "Comprueba tu conexión y vuelve a intentarlo.", @@ -7525,7 +7652,6 @@ "main_title": "Recompensas", "referral_title": "Referidos", "tab_overview_title": "Resumen general", - "tab_snapshots_title": "Instantáneas", "tab_activity_title": "Actividad", "referral_stats_earned_from_referrals": "Ganancias por referidos", "referral_stats_referrals": "Recomendados", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "No ganaste recompensas esta temporada, pero siempre habrá una próxima vez.", "verifying_rewards": "Nos aseguramos de que todo sea correcto antes de que reclames tus recompensas." }, + "previous_season_view": { + "title": "Temporada anterior" + }, "season_status": { "points_earned": "Puntos ganados" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "Potenciadores activos", "season_1": "Temporada 1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "Calculadora del bono en mUSD", + "description": "Descubre cuánto podrías ganar al convertir tus monedas estables a mUSD.", + "amount_label": "Monto convertido", + "estimated_bonus": "Bono anualizado estimado: hasta 3 %", + "initial_amount": "Monto inicial", + "daily_bonus": "Bono diario reclamable", + "annualized_bonus": "Bono anualizado", + "disclaimer": "Esto es solo una estimación. El bono está sujeto a cambios.", "buy_button": "Comprar mUSD", - "swap_button": "Swap to mUSD" + "swap_button": "Canjear por mUSD" }, "upcoming_rewards": { "title": "Recompensas bloqueadas", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "No se pudo cargar" }, - "snapshot": { + "campaign": { "starts_date": "Empieza el {{date}}", "ends_date": "Termina el {{date}}", - "results_coming_soon": "Resultados próximamente", - "tokens_on_the_way": "Los tokens están llegando", + "ended_date": "Ended {{date}}", "pill_up_next": "A continuación", - "pill_live_now": "En vivo ahora", - "pill_calculating": "Calculando", - "pill_results_ready": "Resultados listos", - "pill_complete": "Completado" - }, - "snapshots_section": { - "title": "Instantáneas", - "error_title": "No se pueden cargar las instantáneas", - "error_description": "No pudimos cargar las instantáneas. Inténtalo de nuevo.", - "retry_button": "Reintentar" - }, - "snapshots_tab": { + "pill_active": "En vivo", + "pill_complete": "Completado", + "enter_now": "Participa ahora", + "entered": "Ya participas", + "participant_count": "#{{count}}", + "opt_in_cta": "Participar", + "opt_in_sheet_title": "Únete a la campaña", + "opt_in_sheet_description_pre_link": "Al hacer clic en \"Participar\", aceptas las Recompensas de MetaMask", + "opt_in_sheet_link_text": "Términos de uso complementarios y aviso de privacidad", + "opt_in_sheet_description_post_link": "Realizaremos un seguimiento de la actividad en la cadena para recompensarte automáticamente.", + "geo_restriction_banner_title": "No está disponible en tu región", + "geo_restriction_banner_description": "Esta campaña no está disponible en tu región debido a regulaciones locales." + }, + "campaign_mechanics": { + "title": "Mecánica" + }, + "campaign_details": { + "start_date": "Inicia: {{date}}", + "end_date": "Finaliza: {{date}}", + "opt_in": "Participar", + "opting_in": "Activando participación...", + "opted_in": "Ya estás participando en esta campaña", + "opt_in_error": "Error al activar la participación. Inténtalo de nuevo.", + "join_campaign": "Únete a la campaña", + "checking_opt_in_status": "Comprobando estado de participación", + "swap": "Canjear", + "how_it_works": "Cómo funciona" + }, + "campaigns_preview": { + "title": "Campañas", + "coming_soon": "Próximamente", + "notify_me": "Notificarme" + }, + "earn_rewards": { + "title": "Gana recompensas", + "musd_title": "Bono de hasta el 3 % en monedas estables", + "musd_subtitle": "Calcula tu bono en mUSD", + "card_title": "Hasta 3% de cashback", + "card_subtitle": "Obtén tu tarjeta MetaMask ahora", + "card_subtitle_cardholder": "Accede a los beneficios de tu tarjeta MetaMask" + }, + "campaigns_view": { + "title": "Campañas", "active_title": "Activa", "upcoming_title": "Próxima", "previous_title": "Previa", - "empty_state": "No hay instantáneas disponibles", - "error_title": "No se pueden cargar las instantáneas", - "error_description": "No pudimos cargar las instantáneas. Inténtalo de nuevo.", + "empty_state": "No hay campañas disponibles", + "error_title": "No se pueden cargar las campañas", + "error_description": "No pudimos cargar las campañas. Inténtalo de nuevo.", "retry_button": "Reintentar", "refreshing": "Actualizando..." } @@ -7953,13 +8112,12 @@ "continue": "Continuar" }, "connecting": { - "title": "Conecta tu {{device}}", + "title": "Conectando tu {{device}}...", "searching": "Buscando {{device}}...", - "tips_header": "Para continuar, asegúrate de que:", + "tips_header": "Asegúrate de que:", "tip_unlock": "Tu {{device}} esté desbloqueado", "tip_open_app": "La aplicación de Ethereum esté abierta", "tip_enable_bluetooth": "El Bluetooth esté activado", - "tip_dnd_off": "El modo \"No molestar\" esté desactivado", "tip_bluetooth_permission": "Se hayan concedido permisos de ubicación y Bluetooth", "tip_bluetooth_permission_v12": "Se haya concedido permiso para dispositivos cercanos", "tip_stay_close": "Tu dispositivo se mantenga cerca de tu teléfono" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "Se requiere permiso para dispositivos cercanos", "bluetooth_off": "Activa el Bluetooth para conectar tu dispositivo", "bluetooth_scan_failed": "Error al buscar dispositivos. Inténtalo de nuevo", - "bluetooth_connection_failed": "Activa el Bluetooth en tu dispositivo para continuar", + "bluetooth_connection_failed": "Falló la conexión con tu dispositivo. Inténtalo de nuevo", "not_supported": "No se admite esta operación", "unknown_error": "Asegúrate de que tu {{device}} esté configurado con la frase secreta de recuperación o la frase de contraseña de esta cuenta" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "Efectivo", + "cash_empty_description": "Aún no tienes mUSD. Convierte monedas estables a mUSD desde la sección Efectivo en la página de inicio.", + "cash_empty_description_network_filter": "No hay mUSD en esta red. Cambia de red para ver tus mUSD.", "tokens": "Tokens", "perpetuals": "Contratos perpetuos", "predictions": "Predicciones", + "whats_happening": "Qué está pasando", + "whats_happening_categories": { + "geopolitical": "Geopolítico", + "macro": "Macro", + "regulatory": "Regulatorio", + "technical": "Técnico", + "social": "Social", + "other": "Otro" + }, "defi": "DeFi", "nfts": "NFT", "import_nfts": "Importar NFT", diff --git a/locales/languages/fr.json b/locales/languages/fr.json index edebc02df75..4771a1895c8 100644 --- a/locales/languages/fr.json +++ b/locales/languages/fr.json @@ -20,6 +20,12 @@ "update": "Mettre à jour" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "Alerte", @@ -120,8 +126,8 @@ "title": "Envoi d’actifs vers une adresse de destruction" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "Avertissement concernant le contrat de jetons", + "message": "L’adresse du destinataire ne prend peut-être pas en charge les transferts directs de jetons, ce qui pourrait entraîner une perte de fonds. Ne poursuivez que si vous êtes certain que ce contrat est capable de recevoir votre transfert." }, "gas_sponsorship_reserve_balance": { "message": "Le parrainage de gaz n’est pas disponible pour cette transaction. Vous devrez conserver au moins %{minBalance} %{nativeTokenSymbol} sur votre compte.", @@ -694,8 +700,8 @@ "could_not_resolve_name": "Impossible de résoudre le nom", "invalid_address": "Adresse non valide", "contractAddressError": "Vous envoyez des jetons à l’adresse du contrat du jeton. Cela peut entraîner la perte de ces jetons.", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "Adresse du contrat intelligent", + "smart_contract_address_warning": "L’adresse du destinataire ne prend peut-être pas en charge les transferts directs de jetons, ce qui pourrait entraîner une perte de fonds. Ne poursuivez que si vous êtes certain que ce contrat est capable de recevoir votre transfert.", "i_understand": "Je comprends", "cancel": "Annuler" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "Le stop loss doit être {{direction}} au prix {{priceType}}", "stop_loss_beyond_liquidation_error": "Le stop loss doit être {{direction}} au prix de liquidation", "stop_loss_order_view_warning": "Le stop loss est {{direction}} au prix de liquidation", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "supérieur", "below": "inférieur", "done": "Terminé", @@ -2086,14 +2094,15 @@ "a_closer_look": "Regarder de plus près", "whats_being_said": "Ce qu’on en dit", "footer_disclaimer": "Résumé généré par l’IA fourni à titre informatif uniquement", - "trade_button": "Trader", + "swap_button": "Échanger", + "buy_button": "Acheter", "sources_count": "+{{count}} sources", "sources_title": "Sources d’information", "feedback_submitted": "Commentaire soumis", "helpful_prompt": "Cela vous a-t-il été utile ?", "feedback": { "title": "Commentaires", - "description": "Aidez-nous à améliorer nos analyses de marché générées par l’IA.", + "description": "Votre réponse nous aidera à améliorer nos résumés générés par l’IA.", "not_relevant": "Pas pertinent", "not_accurate": "Inexact", "hard_to_understand": "Difficile à comprendre", @@ -2206,7 +2215,7 @@ "available_balance": "Solde disponible", "claim_amount_text": "Réclamer {{amount}} $", "claim_winnings_text": "Réclamer vos gains", - "claiming_text": "Claiming...", + "claiming_text": "En cours de réclamation…", "unrealized_pnl_label": "Profits et pertes non réalisés", "unrealized_pnl_value": "{{amount}} ({{percent}})", "unrealized_pnl_error": "Impossible de charger", @@ -2287,7 +2296,7 @@ "try_again": "Réessayez" }, "in_progress": { - "title": "Claim already in progress" + "title": "Réclamation déjà en cours" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "Frais payés à la bourse ou au marché", "total_incl_fees": "frais inclus", "close": "Fermer", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "Les prix indiqués sont basés sur une exécution entière de l’ordre. Les montants réels peuvent varier si l’ordre n’est que partiellement exécuté.", + "deposit_fee": "Frais de dépôt", + "deposit_fee_description": "Frais facturés pour déposer des fonds sur votre solde de prédiction" }, "error": { "title": "Impossible de se connecter aux marchés de prédiction", @@ -3059,6 +3068,7 @@ "networks_no_results": "Aucun réseau trouvé", "network_name_label": "Nom du réseau", "network_name_placeholder": "Nom du réseau (facultatif)", + "required": "Requis", "network_rpc_url_label": "URL de l’appel de procédure à distance", "network_rpc_name_label": "Nom du RPC", "network_rpc_placeholder": "Nouveau réseau RPC", @@ -3298,6 +3308,8 @@ "blockaid_desc": "Cette fonctionnalité vous avertit de toute activité malveillante en examinant activement les demandes de transaction et de signature.", "security_alerts": "Alertes de sécurité", "security_alerts_desc": "Cette fonctionnalité vous avertit de toute activité malveillante en examinant localement vos demandes de transaction et de signature. Vous devez faire preuve de diligence raisonnable avant d’approuver toute demande. Rien ne garantit que toutes les activités malveillantes seront détectées par cette fonctionnalité. En l’activant, vous acceptez les conditions d’utilisation du fournisseur.", + "smart_account_dapp_requests_heading": "Demandes de comptes intelligents émanant de dapps", + "smart_account_dapp_requests_desc": "Autoriser les dapps à demander des fonctionnalités de compte intelligent pour les comptes standards. Cela n’affectera pas les comptes qui sont déjà des comptes intelligents.", "smart_transactions_opt_in_heading": "Transactions intelligentes", "smart_transactions_opt_in_desc_supported_networks": "Activez les transactions intelligentes pour profiter de transactions plus fiables et plus sûres sur les réseaux pris en charge.", "smart_transactions_learn_more": "En savoir plus", @@ -3566,6 +3578,53 @@ "activity": "Activité du {{symbol}}", "disclaimer": "Les données du marché sont fournies par des sources tierces telles que CoinGecko. Ces données sont fournies à titre d’information uniquement. MetaMask n’est pas responsable de leur exactitude." }, + "security_trust": { + "title": "Sécurité et confiance", + "malicious": "Malveillant", + "risky": "À risque", + "malicious_token_title": "Jeton malveillant", + "malicious_token_description": "{{symbol}} est un jeton malveillant. Évitez toute interaction avec ce jeton ou de le négocier.", + "verified_token_title": "Jeton vérifié", + "verified_token_description": "{{symbol}} fait l’objet d’un trading actif et est largement reconnu. Cette vérification ne constitue pas une recommandation de la part de MetaMask.", + "risky_token_title": "Jeton à risque", + "risky_token_description": "Signaux d’alerte détectés concernant le jeton {{symbol}}. Renseignez-vous soigneusement avant de négocier ce jeton.", + "malicious_token_sheet_description": "De sérieux signaux d’alerte ont été détectés concernant le jeton {{symbol}}. Nous vous recommandons de ne pas négocier ce jeton.", + "got_it": "J’ai compris", + "proceed": "Continuer", + "cancel": "Annuler", + "data_unavailable": "Données de sécurité non disponibles", + "subtitle_known": "Aucun signal d’alerte détecté. Vous devez faire preuve de diligence raisonnable avant de négocier tout actif.", + "subtitle_no_issues": "Aucun signal d’alerte détecté. Vous devez faire preuve de diligence raisonnable avant de négocier tout actif.", + "subtitle_suspicious": "Signaux d’alerte détectés. Examinez attentivement les problèmes signalés avant de négocier cet actif.", + "subtitle_malicious": "De sérieux signaux d’alerte ont été détectés. Nous vous recommandons d’éviter cet actif.", + "subtitle_unavailable": "L’analyse de sécurité n’a pas pu être chargée pour ce jeton.", + "token_distribution": "Répartition des jetons", + "total_supply": "Offre totale", + "top_10_holders": "Les 10 principaux détenteurs", + "other": "Autre", + "no_hidden_fees_detected": "Aucuns frais cachés détectés", + "buy_sell_tax": "Taxe à l’achat/à la vente", + "buy_tax": "Taxe à l’achat", + "sell_tax": "Taxe à la vente", + "transfer": "Transférer", + "token_info": "Informations sur le jeton", + "created": "Créé", + "token_age": "Âge du jeton", + "network": "Réseau", + "type": "Type", + "official_links": "Liens officiels", + "website": "Site web", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "S.O.", + "verified": "Vérifié", + "no_issues": "Aucun problème", + "suspicious": "Suspect", + "malicious_label": "Malveillant", + "more": "plus", + "evaluation_disclaimer": "Cette analyse de sécurité est fournie à titre indicatif uniquement et ne constitue ni une recommandation ni une incitation à négocier ce jeton." + }, "account_details": { "title": "Détails du compte", "share_account": "Partager", @@ -5934,6 +5993,10 @@ "claimable_bonus": "Bonus réclamable", "claim_bonus": "Réclamer le bonus", "claim_bonus_subtitle": "Le bonus sera versé sur {{networkName}}.", + "percentage_bonus_on_linea": "Bonus de {{percentage}} % sur Linea", + "claim": "Réclamer", + "sounds_good": "Ça a l’air intéressant", + "claimable_bonus_tooltip_with_percentage": "Bonus annualisé de {{percentage}} % que vous avez gagné en détenant des mUSD. Vous pouvez réclamer votre bonus quotidiennement sur Linea.", "empty_state_cta": { "heading": "Prêtez des {{tokenSymbol}} et gagnez", "body": "Prêtez vos {{tokenSymbol}} avec {{protocol}} et touchez un revenu", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "Vos stablecoins" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Stake", "earn": "Gagner", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "Vous ne disposez pas d’un solde suffisant pour effectuer cette action." }, - "trx_unstaking_in_progress": "Déstaking de {{amount}} TRX en cours. Le déstaking prend 14 jours.", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "Déstaking de {{amount}} TRX en cours", + "description": "Le déstaking prendra 14 jours" + }, + "unstaked_banner": { + "title": "Déstaking de {{amount}} TRX terminé", + "description": "Vous pouvez désormais retirer vos TRX déstakés", + "button": "Retirer", + "error": "Le retrait a échoué" + } }, "stake_eth": "Staker des ETH", "unstake_eth": "Déstaker des ETH", @@ -6376,7 +6498,8 @@ "approve": "Approuver la demande", "perps_deposit": "Ajouter des fonds", "predict_deposit": "Ajouter des fonds de prédiction", - "predict_withdraw": "Retirer" + "predict_withdraw": "Retirer", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "Ce site demande l’autorisation de dépenser vos jetons.", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "Transaction {{index}}", "transaction": "Protection des", "available_balance": "Solde disponible: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "Continuer", "deposit_edit_amount_done": "Ajouter des fonds", "deposit_edit_amount_predict_withdraw": "Retirer", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "Les portefeuilles matériels ne sont pas encore pris en charge. Utilisez un portefeuille connecté pour continuer.", "hardware_wallet_not_supported_solana": "Les portefeuilles matériels ne sont pas encore pris en charge pour Solana. Utilisez un portefeuille connecté pour continuer.", "price_impact_info_title": "Impact sur les prix", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "Voici comment votre transaction modifie le prix de marché d’un jeton. Cela dépend de la taille de la transaction, de la liquidité disponible et des frais facturés par le fournisseur. MetaMask n’exerce aucun contrôle sur cet impact.", "price_impact_info_gasless_description": "L’impact sur le prix reflète la manière dont votre ordre de swap affecte le prix de l’actif sur le marché. Si vous ne disposez pas de fonds suffisants pour payer les frais de gaz, une partie de votre jeton source est automatiquement allouée pour couvrir ces frais, ce qui augmente l’impact sur le prix. MetaMask n’influence ni ne contrôle l’impact sur le prix.", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "En raison de la taille de votre transaction et de la liquidité disponible, vous obtiendrez environ {{priceImpact}} de moins que le prix de marché. Cette différence de prix est déjà reflétée dans votre devis.", "price_impact_high": "Impact élevé sur le prix", "price_impact_execution_description": "Vous perdrez environ {{priceImpact}} de la valeur de vos jetons lors de cet échange. Essayez de réduire le montant ou de choisir une voie plus liquide.", "proceed": "Continuer", @@ -6627,8 +6751,8 @@ "total_cost": "Coût total", "got_it": "J’ai compris", "price_impact_warning_title": "Impact élevé sur le prix", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "Impact très élevé sur le prix", + "price_impact_error_description": "En effectuant cet échange, vous perdrez environ {{priceImpact}} du prix de marché de votre jeton. Essayez un montant plus petit ou une voie plus liquide pour améliorer votre taux de change." }, "quote_expired_modal": { "title": "De nouvelles cotations sont disponibles", @@ -6940,7 +7064,7 @@ "upgrade_title": "Passer à Metal", "continue_button": "Continuer", "virtual_card": { - "name": "Virtual Card", + "name": "Carte virtuelle", "price": "Gratuit", "feature_1": "Carte virtuelle pour Apple Pay et Google Pay", "feature_2": "Payer avec des cryptomonnaies (USDC, USDT, WETH, etc.)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "Carte Metal", "price": "199 $/an", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "Tout ce qu’offre la carte virtuelle, avec en plus :", + "feature_1": "Une carte métallique gravée haut de gamme", + "feature_2": "3 % de cashback sur les premiers 10 000 $ que vous dépensez par an", "feature_3": "Pas de frais de transaction à l’étranger" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "Gagnez jusqu’à 300 $ de cashback par an", + "upgrade_to_metal_label": "Ou passez à la carte Metal pour tripler vos récompenses" }, "review_order": { "title": "Vérifiez votre commande", @@ -7104,7 +7228,7 @@ "ssn_description": "Requis par l’émetteur de la carte. Aucune vérification de solvabilité ne sera effectuée.", "invalid_ssn": "Numéro de sécurité sociale non valide", "invalid_date_of_birth": "Date de naissance non valide. Vous devez être âgé d’au moins 18 ans", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "Veuillez saisir votre nom et votre prénom tels qu’ils figurent sur la pièce d’identité que vous avez fournie pour la vérification de votre identité" }, "physical_address": { "title": "Ajoutez votre adresse", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "Vous avez presque atteint votre limite de dépenses", "description": "Veuillez modifier votre limite de dépenses pour éviter tout refus de paiement", - "confirm_button_label": "Fixer une nouvelle limite" + "confirm_button_label": "Fixer une nouvelle limite", + "dismiss_button_label": "Ignorer" }, "need_delegation": { "title": "Vous devez activer votre carte", @@ -7301,7 +7426,6 @@ "dismiss": "Ignorer", "update_success": "Limite de dépenses mise à jour avec succès", "update_error": "Échec de la mise à jour de la limite de dépenses", - "solana_not_supported": "Activez les jetons Solana sur card.metamask.io", "select_token": "Sélectionnez un jeton", "loading": "Chargement des jetons disponibles…", "load_error": "Impossible de charger les jetons. Veuillez réessayer.", @@ -7343,9 +7467,7 @@ "limited": "Limité", "not_enabled": "Non activé", "update_success": "Priorité de dépense mise à jour avec succès", - "update_error": "Échec de la mise à jour de la priorité de dépense", - "solana_not_supported_button_title": "Autres jetons sur Solana", - "solana_not_supported_button_description": "Activer sur card.metamask.io" + "update_error": "Échec de la mise à jour de la priorité de dépense" }, "card_authentication": { "title": "Connectez-vous à votre compte Card", @@ -7443,6 +7565,11 @@ "title": "Échec de l’inscription", "description": "Vérifiez votre connexion et réessayez." }, + "version_guard": { + "title": "Mise à jour requise", + "description": "Une version plus récente de MetaMask est nécessaire pour utiliser « Récompenses ». Veuillez effectuer la mise à jour pour continuer.", + "update_button": "Mettre à jour MetaMask" + }, "season_error": { "error_fetching_title": "Impossible de charger les informations sur la période des récompenses", "error_fetching_description": "Vérifiez votre connexion et réessayez.", @@ -7525,7 +7652,6 @@ "main_title": "Récompenses", "referral_title": "Parrainages", "tab_overview_title": "Aperçu", - "tab_snapshots_title": "Snapshots", "tab_activity_title": "Activité", "referral_stats_earned_from_referrals": "Gagnés grâce aux parrainages", "referral_stats_referrals": "Parrainages", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "Vous n’avez pas gagné de récompenses cette saison, mais il y aura toujours une prochaine fois.", "verifying_rewards": "Nous nous assurons que tout est correct avant que vous ne réclamiez vos récompenses." }, + "previous_season_view": { + "title": "Saison précédente" + }, "season_status": { "points_earned": "Points gagnés" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "Boosts actifs", "season_1": "Saison 1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "Calculateur du bonus en mUSD", + "description": "Découvrez combien vous pourriez gagner en convertissant vos stablecoins en mUSD.", + "amount_label": "Montant converti", + "estimated_bonus": "Bonus annualisé estimé : jusqu’à 3 %", + "initial_amount": "Montant initial", + "daily_bonus": "Bonus quotidien pouvant être réclamé", + "annualized_bonus": "Bonus annualisé", + "disclaimer": "Il ne s’agit que d’une estimation. Le montant du bonus peut varier.", "buy_button": "Acheter des mUSD", - "swap_button": "Swap to mUSD" + "swap_button": "Échanger en mUSD" }, "upcoming_rewards": { "title": "Récompenses verrouillées", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "Impossible de charger" }, - "snapshot": { + "campaign": { "starts_date": "Débute le {{date}}", "ends_date": "Se termine le {{date}}", - "results_coming_soon": "Résultats bientôt disponibles", - "tokens_on_the_way": "Jetons en cours d’envoi", + "ended_date": "Ended {{date}}", "pill_up_next": "À venir", - "pill_live_now": "Disponible dès maintenant", - "pill_calculating": "Calcul en cours", - "pill_results_ready": "Résultats disponibles", - "pill_complete": "Terminé" - }, - "snapshots_section": { - "title": "Snapshots", - "error_title": "Impossible de charger les snapshots", - "error_description": "Impossible de charger les snapshots. Veuillez réessayer.", - "retry_button": "Réessayer" - }, - "snapshots_tab": { + "pill_active": "Active", + "pill_complete": "Terminé", + "enter_now": "S’inscrire maintenant", + "entered": "Déjà inscrit", + "participant_count": "n° {{count}}", + "opt_in_cta": "S’inscrire", + "opt_in_sheet_title": "Rejoindre la campagne", + "opt_in_sheet_description_pre_link": "En cliquant sur « S’inscrire », vous acceptez les conditions générales du programme de fidélité « Récompenses MetaMask »", + "opt_in_sheet_link_text": "Conditions d’utilisation supplémentaires et avis de confidentialité de MetaMask Rewards", + "opt_in_sheet_description_post_link": "Nous suivrons votre activité sur la chaîne pour vous récompenser automatiquement.", + "geo_restriction_banner_title": "Non disponible dans votre région", + "geo_restriction_banner_description": "Cette campagne n’est pas disponible dans votre région en raison de la réglementation locale." + }, + "campaign_mechanics": { + "title": "Déroulement" + }, + "campaign_details": { + "start_date": "Débute le : {{date}}", + "end_date": "Se termine le : {{date}}", + "opt_in": "S’inscrire", + "opting_in": "Inscription en cours…", + "opted_in": "Vous êtes inscrit à cette campagne", + "opt_in_error": "Échec de l’inscription. Veuillez réessayer.", + "join_campaign": "Rejoindre la campagne", + "checking_opt_in_status": "Vérification du statut d’inscription", + "swap": "Échanger", + "how_it_works": "Comment ça marche " + }, + "campaigns_preview": { + "title": "Campagnes", + "coming_soon": "Bientôt disponible", + "notify_me": "M’avertir" + }, + "earn_rewards": { + "title": "Gagner des récompenses", + "musd_title": "Jusqu’à 3 % de bonus sur les stablecoins", + "musd_subtitle": "Calculer votre bonus en mUSD", + "card_title": "Jusqu’à 3 % de cashback", + "card_subtitle": "Obtenez votre carte MetaMask Card dès maintenant", + "card_subtitle_cardholder": "Accédez aux avantages de votre carte MetaMask Card" + }, + "campaigns_view": { + "title": "Campagnes", "active_title": "Actif", "upcoming_title": "À venir", "previous_title": "Précédent", - "empty_state": "Aucun snapshot disponible", - "error_title": "Impossible de charger les snapshots", - "error_description": "Impossible de charger les snapshots. Veuillez réessayer.", + "empty_state": "Aucune campagne disponible", + "error_title": "Impossible de charger les campagnes", + "error_description": "Nous n’avons pas pu charger les campagnes. Veuillez réessayer.", "retry_button": "Réessayer", "refreshing": "Actualisation en cours…" } @@ -7953,13 +8112,12 @@ "continue": "Continuer" }, "connecting": { - "title": "Connectez votre {{device}}", + "title": "Connexion de votre {{device}}…", "searching": "Recherche de {{device}}…", - "tips_header": "Pour continuer, assurez-vous que :", + "tips_header": "Assurez-vous que :", "tip_unlock": "Votre {{device}} est déverrouillé", "tip_open_app": "L’application Ethereum est ouverte", "tip_enable_bluetooth": "Le Bluetooth est activé", - "tip_dnd_off": "Le mode « Ne pas déranger » est désactivé", "tip_bluetooth_permission": "Les autorisations de localisation et de connexion Bluetooth sont accordées", "tip_bluetooth_permission_v12": "L’autorisation d’accès aux appareils à proximité est accordée", "tip_stay_close": "Votre appareil reste à proximité de votre téléphone" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "L’autorisation d’accès aux appareils à proximité est requise", "bluetooth_off": "Veuillez activer le Bluetooth pour établir une connexion avec votre appareil", "bluetooth_scan_failed": "Échec de la recherche d’appareils. Veuillez réessayer", - "bluetooth_connection_failed": "Activez le Bluetooth sur votre appareil pour continuer", + "bluetooth_connection_failed": "La connexion à votre appareil a échoué. Veuillez réessayer", "not_supported": "Cette opération n’est pas prise en charge", "unknown_error": "Assurez-vous que votre {{device}} est configuré avec la phrase de récupération secrète ou la phrase secrète de ce compte" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "Liquidités", + "cash_empty_description": "Vous n’avez pas encore de mUSD. Convertissez vos stablecoins en mUSD depuis la section « Liquidités » de la page d’accueil.", + "cash_empty_description_network_filter": "Pas de mUSD sur ce réseau. Changez de réseau pour consulter votre solde de mUSD.", "tokens": "Jetons", "perpetuals": "Contrats perpétuels", "predictions": "Prédictions", + "whats_happening": "Actualités", + "whats_happening_categories": { + "geopolitical": "Géopolitique", + "macro": "Macroéconomie", + "regulatory": "Réglementation", + "technical": "Technique", + "social": "Réseaux sociaux", + "other": "Autre" + }, "defi": "DeFi", "nfts": "NFT", "import_nfts": "Importer des NFT", diff --git a/locales/languages/hi.json b/locales/languages/hi.json index 363fd6273cf..c99f36605fa 100644 --- a/locales/languages/hi.json +++ b/locales/languages/hi.json @@ -20,6 +20,12 @@ "update": "अपडेट करें" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "एलर्ट", @@ -120,8 +126,8 @@ "title": "एसेट्स को बर्न एड्रेस पर भेजना" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "टोकन कॉन्ट्रैक्ट वार्निंग", + "message": "हो सकता है कि पाने वाले का पता सीधे टोकन ट्रांसफर को सपोर्ट न करे, जिससे फंड का नुकसान हो सकता है। तभी आगे बढ़ें जब आपको पक्का हो कि यह कॉन्ट्रैक्ट आपका ट्रांसफर प्राप्त कर सकता है।" }, "gas_sponsorship_reserve_balance": { "message": "इस ट्रांसेक्शन के लिए गैस स्पॉन्सरशिप उपलब्ध नहीं है। आपको अपने अकाउंट में कम से कम %{minBalance} %{nativeTokenSymbol} रखना होगा।", @@ -694,8 +700,8 @@ "could_not_resolve_name": "नाम हल नहीं किया जा सका", "invalid_address": "एड्रेस ग़लत है", "contractAddressError": "आप टोकन को टोकन के कॉन्ट्रैक्ट एड्रेस पर भेज रहे हैं। इससे इन टोकन को खोने की संभावना है।", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "स्मार्ट कॉन्ट्रैक्ट एड्रेस", + "smart_contract_address_warning": "हो सकता है कि पाने वाले का पता सीधे टोकन ट्रांसफर को सपोर्ट न करे, जिससे फंड का नुकसान हो सकता है। तभी आगे बढ़ें जब आपको पक्का हो कि यह कॉन्ट्रैक्ट आपका ट्रांसफर प्राप्त कर सकता है।", "i_understand": "मैं समझता हूं", "cancel": "कैंसिल करें" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "स्टॉप लॉस {{direction}} {{priceType}} प्राइस का होना चाहिए", "stop_loss_beyond_liquidation_error": "स्टॉप लॉस {{direction}} लिक्विडेशन प्राइस का होना चाहिए", "stop_loss_order_view_warning": "स्टॉप लॉस {{direction}} लिक्विडेशन प्राइस का है", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "ऊपर", "below": "नीचे", "done": "पूरा हुआ", @@ -2086,14 +2094,15 @@ "a_closer_look": "एक करीबी निगाह", "whats_being_said": "क्या कहा जा रहा है", "footer_disclaimer": "AI सारांश केवल जानकारी के लिए है", - "trade_button": "ट्रेड करें", + "swap_button": "स्वैप करें", + "buy_button": "खरीदें", "sources_count": "+{{count}} सोर्स", "sources_title": "समाचार सूत्र", "feedback_submitted": "फीडबैक सबमिट किया गया", "helpful_prompt": "क्या यह सहायक था?", "feedback": { "title": "फीडबैक", - "description": "हमारे AI-जनित मार्केट इनसाइट्स को बेहतर बनाने में सहायता करें।", + "description": "आपका जवाब हमारी AI समरी को बेहतर बनाने में मदद करता है।", "not_relevant": "संबंधित नहीं", "not_accurate": "सही नहीं", "hard_to_understand": "समझने में मुश्किल", @@ -2162,7 +2171,7 @@ "sell_position": "सैल पोज़िशन", "cash_out": "कैश आउट करें", "cash_out_info": "फ़ंड आपके उपलब्ध बैलेंस में जोड़े जाएंगे", - "buy_preview_outcome_at_price": "{{outcome}} at {{price}}", + "buy_preview_outcome_at_price": "{{price}} पर {{outcome}}", "at_price_per_share": "{{size}} शेयरों को {{price}} पर बेचा जा रहा है", "cashout_info": "{{initialPrice}} पर {{outcome}} पर {{amount}}", "cashout_info_multiple": "{{outcomeGroupTitle}} पर {{amount}} • {{initialPrice}} पर {{outcome}}", @@ -2206,7 +2215,7 @@ "available_balance": "उपलब्ध बैलेंस", "claim_amount_text": "${{amount}} क्लेम करें", "claim_winnings_text": "जीत का ईनाम क्लेम करें", - "claiming_text": "Claiming...", + "claiming_text": "क्लेम किया जा रहा है...", "unrealized_pnl_label": "अनरियलाइज्ड P&L", "unrealized_pnl_value": "{{amount}} ({{percent}})", "unrealized_pnl_error": "लोड करने में असमर्थ", @@ -2287,7 +2296,7 @@ "try_again": "फिर से प्रयास करें" }, "in_progress": { - "title": "Claim already in progress" + "title": "क्लेम प्रोग्रेस में है" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "एक्सचेंज या मार्केट को दिया गया शुल्क", "total_incl_fees": "शुल्क सहित", "close": "बंद करें", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "दिखाई गई कीमतें यह मानकर चलती हैं कि आपका ऑर्डर पूरी तरह से भरा हुआ है। अगर ऑर्डर थोड़ा भरा हुआ है, तो असल रकम अलग हो सकती है।", + "deposit_fee": "डिपॉज़िट फीस", + "deposit_fee_description": "आपके प्रेडिक्शन बैलेंस में फंड डिपॉज़िट करने के लिए फीस ली जाती है" }, "error": { "title": "प्रीडिक्शंस से कनेक्ट नहीं हो पाया", @@ -3059,6 +3068,7 @@ "networks_no_results": "कोई नेटवर्क नहीं मिला", "network_name_label": "नेटवर्क का नाम", "network_name_placeholder": "नेटवर्क का नाम (वैकल्पिक)", + "required": "जरुरी", "network_rpc_url_label": "RPC URL", "network_rpc_name_label": "RPC का नाम", "network_rpc_placeholder": "नया RPC नेटवर्क", @@ -3298,6 +3308,8 @@ "blockaid_desc": "यह फीचर सक्रिय रूप से ट्रांसेक्शन और सिग्नेचर अनुरोधों की समीक्षा करके आपको बुरी नीयत वाली गतिविधि के प्रति एलर्ट करती है।", "security_alerts": "सुरक्षा चेतावनियाँ", "security_alerts_desc": "यह सुविधा स्थानीय रूप से आपके ट्रांसेक्शन और हस्ताक्षर अनुरोधों की समीक्षा करके आपको बुरी नीयत वाली गतिविधि के प्रति एलर्ट करती है। किसी भी अनुरोध को मंजूरी देने से पहले हमेशा पूरी जांच-पड़ताल ज़रूर करें। इस बात की कोई गारंटी नहीं है कि यह सुविधा सभी बुरी नीयत वाली गतिविधि का पता लगा लेगी। इस सुविधा को सक्षम करके आप प्रदाता की उपयोग की शर्तों से सहमत होते हैं।", + "smart_account_dapp_requests_heading": "dapps से स्मार्ट अकाउंट रिक्वेस्ट", + "smart_account_dapp_requests_desc": "dapps को स्टैंडर्ड अकाउंट के लिए स्मार्ट अकाउंट फ़ीचर रिक्वेस्ट करने दें। इससे उन अकाउंट पर कोई असर नहीं पड़ेगा जो पहले से ही स्मार्ट अकाउंट हैं।", "smart_transactions_opt_in_heading": "स्मार्ट ट्रांसेक्शन", "smart_transactions_opt_in_desc_supported_networks": "समर्थित नेटवर्क पर अधिक विश्वसनीय और सुरक्षित ट्रांसेक्शन के लिए स्मार्ट ट्रांसेक्शन चालू करें।", "smart_transactions_learn_more": "ज़्यादा जानें", @@ -3566,6 +3578,53 @@ "activity": "{{symbol}} गतिविधि", "disclaimer": "मार्केट डेटा CoinGecko जैसे थर्ड-पार्टी सोर्स से मिलता है। डेटा सिर्फ़ जानकारी के लिए है। इसकी सटीकता के लिए MetaMask ज़िम्मेदार नहीं है।" }, + "security_trust": { + "title": "सुरक्षा और भरोसा", + "malicious": "बुरी नीयत वाला", + "risky": "जोखिम से भरा", + "malicious_token_title": "बुरी नीयत वाला टोकन", + "malicious_token_description": "{{symbol}} एक बुरी नीयत वाला टोकन है। इसके साथ इंटरैक्ट करने या इसे ट्रेड करने से बचें।", + "verified_token_title": "सत्यापित टोकन", + "verified_token_description": "{{symbol}} एक्टिवली ट्रेड किया जाता है और यह काफ़ी जाना-माना है। वेरिफ़िकेशन को MetaMask मेटामास्क द्वारा समर्थन नहीं है।", + "risky_token_title": "जोखिम भरा टोकन", + "risky_token_description": "{{symbol}} पर चेतावनी के संकेत मिले हैं। इस टोकन में ट्रेड करने से पहले ध्यान से रिसर्च करें।", + "malicious_token_sheet_description": "{{symbol}} पर गंभीर जोखिम के संकेत मिले हैं। हम इस टोकन में ट्रेड न करने की सलाह देते हैं।", + "got_it": "समझ गए", + "proceed": "आगे बढ़ें", + "cancel": "कैंसिल करें", + "data_unavailable": "सुरक्षा डेटा उपलब्ध नहीं है", + "subtitle_known": "जोखिम का कोई संकेत नहीं मिला। ट्रेड करने से पहले किसी भी एसेट में रिसर्च ज़रूर करें।", + "subtitle_no_issues": "जोखिम का कोई संकेत नहीं मिला। ट्रेड करने से पहले किसी भी एसेट में रिसर्च ज़रूर करें।", + "subtitle_suspicious": "चेतावनी के संकेत मिले हैं। इस एसेट में ट्रेड करने से पहले फ़्लैग किए गए मुद्दों को ध्यान से देखें।", + "subtitle_malicious": "गंभीर जोखिम के संकेत मिले हैं। हम इस एसेट से बचने की सलाह देते हैं।", + "subtitle_unavailable": "इस टोकन के लिए सिक्योरिटी एनालिसिस लोड नहीं किया जा सका।", + "token_distribution": "टोकन डिस्ट्रीब्यूशन", + "total_supply": "कुल आपूर्ति", + "top_10_holders": "टॉप 10 होल्डर", + "other": "अन्य", + "no_hidden_fees_detected": "कोई छिपी हुई फ़ीस नहीं दिखी", + "buy_sell_tax": "टैक्स खरीदें/बेचें", + "buy_tax": "टैक्स खरीदें", + "sell_tax": "टैक्स बेचें", + "transfer": "स्थानांतरण", + "token_info": "टोकन जानकारी", + "created": "बनाया गया", + "token_age": "टोकन की उम्र", + "network": "नेटवर्क", + "type": "यह पुष्टि करने के लिए टाइप करें", + "official_links": "ऑफिशियल लिंक", + "website": "वेबसाइट", + "twitter_x": "ट्विटर", + "telegram": "टेलीग्राम", + "etherscan": "Etherscan", + "na": "लागू नहीं", + "verified": "वेरीफाई किया गया", + "no_issues": "कोई समस्या नहीं", + "suspicious": "संदिग्ध", + "malicious_label": "बुरी नीयत वाला", + "more": "अधिक", + "evaluation_disclaimer": "यह सिक्योरिटी रिव्यू सिर्फ़ इवैल्यूएशन के लिए है और यह ट्रेड के लिए एंडोर्समेंट या रिकमेंडेशन नहीं है।" + }, "account_details": { "title": "अकाउंट का विवरण", "share_account": "साझा करें", @@ -5934,6 +5993,10 @@ "claimable_bonus": "क्लेम करने योग्य बोनस", "claim_bonus": "बोनस क्लेम करें", "claim_bonus_subtitle": "बोनस {{networkName}} पर दिया जाएगा।", + "percentage_bonus_on_linea": "Linea पर {{percentage}}% बोनस", + "claim": "क्लेम करें", + "sounds_good": "सही लगता है", + "claimable_bonus_tooltip_with_percentage": "mUSD होल्ड करने पर आपको मिला {{percentage}}% सालाना बोनस। आपका बोनस Linea पर रोज़ाना क्लेम किया जा सकता है।", "empty_state_cta": { "heading": "{{tokenSymbol}} उधार दें और कमाएं", "body": "{{protocol}} के साथ अपना {{tokenSymbol}} उधार दें और सालाना", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "आपके स्टेबलकॉइन" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Stake", "earn": "कमाएं", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "आपके पास यह काम करने के लिए पर्याप्त रिसोर्स बैलेंस नहीं है।" }, - "trx_unstaking_in_progress": "{{amount}} TRX का अनस्टेकिंग जारी है। अनस्टेकिंग में 14 दिन लगते हैं।", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "{{amount}} TRX की अनस्टेकिंग प्रोग्रेस में है", + "description": "अनस्टेकिंग में 14 दिन लगेंगे" + }, + "unstaked_banner": { + "title": "{{amount}} TRX की अनस्टेकिंग पूरी हुई", + "description": "आपका अनस्टेक्ड TRX अब विदड्रॉ किया जा सकता है", + "button": "निकालें", + "error": "विदड्रॉवल नहीं हो पाया" + } }, "stake_eth": "ETH स्टेक करें", "unstake_eth": "ETH अनस्टेक करें", @@ -6376,7 +6498,8 @@ "approve": "अनुरोध एप्रूव करें", "perps_deposit": "फंड जोड़ें", "predict_deposit": "प्रिडिक्शन फ़ंड जोड़ें", - "predict_withdraw": "निकालें" + "predict_withdraw": "निकालें", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "यह साइट आपके टोकन खर्च करने की अनुमति चाहती है।", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "ट्रांसेक्शन {{index}}", "transaction": "ट्रांसेक्शन", "available_balance": "उपलब्ध बैलेंस: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "जारी रखें", "deposit_edit_amount_done": "फंड जोड़ें", "deposit_edit_amount_predict_withdraw": "निकालें", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "हार्डवेयर वॉलेट अभी तक सपोर्टेड नहीं हैं। जारी रखने के लिए हॉट वॉलेट का उपयोग करें।", "hardware_wallet_not_supported_solana": "Solana के लिए हार्डवेयर वॉलेट अभी तक सपोर्टेड नहीं हैं। जारी रखने के लिए हॉट वॉलेट का उपयोग करें।", "price_impact_info_title": "कीमत का प्रभाव", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "इस तरह आपका ट्रेड किसी टोकन की मार्केट प्राइस बदलता है। यह ट्रेड साइज़, उपलब्ध लिक्विडिटी और प्रोवाइडर फीस पर निर्भर करता है। MetaMask प्राइस इम्पैक्ट को कंट्रोल नहीं करता है।", "price_impact_info_gasless_description": "प्राइस इम्पैक्ट यह दर्शाता है कि आपका स्वैप ऑर्डर एसेट की मार्केट प्राइस को कैसे प्रभावित करता है। अगर आपके पास गैस के लिए पर्याप्त फंड नहीं हैं, तो आपके स्रोत टोकन का एक हिस्सा ऑटोमैटिकली फीस को कवर करने के लिए इस्तेमाल किया जाएगा, जिससे प्राइस इम्पैक्ट बढ़ जाता है। MetaMask प्राइस इम्पैक्ट को प्रभावित या नियंत्रित नहीं करता।", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "आपके ट्रेड साइज़ और उपलब्ध लिक्विडिटी की वजह से, आपको मार्केट प्राइस से लगभग {{priceImpact}} कम मिलेगा। यह आपके कोट में पहले से ही शामिल है।", "price_impact_high": "हाई प्राइस इम्पैक्ट", "price_impact_execution_description": "इस स्वैप में आप अपने टोकन के मूल्य का लगभग {{priceImpact}} खो देंगे। राशि कम करने की कोशिश करें या अधिक लिक्विड रूट चुनें।", "proceed": "आगे बढ़ें", @@ -6627,8 +6751,8 @@ "total_cost": "कुल लागत", "got_it": "समझ गए", "price_impact_warning_title": "हाई प्राइस इम्पैक्ट", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "वेरी हाई प्राइस इम्पैक्ट", + "price_impact_error_description": "इस स्वैप पर आपको अपने टोकन की मार्केट प्राइस का लगभग {{priceImpact}} का नुकसान होगा। अपना रेट बेहतर करने के लिए छोटा ट्रेड या ज़्यादा लिक्विड तरीका आज़माएँ।" }, "quote_expired_modal": { "title": "नये कोटेशन उपलब्ध हैं", @@ -6940,7 +7064,7 @@ "upgrade_title": "मेटल में अपग्रेड करें", "continue_button": "जारी रखें", "virtual_card": { - "name": "Virtual Card", + "name": "वर्चुअल कार्ड", "price": "मुफ्त", "feature_1": "Apple Pay और Google Pay के लिए वर्चुअल कार्ड", "feature_2": "क्रिप्टो से भुगतान करें (USDC, USDT, WETH और अन्य)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "मेटल कार्ड", "price": "$199/साल", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "सब कुछ वर्चुअल, साथ में:", + "feature_1": "प्रीमियम एनग्रेव्ड मेटल कार्ड", + "feature_2": "पहले $10,000/साल पर 3% कैशबैक", "feature_3": "कोई विदेशी ट्रांसेक्शन फीस नहीं" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "सालाना $300 तक कैशबैक कमाएं", + "upgrade_to_metal_label": "या 3x रिवॉर्ड के लिए मेटल में अपग्रेड करें" }, "review_order": { "title": "अपना ऑर्डर समीक्षा करें", @@ -7104,7 +7228,7 @@ "ssn_description": "कार्ड जारी करने वाले के लिए ज़रूरी है। कोई क्रेडिट चेक नहीं किया जाएगा।", "invalid_ssn": "SSN ग़लत है", "invalid_date_of_birth": "जन्मतिथि ग़लत है। आपकी आयु कम से कम 18 वर्ष होनी चाहिए", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "पहला और आखिरी नाम आपकी वेरिफाइड पहचान से मेल खाना चाहिए" }, "physical_address": { "title": "अपना एड्रेस जोड़ें", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "आप अपनी खर्च सीमा के करीब पहुँच चुके हैं", "description": "अस्वीकृति से बचने के लिए अपडेट करें", - "confirm_button_label": "नई सीमा निर्धारित करें" + "confirm_button_label": "नई सीमा निर्धारित करें", + "dismiss_button_label": "खारिज करें" }, "need_delegation": { "title": "आपको अपना कार्ड चालू करना होगा", @@ -7301,7 +7426,6 @@ "dismiss": "खारिज करें", "update_success": "खर्च सीमा सफलतापूर्वक अपडेट की गई", "update_error": "खर्च सीमा अपडेट करना नहीं हो पाया", - "solana_not_supported": "card.metamask.io पर Solana टोकन चालू करें", "select_token": "टोकन चुनें", "loading": "उपलब्ध टोकन लोड हो रहे हैं…", "load_error": "टोकन लोड नहीं हो पाए। कृपया फिर से प्रयास करें।", @@ -7343,9 +7467,7 @@ "limited": "सीमित", "not_enabled": "चालू नहीं किया गया", "update_success": "खर्च प्राथमिकता सफलतापूर्वक अपडेट की गई", - "update_error": "खर्च प्राथमिकता अपडेट करना नहीं हो पाया", - "solana_not_supported_button_title": "Solana पर अन्य टोकन", - "solana_not_supported_button_description": "card.metamask.io पर चालू करें" + "update_error": "खर्च प्राथमिकता अपडेट करना नहीं हो पाया" }, "card_authentication": { "title": "अपने कार्ड अकाउंट में लॉगिन करें", @@ -7443,6 +7565,11 @@ "title": "ऑप्ट-इन नहीं हो पाया", "description": "अपना कनेक्शन जांचें और फिर से प्रयास करें।" }, + "version_guard": { + "title": "अपडेट ज़रूरी है", + "description": "रिवॉर्ड्स इस्तेमाल करने के लिए MetaMask का नया वर्शन ज़रूरी है। जारी रखने के लिए कृपया अपडेट करें।", + "update_button": "MetaMask को अपडेट करें" + }, "season_error": { "error_fetching_title": "सीज़न लोड नहीं हो पाया", "error_fetching_description": "अपना कनेक्शन जांचें और फिर से प्रयास करें।", @@ -7525,7 +7652,6 @@ "main_title": "पुरस्कार", "referral_title": "रेफरल", "tab_overview_title": "ओवरव्यू", - "tab_snapshots_title": "स्नैपशॉट्स", "tab_activity_title": "गतिविधि", "referral_stats_earned_from_referrals": "रेफरल से अर्जित", "referral_stats_referrals": "रेफरल", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "इस सीज़न में भले ही आपको रिवॉर्ड नहीं मिले, लेकिन अगली बार मिल भी सकते हैं।", "verifying_rewards": "इससे पहले कि आप रिवॉर्ड क्लेम करें, हम पुष्टि कर रहे हैं कि सब कुछ सही है।" }, + "previous_season_view": { + "title": "पिछला सत्र" + }, "season_status": { "points_earned": "पॉइंट्स कमाए" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "सक्रिय बूस्ट्स", "season_1": "सीज़न 1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "mUSD बोनस कैलकुलेटर", + "description": "देखें कि आप अपने स्टेबलकॉइन को mUSD में बदलकर कितना कमा सकते हैं।", + "amount_label": "कन्वर्ट किया गया अमाउंट", + "estimated_bonus": "अनुमानित सालाना बोनस: 3% तक", + "initial_amount": "शुरुआती अमाउंट", + "daily_bonus": "रोज़ाना क्लेम किया जाने वाला बोनस", + "annualized_bonus": "सालाना बोनस", + "disclaimer": "यह सिर्फ़ एक अनुमान है। बोनस बदल सकता है।", "buy_button": "mUSD खरीदें", - "swap_button": "Swap to mUSD" + "swap_button": "mUSD पर स्वैप करें" }, "upcoming_rewards": { "title": "लॉक किए हुए रिवॉर्ड्स", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "लोड नहीं हो सका" }, - "snapshot": { + "campaign": { "starts_date": "{{date}} को शुरू होता है", "ends_date": "{{date}} को समाप्त होता है", - "results_coming_soon": "रिज़ल्ट जल्द ही आ रहे हैं", - "tokens_on_the_way": "टोकन आने वाले हैं", + "ended_date": "Ended {{date}}", "pill_up_next": "आगे आने वाला है", - "pill_live_now": "अब लाइव है", - "pill_calculating": "गणना की जा रही है", - "pill_results_ready": "रिज़ल्ट तैयार हैं", - "pill_complete": "पूरा" - }, - "snapshots_section": { - "title": "स्नैपशॉट्स", - "error_title": "स्नैपशॉट्स लोड नहीं हो पा रहे हैं", - "error_description": "हम स्नैपशॉट्स लोड नहीं कर पाए। कृपया फिर से कोशिश करें।", - "retry_button": "फिर से प्रयास करें" - }, - "snapshots_tab": { + "pill_active": "लाइव", + "pill_complete": "पूरा", + "enter_now": "अभी एंटर करें", + "entered": "एंटर किया", + "participant_count": "#{{count}}", + "opt_in_cta": "ऑप्ट इन करें", + "opt_in_sheet_title": "कैंपेन जॉइन करें", + "opt_in_sheet_description_pre_link": "'ऑप्ट इन' पर क्लिक करके आप MetaMask रिवॉर्ड्स से सहमत होते हैं", + "opt_in_sheet_link_text": "के पूरक उपयोग की शर्तों और गोपनीयता नोटिस से सहमत होते हैं", + "opt_in_sheet_description_post_link": "हम आपको ऑटोमैटिकली रिवॉर्ड देने के लिए ऑनचेन एक्टिविटी को ट्रैक करेंगे।", + "geo_restriction_banner_title": "आपके इलाके में उपलब्ध नहीं है", + "geo_restriction_banner_description": "लोकल नियमों के कारण यह कैंपेन आपके इलाके में उपलब्ध नहीं है।" + }, + "campaign_mechanics": { + "title": "मैकेनिक्स" + }, + "campaign_details": { + "start_date": "इस दिन शुरू होता है: {{date}}", + "end_date": "इस दिन समाप्त होता है: {{date}}", + "opt_in": "ऑप्ट इन करें", + "opting_in": "ऑप्ट इन किया जा रहा है...", + "opted_in": "आपने इस कैंपेन में ऑप्ट इन किया है", + "opt_in_error": "ऑप्ट इन करना नहीं हो पाया। कृपया फिर से प्रयास करें।", + "join_campaign": "कैंपेन जॉइन करें", + "checking_opt_in_status": "ऑप्ट इन स्टेटस चेक किया जा रहा है", + "swap": "स्वैप करें", + "how_it_works": "ये कैसे काम करता है" + }, + "campaigns_preview": { + "title": "कैंपेन", + "coming_soon": "जल्द आ रहा है", + "notify_me": "मुझे सूचित करें" + }, + "earn_rewards": { + "title": "रिवॉर्ड्स कमाएं", + "musd_title": "स्टेबल्स पर 3% तक बोनस", + "musd_subtitle": "अपना mUSD बोनस कैलकुलेट करें", + "card_title": "3% तक कैश बैक", + "card_subtitle": "अपना MetaMask कार्ड अभी पाएं", + "card_subtitle_cardholder": "अपने MetaMask कार्ड के फ़ायदे पाएं" + }, + "campaigns_view": { + "title": "कैंपेन", "active_title": "एक्टिव", "upcoming_title": "आगे आने वाला है", "previous_title": "पिछला", - "empty_state": "कोई स्नैपशॉट उपलब्ध नहीं है", - "error_title": "स्नैपशॉट्स लोड नहीं हो पा रहे हैं", - "error_description": "हम स्नैपशॉट्स लोड नहीं कर पाए। कृपया फिर से कोशिश करें।", + "empty_state": "कोई कैंपेन उपलब्ध नहीं है", + "error_title": "कैंपेन लोड नहीं हो पा रहे हैं", + "error_description": "हम कैंपेन लोड नहीं कर पाए। कृपया फिर से कोशिश करें।", "retry_button": "फिर से प्रयास करें", "refreshing": "रिफ्रेश हो रहा है..." } @@ -7953,13 +8112,12 @@ "continue": "जारी रखें" }, "connecting": { - "title": "अपना {{device}} कनेक्ट करें", + "title": "आपका {{device}} कनेक्ट हो रहा है...", "searching": "{{device}} खोज रहे हैं…", - "tips_header": "आगे बढ़ने के लिए, सुनिश्चित करें:", + "tips_header": "यह ज़रूर करें:", "tip_unlock": "आपका {{device}} अनलॉक है", "tip_open_app": "Ethereum ऐप खुला है", "tip_enable_bluetooth": "ब्लूटूथ चालू है", - "tip_dnd_off": "डू नॉट डिस्टर्ब बंद है", "tip_bluetooth_permission": "लोकेशन और ब्लूटूथ की अनुमति दी गई है", "tip_bluetooth_permission_v12": "नज़दीकी डिवाइस की अनुमति दी गई है", "tip_stay_close": "आपका डिवाइस आपके फोन के पास रहता है" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "नज़दीकी डिवाइस की अनुमति आवश्यक है", "bluetooth_off": "कृपया अपने डिवाइस से कनेक्ट करने के लिए ब्लूटूथ चालू करें", "bluetooth_scan_failed": "डिवाइस स्कैन नहीं हो पाया। कृपया दोबारा प्रयास करें", - "bluetooth_connection_failed": "जारी रखने के लिए अपने डिवाइस पर ब्लूटूथ चालू करें", + "bluetooth_connection_failed": "आपके डिवाइस से कनेक्शन नहीं हो पाया। कृपया फिर से कोशिश करें", "not_supported": "यह ऑपरेशन सपोर्टेड नहीं है", "unknown_error": "सुनिश्चित करें कि आपका {{device}} इस अकाउंट के लिए सीक्रेट रिकवरी फ्रेज़ या पासफ़्रेज़ के साथ सेटअप किया गया है" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "कैश", + "cash_empty_description": "आपके पास अभी तक कोई mUSD नहीं है। होमपेज पर कैश सेक्शन से स्टेबलकॉइन को mUSD में कन्वर्ट करें।", + "cash_empty_description_network_filter": "इस नेटवर्क पर कोई mUSD नहीं है। अपना mUSD देखने के लिए नेटवर्क बदलें।", "tokens": "टोकन", "perpetuals": "परपेचुअल्स", "predictions": "प्रेडिक्शंस", + "whats_happening": "क्या हो रहा है", + "whats_happening_categories": { + "geopolitical": "जियोपॉलिटिकल", + "macro": "मैक्रो", + "regulatory": "रेगुलेटरी", + "technical": "टेक्निकल", + "social": "सोशल", + "other": "अन्य" + }, "defi": "DeFi", "nfts": "NFTs", "import_nfts": "NFTs इंपोर्ट करें", diff --git a/locales/languages/id.json b/locales/languages/id.json index 7aae4c37b0b..0acfe4afd19 100644 --- a/locales/languages/id.json +++ b/locales/languages/id.json @@ -20,6 +20,12 @@ "update": "Perbarui" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "Peringatan", @@ -120,8 +126,8 @@ "title": "Mengirim aset ke alamat burn" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "Peringatan kontrak token", + "message": "Alamat penerima mungkin tidak mendukung transfer token langsung, yang dapat mengakibatkan hilangnya dana. Lanjutkan hanya jika Anda yakin kontrak ini dapat menerima transfer." }, "gas_sponsorship_reserve_balance": { "message": "Sponsor gas tidak tersedia untuk transaksi ini. Anda perlu mempertahankan saldo minimal %{minBalance} %{nativeTokenSymbol} di akun Anda.", @@ -694,8 +700,8 @@ "could_not_resolve_name": "Tidak dapat menyelesaikan nama", "invalid_address": "Alamat tidak valid", "contractAddressError": "Anda mengirimkan token ke alamat kontrak token. Hal ini dapat mengakibatkan hilangnya token tersebut.", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "Alamat kontrak cerdas", + "smart_contract_address_warning": "Alamat penerima mungkin tidak mendukung transfer token langsung, yang dapat mengakibatkan hilangnya dana. Lanjutkan hanya jika Anda yakin kontrak ini dapat menerima transfer.", "i_understand": "Saya mengerti", "cancel": "Batal" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "Stop loss harus dilakukan pada harga {{direction}} {{priceType}}", "stop_loss_beyond_liquidation_error": "Stop loss harus dilakukan pada harga likuidasi {{direction}}", "stop_loss_order_view_warning": "Stop loss merupakan harga likuidasi {{direction}}", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "di atas", "below": "di bawah", "done": "Selesai", @@ -2086,14 +2094,15 @@ "a_closer_look": "Melihat lebih dekat", "whats_being_said": "Yang sedang dibicarakan", "footer_disclaimer": "Ringkasan AI hanya untuk informasi", - "trade_button": "Berdagang", + "swap_button": "Swap", + "buy_button": "Beli", "sources_count": "+{{count}} sumber", "sources_title": "Sumber berita", "feedback_submitted": "Umpan balik telah dikirim", "helpful_prompt": "Apakah ini membantu?", "feedback": { "title": "Umpan balik", - "description": "Bantu tingkatkan wawasan pasar yang dihasilkan AI.", + "description": "Jawaban Anda membantu meningkatkan ringkasan AI kami.", "not_relevant": "Tidak relevan", "not_accurate": "Tidak akurat", "hard_to_understand": "Sulit dipahami", @@ -2206,7 +2215,7 @@ "available_balance": "Saldo tersedia", "claim_amount_text": "Klaim ${{amount}}", "claim_winnings_text": "Klaim kemenangan", - "claiming_text": "Claiming...", + "claiming_text": "Mengklaim...", "unrealized_pnl_label": "P&L Belum Terealisasi", "unrealized_pnl_value": "{{amount}} ({{percent}})", "unrealized_pnl_error": "Tidak dapat memuat", @@ -2287,7 +2296,7 @@ "try_again": "Coba lagi" }, "in_progress": { - "title": "Claim already in progress" + "title": "Klaim sedang diproses" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "Biaya yang dibayarkan ke bursa atau pasar", "total_incl_fees": "termasuk biaya", "close": "Tutup", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "Harga yang tertera diasumsikan order Anda telah terpenuhi sepenuhnya. Jumlah aktual dapat bervariasi jika order hanya terpenuhi sebagian.", + "deposit_fee": "Biaya deposit", + "deposit_fee_description": "Biaya yang dikenakan untuk mendeposit dana ke saldo prediksi Anda" }, "error": { "title": "Tidak dapat terhubung ke prediksi", @@ -3059,6 +3068,7 @@ "networks_no_results": "Jaringan tidak ditemukan", "network_name_label": "Nama jaringan", "network_name_placeholder": "Nama jaringan (opsional)", + "required": "Diperlukan", "network_rpc_url_label": "URL RPC", "network_rpc_name_label": "Nama RPC", "network_rpc_placeholder": "Jaringan RPC baru", @@ -3298,6 +3308,8 @@ "blockaid_desc": "Fitur ini memperingatkan Anda tentang aktivitas jahat dengan meninjau permintaan transaksi dan tanda tangan secara aktif.", "security_alerts": "Peringatan keamanan", "security_alerts_desc": "Fitur ini memperingatkan Anda tentang aktivitas berbahaya dengan meninjau permintaan transaksi dan tanda tangan secara lokal. Selalu lakukan uji tuntas sendiri sebelum menyetujui permintaan apa pun. Tidak ada jaminan bahwa fitur ini akan mendeteksi semua aktivitas berbahaya. Dengan mengaktifkan fitur ini, Anda menyetujui persyaratan penggunaan penyedia.", + "smart_account_dapp_requests_heading": "Permintaan akun cerdas dari dapp", + "smart_account_dapp_requests_desc": "Izinkan dapp meminta fitur akun cerdas untuk akun standar. Ini tidak akan memengaruhi akun yang sudah menjadi akun cerdas.", "smart_transactions_opt_in_heading": "Transaksi Pintar", "smart_transactions_opt_in_desc_supported_networks": "Aktifkan Transaksi Pintar untuk transaksi yang lebih andal dan aman pada jaringan yang didukung.", "smart_transactions_learn_more": "Pelajari selengkapnya", @@ -3566,6 +3578,53 @@ "activity": "Aktivitas {{symbol}}", "disclaimer": "Data pasar disediakan oleh sumber pihak ketiga seperti CoinGecko. Data hanya untuk tujuan informasi. MetaMask tidak bertanggung jawab atas akurasinya." }, + "security_trust": { + "title": "Keamanan dan kepercayaan", + "malicious": "Berbahaya", + "risky": "Berisiko", + "malicious_token_title": "Token berbahaya", + "malicious_token_description": "{{symbol}} adalah token berbahaya. Hindari berinteraksi dengan token ini atau memperdagangkannya.", + "verified_token_title": "Token terverifikasi", + "verified_token_description": "{{symbol}} diperdagangkan secara aktif dan dikenal luas. Verifikasi bukanlah bentuk dukungan dari MetaMask.", + "risky_token_title": "Token berisiko", + "risky_token_description": "Sinyal peringatan terdeteksi pada {{symbol}}. Lakukan riset dengan cermat sebelum memperdagangkan token ini.", + "malicious_token_sheet_description": "Sinyal risiko serius terdeteksi pada {{symbol}}. Sebaiknya jangan memperdagangkan token ini.", + "got_it": "Mengerti", + "proceed": "Lanjutkan", + "cancel": "Batalkan", + "data_unavailable": "Data keamanan tidak tersedia", + "subtitle_known": "Sinyal risiko tidak terdeteksi. Selalu lakukan riset terhadap aset apa pun sebelum melakukan perdagangan.", + "subtitle_no_issues": "Sinyal risiko tidak terdeteksi. Selalu lakukan riset terhadap aset apa pun sebelum melakukan perdagangan.", + "subtitle_suspicious": "Sinyal peringatan terdeteksi. Tinjau masalah yang ditandai dengan cermat sebelum memperdagangkan aset ini.", + "subtitle_malicious": "Sinyal risiko serius terdeteksi. Sebaiknya hindari aset ini.", + "subtitle_unavailable": "Analisis keamanan tidak dapat dimuat untuk token ini.", + "token_distribution": "Distribusi token", + "total_supply": "Total suplai", + "top_10_holders": "Top 10 pemilik", + "other": "Lainnya", + "no_hidden_fees_detected": "Tidak ditemukan biaya tersembunyi", + "buy_sell_tax": "Pajak Jual/Beli", + "buy_tax": "Pajak pembelian", + "sell_tax": "Pajak penjualan", + "transfer": "Transfer", + "token_info": "Informasi Token", + "created": "Dibuat", + "token_age": "Usia token", + "network": "Jaringan", + "type": "Jenis", + "official_links": "Tautan Resmi", + "website": "Situs web", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "T/A", + "verified": "Terverifikasi", + "no_issues": "Tidak ada masalah", + "suspicious": "Mencurigakan", + "malicious_label": "Berbahaya", + "more": "lainnya", + "evaluation_disclaimer": "Tinjauan keamanan ini hanya untuk tujuan evaluasi dan bukan merupakan dukungan atau rekomendasi untuk melakukan perdagangan." + }, "account_details": { "title": "Detail akun", "share_account": "Bagikan", @@ -5934,6 +5993,10 @@ "claimable_bonus": "Bonus yang dapat diklaim", "claim_bonus": "Klaim bonus", "claim_bonus_subtitle": "Bonus akan dibayarkan melalui {{networkName}}.", + "percentage_bonus_on_linea": "Bonus {{percentage}}% di Linea", + "claim": "Klaim", + "sounds_good": "Kedengarannya bagus", + "claimable_bonus_tooltip_with_percentage": "Bonus tahunan sebesar {{percentage}}% yang Anda peroleh karena memiliki mUSD. Bonus dapat diklaim setiap hari di Linea.", "empty_state_cta": { "heading": "Pinjamkan {{tokenSymbol}} dan hasilkan", "body": "Pinjamkan {{tokenSymbol}} Anda dengan {{protocol}} dan dapatkan", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "Stablecoin Anda" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Stake", "earn": "Dapatkan", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "Anda tidak memiliki cukup saldo sumber daya untuk melakukan tindakan ini." }, - "trx_unstaking_in_progress": "Proses pembatalan stake {{amount}} TRX sedang berlangsung. Proses ini membutuhkan waktu 14 hari.", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "Pembatalan stake {{amount}} TRX sedang berlangsung", + "description": "Proses pembatalan stake akan memakan waktu 14 hari" + }, + "unstaked_banner": { + "title": "Pembatalan stake {{amount}} TRX selesai", + "description": "TRX yang batal di-stake kini dapat ditarik", + "button": "Tarik", + "error": "Penarikan gagal" + } }, "stake_eth": "Stake ETH", "unstake_eth": "Batalkan stake ETH", @@ -6376,7 +6498,8 @@ "approve": "Setujui permintaan", "perps_deposit": "Tambahkan dana", "predict_deposit": "Tambahkan dana Prediction", - "predict_withdraw": "Tarik" + "predict_withdraw": "Tarik", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "Situs ini meminta izin untuk menggunakan token Anda.", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "Transaksi {{index}}", "transaction": "Transaksi", "available_balance": "Saldo tersedia: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "Lanjutkan", "deposit_edit_amount_done": "Tambahkan dana", "deposit_edit_amount_predict_withdraw": "Tarik", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "Dompet perangkat keras belum didukung. Gunakan hot wallet untuk melanjutkan.", "hardware_wallet_not_supported_solana": "Dompet perangkat keras belum didukung untuk Solana. Gunakan hot wallet untuk melanjutkan.", "price_impact_info_title": "Dampak harga", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "Beginilah cara perdagangan Anda mengubah harga pasar suatu token. Hal ini bergantung pada ukuran perdagangan, likuiditas yang tersedia, dan biaya penyedia. MetaMask tidak mengontrol dampak harga tersebut.", "price_impact_info_gasless_description": "Dampak harga mencerminkan bagaimana perintah swap Anda memengaruhi harga pasar aset. Jika Anda tidak memiliki cukup dana untuk gas, sebagian token sumber akan dialokasikan secara otomatis untuk menutupi biaya, yang meningkatkan dampak harga. MetaMask tidak memengaruhi atau mengendalikan dampak harga.", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "Karena ukuran perdagangan Anda dan likuiditas yang tersedia, Anda akan mendapatkan sekitar {{priceImpact}} lebih rendah dari harga pasar. Hal ini sudah diperhitungkan dalam kuotasi Anda.", "price_impact_high": "Dampak harga tinggi", "price_impact_execution_description": "Anda akan kehilangan sekitar {{priceImpact}} dari nilai token Anda pada swap ini. Cobalah untuk mengurangi jumlahnya atau pilih rute yang lebih likuid.", "proceed": "Lanjutkan", @@ -6627,8 +6751,8 @@ "total_cost": "Biaya Total", "got_it": "Mengerti", "price_impact_warning_title": "Dampak harga tinggi", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "Dampak harga sangat tinggi", + "price_impact_error_description": "Anda akan kehilangan sekitar {{priceImpact}} dari harga pasar token Anda pada swap ini. Cobalah perdagangan yang lebih kecil atau rute yang lebih likuid untuk meningkatkan tarif Anda." }, "quote_expired_modal": { "title": "Kuotasi baru tersedia", @@ -6940,7 +7064,7 @@ "upgrade_title": "Upgrade ke Logam", "continue_button": "Lanjutkan", "virtual_card": { - "name": "Virtual Card", + "name": "Kartu Virtual", "price": "Gratis", "feature_1": "Kartu virtual untuk Apple Pay dan Google Pay", "feature_2": "Bayar dengan kripto (USDC, USDT, WETH, dan lainnya)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "Kartu Logam", "price": "$199/tahun", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "Semuanya serba virtual, ditambah:", + "feature_1": "Kartu logam berukir premium", + "feature_2": "Cashback 3% untuk $10.000 pertama/tahun", "feature_3": "Tidak ada biaya transaksi luar negeri" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "Dapatkan cashback hingga $300 setiap tahunnya", + "upgrade_to_metal_label": "Atau tingkatkan ke Metal untuk mendapatkan reward 3x" }, "review_order": { "title": "Tinjau order", @@ -7104,7 +7228,7 @@ "ssn_description": "Diperlukan oleh penerbit kartu. Tidak akan dilakukan pengecekan kredit.", "invalid_ssn": "NJS tidak valid", "invalid_date_of_birth": "Tanggal lahir tidak valid. Anda harus berusia minimal 18 tahun", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "Nama depan dan nama belakang harus sesuai dengan identitas Anda yang telah diverifikasi" }, "physical_address": { "title": "Tambahkan alamat Anda", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "Batas penggunaan hampir tercapai", "description": "Perbarui untuk menghindari penurunan", - "confirm_button_label": "Atur batas baru" + "confirm_button_label": "Atur batas baru", + "dismiss_button_label": "Lewatkan" }, "need_delegation": { "title": "Anda perlu mengaktifkan kartu", @@ -7301,7 +7426,6 @@ "dismiss": "Lewatkan", "update_success": "Batas penggunaan berhasil diperbarui", "update_error": "Gagal memperbarui batas penggunaan", - "solana_not_supported": "Aktifkan token Solana di card.metamask.io", "select_token": "Pilih token", "loading": "Memuat token yang tersedia...", "load_error": "Tidak dapat memuat token. coba lagi.", @@ -7343,9 +7467,7 @@ "limited": "Terbatas", "not_enabled": "Tidak diaktifkan", "update_success": "Prioritas penggunaan berhasil diperbarui", - "update_error": "Gagal memperbarui prioritas penggunaan", - "solana_not_supported_button_title": "Token lain di Solana", - "solana_not_supported_button_description": "Aktifkan di card.metamask.io" + "update_error": "Gagal memperbarui prioritas penggunaan" }, "card_authentication": { "title": "Masuk ke akun kartu Anda", @@ -7443,6 +7565,11 @@ "title": "Gagal berpartisipasi", "description": "Periksa koneksi Anda dan coba lagi." }, + "version_guard": { + "title": "Pembaruan diperlukan", + "description": "Versi MetaMask yang lebih baru diperlukan untuk menggunakan Reward. Perbarui untuk melanjutkan.", + "update_button": "Perbarui MetaMask" + }, "season_error": { "error_fetching_title": "Musim tidak dapat dimuat", "error_fetching_description": "Periksa koneksi Anda dan coba lagi.", @@ -7525,7 +7652,6 @@ "main_title": "Reward", "referral_title": "Rujukan", "tab_overview_title": "Ikhtisar", - "tab_snapshots_title": "Snapshot", "tab_activity_title": "Aktivitas", "referral_stats_earned_from_referrals": "Diperoleh dari rujukan", "referral_stats_referrals": "Rujukan", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "Anda tidak mendapatkan reward musim ini, tetapi selalu ada kesempatan lain.", "verifying_rewards": "Kami memastikan semuanya benar sebelum Anda mengklaim reward." }, + "previous_season_view": { + "title": "Musim Sebelumnya" + }, "season_status": { "points_earned": "Poin yang diperoleh" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "Peningkatan aktif", "season_1": "Musim 1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "kalkulator bonus mUSD", + "description": "Lihat berapa banyak yang bisa diperoleh dengan mengonversi stablecoin Anda menjadi mUSD.", + "amount_label": "Jumlah yang dikonversi", + "estimated_bonus": "Estimasi bonus tahunan: hingga 3%", + "initial_amount": "Jumlah awal", + "daily_bonus": "Bonus yang dapat diklaim setiap hari", + "annualized_bonus": "Bonus tahunan", + "disclaimer": "Ini hanya estimasi. Bonus dapat berubah sewaktu-waktu.", "buy_button": "Beli mUSD", - "swap_button": "Swap to mUSD" + "swap_button": "Swap ke mUSD" }, "upcoming_rewards": { "title": "Reward terkunci", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "Tidak dapat memuat" }, - "snapshot": { + "campaign": { "starts_date": "Mulai {{date}}", "ends_date": "Berakhir {{date}}", - "results_coming_soon": "Hasil akan segera diumumkan", - "tokens_on_the_way": "Token sedang dalam perjalanan", + "ended_date": "Ended {{date}}", "pill_up_next": "Selanjutnya", - "pill_live_now": "Sedang live", - "pill_calculating": "Menghitung", - "pill_results_ready": "Hasil Sudah Siap", - "pill_complete": "Selesaikan" - }, - "snapshots_section": { - "title": "Snapshot", - "error_title": "Tidak dapat memuat snapshot", - "error_description": "Kami tidak dapat memuat snapshot. Coba lagi.", - "retry_button": "Coba lagi" - }, - "snapshots_tab": { + "pill_active": "Langsung", + "pill_complete": "Selesaikan", + "enter_now": "Masuk sekarang", + "entered": "Masuk", + "participant_count": "#{{count}}", + "opt_in_cta": "Ikut serta", + "opt_in_sheet_title": "Gabung dalam kampanye ini", + "opt_in_sheet_description_pre_link": "Dengan mengklik 'Ikut serta', Anda menyetujui program Reward MetaMask", + "opt_in_sheet_link_text": "Ketentuan Penggunaan dan Pemberitahuan Privasi Tambahan", + "opt_in_sheet_description_post_link": "Kami akan melacak aktivitas onchain untuk memberikan reward secara otomatis.", + "geo_restriction_banner_title": "Tidak tersedia di wilayah Anda", + "geo_restriction_banner_description": "Kampanye ini tidak tersedia di wilayah Anda karena peraturan setempat." + }, + "campaign_mechanics": { + "title": "Mekanika" + }, + "campaign_details": { + "start_date": "Mulai: {{date}}", + "end_date": "Selesai: {{date}}", + "opt_in": "Ikut serta", + "opting_in": "Sedang ikut serta...", + "opted_in": "Anda telah memilih untuk ikut serta dalam kampanye ini", + "opt_in_error": "Gagal ikut serta. Coba lagi.", + "join_campaign": "Gabung dalam kampanye ini", + "checking_opt_in_status": "Memeriksa status keikutsertaan", + "swap": "Swap", + "how_it_works": "Cara kerjanya" + }, + "campaigns_preview": { + "title": "Kampanye", + "coming_soon": "Segera hadir", + "notify_me": "Beri tahu saya" + }, + "earn_rewards": { + "title": "Dapatkan reward", + "musd_title": "Bonus stabil hingga 3%", + "musd_subtitle": "Hitung bonus mUSD Anda", + "card_title": "Cashback hingga 3%", + "card_subtitle": "Dapatkan Kartu MetaMask sekarang", + "card_subtitle_cardholder": "Manfaatkan keuntungan Kartu MetaMask" + }, + "campaigns_view": { + "title": "Kampanye", "active_title": "Aktif", "upcoming_title": "Mendatang", "previous_title": "Sebelumnya", - "empty_state": "Snapshot tidak tersedia", - "error_title": "Tidak dapat memuat snapshot", - "error_description": "Kami tidak dapat memuat snapshot. Coba lagi.", + "empty_state": "Kampanye tidak tersedia", + "error_title": "Tidak dapat memuat kampanye", + "error_description": "Kami tidak dapat memuat kampanye. Coba lagi.", "retry_button": "Coba lagi", "refreshing": "Menyegarkan..." } @@ -7953,13 +8112,12 @@ "continue": "Lanjutkan" }, "connecting": { - "title": "Hubungkan {{device}} Anda", + "title": "Menghubungkan {{device}} Anda...", "searching": "Mencari {{device}}...", - "tips_header": "Untuk melanjutkan, pastikan:", + "tips_header": "Pastikan:", "tip_unlock": "{{device}} Anda tidak terkunci", "tip_open_app": "Aplikasi Ethereum sudah dibuka", "tip_enable_bluetooth": "Bluetooth diaktifkan", - "tip_dnd_off": "Jangan Ganggu dinonaktifkan", "tip_bluetooth_permission": "Izin lokasi dan Bluetooth telah diberikan", "tip_bluetooth_permission_v12": "Izin perangkat terdekat telah diberikan", "tip_stay_close": "Perangkat Anda tetap berada di dekat ponsel Anda" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "Izin perangkat terdekat diperlukan", "bluetooth_off": "Aktifkan Bluetooth untuk terhubung ke perangkat Anda", "bluetooth_scan_failed": "Gagal memindai perangkat. Coba lagi", - "bluetooth_connection_failed": "Aktifkan Bluetooth di perangkat Anda untuk melanjutkan", + "bluetooth_connection_failed": "Koneksi ke perangkat Anda gagal. Coba lagi", "not_supported": "Operasi ini tidak didukung", "unknown_error": "Pastikan {{device}} Anda telah diatur dengan Frasa Pemulihan Rahasia atau passphrase untuk akun ini" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "Uang tunai", + "cash_empty_description": "Anda belum memiliki mUSD. Konversikan stablecoin ke mUSD dari bagian Uang Tunai di halaman beranda.", + "cash_empty_description_network_filter": "Tidak ada mUSD di jaringan ini. Ganti jaringan untuk melihat mUSD milik Anda.", "tokens": "Token", "perpetuals": "Abadi", "predictions": "Prediksi", + "whats_happening": "Apa yang sedang terjadi", + "whats_happening_categories": { + "geopolitical": "Geopolitik", + "macro": "Makro", + "regulatory": "Peraturan", + "technical": "Teknis", + "social": "Sosial", + "other": "Lainnya" + }, "defi": "DeFi", "nfts": "NFT", "import_nfts": "Impor NFT", diff --git a/locales/languages/ja.json b/locales/languages/ja.json index 32d9a9028f7..fdb8b7268e2 100644 --- a/locales/languages/ja.json +++ b/locales/languages/ja.json @@ -20,6 +20,12 @@ "update": "更新" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "アラート", @@ -120,8 +126,8 @@ "title": "バーンアドレスに資産を送ろうとしています" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "トークンコントラクトに関する警告", + "message": "この受取人のアドレスはトークンの直接送金に対応していない可能性があり、資金を失うおそれがあります。これが送金を受け取れるコントラクトであることを確信している場合のみ、続行してください。" }, "gas_sponsorship_reserve_balance": { "message": "この取引でガススポンサーシップはご利用いただけません。アカウントに%{minBalance} %{nativeTokenSymbol}以上の残高が必要です。", @@ -694,8 +700,8 @@ "could_not_resolve_name": "名前の解決ができませんでした", "invalid_address": "無効なアドレス", "contractAddressError": "トークンのコントラクトアドレスにトークンを送金しようとしています。これにより、当該トークンが失われる可能性があります。", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "スマートコントラクトアドレス", + "smart_contract_address_warning": "この受取人のアドレスはトークンの直接送金に対応していない可能性があり、資金を失うおそれがあります。これが送金を受け取れるコントラクトであることを確信している場合のみ、続行してください。", "i_understand": "理解しています", "cancel": "キャンセル" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "ストップロスは{{priceType}}価格よりも{{direction}}に設定する必要があります", "stop_loss_beyond_liquidation_error": "ストップロスは清算価格よりも{{direction}}に設定する必要があります", "stop_loss_order_view_warning": "ストップロスが清算価格よりも{{direction}}に設定されています", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "上", "below": "下", "done": "完了", @@ -2086,14 +2094,15 @@ "a_closer_look": "詳細", "whats_being_said": "市場の声", "footer_disclaimer": "AIによる要約は参考用です", - "trade_button": "取引", + "swap_button": "スワップ", + "buy_button": "購入", "sources_count": "他{{count}}件のソース", "sources_title": "ニュースソース", "feedback_submitted": "フィードバックが送信されました", "helpful_prompt": "この情報は役に立ちましたか?", "feedback": { "title": "フィードバック", - "description": "AIが生成した市場分析情報の改善にご協力ください。", + "description": "皆様からの回答は、当社のAIサマリーの改善に役立ちます。", "not_relevant": "関連性が低い", "not_accurate": "正確でない", "hard_to_understand": "わかりにくい", @@ -2162,7 +2171,7 @@ "sell_position": "ポジションを売却", "cash_out": "キャッシュアウト", "cash_out_info": "資金は利用可能残高に追加されます", - "buy_preview_outcome_at_price": "{{outcome}} at {{price}}", + "buy_preview_outcome_at_price": "{{price}}で{{outcome}}", "at_price_per_share": "{{size}}株を{{price}}で売却中", "cashout_info": "{{outcome}}を{{initialPrice}}で{{amount}}", "cashout_info_multiple": "{{outcomeGroupTitle}} • {{outcome}}を{{initialPrice}}で{{amount}}", @@ -2206,7 +2215,7 @@ "available_balance": "利用可能残高", "claim_amount_text": "請求額 ${{amount}}", "claim_winnings_text": "報酬を請求", - "claiming_text": "Claiming...", + "claiming_text": "請求中...", "unrealized_pnl_label": "含み損益", "unrealized_pnl_value": "{{amount}} ({{percent}})", "unrealized_pnl_error": "読み込めません", @@ -2287,7 +2296,7 @@ "try_again": "再試行してください" }, "in_progress": { - "title": "Claim already in progress" + "title": "請求処理はすでに進行中です" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "取引所または市場に支払われる手数料", "total_incl_fees": "手数料込", "close": "閉じる", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "表示価格は、ご注文が完全に約定した場合の金額です。ご注文の一部のみが約定した場合は、実際の金額が異なることがあります。", + "deposit_fee": "デポジット手数料", + "deposit_fee_description": "予測残高に入金する際に発生する手数料" }, "error": { "title": "予想に接続できません", @@ -3059,6 +3068,7 @@ "networks_no_results": "ネットワークが見つかりません", "network_name_label": "ネットワーク名", "network_name_placeholder": "ネットワーク名 (オプション)", + "required": "必須", "network_rpc_url_label": "RPC URL", "network_rpc_name_label": "RPC名", "network_rpc_placeholder": "新しいRPCネットワーク", @@ -3298,6 +3308,8 @@ "blockaid_desc": "この機能は、トランザクションと署名要求を能動的に確認し、悪質なアクティビティに関するアラートを発します。", "security_alerts": "セキュリティアラート", "security_alerts_desc": "この機能は、トランザクションと署名要求をローカルで確認することで、悪質な行為に関するアラートを発します。要求を承認する前に、必ず独自のデューデリジェンスを行ってください。この機能がすべての悪質な行為を検出するという保証はありません。この機能を有効にすることで、プロバイダーの利用規約に同意したものとみなされます。", + "smart_account_dapp_requests_heading": "DAppからのスマートアカウントリクエスト", + "smart_account_dapp_requests_desc": "DAppによる、スタンダードアカウント用のスマートアカウント機能のリクエストを許可します。これにより、すでにスマートアカウントのアカウントに影響はありません。", "smart_transactions_opt_in_heading": "スマートトランザクション", "smart_transactions_opt_in_desc_supported_networks": "スマートトランザクションをオンにして、サポートされているネットワーク上でのトランザクションの信頼性と安全性を高めましょう。", "smart_transactions_learn_more": "詳細", @@ -3566,6 +3578,53 @@ "activity": "{{symbol}}のアクティビティ", "disclaimer": "市場データは、CoinGeckoなどのサードパーティソースから提供されます。データは情報目的のみです。MetaMaskはこのデータの正確性に責任を持ちません。" }, + "security_trust": { + "title": "セキュリティと信頼", + "malicious": "悪意のある", + "risky": "リスクのある", + "malicious_token_title": "悪質なトークン", + "malicious_token_description": "{{symbol}}は悪意のあるトークンです。やり取りや取引は避けてください。", + "verified_token_title": "確認済みのトークン", + "verified_token_description": "{{symbol}}はアクティブに取引され、広く認識されています。検証はMetaMaskによる承認ではありません。", + "risky_token_title": "リスクのあるトークン", + "risky_token_description": "{{symbol}}で警告のシグナルが検出されました。このトークンを取引する前に、慎重に調査してください。", + "malicious_token_sheet_description": "{{symbol}}で深刻なリスクシグナルが検出されました。このトークンは取引しないことをお勧めします。", + "got_it": "了解", + "proceed": "先に進む", + "cancel": "キャンセル", + "data_unavailable": "セキュリティデータが利用できません", + "subtitle_known": "リスクシグナルは検出されませんでした。どの資産でも取引前に必ず調査してください。", + "subtitle_no_issues": "リスクシグナルは検出されませんでした。どの資産でも取引前に必ず調査してください。", + "subtitle_suspicious": "警告のシグナルが検出されました。この資産を取引する前に、フラグの付いた問題を慎重に確認してください。", + "subtitle_malicious": "深刻なリスクシグナルが検出されました。この資産は避けることをお勧めします。", + "subtitle_unavailable": "このトークンのセキュリティ分析情報を読み込めませんでした。", + "token_distribution": "トークンの流通", + "total_supply": "合計供給量", + "top_10_holders": "トップ10のトークン", + "other": "その他", + "no_hidden_fees_detected": "隠れた手数料は検出されませんでした", + "buy_sell_tax": "購入・売却税", + "buy_tax": "購入税", + "sell_tax": "売却税", + "transfer": "送金", + "token_info": "トークン情報", + "created": "作成されました", + "token_age": "トークンの年齢", + "network": "ネットワーク", + "type": "タイプ", + "official_links": "公式リンク", + "website": "Webサイト", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "該当なし", + "verified": "検証済み", + "no_issues": "問題なし", + "suspicious": "不審", + "malicious_label": "悪意のある", + "more": "さらに表示", + "evaluation_disclaimer": "このセキュリティレビューは評価目的のみのものであり、取引の承認または推奨とみなされるものではありません。" + }, "account_details": { "title": "アカウント情報", "share_account": "共有", @@ -5934,6 +5993,10 @@ "claimable_bonus": "獲得できるボーナス", "claim_bonus": "ボーナスを請求する", "claim_bonus_subtitle": "ボーナスは{{networkName}}上で支払われます。", + "percentage_bonus_on_linea": "Lineaでの{{percentage}}%ボーナス", + "claim": "請求", + "sounds_good": "いいですね", + "claimable_bonus_tooltip_with_percentage": "mUSDを保有することで獲得した年換算{{percentage}}%のボーナスです。ボーナスはLineaで毎日請求できます。", "empty_state_cta": { "heading": "{{tokenSymbol}}を貸して収益化", "body": "{{protocol}}で{{tokenSymbol}}を貸し付けて、", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "保有中のステーブルコイン" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Stake", "earn": "獲得", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "この操作を行うのに十分なリソース残高がありません。" }, - "trx_unstaking_in_progress": "{{amount}}TRXのステーキング解除を実行中です。ステーキング解除には14日かかります。", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "{{amount}} TRXのステーキングを解除しています", + "description": "ステーキングの解除には14日間かかります" + }, + "unstaked_banner": { + "title": "{{amount}} TRXのステーキングが解除されました", + "description": "ステーキングを解除したTRXが出金可能になりました", + "button": "出金", + "error": "出金失敗" + } }, "stake_eth": "ETHをステーキング", "unstake_eth": "ETHのステーキングを解除", @@ -6376,7 +6498,8 @@ "approve": "要求の承認", "perps_deposit": "資金を追加", "predict_deposit": "予測資金を追加", - "predict_withdraw": "出金" + "predict_withdraw": "出金", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "このサイトがトークンの使用許可を求めています。", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "トランザクション {{index}}", "transaction": "トランザクション", "available_balance": "利用可能残高: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "続行", "deposit_edit_amount_done": "資金を追加", "deposit_edit_amount_predict_withdraw": "出金", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "まだハードウェアウォレットに対応していません。続行するにはホットウォレットをご使用ください。", "hardware_wallet_not_supported_solana": "Solanaはまだハードウェアウォレットに対応していません。続行するにはホットウォレットをご使用ください。", "price_impact_info_title": "プライスインパクト", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "これは、取引によってトークンの市場価格がどのように変動するかを示しています。取引量、利用可能な流動性、プロバイダー手数料によって異なります。MetaMaskは価格への影響を制御することはできません。", "price_impact_info_gasless_description": "プライスインパクトは、スワップ注文がその資産の市場価格にどのように影響するかを反映します。ガス代の支払いに十分な資金を保有していない場合、交換前のトークンの一部が自動的に手数料の支払いに充当され、プライスインパクトが増大します。MetaMaskがプライスインパクトに影響を与えたりコントロールしたりすることはありません。", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "取引規模と利用可能な流動性により、市場価格よりも約{{priceImpact}}安くなります。これはすでに提示価格に反映されています。", "price_impact_high": "高プライスインパクト", "price_impact_execution_description": "このスワップにより、トークンの価値の約{{priceImpact}}が失われます。金額を下げるか、より流動性の高いルートを選択してください。", "proceed": "先に進む", @@ -6627,8 +6751,8 @@ "total_cost": "総コスト", "got_it": "了解", "price_impact_warning_title": "高プライスインパクト", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "非常に高いプライスインパクト", + "price_impact_error_description": "このスワップでは、トークンの市場価格の約{{priceImpact}}を失います。より良いレートを得るには、取引量を減らすか、流動性の高いルートをお試しください。" }, "quote_expired_modal": { "title": "新しい価格が利用可能です", @@ -6940,7 +7064,7 @@ "upgrade_title": "メタルにアップグレード", "continue_button": "続行", "virtual_card": { - "name": "Virtual Card", + "name": "バーチャルカード", "price": "無料", "feature_1": "Apple PayとGoogle Payで使えるバーチャルカード", "feature_2": "仮想通貨でお支払い (USDC、USDT、WETHなど)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "メタルカード", "price": "年間199ドル", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "すべてバーチャルで、さらに:", + "feature_1": "プレミアム刻印入りメタルカード", + "feature_2": "年間最初の$10,000に対して3%のキャッシュバック", "feature_3": "海外トランザクション手数料なし" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "年間最大$300のキャッシュバック獲得", + "upgrade_to_metal_label": "または、メタルカードにアップグレードして3倍の報酬を獲得" }, "review_order": { "title": "ご注文内容の確認", @@ -7104,7 +7228,7 @@ "ssn_description": "カード発行者により求められています。信用調査は行われません。", "invalid_ssn": "無効な社会保障番号です", "invalid_date_of_birth": "生年月日が無効です。18歳以上である必要があります。", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "氏名は本人確認済みの情報と一致している必要があります" }, "physical_address": { "title": "住所を追加", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "利用限度額に近づいています", "description": "支払い拒否を防ぐために更新してください", - "confirm_button_label": "新しい限度額を設定" + "confirm_button_label": "新しい限度額を設定", + "dismiss_button_label": "閉じる" }, "need_delegation": { "title": "カードを有効にする必要があります", @@ -7301,7 +7426,6 @@ "dismiss": "閉じる", "update_success": "使用上限が更新されました", "update_error": "使用上限の更新に失敗しました", - "solana_not_supported": "card.metamask.ioでSolanaトークンを有効にする", "select_token": "トークンを選択", "loading": "ご利用可能なトークンを読み込み中...", "load_error": "トークンを読み込めません。もう一度お試しください。", @@ -7343,9 +7467,7 @@ "limited": "制限付き", "not_enabled": "有効になっていません", "update_success": "使用優先順位が更新されました", - "update_error": "使用優先順位の更新に失敗しました", - "solana_not_supported_button_title": "Solanaの他のトークン", - "solana_not_supported_button_description": "card.metamask.ioで有効にします" + "update_error": "使用優先順位の更新に失敗しました" }, "card_authentication": { "title": "カードアカウントへのログイン", @@ -7443,6 +7565,11 @@ "title": "オプトインに失敗しました", "description": "接続を確認して、もう一度お試しください。" }, + "version_guard": { + "title": "アップデートが必要です", + "description": "Rewardsの利用には、MetaMaskの新しいバージョンが必要です。続けるにはアップデートしてください。", + "update_button": "MetaMaskをアップデート" + }, "season_error": { "error_fetching_title": "シーズンを読み込めませんでした", "error_fetching_description": "接続を確認して、もう一度お試しください。", @@ -7525,7 +7652,6 @@ "main_title": "報酬", "referral_title": "紹介", "tab_overview_title": "概要", - "tab_snapshots_title": "スナップショット", "tab_activity_title": "アクティビティ", "referral_stats_earned_from_referrals": "紹介して報酬を獲得", "referral_stats_referrals": "紹介", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "このセッションではリワードを獲得できませんでしたが、また次があります。", "verifying_rewards": "リワードを獲得する前に、情報がすべて正しいことを確認しています。" }, + "previous_season_view": { + "title": "以前のセッション" + }, "season_status": { "points_earned": "ポイント獲得" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "有効なブースト", "season_1": "シーズン1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "mUSDボーナス計算ツール", + "description": "ステーブルコインをmUSDに交換することで、どれだけの利益が得られるか確認しましょう。", + "amount_label": "変換金額", + "estimated_bonus": "推定年率ボーナス:最大3%", + "initial_amount": "初期金額", + "daily_bonus": "1日あたりに獲得可能なボーナス", + "annualized_bonus": "年率ボーナス", + "disclaimer": "これはあくまで目安です。ボーナスは変更される場合があります。", "buy_button": "mUSDを購入", - "swap_button": "Swap to mUSD" + "swap_button": "mUSDにスワップ" }, "upcoming_rewards": { "title": "ロックされているリワード", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "読み込ませんでした" }, - "snapshot": { + "campaign": { "starts_date": "{{date}}開始", "ends_date": "{{date}}終了", - "results_coming_soon": "間もなく結果が出ます", - "tokens_on_the_way": "トークンを送金中です", + "ended_date": "Ended {{date}}", "pill_up_next": "次", - "pill_live_now": "現在進行中", - "pill_calculating": "計算中", - "pill_results_ready": "結果が出ました", - "pill_complete": "完了" - }, - "snapshots_section": { - "title": "スナップショット", - "error_title": "スナップショットを読み込めません", - "error_description": "スナップショットを読み込めませんでした。もう一度お試しください。", - "retry_button": "再試行" - }, - "snapshots_tab": { + "pill_active": "ライブ", + "pill_complete": "完了", + "enter_now": "今すぐ応募", + "entered": "応募しました", + "participant_count": "#{{count}}", + "opt_in_cta": "オプトイン", + "opt_in_sheet_title": "キャンペーンに参加しましょう", + "opt_in_sheet_description_pre_link": "「オプトイン」をクリックすることで、MetaMask Rewardsに同意したものとみなされます", + "opt_in_sheet_link_text": "補足利用規約およびプライバシー通知", + "opt_in_sheet_description_post_link": "オンチェーンアクティビティを自動的に追跡し、リワードを付与します。", + "geo_restriction_banner_title": "お客様の地域では利用できません", + "geo_restriction_banner_description": "現地の規制により、お住いの地域ではこのキャンペーンにご参加いただけません。" + }, + "campaign_mechanics": { + "title": "仕組み" + }, + "campaign_details": { + "start_date": "開始: {{date}}", + "end_date": "終了: {{date}}", + "opt_in": "オプトイン", + "opting_in": "オプトインしています...", + "opted_in": "このキャンペーンにオプトインしました", + "opt_in_error": "オプトインに失敗しました。もう一度お試しください。", + "join_campaign": "キャンペーンに参加", + "checking_opt_in_status": "オプトインステータスを確認しています", + "swap": "スワップ", + "how_it_works": "報酬獲得の仕組み" + }, + "campaigns_preview": { + "title": "キャンペーン", + "coming_soon": "近日追加予定", + "notify_me": "通知を受ける" + }, + "earn_rewards": { + "title": "報酬を獲得しましょう", + "musd_title": "ステーブルで最大3%のボーナス", + "musd_subtitle": "mUSDボーナスの計算", + "card_title": "最大3%のキャッシュバック", + "card_subtitle": "今すぐMetaMaskカードを取得", + "card_subtitle_cardholder": "MetaMaskカードの特典を利用" + }, + "campaigns_view": { + "title": "キャンペーン", "active_title": "アクティブ", "upcoming_title": "今後", "previous_title": "以前", - "empty_state": "利用可能なスナップショットがありません", - "error_title": "スナップショットを読み込めません", - "error_description": "スナップショットを読み込めませんでした。もう一度お試しください。", + "empty_state": "参加できるキャンペーンがありません", + "error_title": "キャンペーンを読み込めません", + "error_description": "キャンペーンを読み込めませんでした。もう一度お試しください。", "retry_button": "再試行", "refreshing": "更新中..." } @@ -7953,13 +8112,12 @@ "continue": "続行" }, "connecting": { - "title": "{{device}}の接続", + "title": "{{device}}を接続しています...", "searching": "{{device}}を検索中...", - "tips_header": "続行するには、以下の点をご確認ください。", + "tips_header": "次の点を確認してください:", "tip_unlock": "{{device}}のロックが解除されている", "tip_open_app": "イーサリアムアプリが開いている", "tip_enable_bluetooth": "Bluetoothがオンになっている", - "tip_dnd_off": "サイレントモードがオフになっている", "tip_bluetooth_permission": "位置情報とBluetoothへのアクセス許可が付与されている", "tip_bluetooth_permission_v12": "付近のデバイスへのアクセス許可が付与されている", "tip_stay_close": "デバイスがスマートフォンの近くにある" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "付近のデバイスへのアクセス許可が必要です", "bluetooth_off": "デバイスに接続するには、Bluetoothをオンにしてください", "bluetooth_scan_failed": "デバイスを検索できませんでした。もう一度お試しください。", - "bluetooth_connection_failed": "続行するには、デバイスでBluetoothを有効にしてください", + "bluetooth_connection_failed": "デバイスへの接続に失敗しました。もう一度お試しください", "not_supported": "この操作はサポートされていません。", "unknown_error": "{{device}}がこのアカウント用のシークレットリカバリーフレーズまたはパスフレーズを使ってセットアップされていることを確認してください。" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "現金", + "cash_empty_description": "まだmUSDをお持ちではありません。ホームページの「現金」セクションからステーブルコインをmUSDに変換してください。", + "cash_empty_description_network_filter": "このネットワークにはmUSDがありません。ネットワークを切り替えてお持ちのmUSDをご確認ください。", "tokens": "トークン", "perpetuals": "パーペチュアル", "predictions": "予測", + "whats_happening": "現在起きていること", + "whats_happening_categories": { + "geopolitical": "地政学", + "macro": "マクロ", + "regulatory": "規制", + "technical": "技術", + "social": "社会", + "other": "その他" + }, "defi": "DeFi", "nfts": "NFT", "import_nfts": "NFTをインポート", diff --git a/locales/languages/ko.json b/locales/languages/ko.json index 6d484d0a381..cd541c1ed55 100644 --- a/locales/languages/ko.json +++ b/locales/languages/ko.json @@ -20,6 +20,12 @@ "update": "업데이트" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "경고", @@ -120,8 +126,8 @@ "title": "소각 주소로 자산 전송" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "토큰 계약 경고", + "message": "수신자 주소가 직접 토큰 전송을 지원하지 않을 수 있습니다. 이 경우 자금이 손실될 수 있습니다. 이 계약이 전송되는 토큰을 받을 수 있다는 확신이 있을 때만 계속하세요." }, "gas_sponsorship_reserve_balance": { "message": "이 트랜잭션에는 가스 후원이 제공되지 않습니다. 계정에 최소 %{minBalance}의 %{nativeTokenSymbol} 토큰을 보유해야 합니다.", @@ -694,8 +700,8 @@ "could_not_resolve_name": "이름을 확인할 수 없습니다", "invalid_address": "잘못된 주소", "contractAddressError": "토큰의 계약 주소로 토큰을 보내고 있습니다. 이로 인해 해당 토큰이 손실될 수 있습니다.", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "스마트 계약 주소", + "smart_contract_address_warning": "수신자 주소가 직접 토큰 전송을 지원하지 않을 수 있습니다. 이 경우 자금이 손실될 수 있습니다. 이 계약이 전송되는 토큰을 받을 수 있다는 확신이 있을 때만 계속하세요.", "i_understand": "견적은 다음 기간 전에 만료됨을", "cancel": "취소" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "손절은 {{direction}} {{priceType}} 가격이어야 합니다", "stop_loss_beyond_liquidation_error": "손절은 {{direction}} 청산 가격이어야 합니다", "stop_loss_order_view_warning": "손절이 {{direction}} 청산 가격입니다", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": " 이상", "below": " 미만", "done": "완료", @@ -2086,14 +2094,15 @@ "a_closer_look": "자세히 보기", "whats_being_said": "시장 반응", "footer_disclaimer": "참고용 AI 요약", - "trade_button": "거래하기", + "swap_button": "스와프", + "buy_button": "매수", "sources_count": "출처 {{count}}개 이상", "sources_title": "뉴스 출처", "feedback_submitted": "피드백 제출됨", "helpful_prompt": "도움이 되셨나요?", "feedback": { "title": "피드백", - "description": "AI 기반 시장 인사이트를 개선할 수 있도록 도와주세요.", + "description": "귀하의 답변은 AI 요약 개선에 도움이 됩니다.", "not_relevant": "관련 없음", "not_accurate": "정확하지 않음", "hard_to_understand": "이해하기 어려움", @@ -2162,7 +2171,7 @@ "sell_position": "포지션 매도", "cash_out": "출금", "cash_out_info": "자금은 사용 가능한 잔액에 추가됩니다", - "buy_preview_outcome_at_price": "{{outcome}} at {{price}}", + "buy_preview_outcome_at_price": "{{price}}에 {{outcome}}", "at_price_per_share": "{{price}}에 {{size}}주 매도", "cashout_info": "{{outcome}}에 {{amount}}(단가: {{initialPrice}})", "cashout_info_multiple": "{{outcomeGroupTitle}} - {{outcome}}에 {{amount}}(단가:{{initialPrice}}", @@ -2206,7 +2215,7 @@ "available_balance": "사용 가능한 잔액", "claim_amount_text": "${{amount}} 수령", "claim_winnings_text": "수익금 수령", - "claiming_text": "Claiming...", + "claiming_text": "청구 중...", "unrealized_pnl_label": "미실현 손익", "unrealized_pnl_value": "{{amount}}({{percent}})", "unrealized_pnl_error": "불러올 수 없습니다", @@ -2287,7 +2296,7 @@ "try_again": "다시 시도" }, "in_progress": { - "title": "Claim already in progress" + "title": "청구가 이미 진행 중입니다" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "거래소 또는 시장에 지불한 수수료", "total_incl_fees": "수수료 포함", "close": "닫기", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "표시된 가격은 주문이 전량 체결된다고 가정한 값입니다. 주문이 일부만 체결되면 실제 수량은 달라질 수 있습니다.", + "deposit_fee": "예치 수수료", + "deposit_fee_description": "예측 잔액에 자금을 예치할 때 부과되는 수수료" }, "error": { "title": "예측에 연결할 수 없습니다", @@ -3059,6 +3068,7 @@ "networks_no_results": "네트워크를 찾을 수 없습니다", "network_name_label": "네트워크 이름", "network_name_placeholder": "네트워크 이름(옵션)", + "required": "필수", "network_rpc_url_label": "RPC URL", "network_rpc_name_label": "RPC 이름", "network_rpc_placeholder": "신규 RPC 네트워크", @@ -3298,6 +3308,8 @@ "blockaid_desc": "이 기능은 트랜잭션과 서명 요청을 적극적으로 검토하여 악의적인 활동을 경고합니다.", "security_alerts": "보안 경고", "security_alerts_desc": "이 기능은 거래 및 서명 요청을 로컬에서 검토하여 악의적인 활동이 있는 경우 경고합니다. 요청을 승인하기 전에 항상 직접 검토하세요. 이 기능이 모든 악성 활동 탐지를 보장하지는 않습니다. 이 기능을 활성화하면 제공 업체의 이용 약관에 동의하는 것이 됩니다.", + "smart_account_dapp_requests_heading": "디앱의 스마트 계정 요청", + "smart_account_dapp_requests_desc": "디앱이 일반 계정에 스마트 계정 기능을 요청하도록 허용합니다. 이미 스마트 계정인 계정에는 영향을 미치지 않습니다.", "smart_transactions_opt_in_heading": "스마트 트랜잭션", "smart_transactions_opt_in_desc_supported_networks": "지원되는 네트워크에서 더 안정적이고 안전하게 트랜잭션을 진행하려면 스마트 트랜잭션을 활성화하세요.", "smart_transactions_learn_more": "더 보기", @@ -3566,6 +3578,53 @@ "activity": "{{symbol}} 활동", "disclaimer": "시장 데이터는 CoinGecko와 같은 제3자 제공업체에서 제공합니다. 해당 데이터는 정보 제공 목적이며, MetaMask는 정확성을 보장하지 않습니다." }, + "security_trust": { + "title": "보안 및 신뢰", + "malicious": "악성", + "risky": "위험", + "malicious_token_title": "악성 토큰", + "malicious_token_description": "{{symbol}}은(는) 악성 토큰입니다. 이 토큰과 상호작용하거나 거래하지 마세요.", + "verified_token_title": "검증된 토큰", + "verified_token_description": "{{symbol}}은(는) 활발하게 거래되고 있으며 널리 알려져 있습니다. 검증은 MetaMask의 보증을 의미하지 않습니다.", + "risky_token_title": "위험한 토큰", + "risky_token_description": "{{symbol}}에 대해 주의가 필요한 신호가 감지되었습니다. 이 토큰을 거래하기 전에 충분히 조사하세요.", + "malicious_token_sheet_description": "{{symbol}}에 대해 심각한 위험 신호가 감지되었습니다. 이 토큰은 거래하지 않는 것이 좋습니다.", + "got_it": "컨펌", + "proceed": "진행", + "cancel": "취소", + "data_unavailable": "보안 데이터 없음", + "subtitle_known": "위험 신호가 감지되지 않았습니다. 거래하기 전에 항상 자산을 조사하세요.", + "subtitle_no_issues": "위험 신호가 감지되지 않았습니다. 거래하기 전에 항상 자산을 조사하세요.", + "subtitle_suspicious": "주의가 필요한 신호가 감지되었습니다. 이 자산을 거래하기 전에 표시된 문제를 주의 깊게 검토하세요.", + "subtitle_malicious": "심각한 위험 신호가 감지되었습니다. 이 자산은 피하는 것이 좋습니다.", + "subtitle_unavailable": "이 토큰의 보안 분석을 불러올 수 없습니다.", + "token_distribution": "토큰 분포", + "total_supply": "총 공급", + "top_10_holders": "상위 보유자 10인", + "other": "기타", + "no_hidden_fees_detected": "숨은 수수료 감지되지 않음", + "buy_sell_tax": "매수/매도 세금", + "buy_tax": "매수세", + "sell_tax": "매도세", + "transfer": "송금", + "token_info": "토큰 정보", + "created": "생성됨", + "token_age": "토큰 생성 후 경과 시간", + "network": "네트워크", + "type": "유형", + "official_links": "공식 링크", + "website": "웹사이트", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "이용 불가", + "verified": "인증 완료", + "no_issues": "문제 없음", + "suspicious": "의심됨", + "malicious_label": "악성", + "more": "더 보기", + "evaluation_disclaimer": "이 보안 검토는 평가용일 뿐이므로 거래에 대한 보증이나 권유로 받아들여서는 안 됩니다." + }, "account_details": { "title": "계정 세부 정보", "share_account": "공유", @@ -5934,6 +5993,10 @@ "claimable_bonus": "청구 가능한 보너스", "claim_bonus": "보너스 수령", "claim_bonus_subtitle": "보너스는 {{networkName}}에서 지급됩니다.", + "percentage_bonus_on_linea": "Linea에서 {{percentage}}% 보너스", + "claim": "청구", + "sounds_good": "좋아요", + "claimable_bonus_tooltip_with_percentage": "mUSD를 보유하여 {{percentage}}%의 연환산 보너스를 받았습니다. 보너스는 Linea에서 매일 청구할 수 있습니다.", "empty_state_cta": { "heading": "{{tokenSymbol}} 토큰을 빌려주고 수익을 올리세요", "body": "{{protocol}}에서 {{tokenSymbol}}을 예치하고 연간 이자를", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "사용자의 스테이블코인" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Stake", "earn": "수익 창출", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "이 작업을 수행하기에 리소스 잔액이 부족합니다." }, - "trx_unstaking_in_progress": "{{amount}} TRX 언스테이킹이 진행 중입니다. 언스테이킹에는 14일이 소요됩니다.", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "{{amount}} TRX 언스테이킹 진행 중", + "description": "언스테이킹에는 14일이 소요됩니다" + }, + "unstaked_banner": { + "title": "{{amount}} TRX 언스테이킹 완료", + "description": "언스테이킹한 TRX를 이제 출금할 수 있습니다", + "button": "출금", + "error": "출금 실패" + } }, "stake_eth": "ETH 스테이크", "unstake_eth": "ETH 언스테이크", @@ -6376,7 +6498,8 @@ "approve": "요청 승인", "perps_deposit": "자금 추가", "predict_deposit": "예측 자금 추가", - "predict_withdraw": "출금" + "predict_withdraw": "출금", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "이 사이트에서 토큰 사용 권한을 요청합니다.", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "트랜잭션 {{index}}", "transaction": "트랜잭션", "available_balance": "사용 가능한 잔액: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "계속", "deposit_edit_amount_done": "자금 추가", "deposit_edit_amount_predict_withdraw": "출금", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "하드웨어 지갑은 아직 지원되지 않습니다. 핫월렛을 사용하여 계속하세요.", "hardware_wallet_not_supported_solana": "솔라나는 아직 하드웨어 지갑이 지원하지 않습니다. 계속하려면 핫월렛을 사용하세요.", "price_impact_info_title": "가격 영향", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "이는 거래가 토큰의 시장 가격에 어떤 영향을 미치는지 보여줍니다. 거래 규모, 이용 가능한 유동성, 공급자 수수료에 따라 달라집니다. MetaMask는 가격 영향에 관여하지 않습니다.", "price_impact_info_gasless_description": "가격 영향은 사용자의 스왑 주문이 자산의 시장 가격에 미치는 영향을 의미합니다. 가스비를 지불할 충분한 자금이 없는 경우, 스왑할 토큰 일부가 자동으로 수수료로 사용되므로 가격 영향이 커질 수 있습니다. MetaMask는 가격 영향에 관여하지 않으며 이를 통제하지도 않습니다.", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "거래 규모와 이용 가능한 유동성으로 인해 시장 가격보다 약 {{priceImpact}}만큼 낮은 가격을 받게 됩니다. 이 내용은 이미 견적에 반영되어 있습니다.", "price_impact_high": "높은 가격 영향", "price_impact_execution_description": "이 스왑으로 토큰 가치의 약 {{priceImpact}}을(를) 잃게 됩니다. 금액을 낮추거나 유동성이 더 많은 경로를 선택해 보세요.", "proceed": "진행", @@ -6627,8 +6751,8 @@ "total_cost": "총비용", "got_it": "컨펌", "price_impact_warning_title": "높은 가격 영향", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "매우 높은 가격 영향", + "price_impact_error_description": "이번 스왑으로 토큰의 시장 가격 대비 약 {{priceImpact}}만큼 손실이 발생합니다. 더 작은 규모로 거래하거나 유동성이 더 높은 경로를 선택하면 더 나은 비율을 받을 수 있습니다." }, "quote_expired_modal": { "title": "새로운 견적이 있습니다", @@ -6940,7 +7064,7 @@ "upgrade_title": "메탈 카드로 업그레이드", "continue_button": "계속", "virtual_card": { - "name": "Virtual Card", + "name": "가상 카드", "price": "수수료", "feature_1": "Apple Pay 및 Google Pay용 가상 카드", "feature_2": "암호화폐로 결제 (USDC, USDT, WETH 등)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "메탈 카드", "price": "연 $199", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "가상 카드의 모든 혜택에 더해, 다음 특전도 제공됩니다.", + "feature_1": "프리미엄 각인 메탈 카드", + "feature_2": "연간 첫 $10,000 사용액에 대해 3% 캐시백", "feature_3": "해외 결제 수수료 없음" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "연간 최대 $300 캐시백 적립", + "upgrade_to_metal_label": "또는 Metal로 업그레이드하고 3배 보상 적립" }, "review_order": { "title": "주문 검토", @@ -7104,7 +7228,7 @@ "ssn_description": "카드 발급사의 요구 사항입니다. 신용 조회는 진행되지 않습니다.", "invalid_ssn": "잘못된 SSN입니다", "invalid_date_of_birth": "유효하지 않은 생년월일입니다. 18세 이상이어야 합니다", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "이름과 성은 인증된 신원 정보와 일치해야 합니다" }, "physical_address": { "title": "주소 추가", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "이용한도가 얼마 남지 않았습니다", "description": "거절을 피하려면 업데이트하세요", - "confirm_button_label": "새 한도 설정" + "confirm_button_label": "새 한도 설정", + "dismiss_button_label": "닫기" }, "need_delegation": { "title": "카드를 활성화해야 합니다", @@ -7301,7 +7426,6 @@ "dismiss": "닫기", "update_success": "지출 한도 변경됨", "update_error": "지출 한도 변경 실패", - "solana_not_supported": "card.metamask.io에서 솔라나 토큰 활성화", "select_token": "토큰 선택", "loading": "사용 가능한 토큰 불러오는 중...", "load_error": "토큰을 불러올 수 없습니다. 다시 시도해 주세요.", @@ -7343,9 +7467,7 @@ "limited": "제한됨", "not_enabled": "활성화되지 않음", "update_success": "지출 우선순위 변경됨", - "update_error": "지출 우선순위 변경 실패", - "solana_not_supported_button_title": "솔라나 네트워크의 다른 토큰", - "solana_not_supported_button_description": "card.metamask.io에서 활성화" + "update_error": "지출 우선순위 변경 실패" }, "card_authentication": { "title": "카드 계정에 로그인", @@ -7443,6 +7565,11 @@ "title": "참여 실패", "description": "연결 상태를 확인하고 다시 시도하세요." }, + "version_guard": { + "title": "업데이트 필요", + "description": "보상을 사용하려면 더 최신 버전의 MetaMask가 필요합니다. 계속하려면 업데이트하세요.", + "update_button": "MetaMask 업데이트" + }, "season_error": { "error_fetching_title": "시즌을 불러올 수 없습니다", "error_fetching_description": "연결 상태를 확인하고 다시 시도하세요.", @@ -7525,7 +7652,6 @@ "main_title": "보상", "referral_title": "추천", "tab_overview_title": "개요", - "tab_snapshots_title": "스냅샷", "tab_activity_title": "활동", "referral_stats_earned_from_referrals": "추천을 통해 적립", "referral_stats_referrals": "추천", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "이번 시즌에는 보상을 받지 못하셨습니다. 다음 기회를 기다려 주세요.", "verifying_rewards": "회원님이 보상을 수령하기 전에 모든 정보가 정확한지 확인하고 있습니다." }, + "previous_season_view": { + "title": "이전 시즌" + }, "season_status": { "points_earned": "포인트 획득함" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "적용 중인 부스트", "season_1": "시즌 1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "mUSD 보너스 계산기", + "description": "스테이블코인을 mUSD로 전환하면 얼마나 적립할 수 있는지 확인해 보세요.", + "amount_label": "전환 금액", + "estimated_bonus": "예상 연환산 보너스: 최대 3%", + "initial_amount": "초기 금액", + "daily_bonus": "매일 청구 가능 보너스", + "annualized_bonus": "연환산 보너스", + "disclaimer": "이는 추정치일 뿐입니다. 보너스는 변경될 수 있습니다.", "buy_button": "mUSD 구매", - "swap_button": "Swap to mUSD" + "swap_button": "mUSD로 스왑" }, "upcoming_rewards": { "title": "잠긴 리워드", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "불러올 수 없음" }, - "snapshot": { + "campaign": { "starts_date": "시작일: {{date}}", "ends_date": "종료일: {{date}}", - "results_coming_soon": "결과 곧 공개", - "tokens_on_the_way": "토큰 지급 예정", + "ended_date": "Ended {{date}}", "pill_up_next": "다음 일정", - "pill_live_now": "지금 진행 중", - "pill_calculating": "계산 중", - "pill_results_ready": "결과 준비 완료", - "pill_complete": "완료" - }, - "snapshots_section": { - "title": "스냅샷", - "error_title": "스냅샷을 불러올 수 없습니다", - "error_description": "스냅샷을 불러오지 못했습니다. 다시 시도해 주세요.", - "retry_button": "다시 시도" - }, - "snapshots_tab": { + "pill_active": "진행 중", + "pill_complete": "완료", + "enter_now": "지금 참가하기", + "entered": "참가 완료", + "participant_count": "#{{count}}", + "opt_in_cta": "참여하기", + "opt_in_sheet_title": "캠페인 참여", + "opt_in_sheet_description_pre_link": "'참여하기'를 클릭하면 MetaMask 보상 프로그램에 동의하는 것입니다", + "opt_in_sheet_link_text": "추가 이용 약관 및 개인정보 처리방침에 동의하는 것이 됩니다", + "opt_in_sheet_description_post_link": "MetaMask는 온체인 활동을 추적하여 보상을 자동으로 지급합니다.", + "geo_restriction_banner_title": "회원님의 지역에서 사용할 수 없습니다", + "geo_restriction_banner_description": "현지 규정으로 인해 이 캠페인은 거주 지역에서 사용할 수 없습니다." + }, + "campaign_mechanics": { + "title": "운영 방식" + }, + "campaign_details": { + "start_date": "시작일: {{date}}", + "end_date": "종료일: {{date}}", + "opt_in": "참여하기", + "opting_in": "참여 중...", + "opted_in": "이 캠페인에 참여했습니다", + "opt_in_error": "참여하지 못했습니다. 다시 시도하세요.", + "join_campaign": "캠페인 참여", + "checking_opt_in_status": "참여 상태 확인 중", + "swap": "스와프", + "how_it_works": "작동 방식" + }, + "campaigns_preview": { + "title": "캠페인", + "coming_soon": "곧 추가 예정", + "notify_me": "알림 받기" + }, + "earn_rewards": { + "title": "보상 받기", + "musd_title": "스테이블코인 최대 3% 보너스", + "musd_subtitle": "mUSD 보너스 계산하기", + "card_title": "최대 3% 캐시백", + "card_subtitle": "지금 MetaMask 카드를 받으세요", + "card_subtitle_cardholder": "MetaMask 카드 혜택을 이용하세요" + }, + "campaigns_view": { + "title": "캠페인", "active_title": "진행 중", "upcoming_title": "예정", "previous_title": "이전", - "empty_state": "사용 가능한 스냅샷 없음", - "error_title": "스냅샷을 불러올 수 없습니다", - "error_description": "스냅샷을 불러오지 못했습니다. 다시 시도해 주세요.", + "empty_state": "참여 가능한 캠페인이 없습니다", + "error_title": "캠페인을 불러올 수 없습니다", + "error_description": "캠페인을 불러오지 못했습니다. 다시 시도하세요.", "retry_button": "다시 시도", "refreshing": "새로 고침 중..." } @@ -7953,13 +8112,12 @@ "continue": "계속" }, "connecting": { - "title": "{{device}} 연결", + "title": "{{device}} 연결 중...", "searching": "{{device}} 찾는 중...", - "tips_header": "계속하려면 다음 사항을 확인하세요.", + "tips_header": "다음을 확인하세요:", "tip_unlock": "{{device}} 잠금이 해제되어 있어야 합니다", "tip_open_app": "이더리움 앱이 열려 있어야 합니다", "tip_enable_bluetooth": "블루투스가 켜져 있어야 합니다", - "tip_dnd_off": "방해 금지 모드는 꺼져 있어야 합니다", "tip_bluetooth_permission": "위치 및 블루투스 권한이 허용되어 있어야 합니다", "tip_bluetooth_permission_v12": "근처 장치 권한이 허용되어 있어야 합니다", "tip_stay_close": "장치와 휴대전화가 서로 가까이 있어야 합니다" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "근처 장치 권한이 필요합니다", "bluetooth_off": "장치에 연결하려면 블루투스를 켜세요", "bluetooth_scan_failed": "장치를 스캔하지 못했습니다. 다시 시도하세요", - "bluetooth_connection_failed": "장치의 블루투스를 활성화한 후 계속하세요", + "bluetooth_connection_failed": "기기 연결에 실패했습니다. 다시 시도하세요", "not_supported": "지원되지 않는 작업입니다", "unknown_error": "이 계정에 대한 비밀복구구문 또는 패스프레이즈로 {{device}}이(가) 설정되어 있는지 확인하세요" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "현금", + "cash_empty_description": "아직 mUSD가 없습니다. 홈페이지의 현금 섹션에서 스테이블코인을 mUSD로 전환하세요.", + "cash_empty_description_network_filter": "이 네트워크에는 mUSD가 없습니다. mUSD를 확인하려면 네트워크를 전환하세요.", "tokens": "토큰", "perpetuals": "영구계약", "predictions": "예측", + "whats_happening": "주요 동향", + "whats_happening_categories": { + "geopolitical": "지정학", + "macro": "거시 경제", + "regulatory": "규제", + "technical": "기술", + "social": "사회", + "other": "기타" + }, "defi": "디파이", "nfts": "NFT", "import_nfts": "NFT 가져오기", diff --git a/locales/languages/pt.json b/locales/languages/pt.json index a612b4b87de..3a2d1147550 100644 --- a/locales/languages/pt.json +++ b/locales/languages/pt.json @@ -20,6 +20,12 @@ "update": "Atualizar" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "Alerta", @@ -120,8 +126,8 @@ "title": "Enviando ativos para endereço de queima" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "Aviso sobre contrato de token", + "message": "O endereço do destinatário pode não aceitar transferências diretas de tokens, o que pode resultar na perda de fundos. Prossiga apenas se tiver certeza de que este contrato pode receber sua transferência." }, "gas_sponsorship_reserve_balance": { "message": "O patrocínio de gas não está disponível para esta transação. Você precisará manter pelo menos %{minBalance} %{nativeTokenSymbol} em sua conta.", @@ -694,8 +700,8 @@ "could_not_resolve_name": "Não foi possível resolver o nome", "invalid_address": "Endereço inválido", "contractAddressError": "Você está enviando tokens para o endereço do contrato do token. Isso pode levar à perda desses tokens.", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "Endereço de contrato inteligente", + "smart_contract_address_warning": "O endereço do destinatário pode não aceitar transferências diretas de tokens, o que pode resultar na perda de fundos. Prossiga apenas se tiver certeza de que este contrato pode receber sua transferência.", "i_understand": "Eu compreendo", "cancel": "Cancelar" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "O stop loss deve ser um preço {{direction}} {{priceType}}", "stop_loss_beyond_liquidation_error": "O stop loss deve ser um preço de liquidação {{direction}}", "stop_loss_order_view_warning": "O stop loss é um preço de liquidação {{direction}}", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "acima", "below": "abaixo", "done": "Pronto", @@ -2086,14 +2094,15 @@ "a_closer_look": "Uma análise mais detalhada", "whats_being_said": "O que as pessoas dizem", "footer_disclaimer": "Resumo de IA apenas para fins informativos", - "trade_button": "Negociar", + "swap_button": "Troca", + "buy_button": "Comprar", "sources_count": "+{{count}} fontes", "sources_title": "Fontes de notícias", "feedback_submitted": "Feedback enviado", "helpful_prompt": "Isso foi útil?", "feedback": { "title": "Comentário", - "description": "Ajude a aprimorar nossas análises de mercado geradas por IA.", + "description": "Sua resposta ajuda a melhorar nossos resumos de IA.", "not_relevant": "Não relevante", "not_accurate": "Inexato", "hard_to_understand": "Difícil de entender", @@ -2206,7 +2215,7 @@ "available_balance": "Saldo disponível", "claim_amount_text": "Resgatar $ {{amount}}", "claim_winnings_text": "Resgatar ganhos", - "claiming_text": "Claiming...", + "claiming_text": "Reivindicando...", "unrealized_pnl_label": "P&L não realizados", "unrealized_pnl_value": "{{amount}} ({{percent}})", "unrealized_pnl_error": "Não foi possível carregar", @@ -2287,7 +2296,7 @@ "try_again": "Tentar novamente" }, "in_progress": { - "title": "Claim already in progress" + "title": "Reivindicação já em andamento" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "Taxa paga à bolsa ou ao mercado", "total_incl_fees": "incluindo taxas", "close": "Fechar", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "Os preços apresentados pressupõem que sua ordem seja totalmente executada. Os valores reais podem variar se a ordem for executada apenas parcialmente.", + "deposit_fee": "Taxa de depósito", + "deposit_fee_description": "Taxa cobrada para depositar fundos em seu saldo de previsões" }, "error": { "title": "Não foi possível conectar-se às previsões", @@ -3059,6 +3068,7 @@ "networks_no_results": "Nenhuma rede encontrada", "network_name_label": "Nome da rede", "network_name_placeholder": "Nome da rede (opcional)", + "required": "Obrigatório", "network_rpc_url_label": "URL da RPC", "network_rpc_name_label": "Nome da RPC", "network_rpc_placeholder": "Nova rede RPC", @@ -3298,6 +3308,8 @@ "blockaid_desc": "Esse recurso alerta você sobre atividades mal-intencionadas analisando ativamente as solicitações de transações e assinaturas.", "security_alerts": "Alertas de segurança", "security_alerts_desc": "Esse recurso alerta sobre atividades mal-intencionadas por meio da análise local de solicitações de transações e assinaturas. Sempre realize sua própria devida diligência antes de aprovar solicitações. Não há garantia de que esse recurso detectará toda e qualquer atividade mal-intencionada. Ao ativar esse recurso, você concorda com os termos de uso do provedor.", + "smart_account_dapp_requests_heading": "Solicitações de conta inteligente vindas de dapps", + "smart_account_dapp_requests_desc": "Permite que dapps (aplicativos descentralizados) solicitem recursos de conta inteligente para contas padrão. Isso não afetará contas que já são contas inteligentes.", "smart_transactions_opt_in_heading": "Transações inteligentes", "smart_transactions_opt_in_desc_supported_networks": "Ative as transações inteligentes para fazer transações mais confiáveis e seguras nas redes suportadas.", "smart_transactions_learn_more": "Saiba mais", @@ -3566,6 +3578,53 @@ "activity": "Atividade do {{symbol}}", "disclaimer": "Os dados de mercado são fornecidos por fontes terceirizadas, como o CoinGecko. Os dados são apenas para fins informativos. A MetaMask não se responsabiliza por sua exatidão." }, + "security_trust": { + "title": "Segurança e confiança", + "malicious": "Malicioso", + "risky": "De risco", + "malicious_token_title": "Token malicioso", + "malicious_token_description": "{{symbol}} é um token malicioso. Evite interagir com ele ou negociá-lo.", + "verified_token_title": "Token verificado", + "verified_token_description": "{{symbol}} é ativamente negociado e amplamente reconhecido. A verificação não representa um endosso por parte da MetaMask.", + "risky_token_title": "Token de risco", + "risky_token_description": "Sinais de alerta detectados em {{symbol}}. Pesquise cuidadosamente antes de negociar este token.", + "malicious_token_sheet_description": "Sinais de risco graves foram detectados em {{symbol}}. Recomendamos não negociar este token.", + "got_it": "Entendi", + "proceed": "Prosseguir", + "cancel": "Cancelar", + "data_unavailable": "Dados de segurança não disponíveis", + "subtitle_known": "Nenhum sinal de risco detectado. Sempre pesquise qualquer ativo antes de negociar.", + "subtitle_no_issues": "Nenhum sinal de risco detectado. Sempre pesquise qualquer ativo antes de negociar.", + "subtitle_suspicious": "Sinais de alerta detectados. Analise cuidadosamente os problemas sinalizados antes de negociar este ativo.", + "subtitle_malicious": "Sinais de risco graves foram detectados. Recomendamos evitar este ativo.", + "subtitle_unavailable": "Não foi possível carregar a análise de segurança para este token.", + "token_distribution": "Distribuição de token", + "total_supply": "Fornecimento total", + "top_10_holders": "10 principais detentores", + "other": "Outro", + "no_hidden_fees_detected": "Nenhuma taxa oculta detectada", + "buy_sell_tax": "Imposto sobre compra/venda", + "buy_tax": "Imposto sobre compra", + "sell_tax": "Imposto sobre venda", + "transfer": "Transferir", + "token_info": "Informações do token", + "created": "Criado em", + "token_age": "Idade do token", + "network": "Rede", + "type": "Digite", + "official_links": "Links oficiais", + "website": "Website", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "N/D", + "verified": "Verificado", + "no_issues": "Sem problemas", + "suspicious": "Suspeito", + "malicious_label": "Malicioso", + "more": "mais", + "evaluation_disclaimer": "Esta análise de segurança tem caráter meramente avaliativo e não constitui um endosso ou recomendação de negociação." + }, "account_details": { "title": "Detalhes da conta", "share_account": "Compartilhar", @@ -5934,6 +5993,10 @@ "claimable_bonus": "Bônus resgatável", "claim_bonus": "Resgatar bônus", "claim_bonus_subtitle": "O bônus será pago em {{networkName}}.", + "percentage_bonus_on_linea": "Bônus de {{percentage}}% na Linea", + "claim": "Resgatar", + "sounds_good": "Parece bom", + "claimable_bonus_tooltip_with_percentage": "Você ganhou {{percentage}}% de bônus anualizado por manter mUSD. Seu bônus pode ser resgatado diariamente na Linea.", "empty_state_cta": { "heading": "Empreste {{tokenSymbol}} e ganhe", "body": "Empreste seus {{tokenSymbol}} com {{protocol}} e ganhe", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "Suas stablecoins" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Stake", "earn": "Ganhe", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "Você não possui saldo de recursos suficiente para realizar esta ação." }, - "trx_unstaking_in_progress": "Desfazer staking de {{amount}} TRX em andamento. O processo de desfazer staking leva 14 dias.", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "Retirada de staking de {{amount}} TRX em andamento", + "description": "O processo de retirada de staking levará 14 dias" + }, + "unstaked_banner": { + "title": "Retirada de staking de {{amount}} TRX concluída", + "description": "Seus TRX retirados de staking já podem ser sacados", + "button": "Sacar", + "error": "Falha ao sacar" + } }, "stake_eth": "Fazer staking de ETH", "unstake_eth": "Retirar ETH do staking", @@ -6376,7 +6498,8 @@ "approve": "Aprovar solicitação", "perps_deposit": "Adicionar fundos", "predict_deposit": "Adicionar fundos de previsão", - "predict_withdraw": "Sacar" + "predict_withdraw": "Sacar", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "Este site quer permissão para gastar seus tokens.", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "Transação {{index}}", "transaction": "Transações", "available_balance": "Saldo disponível: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "Continuar", "deposit_edit_amount_done": "Adicionar fundos", "deposit_edit_amount_predict_withdraw": "Sacar", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "Ainda não oferecemos suporte a carteiras de hardware. Use uma hot wallet para continuar.", "hardware_wallet_not_supported_solana": "Carteiras de hardware ainda não são compatíveis com Solana. Use uma hot wallet para continuar.", "price_impact_info_title": "Impacto do preço", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "É assim que sua negociação altera o preço de mercado de um token. Ele depende do volume da negociação, da liquidez disponível e das taxas do fornecedor. A MetaMask não controla o impacto no preço.", "price_impact_info_gasless_description": "O impacto no preço reflete como sua ordem de troca afeta o preço de mercado do ativo. Se você não tiver fundos suficientes para o gás, parte do seu token de origem será automaticamente alocada para cobrir taxas, o que aumenta o impacto no preço. A MetaMask não influencia nem controla o impacto no preço.", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "Devido ao volume da sua negociação e à liquidez disponível, você receberá cerca de {{priceImpact}} a menos do que o preço de mercado. Isso já está incluído em sua cotação.", "price_impact_high": "Alto impacto no preço", "price_impact_execution_description": "Você perderá aproximadamente {{priceImpact}} do valor do seu token nesta troca. Tente reduzir o valor ou escolher uma rota com mais liquidez.", "proceed": "Prosseguir", @@ -6627,8 +6751,8 @@ "total_cost": "Custo total", "got_it": "Entendi", "price_impact_warning_title": "Alto impacto no preço", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "Impacto de preço muito elevado", + "price_impact_error_description": "Você perderá aproximadamente {{priceImpact}} do valor de mercado do seu token neste swap. Tente uma negociação de valor menor ou uma rota com mais liquidez para melhorar sua taxa." }, "quote_expired_modal": { "title": "Novas cotações estão disponíveis", @@ -6940,7 +7064,7 @@ "upgrade_title": "Faça upgrade para Metal", "continue_button": "Continuar", "virtual_card": { - "name": "Virtual Card", + "name": "Cartão virtual", "price": "Gratuito", "feature_1": "Cartão virtual para Apple Pay e Google Pay", "feature_2": "Pague com criptomoedas (USDC, USDT, WETH e várias outras)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "Cartão Metal", "price": "US$ 199/ano", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "Tudo em formato virtual, além de:", + "feature_1": "Cartão premium de metal entalhado", + "feature_2": "3% de cashback nos primeiros US$ 10.000/ano", "feature_3": "Sem taxas de transação internacional" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "Ganhe até US$ 300 em cashback anualmente", + "upgrade_to_metal_label": "Ou faça upgrade para Metal e ganhe 3x mais recompensas" }, "review_order": { "title": "Confira sua ordem", @@ -7104,7 +7228,7 @@ "ssn_description": "Exigido pela emissora do cartão. Nenhuma verificação de crédito será realizada.", "invalid_ssn": "NSS inválido", "invalid_date_of_birth": "Data de nascimento inválida. Você deve ter pelo menos 18 anos", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "Nome e sobrenome devem corresponder à sua identidade verificada" }, "physical_address": { "title": "Insira seu endereço", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "Você está próximo do seu limite de gastos", "description": "Atualize para evitar recusas", - "confirm_button_label": "Definir novo limite" + "confirm_button_label": "Definir novo limite", + "dismiss_button_label": "Ignorar" }, "need_delegation": { "title": "Você precisa habilitar seu cartão", @@ -7301,7 +7426,6 @@ "dismiss": "Ignorar", "update_success": "Limite de gastos atualizado com sucesso", "update_error": "Falha ao atualizar limite de gastos", - "solana_not_supported": "Habilite tokens Solana em card.metamask.io", "select_token": "Selecionar token", "loading": "Carregando tokens disponíveis...", "load_error": "Não foi possível carregar os tokens. Tente novamente.", @@ -7343,9 +7467,7 @@ "limited": "Limitado", "not_enabled": "Não ativado", "update_success": "Prioridade de gastos atualizada com sucesso", - "update_error": "Falha ao atualizar prioridade de gastos", - "solana_not_supported_button_title": "Outros tokens em Solana", - "solana_not_supported_button_description": "Ative em card.metamask.io" + "update_error": "Falha ao atualizar prioridade de gastos" }, "card_authentication": { "title": "Faça login na conta do seu cartão", @@ -7443,6 +7565,11 @@ "title": "A participação falhou", "description": "Verifique sua conexão e tente novamente." }, + "version_guard": { + "title": "Atualização necessária", + "description": "O uso do programa de Recompensas requer uma versão mais recente da MetaMask. Atualize para continuar.", + "update_button": "Atualizar MetaMask" + }, "season_error": { "error_fetching_title": "Não foi possível carregar a temporada", "error_fetching_description": "Verifique sua conexão e tente novamente.", @@ -7525,7 +7652,6 @@ "main_title": "Recompensas", "referral_title": "Indicações", "tab_overview_title": "Visão geral", - "tab_snapshots_title": "Capturas de tela", "tab_activity_title": "Atividade", "referral_stats_earned_from_referrals": "Ganho por meio de indicações", "referral_stats_referrals": "Indicações", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "Você não ganhou recompensas nesta temporada, mas sempre haverá uma próxima vez.", "verifying_rewards": "Estamos verificando se tudo está correto antes de você resgatar suas recompensas." }, + "previous_season_view": { + "title": "Temporada anterior" + }, "season_status": { "points_earned": "Pontos ganhos" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "Incrementos ativos", "season_1": "Temporada 1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "Calculadora de bônus mUSD", + "description": "Veja quanto você pode ganhar convertendo suas stablecoins para mUSD.", + "amount_label": "Valor convertido", + "estimated_bonus": "Bônus anual estimado: até 3%", + "initial_amount": "Valor inicial", + "daily_bonus": "Bônus diário resgatável", + "annualized_bonus": "Bônus anualizado", + "disclaimer": "Este valor é apenas uma estimativa. O bônus está sujeito a alterações.", "buy_button": "Comprar mUSD", - "swap_button": "Swap to mUSD" + "swap_button": "Converter para mUSD" }, "upcoming_rewards": { "title": "Recompensas bloqueadas", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "Não foi possível carregar" }, - "snapshot": { + "campaign": { "starts_date": "Começa em {{date}}", "ends_date": "Termina em {{date}}", - "results_coming_soon": "Resultados em breve", - "tokens_on_the_way": "Tokens a caminho", + "ended_date": "Ended {{date}}", "pill_up_next": "Em seguida", - "pill_live_now": "Ao vivo agora", - "pill_calculating": "Calculando", - "pill_results_ready": "Resultados prontos", - "pill_complete": "Concluído" - }, - "snapshots_section": { - "title": "Capturas de tela", - "error_title": "Não foi possível carregar capturas de tela", - "error_description": "Não foi possível carregar as capturas de tela. Tente novamente.", - "retry_button": "Tentar novamente" - }, - "snapshots_tab": { + "pill_active": "Em tempo real", + "pill_complete": "Concluído", + "enter_now": "Insira agora", + "entered": "Inserido", + "participant_count": "#{{count}}", + "opt_in_cta": "Participar", + "opt_in_sheet_title": "Participe da campanha", + "opt_in_sheet_description_pre_link": "Ao clicar em \"Participar\", você concorda com o programa de Recompensas da MetaMask", + "opt_in_sheet_link_text": "Termos de uso suplementares e aviso de privacidade", + "opt_in_sheet_description_post_link": "Rastrearemos a atividade onchain para recompensar você automaticamente.", + "geo_restriction_banner_title": "Não disponível em sua região", + "geo_restriction_banner_description": "Esta campanha não está disponível na sua região devido a regulamentos locais." + }, + "campaign_mechanics": { + "title": "Mecânica" + }, + "campaign_details": { + "start_date": "Início: {{date}}", + "end_date": "Término: {{date}}", + "opt_in": "Participar", + "opting_in": "Optando por participar...", + "opted_in": "Você optou por participar desta campanha", + "opt_in_error": "Falha ao optar por participar. Tente novamente.", + "join_campaign": "Participe da campanha", + "checking_opt_in_status": "Verificando status de participação", + "swap": "Troca", + "how_it_works": "Como funciona" + }, + "campaigns_preview": { + "title": "Campanhas", + "coming_soon": "Em breve", + "notify_me": "Avisar-me" + }, + "earn_rewards": { + "title": "Ganhe recompensas", + "musd_title": "Até 3% de bônus sobre stablecoins", + "musd_subtitle": "Calcule seu bônus em mUSD", + "card_title": "Até 3% em cashback", + "card_subtitle": "Peça já o seu Cartão MetaMask", + "card_subtitle_cardholder": "Acesse os benefícios do seu Cartão MetaMask" + }, + "campaigns_view": { + "title": "Campanhas", "active_title": "Ativo", "upcoming_title": "Próximo", "previous_title": "Anterior", - "empty_state": "Nenhuma captura de tela disponível", - "error_title": "Não foi possível carregar capturas de tela", - "error_description": "Não foi possível carregar as capturas de tela. Tente novamente.", + "empty_state": "Nenhuma campanha disponível", + "error_title": "Não foi possível carregar campanhas", + "error_description": "Não foi possível carregar as campanhas. Tente novamente.", "retry_button": "Tentar novamente", "refreshing": "Atualizando..." } @@ -7953,13 +8112,12 @@ "continue": "Continuar" }, "connecting": { - "title": "Conecte seu {{device}}", + "title": "Conectando seu {{device}}...", "searching": "Procurando {{device}}...", - "tips_header": "Para continuar, certifique-se de que:", + "tips_header": "Certifique-se de que:", "tip_unlock": "Seu {{device}} está desbloqueado", "tip_open_app": "O aplicativo Ethereum está aberto", "tip_enable_bluetooth": "O Bluetooth está ativado", - "tip_dnd_off": "O modo \"Não perturbe\" está desativado", "tip_bluetooth_permission": "Permissão de localização e Bluetooth estão concedidas", "tip_bluetooth_permission_v12": "A permissão para dispositivos próximos foi concedida", "tip_stay_close": "Seu dispositivo permanece próximo ao seu telefone" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "É necessário permissão para dispositivos próximos", "bluetooth_off": "Ative o Bluetooth para se conectar ao seu dispositivo", "bluetooth_scan_failed": "Falha ao procurar dispositivos. Tente novamente", - "bluetooth_connection_failed": "Ative o Bluetooth no seu dispositivo para continuar", + "bluetooth_connection_failed": "A conexão com seu dispositivo falhou. Tente novamente", "not_supported": "Esta operação não é suportada", "unknown_error": "Certifique-se de que seu {{device}} esteja configurado com a Frase de Recuperação Secreta ou com a senha para esta conta" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "Dinheiro em espécie", + "cash_empty_description": "Você ainda não tem mUSD. Converta stablecoins para mUSD na seção \"Dinheiro\" da página inicial.", + "cash_empty_description_network_filter": "Não há mUSD nesta rede. Mude de rede para ver seus mUSD.", "tokens": "Tokens", "perpetuals": "Perpétuos", "predictions": "Previsões", + "whats_happening": "O que está acontecendo", + "whats_happening_categories": { + "geopolitical": "Geopolítica", + "macro": "Macro", + "regulatory": "Regulatório", + "technical": "Técnico", + "social": "Redes sociais", + "other": "Outro" + }, "defi": "DeFi", "nfts": "NFTs", "import_nfts": "Importar NFTs", diff --git a/locales/languages/ru.json b/locales/languages/ru.json index be640321f3f..5710fe027b2 100644 --- a/locales/languages/ru.json +++ b/locales/languages/ru.json @@ -20,6 +20,12 @@ "update": "Обновить" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "Оповещение", @@ -120,8 +126,8 @@ "title": "Активы отправляются на адрес для сжигания" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "Предупреждение о контракте токена", + "message": "Адрес получателя может не поддерживать прямые переводы токенов, что может привести к потере средств. Продолжайте только в том случае, если вы уверены, что этот контракт может получить ваш перевод." }, "gas_sponsorship_reserve_balance": { "message": "Для этой транзакции недоступна спонсорская оплата газа. Вам необходимо постоянно иметь на счету не менее %{minBalance} %{nativeTokenSymbol}.", @@ -694,8 +700,8 @@ "could_not_resolve_name": "Не удалось разрешить имя", "invalid_address": "Недействительный адрес", "contractAddressError": "Вы отправляете токены на адрес контракта токена. Это может привести к потере этих токенов.", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "Адрес смарт-контракта", + "smart_contract_address_warning": "Адрес получателя может не поддерживать прямые переводы токенов, что может привести к потере средств. Продолжайте только в том случае, если вы уверены, что этот контракт может получить ваш перевод.", "i_understand": "Я понимаю", "cancel": "Отмена" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "Стоп-лосс должен быть по цене {{direction}} {{priceType}}", "stop_loss_beyond_liquidation_error": "Стоп-лосс должен быть равен цене ликвидации {{direction}}", "stop_loss_order_view_warning": "Стоп-лосс — это цена ликвидации {{direction}}", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "выше", "below": "ниже", "done": "Готово", @@ -2086,14 +2094,15 @@ "a_closer_look": "Подробный обзор", "whats_being_said": "Что говорят", "footer_disclaimer": "ИИ-сводка только для информации", - "trade_button": "Торговать", + "swap_button": "Обменять", + "buy_button": "Купить", "sources_count": "+{{count}} источника(-ов)", "sources_title": "Источники новостей", "feedback_submitted": "Отзыв отправлен", "helpful_prompt": "Это было полезно?", "feedback": { "title": "Отзыв", - "description": "Помогите улучшить наши обзоры рынка, созданные с помощью ИИ.", + "description": "Ваш ответ помогает улучшить наши сводки, создаваемые ИИ.", "not_relevant": "Неактуально", "not_accurate": "Неточно", "hard_to_understand": "Сложно для понимания", @@ -2162,7 +2171,7 @@ "sell_position": "Позиция на продажу", "cash_out": "Вывести деньги", "cash_out_info": "Средства будут зачислены на ваш доступный баланс.", - "buy_preview_outcome_at_price": "{{outcome}} at {{price}}", + "buy_preview_outcome_at_price": "{{outcome}} по {{price}}", "at_price_per_share": "Продажа {{size}} акции(-ий) по цене {{price}}", "cashout_info": "{{amount}} при {{outcome}} за {{initialPrice}}", "cashout_info_multiple": "{{amount}} при {{outcomeGroupTitle}} • {{outcome}} по цене {{initialPrice}}", @@ -2206,7 +2215,7 @@ "available_balance": "Доступный баланс", "claim_amount_text": "Получить {{amount}} $", "claim_winnings_text": "Получить выигрыши", - "claiming_text": "Claiming...", + "claiming_text": "Получение...", "unrealized_pnl_label": "Нереализованные П/У", "unrealized_pnl_value": "{{amount}} ({{percent}})", "unrealized_pnl_error": "Не удалось загрузить", @@ -2287,7 +2296,7 @@ "try_again": "Повторить попытку" }, "in_progress": { - "title": "Claim already in progress" + "title": "Запрос уже выполняется" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "Комиссия, уплачиваемая бирже или рынку", "total_incl_fees": "вкл. комиссии", "close": "Закрыть", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "Указанные цены действительны при условии полного выполнения вашего ордера. Фактические суммы могут отличаться, если ордер выполнен лишь частично.", + "deposit_fee": "Комиссия за депозит", + "deposit_fee_description": "Комиссия, взимаемая за внесение средств на ваш баланс прогнозов" }, "error": { "title": "Невозможно подключиться к прогнозам", @@ -3059,6 +3068,7 @@ "networks_no_results": "Сети не найдены", "network_name_label": "Имя сети", "network_name_placeholder": "Имя сети (необязательно)", + "required": "Требуется", "network_rpc_url_label": "URL-адрес RPC", "network_rpc_name_label": "Название RPC", "network_rpc_placeholder": "Новая сеть RPC", @@ -3298,6 +3308,8 @@ "blockaid_desc": "Эта функция предупреждает вас о вредоносной активности, активно проверяя транзакции и запросы на подпись.", "security_alerts": "Оповещения безопасности", "security_alerts_desc": "Эта функция предупреждает вас о вредоносной активности, проверяя запросы транзакций и подписей локально. Всегда проводите комплексную проверку перед утверждением каких-либо запросов. Нет никакой гарантии, что эта функция обнаружит всю вредоносную активность. Включая эту функцию, вы соглашаетесь с условиями использования поставщика.", + "smart_account_dapp_requests_heading": "Запросы на создание смарт-счетов от dapps", + "smart_account_dapp_requests_desc": "Разрешите dapps запрашивать функции смарт-счетов для стандартных счетов. Это не повлияет на счета, которые уже являются смарт-счетами.", "smart_transactions_opt_in_heading": "Умные транзакции", "smart_transactions_opt_in_desc_supported_networks": "Включите функцию «Умные транзакции» для более надежных и безопасных транзакций в поддерживаемых сетях.", "smart_transactions_learn_more": "Подробнее", @@ -3566,6 +3578,53 @@ "activity": "Активность {{symbol}}", "disclaimer": "Рыночные данные предоставлены сторонними источниками, такими как CoinGecko. Данные носят исключительно информационный характер. MetaMask не несет ответственности за их точность." }, + "security_trust": { + "title": "Безопасность и доверие", + "malicious": "Вредоносный", + "risky": "Рискованный", + "malicious_token_title": "Вредоносный токен", + "malicious_token_description": "{{symbol}} — это вредоносный токен. Избегайте взаимодействия с ним или торговли им.", + "verified_token_title": "Проверенный токен", + "verified_token_description": "{{symbol}} активно торгуется и широко известен. Подтверждение не является одобрением со стороны MetaMask.", + "risky_token_title": "Рискованный токен", + "risky_token_description": "В отношении {{symbol}} обнаружены предупреждающие сигналы. Перед началом торговли этим токеном внимательно изучите информацию.", + "malicious_token_sheet_description": "Обнаружены серьезные сигналы риска для {{symbol}}. Мы рекомендуем воздержаться от торговли этим токеном.", + "got_it": "Понятно", + "proceed": "Продолжить", + "cancel": "Отмена", + "data_unavailable": "Данные о безопасности недоступны", + "subtitle_known": "Сигналы риска не обнаружены. Всегда проводите анализ любого актива перед совершением сделки.", + "subtitle_no_issues": "Сигналы риска не обнаружены. Всегда проводите анализ любого актива перед совершением сделки.", + "subtitle_suspicious": "Обнаружены предупреждающие сигналы. Внимательно изучите отмеченные проблемы, прежде чем совершать сделки с этим активом.", + "subtitle_malicious": "Обнаружены серьезные сигналы риска. Рекомендуем воздержаться от покупки этого актива.", + "subtitle_unavailable": "Не удалось загрузить анализ безопасности для этого токена.", + "token_distribution": "Распределение токенов", + "total_supply": "Общий запас", + "top_10_holders": "Топ-10 держателей", + "other": "Другое", + "no_hidden_fees_detected": "Скрытых платежей не обнаружено", + "buy_sell_tax": "Налог на куплю-продажу", + "buy_tax": "Налог на покупку", + "sell_tax": "Налог на продажу", + "transfer": "Перевести", + "token_info": "Информация о токене", + "created": "Создан", + "token_age": "Возраст токена", + "network": "Сеть", + "type": "Тип", + "official_links": "Официальные ссылки", + "website": "Веб-сайт", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "Н.Д.", + "verified": "Проверенный", + "no_issues": "Нет проблем", + "suspicious": "Подозрительный", + "malicious_label": "Вредоносный", + "more": "больше", + "evaluation_disclaimer": "Данный обзор безопасности носит исключительно ознакомительный характер и не является рекомендацией или одобрением для совершения сделок." + }, "account_details": { "title": "Сведения о счете", "share_account": "Поделиться", @@ -5934,6 +5993,10 @@ "claimable_bonus": "Встребуемый бонус", "claim_bonus": "Получить бонус", "claim_bonus_subtitle": "Бонус будет выплачен в сети {{networkName}}.", + "percentage_bonus_on_linea": "Бонус {{percentage}}% на Linea", + "claim": "Получить", + "sounds_good": "Звучит отлично", + "claimable_bonus_tooltip_with_percentage": "Годовой бонус в размере {{percentage}}% от суммы вашего накопленного бонуса за владение mUSD. Ваш бонус можно получить ежедневно на Linea.", "empty_state_cta": { "heading": "Давайте взаймы {{tokenSymbol}} и зарабатывайте", "body": "Одолжите свой {{tokenSymbol}} с помощью {{protocol}} и зарабатывайте", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "Ваши стейблкоины" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Stake", "earn": "Заработать", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "У вас недостаточно ресурсов для выполнения этого действия." }, - "trx_unstaking_in_progress": "Выполняется вывод {{amount}} TRX из стейкинга. Он займет 14 дней.", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "Выполняется отмена стейкинга {{amount}} TRX", + "description": "Для отмены стейкинга потребуется 14 дней" + }, + "unstaked_banner": { + "title": "Отмена стейкинга {{amount}} TRX завершена", + "description": "Теперь вы можете вывести свои TRX, стейкинг которых отменили", + "button": "Вывести средства", + "error": "Ошибка вывода средств" + } }, "stake_eth": "Выполнить стейкинг ETH", "unstake_eth": "Отменить стейкинг ETH", @@ -6376,7 +6498,8 @@ "approve": "Одобрить запрос", "perps_deposit": "Внести средства", "predict_deposit": "Внести средства для прогнозирования", - "predict_withdraw": "Вывести средства" + "predict_withdraw": "Вывести средства", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "Этот сайт запрашивает разрешение на трату ваших токенов.", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "Транзакция {{index}}", "transaction": "Защита", "available_balance": "Доступный баланс: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "Продолжить", "deposit_edit_amount_done": "Внести средства", "deposit_edit_amount_predict_withdraw": "Вывести средства", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "Аппаратные кошельки пока не поддерживаются. Используйте горячий кошелек, чтобы продолжить.", "hardware_wallet_not_supported_solana": "Аппаратные кошельки пока не поддерживаются для Solana. Используйте горячий кошелек, чтобы продолжить.", "price_impact_info_title": "Влияние на цену", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "Вот как ваша сделка влияет на рыночную цену токена. Это зависит от размера сделки, доступной ликвидности и комиссий поставщика. MetaMask не контролирует влияние на цену.", "price_impact_info_gasless_description": "Влияние на цену отражает, как ваш ордер на своп влияет на рыночную цену актива. Если у вас недостаточно средств для оплаты газа, часть вашего исходного токена автоматически выделяется на покрытие комиссий, что увеличивает влияние на цену. MetaMask не влияет на воздействие цену и не контролирует его.", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "Из-за размера вашей сделки и доступной ликвидности вы получите примерно на {{priceImpact}} меньше рыночной цены. Это уже учтено в вашей котировке.", "price_impact_high": "Сильное влияние на цену", "price_impact_execution_description": "В результате этого обмена вы потеряете примерно {{priceImpact}} от стоимости вашего токена. Попробуйте уменьшить сумму или выбрать более ликвидный вариант.", "proceed": "Продолжить", @@ -6627,8 +6751,8 @@ "total_cost": "Общая стоимость", "got_it": "Понятно", "price_impact_warning_title": "Сильное влияние на цену", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "Очень высокое влияние на цену", + "price_impact_error_description": "В результате этой сделки вы потеряете примерно {{priceImpact}} от рыночной цены вашего токена. Попробуйте совершить сделку меньшего размера или использовать более ликвидный путь, чтобы улучшить свой курс." }, "quote_expired_modal": { "title": "Доступны новые котировки", @@ -6940,7 +7064,7 @@ "upgrade_title": "Повысить уровень до Металлической", "continue_button": "Продолжить", "virtual_card": { - "name": "Virtual Card", + "name": "Виртуальная карта", "price": "Бесплатно", "feature_1": "Виртуальная карта для Apple Pay и Google Pay", "feature_2": "Оплата криптовалютой (USDC, USDT, WETH и другие)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "Металлическая карта", "price": "$199/год", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "Все то же, что и для виртуальной карты, плюс:", + "feature_1": "Премиальная металлическая карта с гравировкой", + "feature_2": "Кешбэк 3% на первые 10 000 $ в год", "feature_3": "Без комиссий за зарубежные транзакции" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "Зарабатывайте до 300 $ кешбэка ежегодно", + "upgrade_to_metal_label": "Или перейдите на план Metal и получайте трехкратные бонусы" }, "review_order": { "title": "Проверьте свой заказ", @@ -7104,7 +7228,7 @@ "ssn_description": "Требуется эмитентом карты. Проверка кредитной истории проводиться не будет.", "invalid_ssn": "Недопустимый SSN", "invalid_date_of_birth": "Неверная дата рождения. Ваш возраст должен быть не менее 18 лет", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "Имя и фамилия должны совпадать с вашими подтвержденными данными" }, "physical_address": { "title": "Введите свой адрес", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "Вы приближаетесь к своему лимиту расходов", "description": "Обновите, чтобы избежать отказов", - "confirm_button_label": "Установить новый лимит" + "confirm_button_label": "Установить новый лимит", + "dismiss_button_label": "Отклонить" }, "need_delegation": { "title": "Вам нужно включить вашу карту", @@ -7301,7 +7426,6 @@ "dismiss": "Отклонить", "update_success": "Лимит расходов успешно обновлен", "update_error": "Не удалось обновить лимит расходов", - "solana_not_supported": "Включить токены Solana на card.metamask.io", "select_token": "Выбрать токен", "loading": "Загрузка доступных токенов…", "load_error": "Не удалось загрузить токены. Попробуйте еще раз.", @@ -7343,9 +7467,7 @@ "limited": "Ограниченный", "not_enabled": "Не включен", "update_success": "Приоритет расходов успешно обновлен", - "update_error": "Не удалось обновить приоритет расходов", - "solana_not_supported_button_title": "Другие токены на Solana", - "solana_not_supported_button_description": "Включить на card.metamask.io" + "update_error": "Не удалось обновить приоритет расходов" }, "card_authentication": { "title": "Войдите в счет своей карты", @@ -7443,6 +7565,11 @@ "title": "Не удалось согласиться", "description": "Проверьте соединение и повторите попытку." }, + "version_guard": { + "title": "Требуется обновление", + "description": "Для использования функции «Бонусы» требуется более новая версия MetaMask. Обновите приложение, чтобы продолжить.", + "update_button": "Обновить MetaMask" + }, "season_error": { "error_fetching_title": "Не удалось загрузить сезон", "error_fetching_description": "Проверьте соединение и повторите попытку.", @@ -7525,7 +7652,6 @@ "main_title": "Награды", "referral_title": "Рефералы", "tab_overview_title": "Обзор", - "tab_snapshots_title": "Снимки", "tab_activity_title": "Деятельность", "referral_stats_earned_from_referrals": "Заработано на рефералах", "referral_stats_referrals": "Рефералы", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "В этом сезоне вы не получали бонусы, но всегда можно получить их в следующий раз.", "verifying_rewards": "Мы проверяем правильность всех данных, прежде чем вы сможете получить свои бонусы." }, + "previous_season_view": { + "title": "Предыдущий сезон" + }, "season_status": { "points_earned": "Заработанные баллы" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "Активные повышающие коэффициенты", "season_1": "Сезон 1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "Калькулятор бонусов mUSD", + "description": "Посмотрите, сколько вы могли бы заработать, конвертировав ваши стейблкоины в mUSD.", + "amount_label": "Сконвертированная сумма", + "estimated_bonus": "Расчетный годовой бонус: до 3%", + "initial_amount": "Начальная сумма", + "daily_bonus": "Ежедневный бонус, доступный для получения", + "annualized_bonus": "Годовой бонус", + "disclaimer": "Это лишь приблизительная оценка. Размер бонуса может измениться.", "buy_button": "Купить mUSD", - "swap_button": "Swap to mUSD" + "swap_button": "Обменять на mUSD" }, "upcoming_rewards": { "title": "Заблокированные награды", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "Ошибка загрузки" }, - "snapshot": { + "campaign": { "starts_date": "Начинается {{date}}", "ends_date": "Заканчивается {{date}}", - "results_coming_soon": "Скоро появятся результаты", - "tokens_on_the_way": "Токены в пути", + "ended_date": "Ended {{date}}", "pill_up_next": "Далее", - "pill_live_now": "Уже активно", - "pill_calculating": "Расчет", - "pill_results_ready": "Результаты готовы", - "pill_complete": "Завершено" - }, - "snapshots_section": { - "title": "Снимки", - "error_title": "Не удалось загрузить снимки", - "error_description": "Нам не удалось загрузить снимки. Попробуйте еще раз.", - "retry_button": "Повтор" - }, - "snapshots_tab": { + "pill_active": "Идет сейчас", + "pill_complete": "Завершено", + "enter_now": "Принять участие", + "entered": "Уже участвуете", + "participant_count": "#{{count}}", + "opt_in_cta": "Согласиться", + "opt_in_sheet_title": "Присоединиться к кампании", + "opt_in_sheet_description_pre_link": "Нажав на кнопку «Согласиться», вы соглашаетесь на участие в Бонусной программе MetaMask", + "opt_in_sheet_link_text": "Дополнительные условия использования и уведомление о конфиденциальности", + "opt_in_sheet_description_post_link": "Мы будем отслеживать активность в сети, чтобы автоматически начислять вам вознаграждения.", + "geo_restriction_banner_title": "Недоступно в вашем регионе", + "geo_restriction_banner_description": "Данная кампания недоступна в вашем регионе в связи с местными правилами." + }, + "campaign_mechanics": { + "title": "Механизм" + }, + "campaign_details": { + "start_date": "Начинается: {{date}}", + "end_date": "Заканчивается: {{date}}", + "opt_in": "Согласиться", + "opting_in": "Дача согласия...", + "opted_in": "Вы согласились на участие в этой кампании", + "opt_in_error": "Не удалось согласиться. Повторите попытку.", + "join_campaign": "Присоединиться к кампании", + "checking_opt_in_status": "Проверить статуса согласия на участие", + "swap": "Обменять", + "how_it_works": "Как это работает" + }, + "campaigns_preview": { + "title": "Кампании", + "coming_soon": "Скоро появятся", + "notify_me": "Уведомить меня" + }, + "earn_rewards": { + "title": "Заработать вознаграждения", + "musd_title": "Бонус до 3% на стейблкойны", + "musd_subtitle": "Рассчитайте свой бонус в mUSD", + "card_title": "До 3% кешбэка", + "card_subtitle": "Получите свою карту MetaMask прямо сейчас", + "card_subtitle_cardholder": "Воспользуйтесь преимуществами своей карты MetaMask" + }, + "campaigns_view": { + "title": "Кампании", "active_title": "Активно", "upcoming_title": "Далее", "previous_title": "Предыдущее", - "empty_state": "Нет доступных снимков", - "error_title": "Не удалось загрузить снимки", - "error_description": "Нам не удалось загрузить снимки. Попробуйте еще раз.", + "empty_state": "Нет доступных кампаний", + "error_title": "Не удалось загрузить кампании", + "error_description": "Нам не удалось загрузить кампании. Повторите попытку.", "retry_button": "Повтор", "refreshing": "Обновление..." } @@ -7953,13 +8112,12 @@ "continue": "Продолжить" }, "connecting": { - "title": "Подключите ваше {{device}}", + "title": "Подключение вашего {{device}}...", "searching": "Поиск {{device}}...", - "tips_header": "Чтобы продолжить, убедитесь, что:", + "tips_header": "Убедитесь:", "tip_unlock": "Ваше {{device}} разблокировано", "tip_open_app": "Приложение Ethereum открыто", "tip_enable_bluetooth": "Bluetooth включен", - "tip_dnd_off": "Режим «Не беспокоить» выключен", "tip_bluetooth_permission": "Разрешен доступ к геолокации и Bluetooth", "tip_bluetooth_permission_v12": "Разрешен доступ к устройствам поблизости", "tip_stay_close": "Ваше устройство находится рядом с телефоном" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "Требуется разрешение на доступ к устройствам поблизости", "bluetooth_off": "Включите Bluetooth для подключения к вашему устройству", "bluetooth_scan_failed": "Не удалось выполнить поиск устройств. Повторите попытку", - "bluetooth_connection_failed": "Включите Bluetooth на вашем устройстве, чтобы продолжить", + "bluetooth_connection_failed": "Сбой подключения к вашему устройству. Повторите попытку", "not_supported": "Эта операция не поддерживается", "unknown_error": "Убедитесь, что ваш {{device}} настроен с помощью секретной фразой для восстановления или пароля для этого счета" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "Наличные", + "cash_empty_description": "У вас пока нет mUSD. Конвертируйте стейблкоины в mUSD в разделе «Деньги» на главной странице.", + "cash_empty_description_network_filter": "В этой сети нет mUSD. Переключитесь на другую сеть, чтобы увидеть свои mUSD.", "tokens": "Токены", "perpetuals": "Бессрочные контракты", "predictions": "Прогнозы", + "whats_happening": "Что происходит", + "whats_happening_categories": { + "geopolitical": "Геополитика", + "macro": "Макро", + "regulatory": "Регулирование", + "technical": "Техника", + "social": "Социальные вопросы", + "other": "Другое" + }, "defi": "DeFi", "nfts": "NFT", "import_nfts": "Импорт NFT", diff --git a/locales/languages/tl.json b/locales/languages/tl.json index 6a2b36089ce..9c064573120 100644 --- a/locales/languages/tl.json +++ b/locales/languages/tl.json @@ -20,6 +20,12 @@ "update": "I-update" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "Alerto", @@ -120,8 +126,8 @@ "title": "Ipapadala ang mga asset sa burn address" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "Babala sa kontrata ng token", + "message": "Maaaring hindi sinusuportahan ng address ng tatanggap ang direktang mga paglilipat ng token, na maaaring magresulta sa pagkalugi ng pondo. Magpatuloy lamang kung tiyak ka na matatanggap ng kontratang ito ang paglilipat mo." }, "gas_sponsorship_reserve_balance": { "message": "Hindi available ang pag-iisponsor ng gas para sa transaksyong ito. Kakailanganin mo ng hindi bababa sa %{minBalance} %{nativeTokenSymbol} sa account mo.", @@ -694,8 +700,8 @@ "could_not_resolve_name": "Hindi maresolba ang pangalan", "invalid_address": "Di-wastong address", "contractAddressError": "Nagpapadala ka ng mga token sa address ng kontrata ng token. Maaari itong magresulta sa pagkawala ng mga token na ito.", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "Address ng smart na kontrata", + "smart_contract_address_warning": "Maaaring hindi sinusuportahan ng address ng tatanggap ang direktang mga paglilipat ng token, na maaaring magresulta sa pagkalugi ng pondo. Magpatuloy lamang kung tiyak ka na matatanggap ng kontratang ito ang paglilipat mo.", "i_understand": "Nauunawaan ko", "cancel": "Kanselahin" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "Ang stop loss ay dapat na {{direction}} {{priceType}} ang presyo", "stop_loss_beyond_liquidation_error": "Ang stop loss ay dapat na {{direction}} ang presyo ng liquidation", "stop_loss_order_view_warning": "Ang stop loss ay {{direction}} ang liquidation price", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "mas mataas", "below": "mas mababa", "done": "Tapos na", @@ -2086,14 +2094,15 @@ "a_closer_look": "Mas malalim na pagtingin", "whats_being_said": "Ano ang sinasabi", "footer_disclaimer": "Para sa impormasyon lang ang buod ng AI", - "trade_button": "Mag-trade", + "swap_button": "Mag-swap", + "buy_button": "Bumili", "sources_count": "+{{count}} (na) pinagmulan", "sources_title": "Mga mapagkukunan ng balita", "feedback_submitted": "Isinumite ang feedback", "helpful_prompt": "Nakatulong ba ito?", "feedback": { "title": "Feedback", - "description": "Tumulong na mapahusay ang mga pananaw sa market na gawa ng AI.", + "description": "Tinutulungan ng sagot mo na mapahusay ang mga buod ng aming AI.", "not_relevant": "Walang kaugnayan", "not_accurate": "Hindi tumpak", "hard_to_understand": "Mahirap maunawaan", @@ -2206,7 +2215,7 @@ "available_balance": "Available na balanse", "claim_amount_text": "I-claim ang ${{amount}}", "claim_winnings_text": "I-claim ang mga panalo", - "claiming_text": "Claiming...", + "claiming_text": "Kini-claim...", "unrealized_pnl_label": "Unrealized P&L", "unrealized_pnl_value": "{{amount}} ({{percent}})", "unrealized_pnl_error": "Hindi mai-load", @@ -2287,7 +2296,7 @@ "try_again": "Subukang muli" }, "in_progress": { - "title": "Claim already in progress" + "title": "Isinasagawa na ang pagki-claim" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "Bayad sa palitan o market", "total_incl_fees": "kasama ang mga bayarin", "close": "Isara", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "Ipinagpapalagay sa mga ipinakitang presyo na ganap na puno ang order mo. Maaaring iba-iba ang aktwal na mga halaga kapag bahagyang puno lamang ang order.", + "deposit_fee": "Bayad sa deposito", + "deposit_fee_description": "Bayarin na sinisingil para magdeposito ng mga pondo sa iyong balanse ng hula" }, "error": { "title": "Hindi maikonekta sa mga prediksyon", @@ -3059,6 +3068,7 @@ "networks_no_results": "Walang nahanap na network", "network_name_label": "Pangalan ng network", "network_name_placeholder": "Pangalan ng network (opsyonal)", + "required": "Kinakailangan", "network_rpc_url_label": "RPC URL", "network_rpc_name_label": "Pangalan ng RPC", "network_rpc_placeholder": "Bagong RPC network", @@ -3298,6 +3308,8 @@ "blockaid_desc": "Ang tampok na ito ay nag-aalerto sa iyo ng masamang aktibidad sa pamamagitan ng aktibong pagsusuri sa mga transaksyon at paghiling ng pirma.", "security_alerts": "Mga alerto sa seguridad", "security_alerts_desc": "Inaalertuhan ka ng tampok na ito sa mga aktibidad na may masamang hangarin sa pamamagitan ng lokal na pagsusuri sa iyong mga transaksyon at kahilingan sa paglagda. Palaging gumawa ng sarili mong pag-iingat bago aprubahan ang anumang mga kahilingan. Walang garantiya na made-detect ng tampok na ito ang lahat ng aktibidad na may masamang hangarin. Sa pagpapagana sa tampok na ito, sumasang-ayon ka sa mga tuntunin ng paggamit ng provider.", + "smart_account_dapp_requests_heading": "Mga kahilingan na smart account mula sa dapps", + "smart_account_dapp_requests_desc": "Hayaan ang dapps na hilingin ang mga feature ng smart account para sa mga standard na account. Hindi nito maaapektuhan ang mga smart account na.", "smart_transactions_opt_in_heading": "Mga Smart Transaction", "smart_transactions_opt_in_desc_supported_networks": "I-on ang mga Smart na Transaksyon para sa mas maaasahan at ligtas na mga transaksyon sa mga suportadong network.", "smart_transactions_learn_more": "Matuto pa", @@ -3566,6 +3578,53 @@ "activity": "Aktibidad ng {{symbol}}", "disclaimer": "Ibinibigay ang market data ng mga pinagmumulang third-party gaya ng CoinGecko. Ang data ay para lamang sa mga layuning pang-impormasyon. Hindi mananagot ang MetaMask para sa katumpakan nito." }, + "security_trust": { + "title": "Seguridad at tiwala", + "malicious": "Mapaminsala", + "risky": "Mapanganib", + "malicious_token_title": "Mapanganib na token", + "malicious_token_description": "Ang {{symbol}} ay isang mapaminsalang token. Iwasang makipag-ugnayan o i-trade ito.", + "verified_token_title": "Na-verify na token", + "verified_token_description": "Ang {{symbol}} ay aktibong tini-trade at malawakang kinikilala. Ang verification ay hindi pag-eendorso ng MetaMask.", + "risky_token_title": "Mapanganib na token", + "risky_token_description": "Natuklasan ang mga senyales ng pag-iingat sa {{symbol}}. Maingat na magsaliksik bago i-trade ang token na ito.", + "malicious_token_sheet_description": "Natuklasan ang mga senyales ng malubhang panganib sa {{symbol}}. Inirerekomenda namin na huwag i-trade ang token na ito.", + "got_it": "Nakuha ko", + "proceed": "Magpatuloy", + "cancel": "Kanselahin", + "data_unavailable": "Hindi available ang data ng seguridad", + "subtitle_known": "Walang natuklasang mga senyales ng panganib. Laging saliksikin ang anumang asset bago mag-trade.", + "subtitle_no_issues": "Walang natuklasang mga senyales ng panganib. Laging saliksikin ang anumang asset bago mag-trade.", + "subtitle_suspicious": "Natuklasan ang mga senyales ng pag-iingat sa. Maingat na suriin ang mga nai-flag na isyu bago i-trade ang asset na ito.", + "subtitle_malicious": "Natuklasan ang mga senyales ng malubhang panganib. Inirerekomenda namin na iwasan ang asset na ito.", + "subtitle_unavailable": "Hindi mai-load ang pagsusuri sa seguridad para sa token na ito.", + "token_distribution": "Pamamahagi ng token", + "total_supply": "Kabuuang supply", + "top_10_holders": "Nangungunang 10 na holder", + "other": "Iba pa", + "no_hidden_fees_detected": "Walang natuklasang mga nakatagong bayarin", + "buy_sell_tax": "Buwis sa Pagbili/Pagbebenta", + "buy_tax": "Buwis sa pagbili", + "sell_tax": "Buwis sa pagbebenta", + "transfer": "Maglipat", + "token_info": "Impormasyon ng Token", + "created": "Ginawa", + "token_age": "Tagal ng token", + "network": "Network", + "type": "Uri", + "official_links": "Mga Opisyal na Link", + "website": "Website", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "N/A", + "verified": "Na-verify", + "no_issues": "Walang mga isyu", + "suspicious": "Kahina-hinala", + "malicious_label": "Mapaminsala", + "more": "iba pa", + "evaluation_disclaimer": "Ang pagsusuri sa seguridad na ito ay para sa ebalwasyon lamang at hindi nangangahulugan ng pag-eendorso o rekomendasyon na mag-trade." + }, "account_details": { "title": "Mga detalye ng account", "share_account": "Ibahagi", @@ -5934,6 +5993,10 @@ "claimable_bonus": "Naki-claim na bonus", "claim_bonus": "I-claim ang bonus", "claim_bonus_subtitle": "Ibibigay ang bonus sa {{networkName}}.", + "percentage_bonus_on_linea": "{{percentage}}% bonus sa Linea", + "claim": "I-claim", + "sounds_good": "Mukhang maganda", + "claimable_bonus_tooltip_with_percentage": "{{percentage}}% taunang bonus na kinita mo sa pagho-hold ng mUSD. Pwedeng i-claim ang bonus mo sa Linea araw-araw.", "empty_state_cta": { "heading": "Magpahiram ng {{tokenSymbol}} at kumita ng", "body": "Ipahiram ang iyong {{tokenSymbol}} sa {{protocol}} at kumita", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "Mga stablecoin mo" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Mag-stake", "earn": "Kumita", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "Wala kang sapat na balanse ng mapagkukunan para gawin ang aksyong ito." }, - "trx_unstaking_in_progress": "Kasalukuyang ina-unstake ang {{amount}} TRX. Umaabot nang 14 na araw ang pag-unstake.", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "Kasalukuyang nag-a-unstake ng {{amount}} TRX", + "description": "Tatagal ng 14 araw para sa pag-unstake" + }, + "unstaked_banner": { + "title": "Nakumpleto ang pag-unstake ng {{amount}} TRX", + "description": "Maaari ng ma-withdraw ang na-unstake mo na TRX", + "button": "Mag-withdraw", + "error": "Pumalya ang pag-withdraw" + } }, "stake_eth": "Mag-stake ng ETH", "unstake_eth": "Mag-unstake ng ETH", @@ -6376,7 +6498,8 @@ "approve": "Aprubahan ang kahilingan", "perps_deposit": "Magdagdag ng pondo", "predict_deposit": "Magdagdag ng mga pondo para sa Prediksyon", - "predict_withdraw": "Mag-withdraw" + "predict_withdraw": "Mag-withdraw", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "Kailangan ng site na ito ng pahintulot para gastusin ang mga token mo.", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "Transaksyon {{index}}", "transaction": "Transaksyon", "available_balance": "Available na balanse: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "Magpatuloy", "deposit_edit_amount_done": "Magdagdag ng pondo", "deposit_edit_amount_predict_withdraw": "Mag-withdraw", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "Hindi pa sinusuportahan ang mga wallet na hardware. Gumamit ng hot wallet para magpatuloy.", "hardware_wallet_not_supported_solana": "Hindi pa sinusuportahan ang mga wallet na hardware sa Solana. Gumamit ng hot wallet para magpatuloy.", "price_impact_info_title": "Epekto ng presyo", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "Ganito binabago ng mga trade mo ang market price ng isang token. Depende ito sa laki ng trade, available na liquidity, at mga bayarin sa provider. Hindi kontrolado ng MetaMask ang epekto sa presyo.", "price_impact_info_gasless_description": "Ipinapakita ng epekto sa presyo kung paano naaapektuhan ng iyong swap order ang market price ng asset. Kung wala kang sapat na pondo para sa gas, awtomatikong ilalaan ang isang bahagi ng source token mo para sa mga bayarin, na magdaragdag sa epekto ng presyo. Walang impluwensya o kontrol ang MetaMask sa epekto sa presyo.", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "Dahil sa laki ng trade mo at available na liquidity, makakakuha ka ng mga {{priceImpact}} na mas mababa sa market price. Sinukat na ito sa quote mo.", "price_impact_high": "Matinding epekto sa presyo", "price_impact_execution_description": "Mawawalan ka ng tinatayang {{priceImpact}} ng halaga ng token mo sa swap na ito. Subukang ibaba ang halaga o pumili ng mas liquid na ruta.", "proceed": "Magpatuloy", @@ -6627,8 +6751,8 @@ "total_cost": "Kabuuang Halaga", "got_it": "Nakuha ko", "price_impact_warning_title": "Matinding epekto sa presyo", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "Napakatinding epekto sa presyo", + "price_impact_error_description": "Mawawalan ka ng tinatayang {{priceImpact}} ng market price ng token mo sa swap na ito. Subukan ang mas maliit na trade o mas liquid na ruta para pahusayin ang rate mo." }, "quote_expired_modal": { "title": "Available ang mga bagong quote", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "Metal Card", "price": "$199/taon", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "Virtual ang lahat, at:", + "feature_1": "Premium na nakaukit na metal card", + "feature_2": "3% cashback sa unang $10,000 kada taon", "feature_3": "Walang bayad sa transaksyon ang tagaibang bansa" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "Kumita ng hanggang $300 sa cashback taun-taon", + "upgrade_to_metal_label": "O mag-upgrade sa Metal para sa 3x na mga reward" }, "review_order": { "title": "Suriin ang order mo", @@ -7104,7 +7228,7 @@ "ssn_description": "Kinakailangan ng taga-isyu ng card. Hindi magsasagawa ng credit check.", "invalid_ssn": "Di-wastong SSN", "invalid_date_of_birth": "Maling petsa ng kapanganakan. Dapat na hindi bababa sa 18 taong gulang ang edad mo", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "Ang pangalan at apelyido ay dapat tumugma sa na-verify na pagkakakilanlan" }, "physical_address": { "title": "Ilagay ang address mo", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "Malapit mo nang maabot ang limitasyon mo sa paggastos", "description": "Mag-update para maiwasang matanggihan", - "confirm_button_label": "Magtakda ng bagong limitasyon" + "confirm_button_label": "Magtakda ng bagong limitasyon", + "dismiss_button_label": "I-dismiss" }, "need_delegation": { "title": "Kailangan mong i-enable ang card mo", @@ -7301,7 +7426,6 @@ "dismiss": "I-dismiss", "update_success": "Matagumpay na na-update ang limit ng paggastos", "update_error": "Hindi na-update ang limit ng paggastos", - "solana_not_supported": "Paganahin ang mga token ng Solana sa card.metamask.io", "select_token": "Pumili ng token", "loading": "Naglo-load ng mga available na token...", "load_error": "Hindi makapag-load ng mga token. Pakisubukan muli.", @@ -7343,9 +7467,7 @@ "limited": "Limitado", "not_enabled": "Hindi gumagana", "update_success": "Matagumpay na na-update ang prayoridad", - "update_error": "Hindi na-update ang prayoridad sa paggastos", - "solana_not_supported_button_title": "Iba pang mga token sa Solana", - "solana_not_supported_button_description": "Paganahin sa card.metamask.io" + "update_error": "Hindi na-update ang prayoridad sa paggastos" }, "card_authentication": { "title": "Mag-log in sa card account mo", @@ -7443,6 +7565,11 @@ "title": "Nabigong mag-opt-in", "description": "Suriin ang iyong koneksyon at subukang muli." }, + "version_guard": { + "title": "Kinakailangan ng update", + "description": "Kinakailangan ang mas bagong bersyon ng MetaMask para magamit ang mga Reward. Paki-update para makapagpatuloy.", + "update_button": "I-update ang MetaMask" + }, "season_error": { "error_fetching_title": "Hindi mai-load ang season", "error_fetching_description": "Suriin ang iyong koneksyon at subukang muli.", @@ -7525,7 +7652,6 @@ "main_title": "Mga Reward", "referral_title": "Mga Referral", "tab_overview_title": "Overview", - "tab_snapshots_title": "Mga snapshot", "tab_activity_title": "Aktibidad", "referral_stats_earned_from_referrals": "Nakuha mula sa mga referral", "referral_stats_referrals": "Mga Referral", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "Hindi ka nakakuha ng mga reward sa season na ito, pero mayroon pa namang ibang pagkakataon.", "verifying_rewards": "Sinisigurado namin na tama lahat bago mo i-claim ang mga reward mo." }, + "previous_season_view": { + "title": "Nakaraang Season" + }, "season_status": { "points_earned": "Mga point na nakuha" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "Mga active boost", "season_1": "Season 1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "bonus calculator ng mUSD", + "description": "Tingnan kung magkano ang maaari mong kitain sa pamamagitan ng pag-convert sa mga stablecoin mo sa mUSD.", + "amount_label": "Halagang na-convert", + "estimated_bonus": "Tinatayang taunang bonus: hanggang 3%", + "initial_amount": "Paunang halaga", + "daily_bonus": "Maki-claim na bonus araw-araw", + "annualized_bonus": "Taunang bonus", + "disclaimer": "Pagtataya lamang ito. Maaaring magbago ang bonus.", "buy_button": "Bumili ng mUSD", - "swap_button": "Swap to mUSD" + "swap_button": "I-swap sa mUSD" }, "upcoming_rewards": { "title": "Naka-lock na mga reward", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "Hindi mai-load" }, - "snapshot": { + "campaign": { "starts_date": "Magsisimula sa {{date}}", "ends_date": "Matatapos sa {{date}}", - "results_coming_soon": "Paparating na ang mga resulta", - "tokens_on_the_way": "Malapit na ang mga token", + "ended_date": "Ended {{date}}", "pill_up_next": "Susunod", - "pill_live_now": "Live ngayon", - "pill_calculating": "Kinakalkula", - "pill_results_ready": "Handa na ang mga Resulta", - "pill_complete": "Kumpleto na" - }, - "snapshots_section": { - "title": "Mga snapshot", - "error_title": "Hindi mai-load ang mga snapshot", - "error_description": "Hindi namin mai-load ang mga snapshot. Pakisubukan muli.", - "retry_button": "Subukang muli" - }, - "snapshots_tab": { + "pill_active": "Live", + "pill_complete": "Kumpleto na", + "enter_now": "Ilagay ngayon", + "entered": "Nailagay", + "participant_count": "#{{count}}", + "opt_in_cta": "Mag-opt in", + "opt_in_sheet_title": "Sumali sa campaign", + "opt_in_sheet_description_pre_link": "Sa pamamagitan ng pag-click sa ''Mag-opt in', sumasang-ayon ka sa Mga Reward ng MetaMask", + "opt_in_sheet_link_text": "Karagdagang Mga Tuntunin ng Paggamit at Abiso sa Privacy", + "opt_in_sheet_description_post_link": "Susubaybayan namin ang aktibidad sa onchain para agad kang mabigyan ng reward.", + "geo_restriction_banner_title": "Hindi available sa rehiyon mo", + "geo_restriction_banner_description": "Hindi available ang campaign na ito sa iyong rehiyon dahil sa mga lokal na regulasyon." + }, + "campaign_mechanics": { + "title": "Mechanics" + }, + "campaign_details": { + "start_date": "Magsisimula sa: {{date}}", + "end_date": "Matatapos sa: {{date}}", + "opt_in": "Mag-opt in", + "opting_in": "Nag-o-opt in...", + "opted_in": "Nag-opt in ka sa campaign na ito", + "opt_in_error": "Pumalaya ang pag-opt in. Pakisubukan muli.", + "join_campaign": "Sumali sa campaign", + "checking_opt_in_status": "Sinusuri ang katayuan ng pag-opt in", + "swap": "Mag-swap", + "how_it_works": "Paano ito gumagana" + }, + "campaigns_preview": { + "title": "Mga campaign", + "coming_soon": "Paparating na", + "notify_me": "Abisuhan ako" + }, + "earn_rewards": { + "title": "Kumita ng mga reward", + "musd_title": "Hanggang 3% bonus sa mga stable", + "musd_subtitle": "Kalkulahin ang iyong mUSD bonus", + "card_title": "Hanggang 3% cash back", + "card_subtitle": "Kunin ang iyong MetaMask Card ngayon", + "card_subtitle_cardholder": "I-access ang mga benepisyo ng iyong MetaMask Card" + }, + "campaigns_view": { + "title": "Mga campaign", "active_title": "Aktibo", "upcoming_title": "Paparating", "previous_title": "Nakaraan", - "empty_state": "Walang available na mga snapshot", - "error_title": "Hindi mai-load ang mga snapshot", - "error_description": "Hindi namin mai-load ang mga snapshot. Pakisubukan muli.", + "empty_state": "Walang mga campaign na available", + "error_title": "Hindi mai-load ang mga campaign", + "error_description": "Hindi namin mai-load ang mga campaign. Pakisubukan muli.", "retry_button": "Subukang muli", "refreshing": "Nire-refresh..." } @@ -7953,13 +8112,12 @@ "continue": "Magpatuloy" }, "connecting": { - "title": "Ikonekta ang {{device}} mo", + "title": "Ikinokonekta ang iyong {{device}}...", "searching": "Hinahanap ang {{device}}...", - "tips_header": "Para magpatuloy, siguraduhing:", + "tips_header": "Tiyakin na:", "tip_unlock": "Naka-unlock ang {{device}} mo", "tip_open_app": "Bukas ang Ethereum app", "tip_enable_bluetooth": "Naka-on ang bluetooth", - "tip_dnd_off": "Naka-off ang Do Not Disturb", "tip_bluetooth_permission": "Nagbigay ng pahintulot sa Lokasyon at Bluetooth", "tip_bluetooth_permission_v12": "Nagbigay ng pahintulot sa mga device sa paligid", "tip_stay_close": "Malapit ang device mo sa telepono mo" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "Kinakailangan ang pahintulot sa mga device sa paligid", "bluetooth_off": "I-on ang Bluetooth para kumonekta sa device mo", "bluetooth_scan_failed": "Hindi nakapag-scan ng mga device. Subukan ulit", - "bluetooth_connection_failed": "I-enable ang Bluetooh sa device mo para magpatuloy", + "bluetooth_connection_failed": "Pumalya ang koneksyon sa iyong device. Pakisubukan muli", "not_supported": "Hindi sinusuportahan ang operasyong ito", "unknown_error": "Siguraduhing may naka-set up na Lihim na Parirala sa Pagbawi o passphrase ang {{device}} mo para sa account na ito" }, @@ -8034,11 +8192,20 @@ "homepage": { "sections": { "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash_empty_description": "Wala ka pang kahit anong mUSD. I-convert ang mga stablecoin papuntang mUSD mula sa seksyon ng Cash sa homepage.", + "cash_empty_description_network_filter": "Walang mUSD sa network na ito. Lumipat ng network para makita ang mUSD mo.", "tokens": "Mga Token", "perpetuals": "Perpetuals", "predictions": "Mga hula", + "whats_happening": "Ano ang nangyayari", + "whats_happening_categories": { + "geopolitical": "Heopolitikal", + "macro": "Makro", + "regulatory": "Regulatoryo", + "technical": "Teknikal", + "social": "Sosyal", + "other": "Iba pa" + }, "defi": "DeFi", "nfts": "Mga NFT", "import_nfts": "Mag-import ng mga NFT", diff --git a/locales/languages/tr.json b/locales/languages/tr.json index cc453b540d7..8295430842c 100644 --- a/locales/languages/tr.json +++ b/locales/languages/tr.json @@ -20,6 +20,12 @@ "update": "Güncelle" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "Uyarı", @@ -120,8 +126,8 @@ "title": "Varlıklar yakım adresine gönderiliyor" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "Token sözleşmesi uyarısı", + "message": "Alıcı adresi doğrudan token transferlerini desteklemiyor olabilir ve bu durum para kaybına neden olabilir. Yalnızca transferinizin bu sözleşmeye ulaşabileceğinden eminseniz devam edin." }, "gas_sponsorship_reserve_balance": { "message": "Gaz sponsorluğu bu işlem için kullanılamıyor. Hesabınızda en az %{minBalance} %{nativeTokenSymbol} tutmanız gerekecek.", @@ -694,8 +700,8 @@ "could_not_resolve_name": "Adı çözümlenemedi", "invalid_address": "Geçersiz adres", "contractAddressError": "Token'in sözleşme adresine token gönderiyorsunuz. Bu durum, bu token'lerin kaybedilmesine neden olabilir.", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "Akıllı sözleşme adresi", + "smart_contract_address_warning": "Alıcı adresi doğrudan token transferlerini desteklemiyor olabilir ve bu durum para kaybına neden olabilir. Yalnızca transferinizin bu sözleşmeye ulaşabileceğinden eminseniz devam edin.", "i_understand": "Anlıyorum", "cancel": "İptal" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "Zararda durdur emri {{direction}} yönünde {{priceType}} fiyatında olmalıdır", "stop_loss_beyond_liquidation_error": "Zararda durdur emri {{direction}} yönünde likidasyon fiyatında olmalıdır", "stop_loss_order_view_warning": "Zararda durdur emri {{direction}} yönünde likidasyon fiyatında", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "üzerinde", "below": "altında", "done": "Bitti", @@ -2086,14 +2094,15 @@ "a_closer_look": "Daha yakın bir görünüm", "whats_being_said": "Neler söyleniyor", "footer_disclaimer": "Yapay zeka özeti yalnızca bilgi amaçlıdır", - "trade_button": "İşlem Yap", + "swap_button": "Takas", + "buy_button": "Al", "sources_count": "+{{count}} kaynak", "sources_title": "Haber kaynakları", "feedback_submitted": "Geri bildirim gönderildi", "helpful_prompt": "Bu faydalı oldu mu?", "feedback": { "title": "Geri Bildirim", - "description": "Yapay zeka ile oluşturulmuş piyasa içgörülerimizi iyileştirmemize yardımcı olun.", + "description": "Cevabınız yapay zeka özetlerimizi iyileştirmemize yardımcı olur.", "not_relevant": "İlgili değil", "not_accurate": "Doğru değil", "hard_to_understand": "Anlaşılması zor", @@ -2162,7 +2171,7 @@ "sell_position": "Sat pozisyonu", "cash_out": "Paraya çevir", "cash_out_info": "Fonlar kullanılabilir bakiyenize eklenecek", - "buy_preview_outcome_at_price": "{{outcome}} at {{price}}", + "buy_preview_outcome_at_price": "{{price}} için {{outcome}}", "at_price_per_share": "{{size}} hisse {{price}} fiyatından satılıyor", "cashout_info": "{{amount}} {{outcome}} için {{initialPrice}} fiyatıyla", "cashout_info_multiple": "{{amount}} {{outcomeGroupTitle}} • {{outcome}} için {{initialPrice}} fiyatıyla", @@ -2206,7 +2215,7 @@ "available_balance": "Kullanılabilir bakiye", "claim_amount_text": "{{amount}}$ al", "claim_winnings_text": "Kazançları al", - "claiming_text": "Claiming...", + "claiming_text": "Alınıyor...", "unrealized_pnl_label": "Gerçekleşmemiş K&Z", "unrealized_pnl_value": "({{percent}}) {{amount}}", "unrealized_pnl_error": "Yüklenemiyor", @@ -2287,7 +2296,7 @@ "try_again": "Tekrar dene" }, "in_progress": { - "title": "Claim already in progress" + "title": "Alım zaten sürüyor" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "Borsaya veya piyasaya ödenen ücret", "total_incl_fees": "ücretler dahil", "close": "Kapat", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "Gösterilen fiyatlar emrinizin tamamen gerçekleştiği varsayılarak hesaplanmıştır. Emrin sadece kısmen gerçekleşmesi durumunda gerçek tutarlar değişiklik gösterebilir.", + "deposit_fee": "Para yatırma ücreti", + "deposit_fee_description": "Tahmin bakiyenize para yatırma işlemi için alınan ücret" }, "error": { "title": "Tahminlere bağlanılamıyor", @@ -3059,6 +3068,7 @@ "networks_no_results": "Ağ bulunamadı", "network_name_label": "Ağ adı", "network_name_placeholder": "Ağ adı (isteğe bağlı)", + "required": "Gerekli", "network_rpc_url_label": "RPC URL adresi", "network_rpc_name_label": "RPC adı", "network_rpc_placeholder": "Yeni RPC ağı", @@ -3298,6 +3308,8 @@ "blockaid_desc": "Bu özellik, işlem ve imza taleplerini aktif bir şekilde inceleyerek kötü amaçlı aktivite konusunda sizi uyarır.", "security_alerts": "Güvenlik uyarıları", "security_alerts_desc": "Bu özellik, işlem ve imza taleplerinizi yerel olarak incelerken gizliliğinizi koruyarak Ethereum Ana Ağındaki kötü amaçlı aktivitelere karşı sizi uyarır. Talepleri onaylamadan önce her zaman gereken özeni kendiniz gösterin. Bu özelliğin tüm kötü amaçlı faaliyetleri algılayacağına dair herhangi bir garanti bulunmamaktadır. Bu özelliği etkinleştirerek sağlayıcının kullanım koşullarını kabul etmiş olursunuz.", + "smart_account_dapp_requests_heading": "Dapp'lerden akıllı hesap talepleri", + "smart_account_dapp_requests_desc": "Dapp'lerin standart hesaplar için akıllı hesap özellikleri talep etmesine izin verin. Bu durum, halihazırda akıllı hesap olan hesapları etkilemez.", "smart_transactions_opt_in_heading": "Akıllı İşlemler", "smart_transactions_opt_in_desc_supported_networks": "Desteklenen ağlarda daha güvenilir ve güvenli işlemler için Akıllı İşlemleri açın.", "smart_transactions_learn_more": "Daha fazla bilgi edin", @@ -3566,6 +3578,53 @@ "activity": "{{symbol}} aktivitesi", "disclaimer": "Piyasa verileri CoinGecko gibi üçüncü taraf kaynaklar tarafından sağlanır. Veriler yalnızca bilgi amaçlıdır. Verilerin doğruluğundan MetaMask sorumlu değildir." }, + "security_trust": { + "title": "Güvenlik ve güven", + "malicious": "Kötü amaçlı", + "risky": "Riskli", + "malicious_token_title": "Kötü amaçlı tokenlar", + "malicious_token_description": "{{symbol}} kötü amaçlı bir token. Bununla etkileşimde bulunmaktan veya işlem yapmaktan kaçının.", + "verified_token_title": "Doğrulanmış tokenlar", + "verified_token_description": "{{symbol}} aktif olarak işlem görmekte olup yaygın olarak tanınmaktadır. Doğrulama, MetaMask tarafından onaylandığı anlamına gelmez.", + "risky_token_title": "Riskli token", + "risky_token_description": "{{symbol}} üzerinde uyarı sinyalleri tespit edildi. Bu token ile işlem yapmadan önce dikkatli bir şekilde araştırın.", + "malicious_token_sheet_description": "{{symbol}} üzerinde ciddi risk sinyalleri tespit edildi. Bu token ile işlem yapmamanızı tavsiye ederiz.", + "got_it": "Anladım", + "proceed": "Devam et", + "cancel": "İptal et", + "data_unavailable": "Güvenlik verileri mevcut değil", + "subtitle_known": "Risk sinyali algılanmadı. Varlıklarla işlem yapmadan önce her zaman araştırma yapın.", + "subtitle_no_issues": "Risk sinyali algılanmadı. Varlıklarla işlem yapmadan önce her zaman araştırma yapın.", + "subtitle_suspicious": "Uyarı sinyalleri tespit edildi. Bu varlıkla işlem yapmadan önce işaretli sorunları dikkatli bir şekilde inceleyin.", + "subtitle_malicious": "Ciddi risk sinyalleri algılandı. Bu varlıktan kaçınmanızı tavsiye ederiz.", + "subtitle_unavailable": "Bu token için güvenlik analizi yüklenemedi.", + "token_distribution": "Token dağılımı", + "total_supply": "Toplam arz", + "top_10_holders": "En büyük 10 hissedar", + "other": "Diğer", + "no_hidden_fees_detected": "Gizli ücret algılanmadı", + "buy_sell_tax": "Alış/Satış Vergisi", + "buy_tax": "Alış vergisi", + "sell_tax": "Satış vergisi", + "transfer": "Transfer Et", + "token_info": "Token Bilgileri", + "created": "Oluşturuldu", + "token_age": "Token yaşı", + "network": "Ağ", + "type": "Bu snap'i kaldırmak istediğinizi onaylamak için", + "official_links": "Resmi Bağlantılar", + "website": "Web Sitesi", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "Yok", + "verified": "Doğrulandı", + "no_issues": "Sorun yok", + "suspicious": "Şüpheli", + "malicious_label": "Kötü amaçlı", + "more": "daha fazla", + "evaluation_disclaimer": "Bu güvenlik incelemesi yalnızca değerlendirme amaçlıdır ve onay veya işlem tavsiyesi teşkil etmez." + }, "account_details": { "title": "Hesap bilgileri", "share_account": "Paylaş", @@ -5934,6 +5993,10 @@ "claimable_bonus": "Alınabilir bonus", "claim_bonus": "Bonusu al", "claim_bonus_subtitle": "Bonus, {{networkName}} üzerinde ödenecektir.", + "percentage_bonus_on_linea": "Linea üzerinde %{{percentage}} bonus", + "claim": "Al", + "sounds_good": "Kulağa hoş geliyor", + "claimable_bonus_tooltip_with_percentage": "mUSD tuttuğunuz için kazandığınız %{{percentage}} yıllıklandırılmış bonus. Bonusunuzu Linea üzerinde günlük olarak alabilirsiniz.", "empty_state_cta": { "heading": "{{tokenSymbol}} borç verin ve kazanın", "body": "{{protocol}} ile {{tokenSymbol}} token'ınızı borç verin ve", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "Stabil kripto paralarınız" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Pay", "earn": "Kazan", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "Bu eylemi gerçekleştirmek için yeterli kaynak bakiyeniz yok." }, - "trx_unstaking_in_progress": "{{amount}} TRX unstake işlemi devam ediyor. Unstake işlemi 14 gün sürer.", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "{{amount}} TRX unstake işlemi sürüyor", + "description": "Unstake işlemi 14 gün sürecek" + }, + "unstaked_banner": { + "title": "{{amount}} TRX unstake işlemi tamamlandı", + "description": "Unstake edilen TRX'iniz şu anda çekilebilir", + "button": "Çek", + "error": "Para çekme işlemi başarısız" + } }, "stake_eth": "ETH Stake Et", "unstake_eth": "ETH Unstake Et", @@ -6376,7 +6498,8 @@ "approve": "Talebi onayla", "perps_deposit": "Fon ekle", "predict_deposit": "Tahmin fonu ekle", - "predict_withdraw": "Çek" + "predict_withdraw": "Çek", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "Bu site token'larınızı harcamak için izin istiyor.", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "{{index}} işlemi", "transaction": "İşlem", "available_balance": "Kullanılabilir bakiye: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "Devam et", "deposit_edit_amount_done": "Fon ekle", "deposit_edit_amount_predict_withdraw": "Çek", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "Donanım cüzdanları henüz desteklenmiyor. Devam etmek için sıcak cüzdan kullanın.", "hardware_wallet_not_supported_solana": "Donanım cüzdanları henüz Solana için desteklenmiyor. Devam etmek için sıcak cüzdan kullanın.", "price_impact_info_title": "Piyasa etkisi", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "Bu, işleminizin bir tokenın piyasa fiyatını nasıl değiştirdiğini ifade eder. Bu durum; işlem boyutuna, mevcut likiditeye ve sağlayıcı ücretlerine bağlıdır. MetaMask fiyat etkisini kontrol etmez.", "price_impact_info_gasless_description": "Fiyat etkisi, takas emrinizin varlığın piyasa fiyatını nasıl etkilediğini yansıtır. Gaz için yeterli fon tutmazsanız kaynak token'inizin bir kısmı otomatik olarak ücretleri karşılamak üzere ayrılır, bu nedenle fiyat etkisi artar. MetaMask fiyat etkisini etkilemez veya kontrol etmez.", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "İşleminizin büyüklüğü ve kullanılabilir likiditeden dolayı piyasa fiyatından yaklaşık {{priceImpact}} daha az alacaksınız. Bu zaten fiyat teklifinize dahil edilmiştir.", "price_impact_high": "Yüksek fiyat etkisi", "price_impact_execution_description": "Bu takas işleminde tokenınızın değerinden yaklaşık {{priceImpact}} kaybedeceksiniz. Miktarı düşürmeyi veya daha likit bir rota seçmeyi deneyin.", "proceed": "Devam et", @@ -6627,8 +6751,8 @@ "total_cost": "Toplam Maliyet", "got_it": "Anladım", "price_impact_warning_title": "Yüksek fiyat etkisi", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "Çok yüksek fiyat etkisi", + "price_impact_error_description": "Bu takas işleminde tokenınızın piyasa fiyatından yaklaşık {{priceImpact}} kaybedeceksiniz. Oranınızı iyileştirmek için daha küçük bir işlem veya daha likit bir rota deneyin." }, "quote_expired_modal": { "title": "Yeni teklifler mevcut", @@ -6940,7 +7064,7 @@ "upgrade_title": "Metale Yükselt", "continue_button": "Devam et", "virtual_card": { - "name": "Virtual Card", + "name": "Sanal Kart", "price": "Ücretsiz", "feature_1": "Apple Pay ve Google Pay için sanal kart", "feature_2": "Kripto ile öde (USDC, USDT, WETH ve daha fazlası)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "Metal Kart", "price": "199$/yıl", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "Her şey sanal ortamdadır, ayrıca:", + "feature_1": "Premium gravürlü metal kart", + "feature_2": "İlk 10.000$/yıl için %3 para iadesi", "feature_3": "Yabancı işlem ücretleri yok" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "Yılda 300$'a kadar para iadesi kazanın", + "upgrade_to_metal_label": "Veya 3 katı ödül için Metal karta yükseltin" }, "review_order": { "title": "Emrinizi inceleyin", @@ -7104,7 +7228,7 @@ "ssn_description": "Kart düzenleyicisi tarafından talep edilir. Kredi notu sorgulaması yapılmayacaktır.", "invalid_ssn": "Geçersiz Sosyal Güvenlik Numarası (SSN)", "invalid_date_of_birth": "Doğum tarihi geçersiz. En az 18 yaşında olmalısınız", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "Adı ve soyadı, doğrulanmış kimliğinizle uyumlu olmalıdır" }, "physical_address": { "title": "Adresinizi ekleyin", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "Harcama limitinize yakınsınız", "description": "Reddedilmeleri önlemek için güncelle", - "confirm_button_label": "Yeni limit ayarla" + "confirm_button_label": "Yeni limit ayarla", + "dismiss_button_label": "Yok say" }, "need_delegation": { "title": "Kartınızı etkinleştirmeniz gerekiyor", @@ -7301,7 +7426,6 @@ "dismiss": "Yok say", "update_success": "Harcama limiti başarılı bir şekilde güncellendi", "update_error": "Harcama limiti güncellenemedi", - "solana_not_supported": "card.metamask.io adresinde Solana token'larını etkinleştir", "select_token": "Token seç", "loading": "Kullanılabilir tokenler yükleniyor...", "load_error": "Tokenler yüklenemedi. Lütfen tekrar deneyin.", @@ -7343,9 +7467,7 @@ "limited": "Sınırlı", "not_enabled": "Etkinleştirilmedi", "update_success": "Harcama önceliği başarılı bir şekilde güncellendi", - "update_error": "Harcama önceliği güncellenemedi", - "solana_not_supported_button_title": "Solana üzerindeki diğer token'lar", - "solana_not_supported_button_description": "card.metamask.io adresinde etkinleştir" + "update_error": "Harcama önceliği güncellenemedi" }, "card_authentication": { "title": "Kart hesabınızda oturum açın", @@ -7443,6 +7565,11 @@ "title": "Katılım başarısız oldu", "description": "Bağlantınızı kontrol edin ve tekrar deneyin." }, + "version_guard": { + "title": "Güncelleme gerekli", + "description": "Ödüller programının kullanılabilmesi için MetaMask'in daha yeni bir sürümü gereklidir. Devam etmek için lütfen güncelleyin.", + "update_button": "MetaMask'i güncelle" + }, "season_error": { "error_fetching_title": "Sezon yüklenemedi", "error_fetching_description": "Bağlantınızı kontrol edin ve tekrar deneyin.", @@ -7525,7 +7652,6 @@ "main_title": "Ödüller", "referral_title": "Referanslar", "tab_overview_title": "Genel Bakış", - "tab_snapshots_title": "Anlık görüntüler", "tab_activity_title": "Aktivite", "referral_stats_earned_from_referrals": "Referanslardan kazanılan", "referral_stats_referrals": "Referanslar", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "Bu sezon ödül kazanmadınız ancak her zaman bir sonraki sezon vardır.", "verifying_rewards": "Ödüllerinizi almadan önce her şeyin doğru olduğunu teyit ediyoruz." }, + "previous_season_view": { + "title": "Önceki Sezon" + }, "season_status": { "points_earned": "Puan kazanıldı" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "Aktif yükseltmeler", "season_1": "Sezon 1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "mUSD bonus hesaplayıcı", + "description": "Stabil kripto paralarınızı mUSD'ye dönüştürerek ne kadar kazanabileceğinize bakın.", + "amount_label": "Dönüştürülen miktar", + "estimated_bonus": "Tahmini yıllıklandırılmış bonus: %3'e kadar", + "initial_amount": "Başlangıç miktarı", + "daily_bonus": "Günlük alınabilir bonus", + "annualized_bonus": "Yıllıklandırılmış bonus", + "disclaimer": "Bu yalnızca bir tahmindir. Bonus değişikliğe tabidir.", "buy_button": "mUSD al", - "swap_button": "Swap to mUSD" + "swap_button": "mUSD'ye takas et" }, "upcoming_rewards": { "title": "Kilitli ödüller", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "Yüklenemedi" }, - "snapshot": { + "campaign": { "starts_date": "Başlangıç tarihi {{date}}", "ends_date": "Bitiş tarihi {{date}}", - "results_coming_soon": "Sonuçlar çok yakında", - "tokens_on_the_way": "Tokenlar yolda", + "ended_date": "Ended {{date}}", "pill_up_next": "Sırada", - "pill_live_now": "Şimdi canlı", - "pill_calculating": "Hesaplanıyor", - "pill_results_ready": "Sonuçlar Hazır", - "pill_complete": "Tamamlandı" - }, - "snapshots_section": { - "title": "Anlık görüntüler", - "error_title": "Anlık görüntüler yüklenemiyor", - "error_description": "Anlık görüntüleri yükleyemedik. Lütfen tekrar deneyin.", - "retry_button": "Tekrar Dene" - }, - "snapshots_tab": { + "pill_active": "Canlı", + "pill_complete": "Tamamlandı", + "enter_now": "Şimdi giriş yapın", + "entered": "Giriş yapıldı", + "participant_count": "#{{count}}", + "opt_in_cta": "Katıl", + "opt_in_sheet_title": "Kampanyaya katıl", + "opt_in_sheet_description_pre_link": "'Katıl' düğmesine tıklayarak şunları kabul edersiniz: MetaMask Ödüller", + "opt_in_sheet_link_text": "Ek Kullanım Şartları ve Gizlilik Bildirimi", + "opt_in_sheet_description_post_link": "Sizi otomatik olarak ödüllendirmek için zincir üzeri etkinliğinizi takip edeceğiz.", + "geo_restriction_banner_title": "Bölgenizde kullanılamıyor", + "geo_restriction_banner_description": "Bu kampanya yerel düzenlemelerden dolayı bölgenizde kullanılamıyor." + }, + "campaign_mechanics": { + "title": "İşleyiş" + }, + "campaign_details": { + "start_date": "Başlangıç: {{date}}", + "end_date": "Bitiş: {{date}}", + "opt_in": "Katıl", + "opting_in": "Katılım sağlanıyor...", + "opted_in": "Bu kampanyaya katıldınız", + "opt_in_error": "Katılım sağlanamadı. Lütfen tekrar deneyin.", + "join_campaign": "Kampanyaya katıl", + "checking_opt_in_status": "Katılım durumu kontrol ediliyor", + "swap": "Takas", + "how_it_works": "Nasıl çalışır?" + }, + "campaigns_preview": { + "title": "Kampanyalar", + "coming_soon": "Çok yakında", + "notify_me": "Bana bildir" + }, + "earn_rewards": { + "title": "Ödül kazan", + "musd_title": "Stabil kripto paralarda %3'e kadar bonus", + "musd_subtitle": "mUSD bonusunuzu hesaplayın", + "card_title": "%3'e kadar para iadesi", + "card_subtitle": "MetaMask Card'ınızı hemen alın", + "card_subtitle_cardholder": "MetaMask Card faydalarınıza erişim sağlayın" + }, + "campaigns_view": { + "title": "Kampanyalar", "active_title": "Aktif", "upcoming_title": "Yaklaşan", "previous_title": "Önceki", - "empty_state": "Anlık görüntüler kullanılamıyor", - "error_title": "Anlık görüntüler yüklenemiyor", - "error_description": "Anlık görüntüleri yükleyemedik. Lütfen tekrar deneyin.", + "empty_state": "Kampanya mevcut değil", + "error_title": "Kampanyalar yüklenemiyor", + "error_description": "Kampanyaları yükleyemedik. Lütfen tekrar deneyin.", "retry_button": "Tekrar Dene", "refreshing": "Yenileniyor..." } @@ -7953,13 +8112,12 @@ "continue": "Devam et" }, "connecting": { - "title": "{{device}} cihazını bağla", + "title": "{{device}} cihazınız bağlanıyor...", "searching": "{{device}} aranıyor...", - "tips_header": "Devam etmek için şunlardan emin olun:", + "tips_header": "Şunlardan emin olun:", "tip_unlock": "{{device}} kilidi açık olmalı", "tip_open_app": "Ethereum uygulaması açık olmalı", "tip_enable_bluetooth": "Bluetooth açık olmalı", - "tip_dnd_off": "Rahatsız Etme modu kapalı olmalı", "tip_bluetooth_permission": "Konum ve Bluetooth izni verilmiş olmalı", "tip_bluetooth_permission_v12": "Yakındaki cihazlar izni verilmiş olmalı", "tip_stay_close": "Cihazınız telefonunuza yakın durmalı" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "Yakındaki cihazlar izni gereklidir", "bluetooth_off": "Cihazınıza bağlanmak için lütfen Bluetooth'u açın", "bluetooth_scan_failed": "Cihazlar taranamadı. Lütfen tekrar deneyin", - "bluetooth_connection_failed": "Devam etmek için cihazınızda Bluetooth'u etkinleştirin", + "bluetooth_connection_failed": "Cihazınıza bağlanılamadı. Lütfen tekrar deneyin", "not_supported": "Bu işlem desteklenmiyor", "unknown_error": "{{device}} cihazınızda bu hesap için Gizli Kurtarma İfadesi veya parola kurulumunun yapıldığından emin olun" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "Nakit", + "cash_empty_description": "Henüz mUSD'niz yok. Ana sayfada Nakit kısmından stabil kripto paraları mUSD'ye dönüştürün.", + "cash_empty_description_network_filter": "Bu ağda mUSD yok. mUSD'nizi görmek için ağ değiştirin.", "tokens": "tokenlar", "perpetuals": "Sürekli Vadeli İşlemler", "predictions": "Tahminler", + "whats_happening": "Neler oluyor", + "whats_happening_categories": { + "geopolitical": "Jeopolitik", + "macro": "Makro", + "regulatory": "Mevzuat", + "technical": "Teknik", + "social": "Sosyal", + "other": "Diğer" + }, "defi": "DeFi", "nfts": "NFT'ler", "import_nfts": "NFT'leri içe aktar", diff --git a/locales/languages/vi.json b/locales/languages/vi.json index 0ba27f0840b..31240aed98b 100644 --- a/locales/languages/vi.json +++ b/locales/languages/vi.json @@ -20,6 +20,12 @@ "update": "Cập nhật" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "Cảnh báo", @@ -120,8 +126,8 @@ "title": "Gửi tài sản đến địa chỉ đốt" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "Cảnh báo hợp đồng token", + "message": "Địa chỉ người nhận có thể không hỗ trợ chuyển khoản token trực tiếp, điều này có thể dẫn đến mất tiền. Chỉ tiếp tục nếu bạn chắc chắn hợp đồng này có thể nhận khoản chuyển của bạn." }, "gas_sponsorship_reserve_balance": { "message": "Tài trợ phí gas không khả dụng cho giao dịch này. Bạn cần giữ ít nhất %{minBalance} %{nativeTokenSymbol} trong tài khoản của mình.", @@ -694,8 +700,8 @@ "could_not_resolve_name": "Không thể giải mã tên", "invalid_address": "Địa chỉ không hợp lệ", "contractAddressError": "Bạn đang gửi token đến địa chỉ hợp đồng của token. Điều này có thể dẫn đến việc mất token đó.", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "Địa chỉ hợp đồng thông minh", + "smart_contract_address_warning": "Địa chỉ người nhận có thể không hỗ trợ chuyển khoản token trực tiếp, điều này có thể dẫn đến mất tiền. Chỉ tiếp tục nếu bạn chắc chắn hợp đồng này có thể nhận khoản chuyển của bạn.", "i_understand": "Tôi hiểu", "cancel": "Hủy" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "Giá cắt lỗ phải {{direction}} giá {{priceType}}", "stop_loss_beyond_liquidation_error": "Giá cắt lỗ phải {{direction}} giá thanh lý", "stop_loss_order_view_warning": "Giá cắt lỗ {{direction}} giá thanh lý", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "cao hơn", "below": "thấp hơn", "done": "Hoàn tất", @@ -2086,14 +2094,15 @@ "a_closer_look": "Xem chi tiết hơn", "whats_being_said": "Những gì đang được nói", "footer_disclaimer": "Tóm tắt AI chỉ mang tính chất cung cấp thông tin", - "trade_button": "Giao dịch", + "swap_button": "Hoán đổi", + "buy_button": "Mua", "sources_count": "+{{count}} nguồn", "sources_title": "Nguồn tin tức", "feedback_submitted": "Đã gửi phản hồi", "helpful_prompt": "Thông tin này có hữu ích không?", "feedback": { "title": "Phản hồi", - "description": "Giúp cải thiện thông tin thị trường do AI tạo ra.", + "description": "Câu trả lời của bạn giúp cải thiện các bản tóm tắt bằng AI của chúng tôi.", "not_relevant": "Không liên quan", "not_accurate": "Không chính xác", "hard_to_understand": "Khó hiểu", @@ -2206,7 +2215,7 @@ "available_balance": "Số dư khả dụng", "claim_amount_text": "Nhận ${{amount}}", "claim_winnings_text": "Nhận tiền thắng", - "claiming_text": "Claiming...", + "claiming_text": "Đang nhận...", "unrealized_pnl_label": "Lãi/Lỗ chưa thực hiện", "unrealized_pnl_value": "{{amount}} ({{percent}})", "unrealized_pnl_error": "Không thể tải", @@ -2287,7 +2296,7 @@ "try_again": "Thử lại" }, "in_progress": { - "title": "Claim already in progress" + "title": "Yêu cầu nhận đang được xử lý" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "Phí trả cho sàn giao dịch hoặc thị trường", "total_incl_fees": "bao gồm phí", "close": "Đóng", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "Giá hiển thị dựa trên giả định rằng lệnh của bạn đã khớp hoàn toàn. Số tiền thực tế có thể khác nếu lệnh chỉ khớp một phần.", + "deposit_fee": "Phí gửi tiền", + "deposit_fee_description": "Phí được tính để gửi tiền vào số dư dự đoán của bạn" }, "error": { "title": "Không thể kết nối với thị trường dự đoán", @@ -3059,6 +3068,7 @@ "networks_no_results": "Không tìm thấy mạng nào", "network_name_label": "Tên mạng", "network_name_placeholder": "Tên mạng (không bắt buộc)", + "required": "Bắt buộc", "network_rpc_url_label": "URL RPC", "network_rpc_name_label": "Tên RPC", "network_rpc_placeholder": "Mạng RPC mới", @@ -3298,6 +3308,8 @@ "blockaid_desc": "Tính năng này sẽ cảnh báo bạn khi có hoạt động độc hại bằng cách chủ động xem xét các yêu cầu giao dịch và chữ ký.", "security_alerts": "Cảnh báo bảo mật", "security_alerts_desc": "Tính năng này sẽ cảnh báo bạn về hoạt động độc hại bằng cách xem xét cục bộ các yêu cầu giao dịch và chữ ký của bạn. Hãy luôn tự mình thực hiện quy trình thẩm định trước khi chấp thuận bất kỳ yêu cầu nào. Không có gì đảm bảo rằng tính năng này sẽ phát hiện được tất cả các hoạt động độc hại. Bằng cách bật tính năng này, bạn đồng ý với các điều khoản sử dụng của nhà cung cấp.", + "smart_account_dapp_requests_heading": "Yêu cầu tài khoản thông minh từ dapp", + "smart_account_dapp_requests_desc": "Cho phép dapp yêu cầu các tính năng tài khoản thông minh cho tài khoản tiêu chuẩn. Điều này sẽ không ảnh hưởng đến các tài khoản đã là tài khoản thông minh.", "smart_transactions_opt_in_heading": "Giao dịch thông minh", "smart_transactions_opt_in_desc_supported_networks": "Bật Giao dịch thông minh để có các giao dịch đáng tin cậy và an toàn hơn trên các mạng được hỗ trợ.", "smart_transactions_learn_more": "Tìm hiểu thêm", @@ -3566,6 +3578,53 @@ "activity": "Hoạt động {{symbol}}", "disclaimer": "Dữ liệu thị trường được cung cấp bởi các nguồn bên thứ ba như CoinGecko. Dữ liệu chỉ mang tính chất tham khảo. MetaMask không chịu trách nhiệm về tính chính xác của dữ liệu." }, + "security_trust": { + "title": "Bảo mật và độ tin cậy", + "malicious": "Độc hại", + "risky": "Rủi ro", + "malicious_token_title": "Token độc hại", + "malicious_token_description": "{{symbol}} là một token độc hại. Tránh tương tác hoặc giao dịch với token này.", + "verified_token_title": "Token đã xác minh", + "verified_token_description": "{{symbol}} đang được giao dịch tích cực và được công nhận rộng rãi. Việc xác minh không phải là sự chứng thực của MetaMask.", + "risky_token_title": "Token rủi ro", + "risky_token_description": "Phát hiện tín hiệu cảnh báo đối với {{symbol}}. Hãy nghiên cứu kỹ trước khi giao dịch token này.", + "malicious_token_sheet_description": "Phát hiện tín hiệu rủi ro nghiêm trọng đối với {{symbol}}. Chúng tôi khuyến nghị không giao dịch token này.", + "got_it": "Tôi đã hiểu", + "proceed": "Tiếp tục", + "cancel": "Hủy", + "data_unavailable": "Không có dữ liệu bảo mật", + "subtitle_known": "Không phát hiện tín hiệu rủi ro. Luôn nghiên cứu bất kỳ tài sản nào trước khi giao dịch.", + "subtitle_no_issues": "Không phát hiện tín hiệu rủi ro. Luôn nghiên cứu bất kỳ tài sản nào trước khi giao dịch.", + "subtitle_suspicious": "Phát hiện tín hiệu cảnh báo. Hãy xem xét kỹ các vấn đề đã được đánh dấu trước khi giao dịch tài sản này.", + "subtitle_malicious": "Phát hiện tín hiệu rủi ro nghiêm trọng. Chúng tôi khuyến nghị tránh giao dịch với tài sản này.", + "subtitle_unavailable": "Không thể tải phân tích bảo mật cho token này.", + "token_distribution": "Phân bổ token", + "total_supply": "Tổng nguồn cung", + "top_10_holders": "Top 10 người nắm giữ", + "other": "Khác", + "no_hidden_fees_detected": "Không phát hiện phí ẩn", + "buy_sell_tax": "Thuế mua/bán", + "buy_tax": "Thuế mua", + "sell_tax": "Thuế bán", + "transfer": "Chuyển", + "token_info": "Thông tin token", + "created": "Đã tạo", + "token_age": "Tuổi token", + "network": "Mạng", + "type": "Nhập", + "official_links": "Liên kết chính thức", + "website": "Trang web", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "Không áp dụng", + "verified": "Đã xác minh", + "no_issues": "Không có vấn đề", + "suspicious": "Đáng ngờ", + "malicious_label": "Độc hại", + "more": "thêm", + "evaluation_disclaimer": "Đánh giá bảo mật này chỉ nhằm mục đích thẩm định và không cấu thành sự chứng thực hoặc khuyến nghị giao dịch." + }, "account_details": { "title": "Chi tiết tài khoản", "share_account": "Chia sẻ", @@ -5934,6 +5993,10 @@ "claimable_bonus": "Thưởng có thể nhận", "claim_bonus": "Nhận thưởng", "claim_bonus_subtitle": "Tiền thưởng sẽ được trả trên {{networkName}}.", + "percentage_bonus_on_linea": "Thưởng {{percentage}}% trên Linea", + "claim": "Nhận", + "sounds_good": "Có vẻ tốt", + "claimable_bonus_tooltip_with_percentage": "{{percentage}}% phần thưởng quy đổi theo năm bạn đã nhận được khi nắm giữ mUSD. Bạn có thể nhận thưởng hằng ngày trên Linea.", "empty_state_cta": { "heading": "Cho vay {{tokenSymbol}} và nhận lãi", "body": "Cho vay {{tokenSymbol}} với {{protocol}} và kiếm lợi nhuận", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "Đồng ổn định của bạn" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Stake", "earn": "Kiếm lợi nhuận", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "Bạn không có đủ số dư tài nguyên để thực hiện hành động này." }, - "trx_unstaking_in_progress": "Đang tiến hành hủy ký gửi {{amount}} TRX. Quá trình hủy ký gửi sẽ mất 14 ngày.", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "Đang tiến hành hủy ký gửi {{amount}} TRX", + "description": "Quá trình hủy ký gửi sẽ mất 14 ngày" + }, + "unstaked_banner": { + "title": "Đã hoàn tất hủy ký gửi {{amount}} TRX", + "description": "Hiện có thể rút TRX đã hủy ký gửi của bạn", + "button": "Rút tiền", + "error": "Rút tiền thất bại" + } }, "stake_eth": "Ký gửi ETH", "unstake_eth": "Hủy ký gửi ETH", @@ -6376,7 +6498,8 @@ "approve": "Phê duyệt yêu cầu", "perps_deposit": "Nạp tiền", "predict_deposit": "Nạp tiền Dự đoán", - "predict_withdraw": "Rút tiền" + "predict_withdraw": "Rút tiền", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "Trang web này muốn được cấp quyền chi tiêu token của bạn.", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "Giao dịch {{index}}", "transaction": "Bảo vệ", "available_balance": "Số dư khả dụng: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "Tiếp tục", "deposit_edit_amount_done": "Nạp tiền", "deposit_edit_amount_predict_withdraw": "Rút tiền", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "Ví cứng hiện chưa được hỗ trợ. Hãy sử dụng ví nóng để tiếp tục.", "hardware_wallet_not_supported_solana": "Ví cứng hiện chưa được hỗ trợ cho Solana. Hãy sử dụng ví nóng để tiếp tục.", "price_impact_info_title": "Mức tác động giá", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "Đây là cách giao dịch của bạn làm thay đổi giá thị trường của một token. Điều này phụ thuộc vào quy mô giao dịch, thanh khoản hiện có và phí nhà cung cấp. MetaMask không kiểm soát tác động giá.", "price_impact_info_gasless_description": "Tác động giá phản ánh cách lệnh hoán đổi của bạn ảnh hưởng đến giá thị trường của tài sản. Nếu bạn không có đủ tiền để trả phí gas, một phần token gốc của bạn sẽ tự động được phân bổ để trả phí, làm tăng tác động giá. MetaMask không can thiệp hoặc kiểm soát tác động giá.", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "Do quy mô giao dịch của bạn và thanh khoản hiện có, bạn sẽ nhận được ít hơn khoảng {{priceImpact}} so với giá thị trường. Điều này đã được tính vào báo giá của bạn.", "price_impact_high": "Tác động giá cao", "price_impact_execution_description": "Bạn sẽ mất khoảng {{priceImpact}} giá trị token trong lần hoán đổi này. Hãy thử giảm số lượng hoặc chọn tuyến thanh khoản tốt hơn.", "proceed": "Tiếp tục", @@ -6627,8 +6751,8 @@ "total_cost": "Tổng chi phí", "got_it": "Tôi đã hiểu", "price_impact_warning_title": "Tác động giá cao", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "Tác động giá rất cao", + "price_impact_error_description": "Bạn sẽ mất khoảng {{priceImpact}} giá thị trường của token trong giao dịch hoán đổi này. Hãy thử giao dịch với số lượng nhỏ hơn hoặc một lộ trình có thanh khoản cao hơn để cải thiện tỷ giá." }, "quote_expired_modal": { "title": "Đã có báo giá mới", @@ -6940,7 +7064,7 @@ "upgrade_title": "Nâng cấp lên Metal", "continue_button": "Tiếp tục", "virtual_card": { - "name": "Virtual Card", + "name": "Thẻ ảo", "price": "Miễn phí", "feature_1": "Thẻ ảo dùng cho Apple Pay và Google Pay", "feature_2": "Thanh toán bằng tiền mã hoá (USDC, USDT, WETH, v.v.)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "Thẻ Metal", "price": "$199/năm", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "Mọi thứ có trong thẻ ảo, cộng thêm:", + "feature_1": "Thẻ kim loại khắc tên cao cấp", + "feature_2": "Hoàn tiền 3% cho $10.000 đầu tiên mỗi năm", "feature_3": "Không tính phí giao dịch quốc tế" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "Nhận tối đa $300 hoàn tiền mỗi năm", + "upgrade_to_metal_label": "Hoặc nâng cấp lên Metal để nhận phần thưởng gấp 3 lần" }, "review_order": { "title": "Xem lại đơn hàng của bạn", @@ -7104,7 +7228,7 @@ "ssn_description": "Theo yêu cầu của đơn vị phát hành thẻ. Sẽ không thực hiện kiểm tra tín dụng.", "invalid_ssn": "Số An Sinh Xã Hội không hợp lệ", "invalid_date_of_birth": "Ngày sinh không hợp lệ. Bạn phải ít nhất 18 tuổi", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "Tên và họ phải trùng khớp với danh tính đã được xác minh của bạn" }, "physical_address": { "title": "Thêm địa chỉ của bạn", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "Bạn sắp đạt đến hạn mức chi tiêu", "description": "Cập nhật để tránh bị từ chối", - "confirm_button_label": "Đặt hạn mức mới" + "confirm_button_label": "Đặt hạn mức mới", + "dismiss_button_label": "Đóng" }, "need_delegation": { "title": "Bạn cần kích hoạt thẻ", @@ -7301,7 +7426,6 @@ "dismiss": "Đóng", "update_success": "Đã cập nhật hạn mức chi tiêu thành công", "update_error": "Cập nhật hạn mức chi tiêu thất bại", - "solana_not_supported": "Kích hoạt token Solana trên card.metamask.io", "select_token": "Chọn token", "loading": "Đang tải các token khả dụng...", "load_error": "Không thể tải token. Vui lòng thử lại.", @@ -7343,9 +7467,7 @@ "limited": "Bị giới hạn", "not_enabled": "Chưa kích hoạt", "update_success": "Đã cập nhật mức ưu tiên chi tiêu thành công", - "update_error": "Cập nhật mức ưu tiên chi tiêu thất bại", - "solana_not_supported_button_title": "Các token khác trên Solana", - "solana_not_supported_button_description": "Kích hoạt trên card.metamask.io" + "update_error": "Cập nhật mức ưu tiên chi tiêu thất bại" }, "card_authentication": { "title": "Đăng nhập vào tài khoản thẻ của bạn", @@ -7443,6 +7565,11 @@ "title": "Đăng ký tham gia thất bại", "description": "Kiểm tra kết nối của bạn và thử lại." }, + "version_guard": { + "title": "Yêu cầu cập nhật", + "description": "Cần phiên bản MetaMask mới hơn để sử dụng Phần thưởng. Vui lòng cập nhật để tiếp tục.", + "update_button": "Cập nhật MetaMask" + }, "season_error": { "error_fetching_title": "Không thể tải mùa giải", "error_fetching_description": "Kiểm tra kết nối của bạn và thử lại.", @@ -7525,7 +7652,6 @@ "main_title": "Phần thưởng", "referral_title": "Giới thiệu", "tab_overview_title": "Tổng quan", - "tab_snapshots_title": "Ảnh chụp nhanh", "tab_activity_title": "Hoạt động", "referral_stats_earned_from_referrals": "Nhận được từ giới thiệu", "referral_stats_referrals": "Giới thiệu", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "Bạn chưa nhận được phần thưởng trong mùa này, nhưng vẫn còn cơ hội lần sau.", "verifying_rewards": "Chúng tôi đang kiểm tra mọi thứ trước khi bạn nhận phần thưởng." }, + "previous_season_view": { + "title": "Mùa trước" + }, "season_status": { "points_earned": "Điểm đã tích lũy" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "Tính năng tăng cường đang hoạt động", "season_1": "Mùa 1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "Máy tính thưởng mUSD", + "description": "Xem bạn có thể nhận được bao nhiêu khi chuyển đổi đồng ổn định của mình sang mUSD.", + "amount_label": "Số lượng đã chuyển đổi", + "estimated_bonus": "Thưởng ước tính theo năm: tối đa 3%", + "initial_amount": "Số lượng ban đầu", + "daily_bonus": "Thưởng có thể nhận hằng ngày", + "annualized_bonus": "Thưởng theo năm", + "disclaimer": "Đây chỉ là ước tính. Phần thưởng có thể thay đổi.", "buy_button": "Mua mUSD", - "swap_button": "Swap to mUSD" + "swap_button": "Hoán đổi sang mUSD" }, "upcoming_rewards": { "title": "Phần thưởng bị khóa", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "Không thể tải" }, - "snapshot": { + "campaign": { "starts_date": "Bắt đầu {{date}}", "ends_date": "Kết thúc {{date}}", - "results_coming_soon": "Kết quả sẽ sớm được công bố", - "tokens_on_the_way": "Token đang được gửi đến", + "ended_date": "Ended {{date}}", "pill_up_next": "Sắp tới", - "pill_live_now": "Đang diễn ra", - "pill_calculating": "Đang tính toán", - "pill_results_ready": "Kết quả đã sẵn sàng", - "pill_complete": "Hoàn tất" - }, - "snapshots_section": { - "title": "Ảnh chụp nhanh", - "error_title": "Không thể tải ảnh chụp nhanh", - "error_description": "Chúng tôi không thể tải ảnh chụp nhanh. Vui lòng thử lại.", - "retry_button": "Thử lại" - }, - "snapshots_tab": { + "pill_active": "Đang diễn ra", + "pill_complete": "Hoàn tất", + "enter_now": "Tham gia ngay", + "entered": "Đã tham gia", + "participant_count": "#{{count}}", + "opt_in_cta": "Đồng ý tham gia", + "opt_in_sheet_title": "Tham gia chiến dịch", + "opt_in_sheet_description_pre_link": "Bằng cách nhấn vào \"Đồng ý tham gia\", bạn đồng ý với MetaMask Phần thưởng", + "opt_in_sheet_link_text": "Điều khoản sử dụng bổ sung và Thông báo quyền riêng tư", + "opt_in_sheet_description_post_link": "Chúng tôi sẽ theo dõi hoạt động trên chuỗi để tự động tặng thưởng cho bạn.", + "geo_restriction_banner_title": "Không khả dụng tại khu vực của bạn", + "geo_restriction_banner_description": "Chiến dịch này không khả dụng tại khu vực của bạn do quy định địa phương." + }, + "campaign_mechanics": { + "title": "Cơ chế" + }, + "campaign_details": { + "start_date": "Bắt đầu: {{date}}", + "end_date": "Kết thúc: {{date}}", + "opt_in": "Đồng ý tham gia", + "opting_in": "Đang xác nhận tham gia...", + "opted_in": "Bạn đã tham gia chiến dịch này", + "opt_in_error": "Không thể tham gia. Vui lòng thử lại.", + "join_campaign": "Tham gia chiến dịch", + "checking_opt_in_status": "Đang kiểm tra trạng thái tham gia", + "swap": "Hoán đổi", + "how_it_works": "Cách hoạt động" + }, + "campaigns_preview": { + "title": "Chiến dịch", + "coming_soon": "Sắp ra mắt", + "notify_me": "Thông báo cho tôi" + }, + "earn_rewards": { + "title": "Nhận phần thưởng", + "musd_title": "Thưởng lên đến 3% cho đồng ổn định", + "musd_subtitle": "Tính toán thưởng mUSD của bạn", + "card_title": "Hoàn tiền lên đến 3%", + "card_subtitle": "Nhận MetaMask Card của bạn ngay", + "card_subtitle_cardholder": "Truy cập các quyền lợi MetaMask Card của bạn" + }, + "campaigns_view": { + "title": "Chiến dịch", "active_title": "Đang hoạt động", "upcoming_title": "Sắp tới", "previous_title": "Trước đó", - "empty_state": "Không có ảnh chụp nhanh nào", - "error_title": "Không thể tải ảnh chụp nhanh", - "error_description": "Chúng tôi không thể tải ảnh chụp nhanh. Vui lòng thử lại.", + "empty_state": "Không có chiến dịch nào", + "error_title": "Không thể tải các chiến dịch", + "error_description": "Chúng tôi không thể tải các chiến dịch. Vui lòng thử lại.", "retry_button": "Thử lại", "refreshing": "Đang làm mới..." } @@ -7953,13 +8112,12 @@ "continue": "Tiếp tục" }, "connecting": { - "title": "Kết nối {{device}} của bạn", + "title": "Đang kết nối {{device}} của bạn...", "searching": "Đang tìm {{device}}...", - "tips_header": "Để tiếp tục, hãy đảm bảo:", + "tips_header": "Đảm bảo:", "tip_unlock": "{{device}} của bạn đã được mở khóa", "tip_open_app": "Ứng dụng Ethereum đang mở", "tip_enable_bluetooth": "Bluetooth đã được bật", - "tip_dnd_off": "Chế độ Không làm phiền đã được tắt", "tip_bluetooth_permission": "Quyền truy cập Vị trí và Bluetooth đã được cấp", "tip_bluetooth_permission_v12": "Quyền truy cập Thiết bị lân cận đã được cấp", "tip_stay_close": "Thiết bị của bạn ở gần điện thoại" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "Cần quyền truy cập Thiết bị lân cận", "bluetooth_off": "Vui lòng bật Bluetooth để kết nối với thiết bị của bạn", "bluetooth_scan_failed": "Không thể quét thiết bị. Vui lòng thử lại", - "bluetooth_connection_failed": "Bật Bluetooth trên thiết bị của bạn để tiếp tục", + "bluetooth_connection_failed": "Kết nối với thiết bị của bạn không thành công. Vui lòng thử lại", "not_supported": "Thao tác này không được hỗ trợ", "unknown_error": "Đảm bảo {{device}} của bạn đã được thiết lập với Cụm từ khôi phục bí mật hoặc cụm mật khẩu cho tài khoản này" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "Tiền mặt", + "cash_empty_description": "Bạn chưa có mUSD nào. Hãy chuyển đổi đồng ổn định sang mUSD từ mục Tiền mặt trên trang chủ.", + "cash_empty_description_network_filter": "Không có mUSD trên mạng này. Hãy chuyển mạng để xem mUSD của bạn.", "tokens": "Token", "perpetuals": "Hợp đồng vĩnh cửu", "predictions": "Dự đoán", + "whats_happening": "Tình hình hiện nay", + "whats_happening_categories": { + "geopolitical": "Địa chính trị", + "macro": "Vĩ mô", + "regulatory": "Quy định", + "technical": "Kỹ thuật", + "social": "Xã hội", + "other": "Khác" + }, "defi": "DeFi", "nfts": "NFT", "import_nfts": "Nhập NFT", diff --git a/locales/languages/zh.json b/locales/languages/zh.json index 883af8b0d34..dbadf6900b1 100644 --- a/locales/languages/zh.json +++ b/locales/languages/zh.json @@ -20,6 +20,12 @@ "update": "更新" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "提醒", @@ -120,8 +126,8 @@ "title": "正在向销毁地址发送资产" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "代币合约警告", + "message": "该代币接收地址可能不支持直接转账,这可能导致资金损失。建议仅在确认该合约能够接收转账的情况下继续操作。" }, "gas_sponsorship_reserve_balance": { "message": "本次交易无法使用燃料赞助。您的账户中需要至少保留 %{minBalance} %{nativeTokenSymbol}。", @@ -694,8 +700,8 @@ "could_not_resolve_name": "无法解析名称", "invalid_address": "地址无效", "contractAddressError": "您正在向代币的合约地址发送代币。这可能导致这些代币丢失。", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "智能合约地址", + "smart_contract_address_warning": "该代币接收地址可能不支持直接转账,这可能导致资金损失。建议仅在确认该合约能够接收转账的情况下继续操作。", "i_understand": "我理解", "cancel": "取消" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "须以 {{direction}}{{priceType}} 价格止损", "stop_loss_beyond_liquidation_error": "须以 {{direction}} 清算价格止损", "stop_loss_order_view_warning": "以 {{direction}} 清算价格止损", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "高于", "below": "低于", "done": "已完成", @@ -2086,14 +2094,15 @@ "a_closer_look": "深度观察", "whats_being_said": "市场声音", "footer_disclaimer": "AI 摘要仅供参考", - "trade_button": "交易", + "swap_button": "交换", + "buy_button": "买入", "sources_count": "+{{count}} 个来源", "sources_title": "新闻来源", "feedback_submitted": "反馈已提交", "helpful_prompt": "这对您有帮助吗?", "feedback": { "title": "反馈", - "description": "帮助我们改进人工智能生成的市场洞察。", + "description": "您的回答有助于优化我们的 AI 摘要。", "not_relevant": "不相关", "not_accurate": "不准确", "hard_to_understand": "难以理解", @@ -2206,7 +2215,7 @@ "available_balance": "可用余额", "claim_amount_text": "领取 ${{amount}}", "claim_winnings_text": "领取收益", - "claiming_text": "Claiming...", + "claiming_text": "正在领取……", "unrealized_pnl_label": "未实现盈亏", "unrealized_pnl_value": "{{amount}}({{percent}})", "unrealized_pnl_error": "无法加载", @@ -2287,7 +2296,7 @@ "try_again": "请重试" }, "in_progress": { - "title": "Claim already in progress" + "title": "领取正在进行中" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "支付给交易所或市场的费用", "total_incl_fees": "包含费用", "close": "关闭", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "显示的价格基于订单完全成交的假设。若仅部分成交,实际金额可能会有所不同。", + "deposit_fee": "存款手续费", + "deposit_fee_description": "将资金充值到您的预测余额所需支付的手续费" }, "error": { "title": "无法连接预测市场", @@ -3059,6 +3068,7 @@ "networks_no_results": "未找到网络", "network_name_label": "网络名称", "network_name_placeholder": "网络名称(可选)", + "required": "必需", "network_rpc_url_label": "RPC(远程过程调用)URL", "network_rpc_name_label": "RPC(远程过程调用)名称", "network_rpc_placeholder": "新 RPC 网络", @@ -3298,6 +3308,8 @@ "blockaid_desc": "此功能通过主动审查交易和签名请求向您发出恶意活动提醒。", "security_alerts": "安全警报", "security_alerts_desc": "此功能通过本地审查您的交易和签名请求来提醒您注意恶意活动。在批准任何请求之前,请务必自行进行审慎调查。无法保证此功能能够检测到所有恶意活动。启用此功能即表示您同意提供商的使用条款。", + "smart_account_dapp_requests_heading": "来自 dapp 的智能账户请求", + "smart_account_dapp_requests_desc": "让 dapp 为标准账户请求智能账户功能。这不会影响已经是智能账户的账户。", "smart_transactions_opt_in_heading": "智能交易", "smart_transactions_opt_in_desc_supported_networks": "开启智能交易,以便在支持网络上实现更加安全可靠的交易。", "smart_transactions_learn_more": "了解详情", @@ -3566,6 +3578,53 @@ "activity": "{{symbol}} 活动", "disclaimer": "市场数据由 CoinGecko 等第三方来源提供。数据仅供参考。MetaMask 不对其准确性负责。" }, + "security_trust": { + "title": "安全与信任", + "malicious": "恶意", + "risky": "高风险", + "malicious_token_title": "恶意代币", + "malicious_token_description": "{{symbol}} 是恶意代币。请避免与之交互或进行交易。", + "verified_token_title": "已验证代币", + "verified_token_description": "{{symbol}} 交易活跃且被广泛认可。验证并不代表 MetaMask 的背书。", + "risky_token_title": "高风险代币", + "risky_token_description": "检测到关于 {{symbol}} 的警示信号。交易此代币前,请仔细研究。", + "malicious_token_sheet_description": "检测到关于 {{symbol}} 的严重风险信号。建议不要交易此代币。", + "got_it": "知道了", + "proceed": "继续", + "cancel": "取消", + "data_unavailable": "无法获取安全数据", + "subtitle_known": "未检测到风险信号。交易任何资产前请务必自行研究。", + "subtitle_no_issues": "未检测到风险信号。交易任何资产前请务必自行研究。", + "subtitle_suspicious": "检测到警示信号。交易此资产前请仔细查看标记的问题。", + "subtitle_malicious": "检测到严重风险信号。建议规避此资产。", + "subtitle_unavailable": "无法加载此代币的安全分析。", + "token_distribution": "代币分配", + "total_supply": "总供给量", + "top_10_holders": "前十大持有者", + "other": "其他", + "no_hidden_fees_detected": "未检测到隐藏费用", + "buy_sell_tax": "买卖税", + "buy_tax": "买入税", + "sell_tax": "卖出税", + "transfer": "转账", + "token_info": "代币信息", + "created": "已创建", + "token_age": "代币年龄", + "network": "网络", + "type": "类型", + "official_links": "官方链接", + "website": "网站", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "不适用", + "verified": "已验证", + "no_issues": "未发现问题", + "suspicious": "可疑", + "malicious_label": "恶意", + "more": "展开", + "evaluation_disclaimer": "本安全审查仅供评估之用,不构成对交易的背书或推荐。" + }, "account_details": { "title": "账户详情", "share_account": "共享", @@ -5934,6 +5993,10 @@ "claimable_bonus": "可领取奖励", "claim_bonus": "领取奖励", "claim_bonus_subtitle": "奖励将在 {{networkName}} 上发放。", + "percentage_bonus_on_linea": "Linea 上 {{percentage}}% 的奖励", + "claim": "领取", + "sounds_good": "听起来不错", + "claimable_bonus_tooltip_with_percentage": "您持有 mUSD 所获得的 {{percentage}}% 年化奖励。该奖励可在 Linea 上每日领取。", "empty_state_cta": { "heading": "借出 {{tokenSymbol}} 并赚取", "body": "通过 {{protocol}} 借出您的 {{tokenSymbol}},", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "您的稳定币" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Stake", "earn": "赚取", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "您的资源余额不足以执行此操作。" }, - "trx_unstaking_in_progress": "{{amount}} TRX 解除质押正在进行中。解除质押需 14 天。", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "正在解除质押 {{amount}} TRX", + "description": "解除质押需要 14 天" + }, + "unstaked_banner": { + "title": "解除质押 {{amount}} TRX 已完成", + "description": "您已解除质押的 TRX 现在可以提取了", + "button": "提取", + "error": "提款失败" + } }, "stake_eth": "质押 ETH", "unstake_eth": "解除质押 ETH", @@ -6376,7 +6498,8 @@ "approve": "批准请求", "perps_deposit": "充值", "predict_deposit": "存入预测资金", - "predict_withdraw": "提取" + "predict_withdraw": "提取", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "该网站想获得花费您的代币的许可。", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "交易 {{index}}", "transaction": "交易", "available_balance": "可用余额: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "继续", "deposit_edit_amount_done": "充值", "deposit_edit_amount_predict_withdraw": "提取", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "目前暂不支持硬件钱包。请改用热钱包继续操作。", "hardware_wallet_not_supported_solana": "Solana 目前暂不支持硬件钱包。请改用热钱包继续操作。", "price_impact_info_title": "价格影响", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "这是您的交易对代币市场价格产生的影响。影响程度取决于交易规模、可用流动性和服务商费用。MetaMask 无法控制价格影响。", "price_impact_info_gasless_description": "价格影响反映您的兑换订单对资产市场价格的影响程度。如果您没有足够的资金支付燃料费,系统将自动分配部分源代币用于支付费用,这会扩大价格影响。MetaMask 不参与亦不控制价格影响。", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "由于您的交易规模及当前可用流动性,您的成交价格将比市场价格低约 {{priceImpact}}。此差价已包含在当前报价中。", "price_impact_high": "高价格影响", "price_impact_execution_description": "在此兑换中,您将损失约 {{priceImpact}} 的代币价值。请尝试降低金额或选择流动性更高的路径。", "proceed": "继续", @@ -6627,8 +6751,8 @@ "total_cost": "总成本", "got_it": "知道了", "price_impact_warning_title": "高价格影响", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "价格影响极高", + "price_impact_error_description": "本次兑换将导致您损失约 {{priceImpact}} 的代币市值。请尝试减少交易量或选择流动性更高的路径,以获取更优汇率。" }, "quote_expired_modal": { "title": "有新的报价", @@ -6940,7 +7064,7 @@ "upgrade_title": "升级至金属卡", "continue_button": "继续", "virtual_card": { - "name": "Virtual Card", + "name": "虚拟卡", "price": "免费", "feature_1": "用于 Apple Pay 和 Google Pay 的虚拟卡", "feature_2": "使用加密货币支付(支持 USDC、USDT、WETH 等代币)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "金属卡", "price": "199美元/年", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "所有虚拟资产,外加:", + "feature_1": "高级镌刻金属卡", + "feature_2": "每年首 10000 美元消费可获 3% 返现", "feature_3": "无外币交易费用" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "每年最高可获得 300 美元返现", + "upgrade_to_metal_label": "或升级至金属卡可享三倍奖励" }, "review_order": { "title": "查看您的订单", @@ -7104,7 +7228,7 @@ "ssn_description": "此为发卡机构的要求。不会进行信用检查。", "invalid_ssn": "社会安全号码无效", "invalid_date_of_birth": "出生日期无效。您必须年满 18 周岁", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "姓名必须与已验证身份一致" }, "physical_address": { "title": "填写您的地址", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "您即将达到消费限额", "description": "更新以避免被拒", - "confirm_button_label": "设置新限额" + "confirm_button_label": "设置新限额", + "dismiss_button_label": "忽略" }, "need_delegation": { "title": "您需要启用您的卡", @@ -7301,7 +7426,6 @@ "dismiss": "忽略", "update_success": "消费限额更新成功", "update_error": "消费限额更新失败", - "solana_not_supported": "请在 card.metamask.io 启用 Solana 代币", "select_token": "选择代币", "loading": "正在加载可用代币……", "load_error": "无法加载代币。请重试。", @@ -7343,9 +7467,7 @@ "limited": "受限", "not_enabled": "未启用", "update_success": "消费优先级更新成功", - "update_error": "消费优先级更新失败", - "solana_not_supported_button_title": "Solana 上的其他代币", - "solana_not_supported_button_description": "请在 card.metamask.io 启用" + "update_error": "消费优先级更新失败" }, "card_authentication": { "title": "登录您的卡账户", @@ -7443,6 +7565,11 @@ "title": "参与失败", "description": "请检查连接后重试。" }, + "version_guard": { + "title": "需要更新", + "description": "需要更高版本的 MetaMask 才能使用奖励功能。请更新后继续。", + "update_button": "更新 MetaMask" + }, "season_error": { "error_fetching_title": "赛季无法加载", "error_fetching_description": "请检查连接后重试。", @@ -7525,7 +7652,6 @@ "main_title": "奖励", "referral_title": "推荐", "tab_overview_title": "概览", - "tab_snapshots_title": "快照", "tab_activity_title": "活动", "referral_stats_earned_from_referrals": "通过推荐所获奖励", "referral_stats_referrals": "推荐", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "您本季未获得奖励,但未来仍有机会。", "verifying_rewards": "在您领取奖励前,我们正在核对所有信息以确保准确无误。" }, + "previous_season_view": { + "title": "上一季" + }, "season_status": { "points_earned": "已获得积分" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "活跃加成", "season_1": "第 1 季", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "mUSD 奖励计算器", + "description": "看看您将稳定币兑换为 mUSD 能赚取多少收益。", + "amount_label": "已兑换金额", + "estimated_bonus": "预估年化奖励:最高可达 3%", + "initial_amount": "初始金额", + "daily_bonus": "每日可领取奖励", + "annualized_bonus": "年化奖励", + "disclaimer": "仅为预估。奖励可能会有变动。", "buy_button": "购买 mUSD", - "swap_button": "Swap to mUSD" + "swap_button": "兑换为 mUSD" }, "upcoming_rewards": { "title": "锁定的奖励", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "无法加载" }, - "snapshot": { + "campaign": { "starts_date": "开始于 {{date}}", "ends_date": "结束于 {{date}}", - "results_coming_soon": "结果即将公布", - "tokens_on_the_way": "代币即将到账", + "ended_date": "Ended {{date}}", "pill_up_next": "即将到来", - "pill_live_now": "现已上线", - "pill_calculating": "正在计算", - "pill_results_ready": "结果已就绪", - "pill_complete": "完成" - }, - "snapshots_section": { - "title": "快照", - "error_title": "无法加载快照", - "error_description": "无法加载快照。请重试。", - "retry_button": "重试" - }, - "snapshots_tab": { + "pill_active": "进行中", + "pill_complete": "完成", + "enter_now": "立即参加", + "entered": "已参加", + "participant_count": "#{{count}}", + "opt_in_cta": "选择加入", + "opt_in_sheet_title": "加入活动", + "opt_in_sheet_description_pre_link": "点击“选择加入”,即表示您同意参加 MetaMask 奖励计划", + "opt_in_sheet_link_text": "补充使用条款及隐私声明", + "opt_in_sheet_description_post_link": "我们将通过追踪链上活动为您自动发放奖励。", + "geo_restriction_banner_title": "您所在区域不可用", + "geo_restriction_banner_description": "由于当地法规限制,此活动不适用于您所在地区。" + }, + "campaign_mechanics": { + "title": "机制" + }, + "campaign_details": { + "start_date": "开始时间:{{date}}", + "end_date": "结束时间:{{date}}", + "opt_in": "选择加入", + "opting_in": "正在选择加入……", + "opted_in": "您已选择加入此活动", + "opt_in_error": "加入失败。请重试。", + "join_campaign": "加入活动", + "checking_opt_in_status": "正在检查加入状态", + "swap": "交换", + "how_it_works": "如何运行" + }, + "campaigns_preview": { + "title": "活动", + "coming_soon": "即将推出", + "notify_me": "通知我" + }, + "earn_rewards": { + "title": "赚取奖励", + "musd_title": "稳定币最高可享 3% 奖励", + "musd_subtitle": "计算您的 mUSD 奖励", + "card_title": "最高可享 3% 返现", + "card_subtitle": "立即获取您的 MetaMask 卡", + "card_subtitle_cardholder": "查看您的 MetaMask 卡专属福利" + }, + "campaigns_view": { + "title": "活动", "active_title": "已激活", "upcoming_title": "即将到来", "previous_title": "先前", - "empty_state": "暂无快照", - "error_title": "无法加载快照", - "error_description": "无法加载快照。请重试。", + "empty_state": "暂无活动", + "error_title": "无法加载活动", + "error_description": "我们无法加载活动。请重试。", "retry_button": "重试", "refreshing": "正在刷新……" } @@ -7953,13 +8112,12 @@ "continue": "继续" }, "connecting": { - "title": "连接您的 {{device}}", + "title": "正在连接您的 {{device}}……", "searching": "正在查找 {{device}}……", - "tips_header": "若要继续,请确保:", + "tips_header": "确保:", "tip_unlock": "您的 {{device}} 已解锁", "tip_open_app": "以太坊应用已打开", "tip_enable_bluetooth": "蓝牙已开启", - "tip_dnd_off": "勿扰模式已关闭", "tip_bluetooth_permission": "位置和蓝牙许可已授予", "tip_bluetooth_permission_v12": "附近设备许可已授予", "tip_stay_close": "您的设备需保持靠近手机" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "需要附近设备许可", "bluetooth_off": "请开启蓝牙以连接到您的设备", "bluetooth_scan_failed": "扫描设备失败。请重试", - "bluetooth_connection_failed": "在您的设备上启用蓝牙以继续", + "bluetooth_connection_failed": "连接您的设备失败。请重试", "not_supported": "不支持此操作", "unknown_error": "确保您的 {{device}} 已使用此账户的私钥助记词或密语进行设置" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "现金", + "cash_empty_description": "您目前尚未持有任何 mUSD。请从首页的“现金”部分将稳定币兑换为 mUSD。", + "cash_empty_description_network_filter": "此网络中没有 mUSD。请切换网络以查看您的 mUSD。", "tokens": "代币", "perpetuals": "永续合约", "predictions": "预测", + "whats_happening": "发生了什么", + "whats_happening_categories": { + "geopolitical": "地缘政治", + "macro": "宏观", + "regulatory": "监管", + "technical": "技术", + "social": "社会", + "other": "其他" + }, "defi": "DeFi", "nfts": "NFT", "import_nfts": "导入 NFT", From 759fd72ceec299f7d4bf8b7c7e13762ad04ffed1 Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Wed, 25 Mar 2026 20:19:47 +0000 Subject: [PATCH 197/206] [skip ci] Bump version number to 4186 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index fa91bcdba45..086cd47bd80 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4184 + versionCode 4186 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 81122210301..7da7531cf82 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4184 + VERSION_NUMBER: 4186 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4184 + FLASK_VERSION_NUMBER: 4186 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 93003ea86e0..d86b260ca39 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4184; + CURRENT_PROJECT_VERSION = 4186; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4184; + CURRENT_PROJECT_VERSION = 4186; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4184; + CURRENT_PROJECT_VERSION = 4186; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4184; + CURRENT_PROJECT_VERSION = 4186; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4184; + CURRENT_PROJECT_VERSION = 4186; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4184; + CURRENT_PROJECT_VERSION = 4186; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 16da3b130764cd6a89ea53e8999aade7b7e35e6b Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 10:45:35 +0100 Subject: [PATCH 198/206] chore(runway): cherry-pick feat(earn): gate Tron unstaked claim button behind remote flag cp-7.71.0 (#27959) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feat(earn): gate Tron unstaked claim button behind remote flag (#27908) ## **Description** Adds the remote boolean flag **`tronClaimUnstakedTrxButtonEnabled`** so we can hide the **claim** action on the Tron unstaked banner if something goes wrong in production, without removing the banner copy. **Why:** We need a safe kill switch for the claim CTA only. **How:** - Register the flag in `FeatureFlagNames` with default `false` (missing/undefined → button hidden; opt-in). - **`selectTronClaimUnstakedTrxButtonEnabled`** in `app/selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled/` reads merged remote flags (same pattern as other boolean flags). - `TronUnstakedBanner` uses `useSelector(selectTronClaimUnstakedTrxButtonEnabled)` and renders the primary claim button only when the flag is `true`; title and description stay visible when the button is hidden. - Register the flag in **`tests/feature-flags/feature-flag-registry.ts`** (`inProd: true`, `productionDefault: false`) so CI/E2E mocks match production client-config. **Ops:** Ensure **`tronClaimUnstakedTrxButtonEnabled`** exists in LaunchDarkly / client-config; set to **`true`** where the claim button should appear. ## **Changelog** CHANGELOG entry: Added a remote feature flag to control visibility of the Tron unstaked TRX claim button on the token details banner. ## **Related issues** Fixes: NEB-838 ## **Manual testing steps** ```gherkin Feature: Tron unstaked banner claim button behind remote flag Scenario: user views TRX token details with claimable unstaked balance and flag enabled Given a Tron account with TRX ready for withdrawal and remote flag `tronClaimUnstakedTrxButtonEnabled` is true (or overridden in dev tools) When user opens native TRX token details and the unstaked banner is shown Then the banner shows title, description, and the claim button, and tapping claim still triggers the existing flow Scenario: user views TRX token details when flag is off or unset Given the same balance state but `tronClaimUnstakedTrxButtonEnabled` is false, missing, or undefined in remote flags When user opens native TRX token details and the unstaked banner is shown Then the banner shows title and description but does not show the claim button ``` ## **Screenshots/Recordings** ### **Before** See prior screenshots on this PR (token details with banner). ### **After** Feature flag disabled / enabled — screenshots attached in thread (banner with and without claim CTA). ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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. [ef5e684](https://github.com/MetaMask/metamask-mobile/commit/ef5e684c05e88164d2952f9fcdca6cdf1af68cd8) Co-authored-by: Ulisses Ferreira --- .../TronUnstakedBanner.test.tsx | 78 ++++++++++++++----- .../TronUnstakedBanner/TronUnstakedBanner.tsx | 25 +++--- app/components/UI/Perps/utils/wait.test.ts | 4 +- app/constants/featureFlags.ts | 2 + .../index.test.ts | 49 ++++++++++++ .../index.ts | 17 ++++ .../feature-flag-registry.test.ts | 1 + tests/feature-flags/feature-flag-registry.ts | 10 ++- 8 files changed, 153 insertions(+), 33 deletions(-) create mode 100644 app/selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled/index.test.ts create mode 100644 app/selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled/index.ts diff --git a/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.test.tsx b/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.test.tsx index c50b458e01a..9ba6d9e7854 100644 --- a/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.test.tsx +++ b/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.test.tsx @@ -1,11 +1,21 @@ import React from 'react'; -import { render, fireEvent } from '@testing-library/react-native'; +import { fireEvent } from '@testing-library/react-native'; +import type { CaipChainId } from '@metamask/utils'; import TronUnstakedBanner from './TronUnstakedBanner'; import { strings } from '../../../../../../../locales/i18n'; import useTronClaimUnstakedTrx from '../../../hooks/useTronClaimUnstakedTrx'; import useEarnToasts from '../../../hooks/useEarnToasts'; +import renderWithProvider from '../../../../../../util/test/renderWithProvider'; +import { selectTronClaimUnstakedTrxButtonEnabled } from '../../../../../../selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled'; import { TronUnstakedBannerTestIds } from './TronUnstakedBanner.testIds'; +jest.mock( + '../../../../../../selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled', + () => ({ + selectTronClaimUnstakedTrxButtonEnabled: jest.fn(), + }), +); + jest.mock('../../../hooks/useTronClaimUnstakedTrx'); const mockUseTronClaimUnstakedTrx = useTronClaimUnstakedTrx as jest.MockedFunction< @@ -23,11 +33,18 @@ jest.mock('../../../hooks/useEarnToasts'); }, }); +const mockSelectTronClaimUnstakedTrxButtonEnabled = + selectTronClaimUnstakedTrxButtonEnabled as unknown as jest.Mock; + +const renderBanner = (props: { amount: string; chainId: CaipChainId }) => + renderWithProvider(, undefined, false); + describe('TronUnstakedBanner', () => { const mockHandleClaimUnstakedTrx = jest.fn(); beforeEach(() => { jest.clearAllMocks(); + mockSelectTronClaimUnstakedTrxButtonEnabled.mockReturnValue(true); mockUseTronClaimUnstakedTrx.mockReturnValue({ handleClaimUnstakedTrx: mockHandleClaimUnstakedTrx, isSubmitting: false, @@ -42,9 +59,10 @@ describe('TronUnstakedBanner', () => { }); it('renders the title with the given amount', () => { - const { getByText } = render( - , - ); + const { getByText } = renderBanner({ + amount: '100', + chainId: 'tron:728126428', + }); const expectedTitle = strings('stake.tron.unstaked_banner.title', { amount: '100', @@ -53,9 +71,10 @@ describe('TronUnstakedBanner', () => { }); it('renders the description', () => { - const { getByText } = render( - , - ); + const { getByText } = renderBanner({ + amount: '100', + chainId: 'tron:728126428', + }); const expectedDescription = strings( 'stake.tron.unstaked_banner.description', @@ -63,20 +82,38 @@ describe('TronUnstakedBanner', () => { expect(getByText(expectedDescription)).toBeOnTheScreen(); }); - it('renders the Withdraw button', () => { - const { getByTestId } = render( - , - ); + it('renders the claim button when tronClaimUnstakedTrxButtonEnabled is true', () => { + const { getByTestId } = renderBanner({ + amount: '100', + chainId: 'tron:728126428', + }); expect( getByTestId(TronUnstakedBannerTestIds.CLAIM_BUTTON), ).toBeOnTheScreen(); }); + it('does not render the claim button when tronClaimUnstakedTrxButtonEnabled is false', () => { + mockSelectTronClaimUnstakedTrxButtonEnabled.mockReturnValue(false); + + const { getByText, queryByTestId } = renderBanner({ + amount: '100', + chainId: 'tron:728126428', + }); + + expect( + queryByTestId(TronUnstakedBannerTestIds.CLAIM_BUTTON), + ).not.toBeOnTheScreen(); + expect( + getByText(strings('stake.tron.unstaked_banner.description')), + ).toBeOnTheScreen(); + }); + it('calls handleClaimUnstakedTrx when button is pressed', () => { - const { getByTestId } = render( - , - ); + const { getByTestId } = renderBanner({ + amount: '100', + chainId: 'tron:728126428', + }); fireEvent.press(getByTestId(TronUnstakedBannerTestIds.CLAIM_BUTTON)); expect(mockHandleClaimUnstakedTrx).toHaveBeenCalledTimes(1); @@ -89,9 +126,10 @@ describe('TronUnstakedBanner', () => { errors: undefined, }); - const { getByTestId } = render( - , - ); + const { getByTestId } = renderBanner({ + amount: '100', + chainId: 'tron:728126428', + }); const button = getByTestId(TronUnstakedBannerTestIds.CLAIM_BUTTON); expect(button.props.accessibilityState?.disabled).toBe(true); @@ -104,7 +142,7 @@ describe('TronUnstakedBanner', () => { errors: ['InsufficientBalance'], }); - render(); + renderBanner({ amount: '100', chainId: 'tron:728126428' }); expect(mockFailedToastFn).toHaveBeenCalledWith(['InsufficientBalance']); expect(mockShowToast).toHaveBeenCalledWith(mockFailedToastResult); @@ -117,14 +155,14 @@ describe('TronUnstakedBanner', () => { errors: [], }); - render(); + renderBanner({ amount: '100', chainId: 'tron:728126428' }); expect(mockFailedToastFn).toHaveBeenCalledWith([]); expect(mockShowToast).toHaveBeenCalledWith(mockFailedToastResult); }); it('does not show error toast when there are no errors', () => { - render(); + renderBanner({ amount: '100', chainId: 'tron:728126428' }); expect(mockShowToast).not.toHaveBeenCalled(); }); diff --git a/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.tsx b/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.tsx index 5e245729048..65c6ba3d3d1 100644 --- a/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.tsx +++ b/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.tsx @@ -1,4 +1,5 @@ import React, { useEffect } from 'react'; +import { useSelector } from 'react-redux'; import type { CaipChainId } from '@metamask/utils'; import { strings } from '../../../../../../../locales/i18n'; import Banner, { @@ -13,6 +14,7 @@ import { } from '@metamask/design-system-react-native'; import useTronClaimUnstakedTrx from '../../../hooks/useTronClaimUnstakedTrx'; import useEarnToasts from '../../../hooks/useEarnToasts'; +import { selectTronClaimUnstakedTrxButtonEnabled } from '../../../../../../selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled'; import { TronUnstakedBannerTestIds } from './TronUnstakedBanner.testIds'; interface TronUnstakedBannerProps { @@ -21,6 +23,7 @@ interface TronUnstakedBannerProps { } const TronUnstakedBanner = ({ amount, chainId }: TronUnstakedBannerProps) => { + const showClaimButton = useSelector(selectTronClaimUnstakedTrxButtonEnabled); const { handleClaimUnstakedTrx, isSubmitting, errors } = useTronClaimUnstakedTrx({ chainId }); const { showToast, EarnToastOptions } = useEarnToasts(); @@ -41,19 +44,21 @@ const TronUnstakedBanner = ({ amount, chainId }: TronUnstakedBannerProps) => { <> {strings('stake.tron.unstaked_banner.description')} - + {showClaimButton ? ( + + ) : null} } /> diff --git a/app/components/UI/Perps/utils/wait.test.ts b/app/components/UI/Perps/utils/wait.test.ts index aea87705d79..ac38e76a03a 100644 --- a/app/components/UI/Perps/utils/wait.test.ts +++ b/app/components/UI/Perps/utils/wait.test.ts @@ -13,14 +13,14 @@ describe('wait', () => { const promise = wait(100); jest.advanceTimersByTime(100); await promise; - expect(promise).resolves.toBeUndefined(); + await expect(promise).resolves.toBeUndefined(); }); it('should handle zero duration', async () => { const promise = wait(0); jest.advanceTimersByTime(0); await promise; - expect(promise).resolves.toBeUndefined(); + await expect(promise).resolves.toBeUndefined(); }); it('should return a Promise that resolves to undefined', async () => { diff --git a/app/constants/featureFlags.ts b/app/constants/featureFlags.ts index a439f406657..67da83992fe 100644 --- a/app/constants/featureFlags.ts +++ b/app/constants/featureFlags.ts @@ -16,6 +16,7 @@ export enum FeatureFlagNames { tokenDetailsV2ButtonLayout = 'tokenDetailsV2ButtonLayout', complianceEnabled = 'complianceEnabled', legacyIosGoogleConfigEnabled = 'legacyIosGoogleConfigEnabled', + tronClaimUnstakedTrxButtonEnabled = 'tronClaimUnstakedTrxButtonEnabled', } export const DEFAULT_FEATURE_FLAG_VALUES: Partial< @@ -24,4 +25,5 @@ export const DEFAULT_FEATURE_FLAG_VALUES: Partial< [FeatureFlagNames.assetsDefiPositionsEnabled]: true, [FeatureFlagNames.tokenDetailsV2Buttons]: false, [FeatureFlagNames.tokenDetailsV2ButtonLayout]: false, + [FeatureFlagNames.tronClaimUnstakedTrxButtonEnabled]: false, }; diff --git a/app/selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled/index.test.ts b/app/selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled/index.test.ts new file mode 100644 index 00000000000..55cb6066b58 --- /dev/null +++ b/app/selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled/index.test.ts @@ -0,0 +1,49 @@ +import { Json } from '@metamask/utils'; +import { selectTronClaimUnstakedTrxButtonEnabled } from '.'; +import { + DEFAULT_FEATURE_FLAG_VALUES, + FeatureFlagNames, +} from '../../../constants/featureFlags'; + +describe('Tron claim unstaked TRX button enabled feature flag selector', () => { + describe('selectTronClaimUnstakedTrxButtonEnabled', () => { + it('returns true when remote flag is explicitly true', () => { + const result = selectTronClaimUnstakedTrxButtonEnabled.resultFunc({ + [FeatureFlagNames.tronClaimUnstakedTrxButtonEnabled]: true, + }); + + expect(result).toBe(true); + }); + + it('returns false when remote flag is explicitly false', () => { + const result = selectTronClaimUnstakedTrxButtonEnabled.resultFunc({ + [FeatureFlagNames.tronClaimUnstakedTrxButtonEnabled]: false, + }); + + expect(result).toBe(false); + }); + + it('returns default value when remote flag is not set', () => { + const result = selectTronClaimUnstakedTrxButtonEnabled.resultFunc({}); + + expect(result).toBe( + DEFAULT_FEATURE_FLAG_VALUES[ + FeatureFlagNames.tronClaimUnstakedTrxButtonEnabled + ], + ); + }); + + it('returns default value when remote flag is undefined', () => { + const result = selectTronClaimUnstakedTrxButtonEnabled.resultFunc({ + [FeatureFlagNames.tronClaimUnstakedTrxButtonEnabled]: + undefined as unknown as Json, + }); + + expect(result).toBe( + DEFAULT_FEATURE_FLAG_VALUES[ + FeatureFlagNames.tronClaimUnstakedTrxButtonEnabled + ], + ); + }); + }); +}); diff --git a/app/selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled/index.ts b/app/selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled/index.ts new file mode 100644 index 00000000000..390dbe3a5a1 --- /dev/null +++ b/app/selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled/index.ts @@ -0,0 +1,17 @@ +import { createSelector } from 'reselect'; +import { selectRemoteFeatureFlags } from '..'; +import { + DEFAULT_FEATURE_FLAG_VALUES, + FeatureFlagNames, +} from '../../../constants/featureFlags'; + +export const selectTronClaimUnstakedTrxButtonEnabled = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => + Boolean( + remoteFeatureFlags[FeatureFlagNames.tronClaimUnstakedTrxButtonEnabled] ?? + DEFAULT_FEATURE_FLAG_VALUES[ + FeatureFlagNames.tronClaimUnstakedTrxButtonEnabled + ], + ), +); diff --git a/tests/feature-flags/feature-flag-registry.test.ts b/tests/feature-flags/feature-flag-registry.test.ts index ed238d077e2..a86028cd6b1 100644 --- a/tests/feature-flags/feature-flag-registry.test.ts +++ b/tests/feature-flags/feature-flag-registry.test.ts @@ -85,6 +85,7 @@ describe('Feature Flag Registry', () => { expect(flagNames).toContain('bridgeConfigV2'); expect(flagNames).toContain('bitcoinAccounts'); expect(flagNames).toContain('tronAccounts'); + expect(flagNames).toContain('tronClaimUnstakedTrxButtonEnabled'); }); }); diff --git a/tests/feature-flags/feature-flag-registry.ts b/tests/feature-flags/feature-flag-registry.ts index 2c696a8020c..c16c13a2fb3 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-03-02 + * Production defaults last synced: 2026-03-25 * Source: https://client-config.api.cx.metamask.io/v1/flags?client=mobile&distribution=main&environment=prod */ export const FEATURE_FLAG_REGISTRY: Record = { @@ -3618,6 +3618,14 @@ export const FEATURE_FLAG_REGISTRY: Record = { status: FeatureFlagStatus.Active, }, + tronClaimUnstakedTrxButtonEnabled: { + name: 'tronClaimUnstakedTrxButtonEnabled', + type: FeatureFlagType.Remote, + inProd: true, + productionDefault: false, + status: FeatureFlagStatus.Active, + }, + tronStaking: { name: 'tronStaking', type: FeatureFlagType.Remote, From 9c40658056758dd2a64653f2be0caeb23a5e46da Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Thu, 26 Mar 2026 09:47:13 +0000 Subject: [PATCH 199/206] [skip ci] Bump version number to 4191 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 086cd47bd80..452b110ef63 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4186 + versionCode 4191 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 7da7531cf82..66bc38c842f 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4186 + VERSION_NUMBER: 4191 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4186 + FLASK_VERSION_NUMBER: 4191 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index d86b260ca39..7206c71e8d9 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4186; + CURRENT_PROJECT_VERSION = 4191; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4186; + CURRENT_PROJECT_VERSION = 4191; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4186; + CURRENT_PROJECT_VERSION = 4191; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4186; + CURRENT_PROJECT_VERSION = 4191; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4186; + CURRENT_PROJECT_VERSION = 4191; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4186; + CURRENT_PROJECT_VERSION = 4191; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 6ae902309485c17ea34428f918d8b7321cb8c226 Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:53:54 +0100 Subject: [PATCH 200/206] chore(runway): cherry-pick feat: show legacy ios login warning prompt cp-7.71.0 (#27941) - feat: show legacy ios login warning prompt cp-7.71.0 (#27875) ## **Description** Add warning prompt for ios <17.4 for google login Supports the fix for: https://github.com/MetaMask/MetaMask-planning/issues/7148 Part 1/ 4 - #27741 Part 2/ 4 - #27848 Part 3/ 4 - #27850 (deferred to 7.72.0) Part 4/ 4 - #27875 ## **Changelog** CHANGELOG entry: Add warning prompt for ios <17.4 for google login ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** For < iOS 17.4 https://github.com/user-attachments/assets/f6f3a031-82cc-486d-af5f-e6e1bbc7ed10 For >= iOS 17.4 https://github.com/user-attachments/assets/2cdc0bf3-d59b-4858-be81-baae5e0a4dd2 ## **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 - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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** > Modifies the onboarding social login path by inserting a conditional pre-login warning and new navigation helper, which could affect Google login flow timing/navigation on iOS devices. Changes are localized but touch user authentication entrypoints and analytics tracking. > > **Overview** > Adds an **iOS < 17.4 warning gate** before starting Google OAuth during onboarding (both create and import flows), showing a non-interactable `SuccessErrorSheet` that must be acknowledged before proceeding. > > Introduces `Device.comparePlatformVersionTo()` (using `compare-versions`) and a reusable `navigateToSuccessErrorSheetPromise()` helper to await sheet dismissal, plus a new MetaMetrics event (`WALLET_GOOGLE_IOS_WARNING_VIEWED`) and localized warning copy. > > Updates onboarding tests to mock the new device helper/navigation and to assert the warning sheet + tracking fire before continuing with Google login. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 3b43b83cf7c608c88da4b01bfb67603d840d1582. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [002d91a](https://github.com/MetaMask/metamask-mobile/commit/002d91ac2a09420c712b1fdadca0a6aa6d3326cb) Co-authored-by: ieow <4881057+ieow@users.noreply.github.com> --- .../Views/Onboarding/index.test.tsx | 211 +++++++++++++++++- app/components/Views/Onboarding/index.tsx | 41 ++++ .../Views/SuccessErrorSheet/utils.test.ts | 113 ++++++++++ .../Views/SuccessErrorSheet/utils.ts | 37 +++ app/core/Analytics/MetaMetrics.events.ts | 4 + app/util/device/index.js | 16 ++ locales/languages/en.json | 8 +- 7 files changed, 420 insertions(+), 10 deletions(-) create mode 100644 app/components/Views/SuccessErrorSheet/utils.test.ts create mode 100644 app/components/Views/SuccessErrorSheet/utils.ts diff --git a/app/components/Views/Onboarding/index.test.tsx b/app/components/Views/Onboarding/index.test.tsx index 771c308e15b..8f59eef7433 100644 --- a/app/components/Views/Onboarding/index.test.tsx +++ b/app/components/Views/Onboarding/index.test.tsx @@ -71,6 +71,7 @@ import Routes from '../../../constants/navigation/Routes'; import { ONBOARDING, PREVIOUS_SCREEN } from '../../../constants/navigation'; import { strings } from '../../../../locales/i18n'; import { OAuthError, OAuthErrorType } from '../../../core/OAuthService/error'; +import { IconName } from '../../../component-library/components/Icons/Icon'; import { captureException } from '@sentry/react-native'; import Logger from '../../../util/Logger'; import { MIGRATION_ERROR_HAPPENED } from '../../../constants/storage'; @@ -92,9 +93,22 @@ jest.mock('../../../util/test/utils', () => ({ import { fetch as netInfoFetch } from '@react-native-community/netinfo'; const mockNetInfoFetch = netInfoFetch as jest.Mock; +const mockNavigate = jest.fn(); +const mockReplace = jest.fn(); +const mockGoBack = jest.fn(); // Helper to flush all pending promises const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); +const IOS_GOOGLE_WARNING_TITLE = strings('error_sheet.ios_need_update_title'); +const IOS_GOOGLE_WARNING_BUTTON = strings('error_sheet.ios_need_update_button'); + +const getIosGoogleWarningSheetCall = () => + mockNavigate.mock.calls.find( + ([route, params]) => + route === Routes.MODAL.ROOT_MODAL_FLOW && + params?.screen === Routes.SHEET.SUCCESS_ERROR_SHEET && + params?.params?.title === IOS_GOOGLE_WARNING_TITLE, + ); const mockInitialState = { engine: { @@ -127,13 +141,22 @@ const mockInitialStateWithExistingUserAndPassword = { }, }; -jest.mock('../../../util/device', () => ({ - isLargeDevice: jest.fn(), - isIphoneX: jest.fn(), - isAndroid: jest.fn(), - isIos: jest.fn(), - isMediumDevice: jest.fn(), -})); +jest.mock('../../../util/device', () => { + const mockDevice = { + isLargeDevice: jest.fn(), + isIphoneX: jest.fn(), + isAndroid: jest.fn(), + isIos: jest.fn(), + isMediumDevice: jest.fn(), + comparePlatformVersionTo: jest.fn().mockReturnValue(1), + }; + + return { + __esModule: true, + default: mockDevice, + ...mockDevice, + }; +}); // expo library are not supported in jest ( unless using jest-expo as preset ), so we need to mock them jest.mock('../../../core/OAuthService/OAuthLoginHandlers', () => ({ @@ -276,13 +299,12 @@ jest.mock('../../../core/OAuthService/OAuthLoginHandlers/constants', () => ({ }, })); -const mockNavigate = jest.fn(); -const mockReplace = jest.fn(); const mockNav = { navigate: mockNavigate, replace: mockReplace, reset: jest.fn(), setOptions: jest.fn(), + goBack: mockGoBack, dispatch: jest.fn((action) => { if (action.type === 'REPLACE') { mockReplace(action.payload.name, action.payload.params); @@ -956,10 +978,13 @@ describe('Onboarding', () => { beforeEach(() => { mockSeedlessOnboardingEnabled.mockReturnValue(true); (StorageWrapper.getItem as jest.Mock).mockResolvedValue(null); + (Device.isIos as jest.Mock).mockReturnValue(false); + (Device.comparePlatformVersionTo as jest.Mock).mockReturnValue(1); }); afterEach(() => { jest.clearAllMocks(); + mockNavigate.mockReset(); mockSeedlessOnboardingEnabled.mockReset(); }); @@ -1263,6 +1288,174 @@ describe('Onboarding', () => { ); }); + it('shows iOS version warning sheet before Google login on iOS < 17.4', async () => { + Platform.OS = 'ios'; + (Device.isIos as jest.Mock).mockReturnValue(true); + (Device.comparePlatformVersionTo as jest.Mock).mockReturnValue(-1); + (mockAnalytics.isEnabled as jest.Mock).mockReturnValue(true); + mockCreateLoginHandler.mockReturnValue('mockGoogleHandler'); + mockOAuthService.handleOAuthLogin.mockResolvedValue({ + type: 'success', + existingUser: false, + accountName: 'test@example.com', + }); + + const { getByTestId } = renderScreen( + Onboarding, + { name: 'Onboarding' }, + { + state: mockInitialState, + }, + ); + + const createWalletButton = getByTestId( + OnboardingSelectorIDs.NEW_WALLET_BUTTON, + ); + await act(async () => { + fireEvent.press(createWalletButton); + }); + + const navCall = mockNavigate.mock.calls.find( + (call) => + call[0] === Routes.MODAL.ROOT_MODAL_FLOW && + call[1]?.screen === Routes.SHEET.ONBOARDING_SHEET, + ); + + const googleOAuthFunction = navCall[1].params.onPressContinueWithGoogle; + + await act(async () => { + await googleOAuthFunction(true); + await flushPromises(); + await flushPromises(); + }); + + // Verify the warning sheet was shown with the iOS not-supported message. + const warningSheetCall = getIosGoogleWarningSheetCall(); + + expect(warningSheetCall).toEqual([ + Routes.MODAL.ROOT_MODAL_FLOW, + expect.objectContaining({ + screen: Routes.SHEET.SUCCESS_ERROR_SHEET, + params: expect.objectContaining({ + type: 'error', + icon: IconName.Warning, + isInteractable: false, + title: IOS_GOOGLE_WARNING_TITLE, + description: expect.anything(), + primaryButtonLabel: IOS_GOOGLE_WARNING_BUTTON, + onPrimaryButtonPress: expect.any(Function), + closeOnPrimaryButtonPress: true, + }), + }), + ]); + expect(warningSheetCall?.[1].params.onPrimaryButtonPress).toEqual( + expect.any(Function), + ); + expect(Device.comparePlatformVersionTo).toHaveBeenCalledWith('17.4'); + + await act(async () => { + await warningSheetCall?.[1].params.onPrimaryButtonPress?.(); + await flushPromises(); + await flushPromises(); + }); + + expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Wallet Google Ios Warning Viewed', + properties: expect.objectContaining({ + account_type: AccountType.MetamaskGoogle, + }), + }), + ); + expect(mockCreateLoginHandler).toHaveBeenCalledWith('ios', 'google'); + expect(mockOAuthService.handleOAuthLogin).toHaveBeenCalledWith( + 'mockGoogleHandler', + false, + ); + }); + + it('shows iOS version warning for Google login on iOS < 17.4 during import wallet flow', async () => { + Platform.OS = 'ios'; + (Device.isIos as jest.Mock).mockReturnValue(true); + (Device.comparePlatformVersionTo as jest.Mock).mockReturnValue(-1); + (mockAnalytics.isEnabled as jest.Mock).mockReturnValue(true); + mockCreateLoginHandler.mockReturnValue('mockGoogleHandler'); + mockOAuthService.handleOAuthLogin.mockResolvedValue({ + type: 'success', + existingUser: false, + accountName: 'test@example.com', + }); + + const { getByTestId } = renderScreen( + Onboarding, + { name: 'Onboarding' }, + { + state: mockInitialState, + }, + ); + + const importWalletButton = getByTestId( + OnboardingSelectorIDs.EXISTING_WALLET_BUTTON, + ); + await act(async () => { + fireEvent.press(importWalletButton); + }); + + const navCall = mockNavigate.mock.calls.find( + (call) => + call[0] === Routes.MODAL.ROOT_MODAL_FLOW && + call[1]?.screen === Routes.SHEET.ONBOARDING_SHEET, + ); + + const googleOAuthFunction = navCall[1].params.onPressContinueWithGoogle; + + await act(async () => { + await googleOAuthFunction(false); + await flushPromises(); + await flushPromises(); + }); + + const warningSheetCall = getIosGoogleWarningSheetCall(); + + expect(warningSheetCall).toBeDefined(); + expect(warningSheetCall?.[1].params).toEqual( + expect.objectContaining({ + type: 'error', + icon: IconName.Warning, + title: IOS_GOOGLE_WARNING_TITLE, + description: expect.anything(), + primaryButtonLabel: IOS_GOOGLE_WARNING_BUTTON, + onPrimaryButtonPress: expect.any(Function), + closeOnPrimaryButtonPress: true, + isInteractable: false, + }), + ); + expect(warningSheetCall?.[1].params.onPrimaryButtonPress).toEqual( + expect.any(Function), + ); + expect(Device.comparePlatformVersionTo).toHaveBeenCalledWith('17.4'); + + await act(async () => { + await warningSheetCall?.[1].params.onPrimaryButtonPress?.(); + await flushPromises(); + await flushPromises(); + }); + + expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Wallet Google Ios Warning Viewed', + properties: expect.objectContaining({ + account_type: AccountType.ImportedGoogle, + }), + }), + ); + expect(mockCreateLoginHandler).toHaveBeenCalledWith('ios', 'google'); + expect(mockOAuthService.handleOAuthLogin).toHaveBeenCalledWith( + 'mockGoogleHandler', + true, + ); + }); + it('navigates to AccountAlreadyExists for existing user in create wallet flow', async () => { mockCreateLoginHandler.mockReturnValue('mockGoogleHandler'); mockOAuthService.handleOAuthLogin.mockResolvedValue({ diff --git a/app/components/Views/Onboarding/index.tsx b/app/components/Views/Onboarding/index.tsx index f2d715f0a34..3c105f2a087 100644 --- a/app/components/Views/Onboarding/index.tsx +++ b/app/components/Views/Onboarding/index.tsx @@ -110,6 +110,11 @@ import { } from '@metamask/design-system-twrnc-preset'; import { getBuildNumber, getVersion } from 'react-native-device-info'; +import { navigateToSuccessErrorSheetPromise } from '../SuccessErrorSheet/utils'; +import { + IconColor, + IconName, +} from '../../../component-library/components/Icons/Icon'; interface OnboardingState { warningModalVisible: boolean; loading: boolean; @@ -770,6 +775,41 @@ const Onboarding = () => { }); const action = async () => { + // prompt for ios google login not supported below iOS 17.4 + if ( + provider === AuthConnection.Google && + Device.isIos() && + Device.comparePlatformVersionTo('17.4') < 0 + ) { + const description = () => ( + <> + + {strings(`error_sheet.ios_need_update_description`)} + + {strings(`error_sheet.ios_need_update_description_version`)} + + {strings(`error_sheet.ios_need_update_description_end`)} + + + {strings(`error_sheet.ios_need_update_description2`)} + + + ); + + await navigateToSuccessErrorSheetPromise(navigation, { + type: 'error', + icon: IconName.Warning, + iconColor: IconColor.Warning, + title: strings(`error_sheet.ios_need_update_title`), + description: description(), + primaryButtonLabel: strings(`error_sheet.ios_need_update_button`), + closeOnPrimaryButtonPress: true, + isInteractable: false, + }); + track(MetaMetricsEvents.WALLET_GOOGLE_IOS_WARNING_VIEWED, { + account_type: accountType, + }); + } setLoading(); const loginHandler = createLoginHandler(Platform.OS, provider); try { @@ -799,6 +839,7 @@ const Onboarding = () => { handleExistingUser(action); }, [ + tw, navigation, metrics, track, diff --git a/app/components/Views/SuccessErrorSheet/utils.test.ts b/app/components/Views/SuccessErrorSheet/utils.test.ts new file mode 100644 index 00000000000..a0b29825c74 --- /dev/null +++ b/app/components/Views/SuccessErrorSheet/utils.test.ts @@ -0,0 +1,113 @@ +import { NavigationProp, ParamListBase } from '@react-navigation/native'; +import Routes from '../../../constants/navigation/Routes'; +import { + navigateToSuccessErrorSheet, + navigateToSuccessErrorSheetPromise, +} from './utils'; + +const mockNavigate = jest.fn(); +const mockNavigation = { + navigate: mockNavigate, +} as unknown as NavigationProp; + +const baseParams = { + type: 'error' as const, + title: 'Error Title', + description: 'Error description', +}; + +describe('navigateToSuccessErrorSheet', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('forwards params to the success error sheet route', () => { + const onClose = jest.fn(); + const onPrimaryButtonPress = jest.fn(); + const params = { + ...baseParams, + type: 'success' as const, + onClose, + onPrimaryButtonPress, + descriptionAlign: 'center' as const, + primaryButtonLabel: 'OK', + }; + + navigateToSuccessErrorSheet(mockNavigation, params); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.SUCCESS_ERROR_SHEET, + params: expect.objectContaining({ + type: 'success', + onClose, + onPrimaryButtonPress, + descriptionAlign: 'center', + primaryButtonLabel: 'OK', + }), + }); + }); +}); + +describe('navigateToSuccessErrorSheetPromise', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('resolves and invokes the original onPrimaryButtonPress callback', async () => { + const onPrimaryButtonPress = jest.fn(); + + mockNavigate.mockImplementation((_route, params) => { + params.params.onPrimaryButtonPress(); + }); + + await expect( + navigateToSuccessErrorSheetPromise(mockNavigation, { + ...baseParams, + onPrimaryButtonPress, + }), + ).resolves.toBeUndefined(); + + expect(onPrimaryButtonPress).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith( + Routes.MODAL.ROOT_MODAL_FLOW, + expect.objectContaining({ + screen: Routes.SHEET.SUCCESS_ERROR_SHEET, + }), + ); + }); + + it('resolves and invokes the original onSecondaryButtonPress callback', async () => { + const onSecondaryButtonPress = jest.fn(); + + mockNavigate.mockImplementation((_route, params) => { + params.params.onSecondaryButtonPress(); + }); + + await expect( + navigateToSuccessErrorSheetPromise(mockNavigation, { + ...baseParams, + onSecondaryButtonPress, + }), + ).resolves.toBeUndefined(); + + expect(onSecondaryButtonPress).toHaveBeenCalledTimes(1); + }); + + it('resolves and invokes the original onClose callback', async () => { + const onClose = jest.fn(); + + mockNavigate.mockImplementation((_route, params) => { + params.params.onClose(); + }); + + await expect( + navigateToSuccessErrorSheetPromise(mockNavigation, { + ...baseParams, + onClose, + }), + ).resolves.toBeUndefined(); + + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/components/Views/SuccessErrorSheet/utils.ts b/app/components/Views/SuccessErrorSheet/utils.ts new file mode 100644 index 00000000000..e9c655fa924 --- /dev/null +++ b/app/components/Views/SuccessErrorSheet/utils.ts @@ -0,0 +1,37 @@ +import { NavigationProp, ParamListBase } from '@react-navigation/native'; +import { SuccessErrorSheetParams } from './interface'; +import Routes from '../../../constants/navigation/Routes'; + +export const navigateToSuccessErrorSheet = ( + navigation: NavigationProp, + params: SuccessErrorSheetParams, +) => { + navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.SUCCESS_ERROR_SHEET, + params: { + ...params, + }, + }); +}; + +export const navigateToSuccessErrorSheetPromise = async ( + navigation: NavigationProp, + params: SuccessErrorSheetParams, +) => + new Promise((resolve) => { + navigateToSuccessErrorSheet(navigation, { + ...params, + onPrimaryButtonPress: () => { + params.onPrimaryButtonPress?.(); + resolve(); + }, + onSecondaryButtonPress: () => { + params.onSecondaryButtonPress?.(); + resolve(); + }, + onClose: () => { + params.onClose?.(); + resolve(); + }, + }); + }); diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index 3e0d1d23446..745311015fd 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -147,6 +147,7 @@ enum EVENT_NAME { WALLET_CREATION_ATTEMPTED = 'Wallet Creation Attempted', WALLET_CREATED = 'Wallet Created', WALLET_SETUP_FAILURE = 'Wallet Setup Failure', + WALLET_GOOGLE_IOS_WARNING_VIEWED = 'Wallet Google Ios Warning Viewed', WALLET_CREATION_ERROR_SCREEN_VIEWED = 'Wallet Creation Error Screen Viewed', WALLET_CREATION_ERROR_RETRY_CLICKED = 'Wallet Creation Error Retry Clicked', WALLET_CREATION_ERROR_REPORT_SENT = 'Wallet Creation Error Report Sent', @@ -859,6 +860,9 @@ const events = { WALLET_CREATION_ATTEMPTED: generateOpt(EVENT_NAME.WALLET_CREATION_ATTEMPTED), WALLET_CREATED: generateOpt(EVENT_NAME.WALLET_CREATED), WALLET_SETUP_FAILURE: generateOpt(EVENT_NAME.WALLET_SETUP_FAILURE), + WALLET_GOOGLE_IOS_WARNING_VIEWED: generateOpt( + EVENT_NAME.WALLET_GOOGLE_IOS_WARNING_VIEWED, + ), WALLET_CREATION_ERROR_SCREEN_VIEWED: generateOpt( EVENT_NAME.WALLET_CREATION_ERROR_SCREEN_VIEWED, ), diff --git a/app/util/device/index.js b/app/util/device/index.js index df04b68a24d..c3329ec2161 100644 --- a/app/util/device/index.js +++ b/app/util/device/index.js @@ -2,6 +2,7 @@ import { Dimensions, Platform } from 'react-native'; import { hasNotch, getApiLevel } from 'react-native-device-info'; +import compareVersions from 'compare-versions'; export default class Device { static getDeviceWidth() { @@ -12,6 +13,21 @@ export default class Device { return Dimensions.get('window').height; } + /** + * Compares this device's React Native {@link Platform.Version} to `referenceVersion` + * using the shared `compare-versions` package after normalizing both values to strings. + * + * @param {string|number} referenceVersion - Version to compare against (e.g. `"17.4"`). + * @returns {number} `1` if current > reference, `-1` if current < reference, `0` if equal. + * @remarks On iOS, `Platform.Version` is usually a string (`"17.3.1"`). On Android it is + * typically the API level as a number (for example `34`). Both values are coerced to strings + * before comparison so the helper remains safe across platforms while preserving component-wise + * numeric comparison semantics. + */ + static comparePlatformVersionTo(referenceVersion) { + return compareVersions(String(Platform.Version), String(referenceVersion)); + } + static isIos() { return Platform.OS === 'ios'; } diff --git a/locales/languages/en.json b/locales/languages/en.json index a30519785e2..c4c7879604d 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -6751,7 +6751,13 @@ "oauth_error_button": "Try again", "no_internet_connection_title": "Unable to connect", "no_internet_connection_description": "Your internet connection is unstable. Check your connection and try again.", - "no_internet_connection_button": "Try again" + "no_internet_connection_button": "Try again", + "ios_need_update_title": "iOS update required", + "ios_need_update_description": "MetaMask Google Sign-In will soon require ", + "ios_need_update_description_version": "iOS 17.4 or later", + "ios_need_update_description_end": ". You can continue using Google Sign-In on this device for now, but it will no longer be supported in an upcoming update.", + "ios_need_update_description2": "You can still access your wallet using the same Google account on a supported device or the MetaMask extension. We strongly recommend backing up your Secret Recovery Phrase to ensure uninterrupted access.", + "ios_need_update_button": "Continue" }, "password_hint": { "title": "Password hint", From 7ebe54c2d11cc001884da58a388364a0751c9b67 Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Thu, 26 Mar 2026 10:55:39 +0000 Subject: [PATCH 201/206] [skip ci] Bump version number to 4192 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 452b110ef63..ccfe0dc4687 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4191 + versionCode 4192 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 66bc38c842f..b9a0dcef7fe 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4191 + VERSION_NUMBER: 4192 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4191 + FLASK_VERSION_NUMBER: 4192 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 7206c71e8d9..936c566402b 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4191; + CURRENT_PROJECT_VERSION = 4192; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4191; + CURRENT_PROJECT_VERSION = 4192; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4191; + CURRENT_PROJECT_VERSION = 4192; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4191; + CURRENT_PROJECT_VERSION = 4192; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4191; + CURRENT_PROJECT_VERSION = 4192; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4191; + CURRENT_PROJECT_VERSION = 4192; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 32d70a8d131bc1e089384101343aa2129a124b79 Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:16:50 +0100 Subject: [PATCH 202/206] chore(runway): cherry-pick fix: hides perps buttons in ai insights when user has a position cp-7.71.0 (#27924) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix: hides perps buttons in ai insights when user has a position cp-7.71.0 (#27919) ## **Description** This PR is a bug fix for https://github.com/MetaMask/metamask-mobile/issues/27916 where: - In the AI Market Insights in Perps, the action buttons are wrong when user has an open position: the action buttons should be the same in AI market insight page and market page, ie. “modify” and “Close” when user has an open position Fix: - When the user has an existing perps position, the MarketInsights footer action buttons (Long/Short) are hidden since the relevant actions (modify/close) live on the Perps market details page - The "AI summary for information only" disclaimer is moved inline below the feedback section when the footer is hidden, so it remains visible - Position state is passed via route params (hasPerpsPosition) from the caller rather than fetched async inside MarketInsightsView, preventing a flash where buttons briefly appear then disappear while the position loads ## **Changelog** CHANGELOG entry: Fixed a bug that was causing incorrect Perps action buttons to be displayed ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/27916 ## **Manual testing steps** ```gherkin - Open MarketInsights from token details → Swap/Buy buttons visible with disclaimer in footer - Open MarketInsights from perps with no position → Long/Short buttons visible with disclaimer in footer - Open MarketInsights from perps with an existing position → no footer buttons, disclaimer shown below "Was this helpful?" - Verify no flash/layout shift on the perps + position flow ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/0373c1bb-d433-4c9a-8768-117a40d98f9e ### **After** SCR-20260325-nzfa ## **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. ## **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** > Low risk UI/navigation tweak that changes which CTAs are shown based on a new route param; main risk is incorrect param wiring causing missing actions or disclaimer placement in the Perps insights flow. > > **Overview** > Fixes Perps AI Market Insights showing inappropriate `Long`/`Short` CTAs when the user already has an open position. > > Adds a `hasPerpsPosition` route param (set by `PerpsMarketDetailsView`) and uses it in `MarketInsightsView` to **hide the footer action buttons** for Perps-with-position while **keeping the informational disclaimer visible** by moving it inline under the feedback section for that case. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 833593b6e060e6e8c51a711b921f69d7b53ed4aa. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [8a7bced](https://github.com/MetaMask/metamask-mobile/commit/8a7bcedd42c36ea7ec7d54782e509d154b549d41) Co-authored-by: João Santos --- .../MarketInsightsView/MarketInsightsView.tsx | 103 ++++++++++-------- .../PerpsMarketDetailsView.tsx | 3 +- 2 files changed, 62 insertions(+), 44 deletions(-) diff --git a/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx b/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx index facf0801787..dc761d86a29 100644 --- a/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx +++ b/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx @@ -153,6 +153,8 @@ interface MarketInsightsRouteParams { tokenChainId?: string; /** When true, indicates the view was opened from the Perps market details view */ isPerps?: boolean; + /** When true, the user has an existing perps position for this asset */ + hasPerpsPosition?: boolean; } /** @@ -182,6 +184,7 @@ const MarketInsightsView: React.FC = () => { tokenName, tokenChainId, isPerps = false, + hasPerpsPosition = false, } = route.params; const isMarketInsightsEnabled = isPerps @@ -660,65 +663,79 @@ const MarketInsightsView: React.FC = () => { > {strings('market_insights.helpful_prompt')} + {isPerps && hasPerpsPosition && ( + + {strings('market_insights.footer_disclaimer')} + + )} - - {isPerps ? ( - - - - - ) : ( - - + {!(isPerps && hasPerpsPosition) && ( + + {isPerps ? ( + - - + ) : ( + + + + + + + + + )} + + + {strings('market_insights.footer_disclaimer')} + - )} - - - {strings('market_insights.footer_disclaimer')} - - + )} {selectedTrend ? ( = () => { assetSymbol: market.symbol, assetIdentifier: market.symbol, isPerps: true, + hasPerpsPosition: !!existingPosition, }); - }, [market?.symbol, navigation, track]); + }, [market?.symbol, navigation, track, existingPosition]); // Handler for order selection - navigates to order details const handleOrderSelect = useCallback( From 00749704bd33d02a056be75f8bd2b92bdcc82591 Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Thu, 26 Mar 2026 11:18:38 +0000 Subject: [PATCH 203/206] [skip ci] Bump version number to 4193 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index ccfe0dc4687..3a707d90ed8 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4192 + versionCode 4193 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index b9a0dcef7fe..a03422f5205 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4192 + VERSION_NUMBER: 4193 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4192 + FLASK_VERSION_NUMBER: 4193 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 936c566402b..e8c8cd7d5ef 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4192; + CURRENT_PROJECT_VERSION = 4193; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4192; + CURRENT_PROJECT_VERSION = 4193; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4192; + CURRENT_PROJECT_VERSION = 4193; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4192; + CURRENT_PROJECT_VERSION = 4193; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4192; + CURRENT_PROJECT_VERSION = 4193; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4192; + CURRENT_PROJECT_VERSION = 4193; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 42968c3a119bd6d3f88f7ee528639797fd92cbb1 Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Thu, 26 Mar 2026 20:32:58 +0000 Subject: [PATCH 204/206] [skip ci] Bump version number to 4199 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 3a707d90ed8..757984fcb69 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4193 + versionCode 4199 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index a03422f5205..ee3599c5940 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4193 + VERSION_NUMBER: 4199 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4193 + FLASK_VERSION_NUMBER: 4199 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index e8c8cd7d5ef..e6bc54ea18b 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4193; + CURRENT_PROJECT_VERSION = 4199; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4193; + CURRENT_PROJECT_VERSION = 4199; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4193; + CURRENT_PROJECT_VERSION = 4199; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4193; + CURRENT_PROJECT_VERSION = 4199; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4193; + CURRENT_PROJECT_VERSION = 4199; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4193; + CURRENT_PROJECT_VERSION = 4199; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From ad9aa0f648f9bae17de0e889fa6b93a4a8bfc854 Mon Sep 17 00:00:00 2001 From: MetaMask Bot <37885440+metamaskbot@users.noreply.github.com> Date: Fri, 27 Mar 2026 05:58:58 -0230 Subject: [PATCH 205/206] release: release-changelog/7.71.0 (#27710) This PR updates the change log for 7.71.0. (Hotfix - no test plan generated.) --------- Co-authored-by: metamaskbot Co-authored-by: chloeYue --- CHANGELOG.md | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29a67c3c648..54895ba1644 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [7.71.0] + +### Added + +- Added backend-provided intent typedData for signing intent swap txs (#25913) +- Added Security & Trust section to Token Details page showing risk level, contract security features, buy/sell tax, token distribution, and official links powered by Blockaid (#27073) +- Added a "Withdraw" button to the unstaked TRX banner so users can claim TRX that has completed the lock period (#27076) +- Added handling for aggregated balance on the new home page (#27172) +- Added LD flags to consume price impact threshold (#27196) +- Added Segment event tracking for mUSD Quick Convert flow and enriched generic Transaction\* events for mUSD conversion transactions (#27305) +- Improved bridge/swap quote expiry experience; expired quotes now remain visible inline with a prompt to refresh, replacing a separate modal flow (#27340) +- Added support for ramps providers such as PayPal, Robinhood & Coinbase that use a different checkout browser (#27364) +- Added authentication for transaction submission to sentinel and transaction API (#27410) +- Added skeleton loading indicator to NFT grid items while images are loading (#27413) +- Embedded the metal card checkout flow into the Card onboarding/sign-up flow (#27420) +- Added attention badge on Card button (#27425) +- Added a new tab for users to see their NFTs and fixed NFT flicker on that view (#27437) +- Added press opacity feedback to NFT grid items (#27488) +- Applied a minimum $0.01 threshold for showing the "Claim bonus" CTA for Merkl rewards so that amounts below the threshold show the 3% bonus label instead (#27522) +- Updated Predict withdraw to default to the user’s last used destination token before falling back to the remote preferred token (#27532) +- Enabled campaigns view under feature flag (#27556) +- Redirected buy deeplinks to the new Ramps Buy flow when Ramps Unified V2 is enabled; deprecated cash deposit deeplinks (#27557) +- Restored mUSD claimable bonus claim section on asset overview screen (#27567) +- Added campaign opt-in flow with details and mechanics screens in the Rewards section (#27619) +- Updated Ramp buy flow modal headers and typography to use shared compact header and design system components (#27627) +- Migrated Card authentication to CardController with new `useCardAuth` hook for controller-based auth flow (#27656) +- Extracted Card supported-country check into `selectIsUserInSupportedCardCountry` selector (#27695) +- Updated mUSD aggregated balance row to redirect to the Cash tokens list when the user holds mUSD on any network (#27703) + +### Changed + +- Removed deprecated payment request (#27519) +- Updated earn balance row layout (logo size, badge size, balance/percentage placement) and added privacy mode support for StakingBalance and EarnLendingBalance (#27457) +- Refactored Card onboarding to use the `useRegions` hook instead of Redux `selectedCountry` for region/country data (#27539) +- Adjusted spacing in homepage (#27637) + +### Fixed + +- Fixed a bug where closing the "Token not available" modal left the user in a stuck state instead of navigating back to the token selection screen (#27277) +- Fixed false "Token Not Available" errors during Buy flow when payment methods are still loading after provider change; fixed missing "Token Not Available" modal in home buy flow; fixed crash when navigating back from "Token Not Available" modal in token info buy flow (#27448) +- Fixed token row display on homepage to show price and variation separated by a dot for consistency with token list items (#27449) +- Fixed stop loss banner rendering issue (#27458) +- Fixed Order Details screen displaying excessive decimal places for crypto amounts after ramp purchases (#27469) +- Fixed remove network confirmation header casing to sentence case (#27480) +- Fixed the custom network header trash icon color to match other trash icons in the app (#27481) +- Fixed a bug where the RPC URL field in network details could appear focused after blur and had inconsistent typography between states (#27482) +- Fixed RAMP_INTERNAL_BUILD default for OTA push (#27507) +- Fixed a bug where Perps activity could appear blank after reopening the Activity screen from Perps home (#27509) +- Fixed universal link handling for redirect-oauth (#27511) +- Fixed Network Details so network name is required and no longer labeled optional (#27541) +- Fixed onboarding import button text being invisible in dark mode; ensured both CTAs have proper contrast in dark mode (#27550) +- Removed a stale feature-flag gate so the Networks menu item is always available (#27591) +- Fixed MegaETH explorer button to display "View on Megaeth Explorer" instead of "View on Megaeth" (#27592) +- Fixed padding in security screen header (#27621) +- Fixed TokenList crash when switching networks (#27655) +- Fixed miscategorization of BRENTOIL and other non-crypto instruments appearing in the "Explore Crypto" section on Perps Home (#27699) + ## [7.70.1] ### Fixed @@ -11015,7 +11072,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#957](https://github.com/MetaMask/metamask-mobile/pull/957): fix timeouts (#957) - [#954](https://github.com/MetaMask/metamask-mobile/pull/954): Bugfix: onboarding navigation (#954) -[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.70.1...HEAD +[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.71.0...HEAD +[7.71.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.70.1...v7.71.0 [7.70.1]: https://github.com/MetaMask/metamask-mobile/compare/v7.70.0...v7.70.1 [7.70.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.69.1...v7.70.0 [7.69.1]: https://github.com/MetaMask/metamask-mobile/compare/v7.69.0...v7.69.1 From 0ceeef02c13fdf61768f4e8498892276f9b301f3 Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Fri, 27 Mar 2026 08:30:36 +0000 Subject: [PATCH 206/206] [skip ci] Bump version number to 4208 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 757984fcb69..52a78ec36ff 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4199 + versionCode 4208 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index ee3599c5940..470123948e7 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4199 + VERSION_NUMBER: 4208 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4199 + FLASK_VERSION_NUMBER: 4208 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index e6bc54ea18b..ccc4e0c1aff 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4199; + CURRENT_PROJECT_VERSION = 4208; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4199; + CURRENT_PROJECT_VERSION = 4208; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4199; + CURRENT_PROJECT_VERSION = 4208; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4199; + CURRENT_PROJECT_VERSION = 4208; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4199; + CURRENT_PROJECT_VERSION = 4208; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4199; + CURRENT_PROJECT_VERSION = 4208; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG;

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
Ml+;UfgaAYa_YNa;1D+ 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