From bf19c03bb414d2faee1ac89390c2477a3afca17b Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Fri, 30 Jan 2026 09:31:03 +0100 Subject: [PATCH 01/17] refactor(perps): use params in the controller and remove unused route (#25412) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Refactor perps controller and remove unused route ## **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** No visible change ### **After** No visible 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 - [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 the public `PerpsController.depositWithConfirmation`/hook call signatures and the deposit+order transaction path toggle, which could break existing callers if any werenโ€™t updated. > > **Overview** > **Refactors perps deposit entrypoints** by changing `PerpsController.depositWithConfirmation` from positional args to a single `DepositWithConfirmationParams` object (`amount`, `placeOrder`), and updates `depositWithOrder`/`usePerpsTrading` accordingly. > > **Cleans up navigation** by removing the unused `Routes.PERPS.ORDER` constant and its screen registration from the perps route stack. Tests are updated to match the new deposit API shape. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 8ac40357a2f2731f493504bd2c5369f3a646ce40. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../Perps/controllers/PerpsController.test.ts | 89 +++++++++++-------- .../UI/Perps/controllers/PerpsController.ts | 21 +++-- app/components/UI/Perps/controllers/index.ts | 1 + .../UI/Perps/controllers/types/index.ts | 8 ++ .../UI/Perps/hooks/usePerpsTrading.ts | 19 ++-- app/components/UI/Perps/routes/index.tsx | 10 --- app/constants/navigation/Routes.ts | 1 - 7 files changed, 79 insertions(+), 70 deletions(-) diff --git a/app/components/UI/Perps/controllers/PerpsController.test.ts b/app/components/UI/Perps/controllers/PerpsController.test.ts index 3ee6237d2a6b..5a30c55f807e 100644 --- a/app/components/UI/Perps/controllers/PerpsController.test.ts +++ b/app/components/UI/Perps/controllers/PerpsController.test.ts @@ -2381,7 +2381,9 @@ describe('PerpsController', () => { markControllerAsInitialized(); controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - const result = await controller.depositWithConfirmation('100'); + const result = await controller.depositWithConfirmation({ + amount: '100', + }); expect(result).toEqual({ result: expect.any(Promise), @@ -2392,7 +2394,7 @@ describe('PerpsController', () => { markControllerAsInitialized(); controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - await controller.depositWithConfirmation('100'); + await controller.depositWithConfirmation({ amount: '100' }); expect( mockDepositServiceInstance.prepareTransaction, @@ -2405,7 +2407,7 @@ describe('PerpsController', () => { markControllerAsInitialized(); controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - await controller.depositWithConfirmation('100'); + await controller.depositWithConfirmation({ amount: '100' }); expect( mockInfrastructure.controllers.network.findNetworkClientIdForChain, @@ -2416,7 +2418,7 @@ describe('PerpsController', () => { markControllerAsInitialized(); controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - await controller.depositWithConfirmation('100'); + await controller.depositWithConfirmation({ amount: '100' }); expect( mockInfrastructure.controllers.transaction.submit, @@ -2431,16 +2433,18 @@ describe('PerpsController', () => { it('throws error when controller not initialized', async () => { controller.testSetInitialized(false); - await expect(controller.depositWithConfirmation('100')).rejects.toThrow( - 'CLIENT_NOT_INITIALIZED', - ); + await expect( + controller.depositWithConfirmation({ amount: '100' }), + ).rejects.toThrow('CLIENT_NOT_INITIALIZED'); }); it('throws error when no active provider', async () => { markControllerAsInitialized(); controller.testSetProviders(new Map()); - await expect(controller.depositWithConfirmation('100')).rejects.toThrow(); + await expect( + controller.depositWithConfirmation({ amount: '100' }), + ).rejects.toThrow(); }); it('propagates DepositService errors', async () => { @@ -2451,9 +2455,9 @@ describe('PerpsController', () => { .spyOn(mockDepositServiceInstance, 'prepareTransaction') .mockRejectedValue(mockError); - await expect(controller.depositWithConfirmation('100')).rejects.toThrow( - 'Deposit service failed', - ); + await expect( + controller.depositWithConfirmation({ amount: '100' }), + ).rejects.toThrow('Deposit service failed'); }); it('propagates controllers.network.findNetworkClientIdForChain errors', async () => { @@ -2467,9 +2471,9 @@ describe('PerpsController', () => { throw mockError; }); - await expect(controller.depositWithConfirmation('100')).rejects.toThrow( - 'Network client not found', - ); + await expect( + controller.depositWithConfirmation({ amount: '100' }), + ).rejects.toThrow('Network client not found'); }); it('propagates controllers.transaction.submit errors', async () => { @@ -2480,9 +2484,9 @@ describe('PerpsController', () => { mockInfrastructure.controllers.transaction.submit as jest.Mock ).mockRejectedValue(mockError); - await expect(controller.depositWithConfirmation('100')).rejects.toThrow( - 'Transaction failed', - ); + await expect( + controller.depositWithConfirmation({ amount: '100' }), + ).rejects.toThrow('Transaction failed'); }); it('clears transaction ID when error occurs and not user cancellation', async () => { @@ -2496,9 +2500,9 @@ describe('PerpsController', () => { mockInfrastructure.controllers.transaction.submit as jest.Mock ).mockRejectedValue(mockError); - await expect(controller.depositWithConfirmation('100')).rejects.toThrow( - 'Network error', - ); + await expect( + controller.depositWithConfirmation({ amount: '100' }), + ).rejects.toThrow('Network error'); expect(controller.state.lastDepositTransactionId).toBeNull(); }); @@ -2514,9 +2518,9 @@ describe('PerpsController', () => { mockInfrastructure.controllers.transaction.submit as jest.Mock ).mockRejectedValue(mockError); - await expect(controller.depositWithConfirmation('100')).rejects.toThrow( - 'User denied', - ); + await expect( + controller.depositWithConfirmation({ amount: '100' }), + ).rejects.toThrow('User denied'); // When user cancels, transaction ID is not cleared expect(controller.state.lastDepositTransactionId).toBe('old-tx-id'); @@ -2536,7 +2540,9 @@ describe('PerpsController', () => { }; }); - const { result } = await controller.depositWithConfirmation('100'); + const { result } = await controller.depositWithConfirmation({ + amount: '100', + }); await result; @@ -2549,7 +2555,7 @@ describe('PerpsController', () => { markControllerAsInitialized(); controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - await controller.depositWithConfirmation('100'); + await controller.depositWithConfirmation({ amount: '100' }); expect(controller.state.lastDepositTransactionId).toBe('tx-meta-123'); }); @@ -2558,7 +2564,7 @@ describe('PerpsController', () => { markControllerAsInitialized(); controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - await controller.depositWithConfirmation('100'); + await controller.depositWithConfirmation({ amount: '100' }); expect(controller.state.depositRequests[0].id).toBe(mockDepositId); }); @@ -2567,7 +2573,7 @@ describe('PerpsController', () => { markControllerAsInitialized(); controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - await controller.depositWithConfirmation('100'); + await controller.depositWithConfirmation({ amount: '100' }); expect( mockDepositServiceInstance.prepareTransaction, @@ -2580,7 +2586,7 @@ describe('PerpsController', () => { markControllerAsInitialized(); controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - await controller.depositWithConfirmation('100'); + await controller.depositWithConfirmation({ amount: '100' }); expect(controller.state.depositRequests).toHaveLength(1); expect(controller.state.depositRequests[0].id).toBe(mockDepositId); @@ -2601,7 +2607,9 @@ describe('PerpsController', () => { markControllerAsInitialized(); controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - const { result } = await controller.depositWithConfirmation('100'); + const { result } = await controller.depositWithConfirmation({ + amount: '100', + }); await result; @@ -2615,8 +2623,8 @@ describe('PerpsController', () => { markControllerAsInitialized(); controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - const deposit1 = controller.depositWithConfirmation('100'); - const deposit2 = controller.depositWithConfirmation('200'); + const deposit1 = controller.depositWithConfirmation({ amount: '100' }); + const deposit2 = controller.depositWithConfirmation({ amount: '200' }); await Promise.all([deposit1, deposit2]); @@ -2626,14 +2634,17 @@ describe('PerpsController', () => { expect(amounts).toContain('200'); }); - it('uses addTransaction when depositAndPlaceOrder is true', async () => { + it('uses addTransaction when placeOrder is true', async () => { markControllerAsInitialized(); controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); mockAddTransaction.mockResolvedValue({ transactionMeta: mockTransactionMeta, }); - await controller.depositWithConfirmation('100', true); + await controller.depositWithConfirmation({ + amount: '100', + placeOrder: true, + }); expect(mockAddTransaction).toHaveBeenCalledWith(mockTransaction, { networkClientId: mockNetworkClientId, @@ -2652,7 +2663,9 @@ describe('PerpsController', () => { markControllerAsInitialized(); controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - const { result } = await controller.depositWithConfirmation('100'); + const { result } = await controller.depositWithConfirmation({ + amount: '100', + }); // Transaction succeeds await result; @@ -2684,7 +2697,9 @@ describe('PerpsController', () => { transactionMeta: mockTransactionMeta, }); - const { result } = await controller.depositWithConfirmation('100'); + const { result } = await controller.depositWithConfirmation({ + amount: '100', + }); // Wait for the result promise to reject await expect(result).rejects.toThrow('Network error occurred'); @@ -2729,7 +2744,9 @@ describe('PerpsController', () => { transactionMeta: mockTransactionMeta, }); - const { result } = await controller.depositWithConfirmation('100'); + const { result } = await controller.depositWithConfirmation({ + amount: '100', + }); await expect(result).rejects.toThrow(message); diff --git a/app/components/UI/Perps/controllers/PerpsController.ts b/app/components/UI/Perps/controllers/PerpsController.ts index 57183d8044f7..1a13813a398e 100644 --- a/app/components/UI/Perps/controllers/PerpsController.ts +++ b/app/components/UI/Perps/controllers/PerpsController.ts @@ -57,6 +57,7 @@ import { type ClosePositionParams, type ClosePositionsParams, type ClosePositionsResult, + type DepositWithConfirmationParams, type EditOrderParams, type FeeCalculationParams, type FeeCalculationResult, @@ -1484,13 +1485,12 @@ export class PerpsController extends BaseController< /** * Simplified deposit method that prepares transaction for confirmation screen * No complex state tracking - just sets a loading flag - * @param amount - Optional deposit amount - * @param depositAndPlaceOrder - If true, uses addTransaction instead of submit to avoid navigation + * @param params - Parameters for the deposit flow + * @param params.amount - Optional deposit amount + * @param params.placeOrder - If true, uses addTransaction instead of submit to avoid navigation */ - async depositWithConfirmation( - amount?: string, - depositAndPlaceOrder?: boolean, - ) { + async depositWithConfirmation(params: DepositWithConfirmationParams = {}) { + const { amount, placeOrder } = params; const { controllers } = this.options.infrastructure; try { @@ -1545,7 +1545,7 @@ export class PerpsController extends BaseController< skipInitialGasEstimate: true, }; - if (depositAndPlaceOrder) { + if (placeOrder) { // Use addTransaction to create transaction without navigating to confirmation screen const { transactionMeta: addedTransactionMeta } = await addTransaction( transaction, @@ -1577,7 +1577,7 @@ export class PerpsController extends BaseController< }); // Track the transaction lifecycle only when using submit (deposit-only flow) - if (!depositAndPlaceOrder) { + if (!placeOrder) { // At this point, the confirmation modal is shown to the user // The result promise will resolve/reject based on user action and transaction outcome @@ -1698,10 +1698,9 @@ export class PerpsController extends BaseController< /** * Same as depositWithConfirmation - prepares transaction for confirmation screen. - * @param amount - Optional deposit amount */ - async depositWithOrder(amount?: string) { - return this.depositWithConfirmation(amount, true); + async depositWithOrder() { + return this.depositWithConfirmation({ placeOrder: true }); } /** diff --git a/app/components/UI/Perps/controllers/index.ts b/app/components/UI/Perps/controllers/index.ts index 181bc476e504..3dac5894b286 100644 --- a/app/components/UI/Perps/controllers/index.ts +++ b/app/components/UI/Perps/controllers/index.ts @@ -63,6 +63,7 @@ export type { // Deposit/withdrawal types DepositParams, + DepositWithConfirmationParams, DepositResult, WithdrawParams, WithdrawResult, diff --git a/app/components/UI/Perps/controllers/types/index.ts b/app/components/UI/Perps/controllers/types/index.ts index eaf50a0e42e7..ec669e5f8845 100644 --- a/app/components/UI/Perps/controllers/types/index.ts +++ b/app/components/UI/Perps/controllers/types/index.ts @@ -459,6 +459,14 @@ export interface DepositParams { recipient?: Hex; // Recipient address (defaults to selected account) } +/** Params for depositWithConfirmation: prepares transaction for confirmation screen */ +export interface DepositWithConfirmationParams { + /** Optional deposit amount (display/tracking; actual amount comes from prepared transaction) */ + amount?: string; + /** If true, uses addTransaction instead of submit to avoid navigation (e.g. deposit + place order flow) */ + placeOrder?: boolean; +} + export interface DepositResult { success: boolean; txHash?: string; diff --git a/app/components/UI/Perps/hooks/usePerpsTrading.ts b/app/components/UI/Perps/hooks/usePerpsTrading.ts index 1bc9f608fa26..e149e4984d33 100644 --- a/app/components/UI/Perps/hooks/usePerpsTrading.ts +++ b/app/components/UI/Perps/hooks/usePerpsTrading.ts @@ -113,22 +113,17 @@ export function usePerpsTrading() { result: Promise; }> => { const controller = Engine.context.PerpsController; - return controller.depositWithConfirmation(amount, false); + return controller.depositWithConfirmation({ amount, placeOrder: false }); }, [], ); - const depositWithOrder = useCallback( - async ( - amount?: string, - ): Promise<{ - result: Promise; - }> => { - const controller = Engine.context.PerpsController; - return controller.depositWithOrder(amount); - }, - [], - ); + const depositWithOrder = useCallback(async (): Promise<{ + result: Promise; + }> => { + const controller = Engine.context.PerpsController; + return controller.depositWithOrder(); + }, []); const clearDepositResult = useCallback((): void => { const controller = Engine.context.PerpsController; diff --git a/app/components/UI/Perps/routes/index.tsx b/app/components/UI/Perps/routes/index.tsx index 1bc239a258d1..f1ac4dab6728 100644 --- a/app/components/UI/Perps/routes/index.tsx +++ b/app/components/UI/Perps/routes/index.tsx @@ -16,7 +16,6 @@ import PerpsMarketListView from '../Views/PerpsMarketListView'; import PerpsRedirect from '../Views/PerpsRedirect'; import PerpsPositionsView from '../Views/PerpsPositionsView'; import PerpsWithdrawView from '../Views/PerpsWithdrawView'; -import PerpsOrderView from '../Views/PerpsOrderView'; import PerpsClosePositionView from '../Views/PerpsClosePositionView'; import PerpsCloseAllPositionsView from '../Views/PerpsCloseAllPositionsView/PerpsCloseAllPositionsView'; import PerpsCancelAllOrdersView from '../Views/PerpsCancelAllOrdersView/PerpsCancelAllOrdersView'; @@ -276,15 +275,6 @@ const PerpsScreenStack = () => { }} /> - - Date: Fri, 30 Jan 2026 10:23:19 +0100 Subject: [PATCH 02/17] test: mUSD conversion component view test (#25169) ## **Description** Added comprehensive component view tests for the following mUSD conversion components: 1. **MusdConversionAssetListCta** (3 test cases) - Feature flag visibility logic (`earnMusdCtaEnabled` and `earnMusdConversionFlowEnabled`) - Component behavior when visibility conditions are not met - Graceful handling when component returns null 2. **MusdConversionAssetOverviewCta** (15 test cases) - Component rendering with different asset configurations (USDC, DAI, USDT) - CTA text content verification (title and description) - Close button visibility and interaction (`onDismiss` callback) - Presentational component behavior (renders regardless of allowlist - logic handled by parent) - Asset balance scenarios (above minimum, low balance) - Multi-chain support (different chainIds) - Edge cases (missing asset address, assets not in allowlist) - Feature flag configuration (`earnMusdConversionAssetOverviewCtaEnabled`, `earnMusdConversionFlowEnabled`, `earnMusdConversionCtaTokens`) 3. **EarnMusdConversionEducationView** (12 test cases) - Complete UI rendering (heading, description, primary and secondary buttons) - APY percentage display in heading and description - Button interaction states (go back and continue buttons remain visible after press) - Route parameter handling (missing params, partial params, complete params) - Education seen state management (`musdConversionEducationSeen`) - Feature flag configuration (`earnMusdConversionFlowEnabled`) ## **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] > Adds comprehensive component-view tests for mUSD conversion UI and strengthens the test harness/mocks for stable execution. > > - New tests cover `EarnMusdConversionEducationView`, `MusdConversionAssetListCta`, and `MusdConversionAssetOverviewCta` including feature flag gating, visibility conditions, APY text, route params handling, and interaction (press/close) > - Test utilities enhanced: Engine mock gains `controllerMessenger.call`; state fixture builder adds `withMinimalAnalyticsController`; wallet/bridge presets include minimal analytics and `TokensController` defaults; expanded RN/analytics/filesystem mocks in `testSetupView` > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 0a53be73da666f2d5185906dc13b358bec720c6a. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- ...nMusdConversionEducationView.view.test.tsx | 487 +++++++++++++++ .../MusdConversionAssetListCta.view.test.tsx | 117 ++++ ...sdConversionAssetOverviewCta.view.test.tsx | 577 ++++++++++++++++++ app/util/test/component-view/mocks.ts | 5 + .../test/component-view/presets/bridge.ts | 1 + .../test/component-view/presets/wallet.ts | 10 + app/util/test/component-view/stateFixture.ts | 26 + app/util/test/testSetupView.js | 1 + 8 files changed, 1224 insertions(+) create mode 100644 app/components/UI/Earn/Views/EarnMusdConversionEducationView/EarnMusdConversionEducationView.view.test.tsx create mode 100644 app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.view.test.tsx create mode 100644 app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.view.test.tsx diff --git a/app/components/UI/Earn/Views/EarnMusdConversionEducationView/EarnMusdConversionEducationView.view.test.tsx b/app/components/UI/Earn/Views/EarnMusdConversionEducationView/EarnMusdConversionEducationView.view.test.tsx new file mode 100644 index 000000000000..2e97d4979d27 --- /dev/null +++ b/app/components/UI/Earn/Views/EarnMusdConversionEducationView/EarnMusdConversionEducationView.view.test.tsx @@ -0,0 +1,487 @@ +import '../../../../../util/test/component-view/mocks'; +import { renderScreenWithRoutes } from '../../../../../util/test/component-view/render'; +import { initialStateWallet } from '../../../../../util/test/component-view/presets/wallet'; +import { describeForPlatforms } from '../../../../../util/test/platform'; +import React from 'react'; +import EarnMusdConversionEducationView from './index'; +import { strings } from '../../../../../../locales/i18n'; +import { fireEvent, act } from '@testing-library/react-native'; +import Routes from '../../../../../constants/navigation/Routes'; +import { Hex } from '@metamask/utils'; +import { MUSD_CONVERSION_APY } from '../../constants/musd'; + +describeForPlatforms('EarnMusdConversionEducationView', () => { + const mockRouteParams = { + preferredPaymentToken: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Hex, + chainId: '0x1' as Hex, + }, + outputChainId: '0x1' as Hex, + }; + + it('renders education screen with all UI elements', () => { + // Arrange + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionFlowEnabled: { enabled: true }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { getByText } = renderScreenWithRoutes( + EarnMusdConversionEducationView as unknown as React.ComponentType, + { name: Routes.EARN.MUSD.CONVERSION_EDUCATION }, + [], + { + state, + }, + mockRouteParams, + ); + + // Assert + expect( + getByText( + strings('earn.musd_conversion.education.heading', { + percentage: MUSD_CONVERSION_APY, + }), + ), + ).toBeOnTheScreen(); + expect( + getByText( + strings('earn.musd_conversion.education.description', { + percentage: MUSD_CONVERSION_APY, + }), + ), + ).toBeOnTheScreen(); + expect( + getByText(strings('earn.musd_conversion.education.primary_button')), + ).toBeOnTheScreen(); + expect( + getByText(strings('earn.musd_conversion.education.secondary_button')), + ).toBeOnTheScreen(); + }); + + it('renders education screen heading', () => { + // Arrange + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionFlowEnabled: { enabled: true }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { getByText } = renderScreenWithRoutes( + EarnMusdConversionEducationView as unknown as React.ComponentType, + { name: Routes.EARN.MUSD.CONVERSION_EDUCATION }, + [], + { + state, + }, + mockRouteParams, + ); + + // Assert + // Verify screen renders with heading + expect( + getByText( + strings('earn.musd_conversion.education.heading', { + percentage: MUSD_CONVERSION_APY, + }), + ), + ).toBeOnTheScreen(); + }); + + it('keeps go back button visible after press', async () => { + // Arrange + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionFlowEnabled: { enabled: true }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + const { getByText } = renderScreenWithRoutes( + EarnMusdConversionEducationView as unknown as React.ComponentType, + { name: Routes.EARN.MUSD.CONVERSION_EDUCATION }, + [], + { + state, + }, + mockRouteParams, + ); + + const goBackButton = getByText( + strings('earn.musd_conversion.education.secondary_button'), + ); + + // Act + await act(async () => { + fireEvent.press(goBackButton); + }); + + // Assert + // Button should still be on screen after press + expect(goBackButton).toBeOnTheScreen(); + }); + + it('keeps continue button visible after press when education not seen', async () => { + // Arrange + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionFlowEnabled: { enabled: true }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + user: { + musdConversionEducationSeen: false, + }, + } as unknown as Record) + .build(); + + const { getByText } = renderScreenWithRoutes( + EarnMusdConversionEducationView as unknown as React.ComponentType, + { name: Routes.EARN.MUSD.CONVERSION_EDUCATION }, + [], + { + state, + }, + mockRouteParams, + ); + + const continueButton = getByText( + strings('earn.musd_conversion.education.primary_button'), + ); + + // Act + await act(async () => { + fireEvent.press(continueButton); + }); + + // Assert + expect(continueButton).toBeOnTheScreen(); + }); + + it('renders screen when route params are missing', () => { + // Arrange + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionFlowEnabled: { enabled: true }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { getByText } = renderScreenWithRoutes( + EarnMusdConversionEducationView as unknown as React.ComponentType, + { name: Routes.EARN.MUSD.CONVERSION_EDUCATION }, + [], + { + state, + }, + {}, // Missing params + ); + + // Assert + // Component should still render + expect( + getByText( + strings('earn.musd_conversion.education.heading', { + percentage: MUSD_CONVERSION_APY, + }), + ), + ).toBeOnTheScreen(); + }); + + it('renders screen when outputChainId is missing in route params', () => { + // Arrange + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionFlowEnabled: { enabled: true }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { getByText } = renderScreenWithRoutes( + EarnMusdConversionEducationView as unknown as React.ComponentType, + { name: Routes.EARN.MUSD.CONVERSION_EDUCATION }, + [], + { + state, + }, + { + preferredPaymentToken: mockRouteParams.preferredPaymentToken, + // Missing outputChainId + }, + ); + + // Assert + // Component should still render + expect( + getByText( + strings('earn.musd_conversion.education.heading', { + percentage: MUSD_CONVERSION_APY, + }), + ), + ).toBeOnTheScreen(); + }); + + it('renders screen when preferredPaymentToken is missing in route params', () => { + // Arrange + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionFlowEnabled: { enabled: true }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { getByText } = renderScreenWithRoutes( + EarnMusdConversionEducationView as unknown as React.ComponentType, + { name: Routes.EARN.MUSD.CONVERSION_EDUCATION }, + [], + { + state, + }, + { + outputChainId: mockRouteParams.outputChainId, + // Missing preferredPaymentToken + }, + ); + + // Assert + // Component should still render + expect( + getByText( + strings('earn.musd_conversion.education.heading', { + percentage: MUSD_CONVERSION_APY, + }), + ), + ).toBeOnTheScreen(); + }); + + it('renders education screen with correct APY percentage in heading', () => { + // Arrange + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionFlowEnabled: { enabled: true }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { getByText } = renderScreenWithRoutes( + EarnMusdConversionEducationView as unknown as React.ComponentType, + { name: Routes.EARN.MUSD.CONVERSION_EDUCATION }, + [], + { + state, + }, + mockRouteParams, + ); + + // Assert + const heading = getByText( + strings('earn.musd_conversion.education.heading', { + percentage: MUSD_CONVERSION_APY, + }), + ); + expect(heading).toBeOnTheScreen(); + expect(heading.props.children).toContain(`${MUSD_CONVERSION_APY}%`); + }); + + it('renders education screen with correct APY percentage in description', () => { + // Arrange + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionFlowEnabled: { enabled: true }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { getByText } = renderScreenWithRoutes( + EarnMusdConversionEducationView as unknown as React.ComponentType, + { name: Routes.EARN.MUSD.CONVERSION_EDUCATION }, + [], + { + state, + }, + mockRouteParams, + ); + + // Assert + const description = getByText( + strings('earn.musd_conversion.education.description', { + percentage: MUSD_CONVERSION_APY, + }), + ); + expect(description).toBeOnTheScreen(); + expect(description.props.children).toContain(`${MUSD_CONVERSION_APY}%`); + }); + + it('renders education screen when education has been seen', () => { + // Arrange + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionFlowEnabled: { enabled: true }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + user: { + musdConversionEducationSeen: true, + }, + } as unknown as Record) + .build(); + + // Act + const { getByText } = renderScreenWithRoutes( + EarnMusdConversionEducationView as unknown as React.ComponentType, + { name: Routes.EARN.MUSD.CONVERSION_EDUCATION }, + [], + { + state, + }, + mockRouteParams, + ); + + // Assert + expect( + getByText( + strings('earn.musd_conversion.education.heading', { + percentage: MUSD_CONVERSION_APY, + }), + ), + ).toBeOnTheScreen(); + }); + + it('renders education screen with all route params provided', () => { + // Arrange + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionFlowEnabled: { enabled: true }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { getByText } = renderScreenWithRoutes( + EarnMusdConversionEducationView as unknown as React.ComponentType, + { name: Routes.EARN.MUSD.CONVERSION_EDUCATION }, + [], + { + state, + }, + { + preferredPaymentToken: mockRouteParams.preferredPaymentToken, + outputChainId: mockRouteParams.outputChainId, + }, + ); + + // Assert + expect( + getByText( + strings('earn.musd_conversion.education.heading', { + percentage: MUSD_CONVERSION_APY, + }), + ), + ).toBeOnTheScreen(); + }); +}); 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 new file mode 100644 index 000000000000..663d74ea47c6 --- /dev/null +++ b/app/components/UI/Earn/components/Musd/MusdConversionAssetListCta/MusdConversionAssetListCta.view.test.tsx @@ -0,0 +1,117 @@ +import '../../../../../../util/test/component-view/mocks'; +import { renderComponentViewScreen } from '../../../../../../util/test/component-view/render'; +import { initialStateWallet } from '../../../../../../util/test/component-view/presets/wallet'; +import { describeForPlatforms } from '../../../../../../util/test/platform'; +import React from 'react'; +import { View } from 'react-native'; +import MusdConversionAssetListCta from './index'; +import { EARN_TEST_IDS } from '../../../constants/testIds'; + +// Wrapper component to render the CTA in a screen context +const MusdConversionAssetListCtaScreen = () => ( + + + +); + +describeForPlatforms('MusdConversionAssetListCta', () => { + it('hides CTA when feature flag disabled', () => { + // Arrange + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdCtaEnabled: { enabled: false }, + earnMusdConversionFlowEnabled: { enabled: true }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { queryByTestId } = renderComponentViewScreen( + MusdConversionAssetListCtaScreen, + { name: 'TestScreen' }, + { state }, + ); + + // Assert + expect( + queryByTestId(EARN_TEST_IDS.MUSD.ASSET_LIST_CONVERSION_CTA), + ).toBeNull(); + }); + + it('hides CTA when conversion flow feature flag disabled', () => { + // Arrange + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdCtaEnabled: { enabled: true }, + earnMusdConversionFlowEnabled: { enabled: false }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { queryByTestId } = renderComponentViewScreen( + MusdConversionAssetListCtaScreen, + { name: 'TestScreen' }, + { state }, + ); + + // Assert + expect( + queryByTestId(EARN_TEST_IDS.MUSD.ASSET_LIST_CONVERSION_CTA), + ).toBeNull(); + }); + + it('does not render CTA when visibility conditions are not met', () => { + // Arrange + // Component visibility depends on complex hook logic that requires + // specific state configuration. When conditions aren't met, component returns null. + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdCtaEnabled: { enabled: true }, + earnMusdConversionFlowEnabled: { enabled: true }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { queryByTestId } = renderComponentViewScreen( + MusdConversionAssetListCtaScreen, + { name: 'TestScreen' }, + { state }, + ); + + // Assert + // Component returns null when visibility conditions are not met + // This test verifies the component handles the case gracefully without crashing + const cta = queryByTestId(EARN_TEST_IDS.MUSD.ASSET_LIST_CONVERSION_CTA); + // When conditions aren't met, component should return null + expect(cta).toBeNull(); + }); +}); 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 new file mode 100644 index 000000000000..f2623586d561 --- /dev/null +++ b/app/components/UI/Earn/components/Musd/MusdConversionAssetOverviewCta/MusdConversionAssetOverviewCta.view.test.tsx @@ -0,0 +1,577 @@ +import '../../../../../../util/test/component-view/mocks'; +import { renderComponentViewScreen } from '../../../../../../util/test/component-view/render'; +import { initialStateWallet } from '../../../../../../util/test/component-view/presets/wallet'; +import { describeForPlatforms } from '../../../../../../util/test/platform'; +import React from 'react'; +import { View } from 'react-native'; +import MusdConversionAssetOverviewCta from './index'; +import { EARN_TEST_IDS } from '../../../constants/testIds'; +import { fireEvent } from '@testing-library/react-native'; +import { TokenI } from '../../../../Tokens/types'; + +// Wrapper component to render the CTA in a screen context +const MusdConversionAssetOverviewCtaScreen = ({ + asset, + onDismiss, +}: { + asset: TokenI; + onDismiss?: () => void; +}) => ( + + + +); + +describeForPlatforms('MusdConversionAssetOverviewCta', () => { + const mockAsset = { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: '0x1', + symbol: 'USDC', + aggregators: [], + decimals: 6, + image: 'https://example.com/usdc.png', + name: 'USD Coin', + balance: '1000000000', + logo: 'https://example.com/usdc.png', + isETH: false, + }; + + it('renders CTA with asset', () => { + // Arrange + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionAssetOverviewCtaEnabled: { enabled: true }, + earnMusdConversionFlowEnabled: { enabled: true }, + earnMusdConversionCtaTokens: { '*': ['USDC'] }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { getByTestId } = renderComponentViewScreen( + () => , + { name: 'TestScreen' }, + { state }, + ); + + // Assert + expect( + getByTestId(EARN_TEST_IDS.MUSD.ASSET_OVERVIEW_CONVERSION_CTA), + ).toBeOnTheScreen(); + }); + + it('displays CTA text correctly', () => { + // Arrange + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionAssetOverviewCtaEnabled: { enabled: true }, + earnMusdConversionFlowEnabled: { enabled: true }, + earnMusdConversionCtaTokens: { '*': ['USDC'] }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { getByText } = renderComponentViewScreen( + () => , + { name: 'TestScreen' }, + { state }, + ); + + // Assert + expect(getByText('Boost your stablecoin balance')).toBeOnTheScreen(); + expect( + getByText(/Earn a bonus every time you convert stablecoins to/), + ).toBeOnTheScreen(); + expect(getByText('mUSD')).toBeOnTheScreen(); + }); + + it('renders close button when onDismiss is provided', () => { + // Arrange + const mockOnDismiss = jest.fn(); + + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionAssetOverviewCtaEnabled: { enabled: true }, + earnMusdConversionFlowEnabled: { enabled: true }, + earnMusdConversionCtaTokens: { '*': ['USDC'] }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { getByTestId } = renderComponentViewScreen( + () => ( + + ), + { name: 'TestScreen' }, + { state }, + ); + + // Assert + expect( + getByTestId( + EARN_TEST_IDS.MUSD.ASSET_OVERVIEW_CONVERSION_CTA_CLOSE_BUTTON, + ), + ).toBeOnTheScreen(); + }); + + it('does not render close button when onDismiss is not provided', () => { + // Arrange + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionAssetOverviewCtaEnabled: { enabled: true }, + earnMusdConversionFlowEnabled: { enabled: true }, + earnMusdConversionCtaTokens: { '*': ['USDC'] }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { queryByTestId } = renderComponentViewScreen( + () => , + { name: 'TestScreen' }, + { state }, + ); + + // Assert + expect( + queryByTestId( + EARN_TEST_IDS.MUSD.ASSET_OVERVIEW_CONVERSION_CTA_CLOSE_BUTTON, + ), + ).toBeNull(); + }); + + it('calls onDismiss when close button is pressed', () => { + // Arrange + const mockOnDismiss = jest.fn(); + + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionAssetOverviewCtaEnabled: { enabled: true }, + earnMusdConversionFlowEnabled: { enabled: true }, + earnMusdConversionCtaTokens: { '*': ['USDC'] }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + const { getByTestId } = renderComponentViewScreen( + () => ( + + ), + { name: 'TestScreen' }, + { state }, + ); + + // Act + fireEvent.press( + getByTestId( + EARN_TEST_IDS.MUSD.ASSET_OVERVIEW_CONVERSION_CTA_CLOSE_BUTTON, + ), + ); + + // Assert + expect(mockOnDismiss).toHaveBeenCalledTimes(1); + }); + + it('renders CTA component as presentational component regardless of allowlist', () => { + // Arrange + const mockAssetNotInAllowlist = { + ...mockAsset, + symbol: 'USDT', // Not in allowlist + }; + + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionAssetOverviewCtaEnabled: { enabled: true }, + earnMusdConversionFlowEnabled: { enabled: true }, + earnMusdConversionCtaTokens: { '*': ['USDC'] }, // Only USDC in allowlist + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { queryByTestId } = renderComponentViewScreen( + () => ( + + ), + { name: 'TestScreen' }, + { state }, + ); + + // Assert + // Component always renders when called - it's a presentational component + // Visibility logic (including allowlist checks) is handled by the parent + // component via shouldShowAssetOverviewCta hook, not by this component itself + expect( + queryByTestId(EARN_TEST_IDS.MUSD.ASSET_OVERVIEW_CONVERSION_CTA), + ).toBeOnTheScreen(); + }); + + it('renders CTA when asset balance is above minimum', () => { + // Arrange + const mockAssetWithBalance = { + ...mockAsset, + balance: '1000000000', // 1000 USDC (above minimum) + }; + + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionAssetOverviewCtaEnabled: { enabled: true }, + earnMusdConversionFlowEnabled: { enabled: true }, + earnMusdConversionCtaTokens: { '*': ['USDC'] }, + earnMusdConversionMinAssetBalanceRequired: 0.01, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { getByTestId } = renderComponentViewScreen( + () => ( + + ), + { name: 'TestScreen' }, + { state }, + ); + + // Assert + expect( + getByTestId(EARN_TEST_IDS.MUSD.ASSET_OVERVIEW_CONVERSION_CTA), + ).toBeOnTheScreen(); + }); + + it('renders CTA for different asset symbols', () => { + // Arrange + const mockDAIAsset = { + ...mockAsset, + symbol: 'DAI', + address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + }; + + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionAssetOverviewCtaEnabled: { enabled: true }, + earnMusdConversionFlowEnabled: { enabled: true }, + earnMusdConversionCtaTokens: { '*': ['DAI', 'USDC'] }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { getByTestId } = renderComponentViewScreen( + () => , + { name: 'TestScreen' }, + { state }, + ); + + // Assert + expect( + getByTestId(EARN_TEST_IDS.MUSD.ASSET_OVERVIEW_CONVERSION_CTA), + ).toBeOnTheScreen(); + }); + + it('renders CTA when asset address is missing', () => { + // Arrange + const mockAssetWithoutAddress = { + ...mockAsset, + address: '', + }; + + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionAssetOverviewCtaEnabled: { enabled: true }, + earnMusdConversionFlowEnabled: { enabled: true }, + earnMusdConversionCtaTokens: { '*': ['USDC'] }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + // Component should still render, error handling happens in handlePress + const { getByTestId } = renderComponentViewScreen( + () => ( + + ), + { name: 'TestScreen' }, + { state }, + ); + + // Assert + expect( + getByTestId(EARN_TEST_IDS.MUSD.ASSET_OVERVIEW_CONVERSION_CTA), + ).toBeOnTheScreen(); + }); + + it('renders CTA with asset having low balance', () => { + // Arrange + const mockAssetLowBalance = { + ...mockAsset, + balance: '1000', // 0.001 USDC (very low) + }; + + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionAssetOverviewCtaEnabled: { enabled: true }, + earnMusdConversionFlowEnabled: { enabled: true }, + earnMusdConversionCtaTokens: { '*': ['USDC'] }, + earnMusdConversionMinAssetBalanceRequired: 0.01, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { getByTestId } = renderComponentViewScreen( + () => ( + + ), + { name: 'TestScreen' }, + { state }, + ); + + // Assert + // Component renders regardless of balance - balance check is in parent + expect( + getByTestId(EARN_TEST_IDS.MUSD.ASSET_OVERVIEW_CONVERSION_CTA), + ).toBeOnTheScreen(); + }); + + it('renders CTA for asset on different chain', () => { + // Arrange + const mockLineaAsset = { + ...mockAsset, + chainId: '0xe708', // Linea Mainnet + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + }; + + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionAssetOverviewCtaEnabled: { enabled: true }, + earnMusdConversionFlowEnabled: { enabled: true }, + earnMusdConversionCtaTokens: { '*': ['USDC'] }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { getByTestId } = renderComponentViewScreen( + () => , + { name: 'TestScreen' }, + { state }, + ); + + // Assert + expect( + getByTestId(EARN_TEST_IDS.MUSD.ASSET_OVERVIEW_CONVERSION_CTA), + ).toBeOnTheScreen(); + }); + + it('renders CTA with correct boost title text', () => { + // Arrange + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionAssetOverviewCtaEnabled: { enabled: true }, + earnMusdConversionFlowEnabled: { enabled: true }, + earnMusdConversionCtaTokens: { '*': ['USDC'] }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { getByText } = renderComponentViewScreen( + () => , + { name: 'TestScreen' }, + { state }, + ); + + // Assert + expect(getByText('Boost your stablecoin balance')).toBeOnTheScreen(); + }); + + it('renders CTA with correct boost description text', () => { + // Arrange + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionAssetOverviewCtaEnabled: { enabled: true }, + earnMusdConversionFlowEnabled: { enabled: true }, + earnMusdConversionCtaTokens: { '*': ['USDC'] }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { getByText } = renderComponentViewScreen( + () => , + { name: 'TestScreen' }, + { state }, + ); + + // Assert + expect( + getByText(/Earn a bonus every time you convert stablecoins to/), + ).toBeOnTheScreen(); + expect(getByText('mUSD')).toBeOnTheScreen(); + }); + + it('renders CTA for USDT asset when in allowlist', () => { + // Arrange + const mockUSDTAsset = { + ...mockAsset, + symbol: 'USDT', + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + name: 'Tether USD', + }; + + const state = initialStateWallet() + .withMinimalMultichainAssets() + .withRemoteFeatureFlags({ + earnMusdConversionAssetOverviewCtaEnabled: { enabled: true }, + earnMusdConversionFlowEnabled: { enabled: true }, + earnMusdConversionCtaTokens: { '*': ['USDC', 'USDT'] }, + }) + .withOverrides({ + engine: { + backgroundState: { + AssetsController: { + assets: {}, + }, + }, + }, + } as unknown as Record) + .build(); + + // Act + const { getByTestId } = renderComponentViewScreen( + () => , + { name: 'TestScreen' }, + { state }, + ); + + // Assert + expect( + getByTestId(EARN_TEST_IDS.MUSD.ASSET_OVERVIEW_CONVERSION_CTA), + ).toBeOnTheScreen(); + }); +}); diff --git a/app/util/test/component-view/mocks.ts b/app/util/test/component-view/mocks.ts index a1cec7f3105d..3a068cb9e188 100644 --- a/app/util/test/component-view/mocks.ts +++ b/app/util/test/component-view/mocks.ts @@ -143,6 +143,11 @@ jest.mock('../../../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 + return Promise.resolve(undefined); + }, }, getTotalEvmFiatAccountBalance() { return { balance: '0', fiatBalance: '0' }; diff --git a/app/util/test/component-view/presets/bridge.ts b/app/util/test/component-view/presets/bridge.ts index bd07213171f5..aa3005ca512d 100644 --- a/app/util/test/component-view/presets/bridge.ts +++ b/app/util/test/component-view/presets/bridge.ts @@ -33,6 +33,7 @@ export const initialStateBridge = (options?: InitialStateBridgeOptions) => { .withMinimalTokenRates() .withMinimalMultichainAssetsRates() .withMinimalMultichainBalances() + .withMinimalAnalyticsController() .withAccountTreeForSelectedAccount() .withRemoteFeatureFlags({}); diff --git a/app/util/test/component-view/presets/wallet.ts b/app/util/test/component-view/presets/wallet.ts index 41c9687a5cd3..411846d9e8bd 100644 --- a/app/util/test/component-view/presets/wallet.ts +++ b/app/util/test/component-view/presets/wallet.ts @@ -28,6 +28,7 @@ export const initialStateWallet = (options?: InitialStateWalletOptions) => { .withMinimalKeyringController() .withMinimalTokenRates() .withMinimalMultichainAssetsRates() + .withMinimalAnalyticsController() .withAccountTreeForSelectedAccount() .withRemoteFeatureFlags({}) .withOverrides({ @@ -50,6 +51,15 @@ export const initialStateWallet = (options?: InitialStateWalletOptions) => { TokenBalancesController: { tokenBalances: {}, }, + TokensController: { + allTokens: { + '0x1': { + '0x0000000000000000000000000000000000000001': [], + }, + }, + allDetectedTokens: {}, + allIgnoredTokens: {}, + }, MultichainBalancesController: { balances: {}, }, diff --git a/app/util/test/component-view/stateFixture.ts b/app/util/test/component-view/stateFixture.ts index 51b2faf7e343..f815d133959e 100644 --- a/app/util/test/component-view/stateFixture.ts +++ b/app/util/test/component-view/stateFixture.ts @@ -148,6 +148,10 @@ export interface StateFixtureBuilder { withMinimalMultichainBalances(): StateFixtureBuilder; withMinimalMultichainAssets(): StateFixtureBuilder; withMinimalMultichainTransactions(): StateFixtureBuilder; + withMinimalAnalyticsController(options?: { + optedIn?: boolean; + analyticsId?: string; + }): StateFixtureBuilder; withBridgeRecommendedQuoteEvmSimple(params?: { srcAmount?: string; srcTokenAddress?: string; @@ -709,6 +713,28 @@ export function createStateFixture(): StateFixtureBuilder { ); return api; }, + withMinimalAnalyticsController(options = {}) { + const bg = (current.engine?.backgroundState ?? {}) as unknown as Record< + string, + unknown + >; + const { optedIn = false, analyticsId = 'test-analytics-id' } = options; + current = deepMerge( + current as PlainObject, + { + engine: { + backgroundState: { + ...bg, + AnalyticsController: { + optedIn, + analyticsId, + }, + }, + }, + } as unknown as DeepPartial as PlainObject, + ); + return api; + }, withOverrides(overrides) { current = deepMerge(current as PlainObject, overrides as PlainObject); return api; diff --git a/app/util/test/testSetupView.js b/app/util/test/testSetupView.js index c8f063e7761b..c2bcc8db50a4 100644 --- a/app/util/test/testSetupView.js +++ b/app/util/test/testSetupView.js @@ -7,6 +7,7 @@ /* eslint-disable import/no-commonjs */ /* eslint-disable react/prop-types */ /* eslint-disable react/display-name */ + const { NativeModules } = require('react-native'); // eslint-disable-next-line import/no-nodejs-modules const nodeCrypto = require('crypto'); From 630e3974f634619296bb8bec43529cdcbf3e6ebb Mon Sep 17 00:00:00 2001 From: Daniel <80175477+dan437@users.noreply.github.com> Date: Fri, 30 Jan 2026 11:46:54 +0100 Subject: [PATCH 03/17] chore: Bump transaction-pay-controller to ^12.0.2 and other related controllers (#25376) ## **Description** Bump transaction-pay-controller to ^12.0.2 Release notes: https://github.com/MetaMask/core/blob/main/packages/transaction-pay-controller/CHANGELOG.md ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/25262 ## **Manual testing steps** ## **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** > Upgrades core transaction/payment-related controller dependencies, which can affect transaction submission, fees, and bridging behavior despite no app-code changes. > > **Overview** > Bumps `@metamask/transaction-pay-controller` from `^11.1.0` to `^12.0.2`. > > Updates dependency resolution/lockfile to align with newer controller versions (notably `@metamask/assets-controllers` up to `99.1.0`, `@metamask/bridge-controller` to `65.1.0`, `@metamask/bridge-status-controller` to `65.0.1`, and `@metamask/transaction-controller` to `^62.11.0`), including adding a `resolutions` entry so the existing `transaction-controller` patch also applies to `^62.11.0`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 812ad6562d584d029946ac3b670e279caa7f4e27. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- package.json | 5 ++- yarn.lock | 104 ++++++++++++++++++++++++++++++++++++++------------- 2 files changed, 82 insertions(+), 27 deletions(-) diff --git a/package.json b/package.json index 1fdbf266c4a7..c86f719c0a42 100644 --- a/package.json +++ b/package.json @@ -183,7 +183,8 @@ "qs": "6.14.1", "@playwright/test": "^1.57.0", "@metamask/transaction-controller@npm:^61.0.0": "patch:@metamask/transaction-controller@npm%3A62.9.0#~/.yarn/patches/@metamask-transaction-controller-npm-62.9.0-5c8f871530.patch", - "@metamask/transaction-controller@npm:^62.9.2": "patch:@metamask/transaction-controller@npm%3A62.9.0#~/.yarn/patches/@metamask-transaction-controller-npm-62.9.0-5c8f871530.patch" + "@metamask/transaction-controller@npm:^62.9.2": "patch:@metamask/transaction-controller@npm%3A62.9.0#~/.yarn/patches/@metamask-transaction-controller-npm-62.9.0-5c8f871530.patch", + "@metamask/transaction-controller@npm:^62.11.0": "patch:@metamask/transaction-controller@npm%3A62.9.0#~/.yarn/patches/@metamask-transaction-controller-npm-62.9.0-5c8f871530.patch" }, "dependencies": { "@config-plugins/detox": "^9.0.0", @@ -298,7 +299,7 @@ "@metamask/swaps-controller": "^15.0.0", "@metamask/token-search-discovery-controller": "^4.0.0", "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.9.0#~/.yarn/patches/@metamask-transaction-controller-npm-62.9.0-5c8f871530.patch", - "@metamask/transaction-pay-controller": "^11.1.0", + "@metamask/transaction-pay-controller": "^12.0.2", "@metamask/tron-wallet-snap": "^1.19.2", "@metamask/utils": "^11.8.1", "@ngraveio/bc-ur": "^1.1.6", diff --git a/yarn.lock b/yarn.lock index bf45c6bde9f0..9ff79e9c8d24 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7424,9 +7424,9 @@ __metadata: languageName: node linkType: hard -"@metamask/assets-controllers@npm:^96.0.0": - version: 96.0.0 - resolution: "@metamask/assets-controllers@npm:96.0.0" +"@metamask/assets-controllers@npm:^97.0.0": + version: 97.0.0 + resolution: "@metamask/assets-controllers@npm:97.0.0" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@ethersproject/abi": "npm:^5.7.0" @@ -7444,11 +7444,12 @@ __metadata: "@metamask/core-backend": "npm:^5.0.0" "@metamask/eth-query": "npm:^4.0.0" "@metamask/keyring-api": "npm:^21.0.0" - "@metamask/keyring-controller": "npm:^25.0.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:^5.1.0" "@metamask/network-controller": "npm:^29.0.0" + "@metamask/network-enablement-controller": "npm:^4.1.0" "@metamask/permission-controller": "npm:^12.2.0" "@metamask/phishing-controller": "npm:^16.1.0" "@metamask/polling-controller": "npm:^16.0.2" @@ -7474,13 +7475,13 @@ __metadata: peerDependencies: "@metamask/providers": ^22.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/c5cf7363972b2f267ba96a925fd74eaee3eebde8bf470af7d4c49589b33b34fc9b8574289e4592cbce13e941201893d2ad20018da0dada8025317db0ce33df0f + checksum: 10/44f6adc0f3263a17c2be49aff7c558f0478c41f8c0318c03db5706451284ccf56c5534d56366c6504538e89eda8aa86669310467a170276f28875c17bcbd6367 languageName: node linkType: hard -"@metamask/assets-controllers@npm:^97.0.0": - version: 97.0.0 - resolution: "@metamask/assets-controllers@npm:97.0.0" +"@metamask/assets-controllers@npm:^98.0.0": + version: 98.0.0 + resolution: "@metamask/assets-controllers@npm:98.0.0" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@ethersproject/abi": "npm:^5.7.0" @@ -7513,6 +7514,7 @@ __metadata: "@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:^0.0.1" "@metamask/transaction-controller": "npm:^62.9.2" "@metamask/utils": "npm:^11.9.0" "@types/bn.js": "npm:^5.1.5" @@ -7529,13 +7531,13 @@ __metadata: peerDependencies: "@metamask/providers": ^22.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/44f6adc0f3263a17c2be49aff7c558f0478c41f8c0318c03db5706451284ccf56c5534d56366c6504538e89eda8aa86669310467a170276f28875c17bcbd6367 + checksum: 10/a2a3564ae4cb5349a134c40844db62db43d8aefa7ac00c8d6a31ace63573b6ea2a77988e1daae7719a99a79736f4dee3af7886d85829cd2efd68dd55b55fb5f6 languageName: node linkType: hard -"@metamask/assets-controllers@npm:^98.0.0": - version: 98.0.0 - resolution: "@metamask/assets-controllers@npm:98.0.0" +"@metamask/assets-controllers@npm:^99.0.0, @metamask/assets-controllers@npm:^99.1.0": + version: 99.1.0 + resolution: "@metamask/assets-controllers@npm:99.1.0" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@ethersproject/abi": "npm:^5.7.0" @@ -7569,7 +7571,7 @@ __metadata: "@metamask/snaps-sdk": "npm:^10.3.0" "@metamask/snaps-utils": "npm:^11.7.0" "@metamask/storage-service": "npm:^0.0.1" - "@metamask/transaction-controller": "npm:^62.9.2" + "@metamask/transaction-controller": "npm:^62.11.0" "@metamask/utils": "npm:^11.9.0" "@types/bn.js": "npm:^5.1.5" "@types/uuid": "npm:^8.3.0" @@ -7585,7 +7587,7 @@ __metadata: peerDependencies: "@metamask/providers": ^22.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/a2a3564ae4cb5349a134c40844db62db43d8aefa7ac00c8d6a31ace63573b6ea2a77988e1daae7719a99a79736f4dee3af7886d85829cd2efd68dd55b55fb5f6 + checksum: 10/01e9e33f50d5207817264c969ee37ad534399756d580ff1ebb965018cba0c669a181ba70273ba6901e5c71c2f5acd7506b2ff12432c9b218cfa778e3d12c59b7 languageName: node linkType: hard @@ -7658,7 +7660,7 @@ __metadata: languageName: node linkType: hard -"@metamask/bridge-controller@npm:^64.8.0, @metamask/bridge-controller@npm:^64.8.1, @metamask/bridge-controller@npm:^64.8.2": +"@metamask/bridge-controller@npm:^64.8.0, @metamask/bridge-controller@npm:^64.8.2": version: 64.8.2 resolution: "@metamask/bridge-controller@npm:64.8.2" dependencies: @@ -7689,7 +7691,38 @@ __metadata: languageName: node linkType: hard -"@metamask/bridge-status-controller@npm:^64.4.4, @metamask/bridge-status-controller@npm:^64.4.5": +"@metamask/bridge-controller@npm:^65.1.0": + version: 65.1.0 + resolution: "@metamask/bridge-controller@npm:65.1.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:^35.0.2" + "@metamask/assets-controllers": "npm:^99.0.0" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/controller-utils": "npm:^11.18.0" + "@metamask/gas-fee-controller": "npm:^26.0.2" + "@metamask/keyring-api": "npm:^21.0.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/metamask-eth-abis": "npm:^3.1.1" + "@metamask/multichain-network-controller": "npm:^3.0.2" + "@metamask/network-controller": "npm:^29.0.0" + "@metamask/polling-controller": "npm:^16.0.2" + "@metamask/remote-feature-flag-controller": "npm:^4.0.0" + "@metamask/snaps-controllers": "npm:^17.2.0" + "@metamask/transaction-controller": "npm:^62.11.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/778c219ecd44f808f936ebb6f5ffe1dd8689e3b160522482d861f7be37d7fed6488561f4d5d4adbcdf63a57f1cedb0f50ff65e06098af5ecf20abfb2ecbbcabb + languageName: node + linkType: hard + +"@metamask/bridge-status-controller@npm:^64.4.5": version: 64.4.5 resolution: "@metamask/bridge-status-controller@npm:64.4.5" dependencies: @@ -7710,6 +7743,27 @@ __metadata: languageName: node linkType: hard +"@metamask/bridge-status-controller@npm:^65.0.1": + version: 65.0.1 + resolution: "@metamask/bridge-status-controller@npm:65.0.1" + dependencies: + "@metamask/accounts-controller": "npm:^35.0.2" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/bridge-controller": "npm:^65.1.0" + "@metamask/controller-utils": "npm:^11.18.0" + "@metamask/gas-fee-controller": "npm:^26.0.2" + "@metamask/network-controller": "npm:^29.0.0" + "@metamask/polling-controller": "npm:^16.0.2" + "@metamask/snaps-controllers": "npm:^17.2.0" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/transaction-controller": "npm:^62.11.0" + "@metamask/utils": "npm:^11.9.0" + bignumber.js: "npm:^9.1.2" + uuid: "npm:^8.3.2" + checksum: 10/aaf19debd3f19ebd1ca7ff1faada30b4aae5dcd18f24d657293295922b4a4f621f6bb095f2135f70430c187c45d83b7463d300ae2b008774f236b2208c004972 + languageName: node + linkType: hard + "@metamask/browser-passworder@npm:^5.0.0": version: 5.0.0 resolution: "@metamask/browser-passworder@npm:5.0.0" @@ -9729,29 +9783,29 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-pay-controller@npm:^11.1.0": - version: 11.1.0 - resolution: "@metamask/transaction-pay-controller@npm:11.1.0" +"@metamask/transaction-pay-controller@npm:^12.0.2": + version: 12.0.2 + resolution: "@metamask/transaction-pay-controller@npm:12.0.2" dependencies: "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" - "@metamask/assets-controllers": "npm:^96.0.0" + "@metamask/assets-controllers": "npm:^99.1.0" "@metamask/base-controller": "npm:^9.0.0" - "@metamask/bridge-controller": "npm:^64.8.1" - "@metamask/bridge-status-controller": "npm:^64.4.4" + "@metamask/bridge-controller": "npm:^65.1.0" + "@metamask/bridge-status-controller": "npm:^65.0.1" "@metamask/controller-utils": "npm:^11.18.0" "@metamask/gas-fee-controller": "npm:^26.0.2" "@metamask/messenger": "npm:^0.3.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/network-controller": "npm:^29.0.0" "@metamask/remote-feature-flag-controller": "npm:^4.0.0" - "@metamask/transaction-controller": "npm:^62.9.2" + "@metamask/transaction-controller": "npm:^62.11.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/98385db74a16ed91e21d5e646bf302046b80bbf6f48303f8393b9bf98c42e6f5dac0b5c883abb999be884f2cebf422b15dd34aa04cc7a215ea17a15c10a4e1ad + checksum: 10/e22613a2dce4670bd00d09e698666bf633737bef99125ebcc186dceb151c629c3386ef6f3373c436e9970b09fc1c52bd1c9ffc4e26140bb6a8a3f7cf75ca4299 languageName: node linkType: hard @@ -34774,7 +34828,7 @@ __metadata: "@metamask/test-dapp-solana": "npm:^0.3.0" "@metamask/token-search-discovery-controller": "npm:^4.0.0" "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.9.0#~/.yarn/patches/@metamask-transaction-controller-npm-62.9.0-5c8f871530.patch" - "@metamask/transaction-pay-controller": "npm:^11.1.0" + "@metamask/transaction-pay-controller": "npm:^12.0.2" "@metamask/tron-wallet-snap": "npm:^1.19.2" "@metamask/utils": "npm:^11.8.1" "@ngraveio/bc-ur": "npm:^1.1.6" From 77482899ec55bd4b10578a3d8bbd1252fadb1d58 Mon Sep 17 00:00:00 2001 From: Ramon AC <36987446+racitores@users.noreply.github.com> Date: Fri, 30 Jan 2026 12:32:09 +0100 Subject: [PATCH 04/17] fix: component view test in main (#25428) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** - MusdConversionAssetOverviewCta: Align text assertions with current i18n (boost_title / boost_description); use MUSD_CONVERSION_APY and drop redundant getByText('mUSD'). - EarnMusdConversionEducationView: Match description via regex (description is rendered with " Terms apply." in the same Text); assert APY percentage on children[0] instead of children. ## **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 limited to component-view test assertions and how text is matched/inspected, with no production logic modifications. > > **Overview** > Updates mUSD conversion component-view tests to match the current rendered copy and i18n behavior. > > The education view tests now match the description via regex (to tolerate the appended โ€œTerms apply.โ€ text) and adjust APY assertions to check the first child node. The asset overview CTA tests switch to asserting the new `boost_title`/`boost_description` strings using `MUSD_CONVERSION_APY`, and remove redundant checks for a separate `mUSD` text node. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 03c42f64200e198ddfaed43d1f674f0a156d63a3. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- ...nMusdConversionEducationView.view.test.tsx | 12 +++--------- ...sdConversionAssetOverviewCta.view.test.tsx | 19 +++++++++++++------ 2 files changed, 16 insertions(+), 15 deletions(-) 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 2e97d4979d27..876187c9c6aa 100644 --- a/app/components/UI/Earn/Views/EarnMusdConversionEducationView/EarnMusdConversionEducationView.view.test.tsx +++ b/app/components/UI/Earn/Views/EarnMusdConversionEducationView/EarnMusdConversionEducationView.view.test.tsx @@ -57,11 +57,7 @@ describeForPlatforms('EarnMusdConversionEducationView', () => { ), ).toBeOnTheScreen(); expect( - getByText( - strings('earn.musd_conversion.education.description', { - percentage: MUSD_CONVERSION_APY, - }), - ), + getByText(/Convert your stablecoins to mUSD.*receive up to a \d+% bonus/), ).toBeOnTheScreen(); expect( getByText(strings('earn.musd_conversion.education.primary_button')), @@ -393,12 +389,10 @@ describeForPlatforms('EarnMusdConversionEducationView', () => { // Assert const description = getByText( - strings('earn.musd_conversion.education.description', { - percentage: MUSD_CONVERSION_APY, - }), + /Convert your stablecoins to mUSD.*receive up to a \d+% bonus/, ); expect(description).toBeOnTheScreen(); - expect(description.props.children).toContain(`${MUSD_CONVERSION_APY}%`); + expect(description.props.children[0]).toContain(`${MUSD_CONVERSION_APY}%`); }); it('renders education screen when education has been seen', () => { 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 f2623586d561..67223f1a31f4 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 @@ -6,6 +6,7 @@ import React from 'react'; import { View } from 'react-native'; import MusdConversionAssetOverviewCta from './index'; import { EARN_TEST_IDS } from '../../../constants/testIds'; +import { MUSD_CONVERSION_APY } from '../../../constants/musd'; import { fireEvent } from '@testing-library/react-native'; import { TokenI } from '../../../../Tokens/types'; @@ -97,11 +98,14 @@ describeForPlatforms('MusdConversionAssetOverviewCta', () => { ); // Assert - expect(getByText('Boost your stablecoin balance')).toBeOnTheScreen(); expect( - getByText(/Earn a bonus every time you convert stablecoins to/), + getByText(`Get ${MUSD_CONVERSION_APY}% on your stablecoins`), + ).toBeOnTheScreen(); + expect( + getByText( + `Convert your stablecoins to mUSD and receive up to a ${MUSD_CONVERSION_APY}% bonus.`, + ), ).toBeOnTheScreen(); - expect(getByText('mUSD')).toBeOnTheScreen(); }); it('renders close button when onDismiss is provided', () => { @@ -498,7 +502,9 @@ describeForPlatforms('MusdConversionAssetOverviewCta', () => { ); // Assert - expect(getByText('Boost your stablecoin balance')).toBeOnTheScreen(); + expect( + getByText(`Get ${MUSD_CONVERSION_APY}% on your stablecoins`), + ).toBeOnTheScreen(); }); it('renders CTA with correct boost description text', () => { @@ -530,9 +536,10 @@ describeForPlatforms('MusdConversionAssetOverviewCta', () => { // Assert expect( - getByText(/Earn a bonus every time you convert stablecoins to/), + getByText( + `Convert your stablecoins to mUSD and receive up to a ${MUSD_CONVERSION_APY}% bonus.`, + ), ).toBeOnTheScreen(); - expect(getByText('mUSD')).toBeOnTheScreen(); }); it('renders CTA for USDT asset when in allowlist', () => { From 57729e12b2441b3a52189b257b4ea57fc7c1f16d Mon Sep 17 00:00:00 2001 From: MetaMask Bot <37885440+metamaskbot@users.noreply.github.com> Date: Fri, 30 Jan 2026 07:37:06 -0500 Subject: [PATCH 05/17] release: Bump main version to 7.65.0 (#25411) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Version Bump After Release This PR bumps the main branch version from 7.64.0 to 7.65.0 after cutting the release branch. ### Why this is needed: - **Nightly builds**: Each nightly build needs to be one minor version ahead of the current release candidate - **Version conflicts**: Prevents conflicts between nightlies and release candidates - **Platform alignment**: Maintains version alignment between MetaMask mobile and extension - **Update systems**: Ensures nightlies are accepted by app stores and browser update systems ### What changed: - Version bumped from `7.64.0` to `7.65.0` - Platform: `mobile` - Files updated by `set-semvar-version.sh` script ### Next steps: This PR should be **manually reviewed and merged by the release manager** to maintain proper version flow. ### Related: - Release version: 7.64.0 - Release branch: release/7.64.0 - Platform: mobile - Test mode: false --- *This PR was automatically created by the `create-platform-release-pr.sh` script.* Co-authored-by: metamaskbot Co-authored-by: Joรฃo Loureiro <175489935+joaoloureirop@users.noreply.github.com> --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ package.json | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index bd7a24a799bc..38bfba42ffd5 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -187,7 +187,7 @@ android { applicationId "io.metamask" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionName "7.64.0" + versionName "7.65.0" versionCode 3418 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/bitrise.yml b/bitrise.yml index c138a3347b37..84b0a0ea4307 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3469,13 +3469,13 @@ app: PROJECT_LOCATION_IOS: ios - opts: is_expand: false - VERSION_NAME: 7.64.0 + VERSION_NAME: 7.65.0 - opts: is_expand: false VERSION_NUMBER: 3418 - opts: is_expand: false - FLASK_VERSION_NAME: 7.64.0 + FLASK_VERSION_NAME: 7.65.0 - opts: is_expand: false FLASK_VERSION_NUMBER: 3418 diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 2494c51e9575..9a0566a5619b 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1319,7 +1319,7 @@ "${inherited}", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.64.0; + MARKETING_VERSION = 7.65.0; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1385,7 +1385,7 @@ "${inherited}", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.64.0; + MARKETING_VERSION = 7.65.0; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1454,7 +1454,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.64.0; + MARKETING_VERSION = 7.65.0; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1518,7 +1518,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.64.0; + MARKETING_VERSION = 7.65.0; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1684,7 +1684,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.64.0; + MARKETING_VERSION = 7.65.0; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = ( "$(inherited)", @@ -1751,7 +1751,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.64.0; + MARKETING_VERSION = 7.65.0; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = ( "$(inherited)", diff --git a/package.json b/package.json index c86f719c0a42..525279c9752e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metamask", - "version": "7.64.0", + "version": "7.65.0", "private": true, "scripts": { "install:foundryup": "yarn mm-foundryup", From 85f52c7d7f65b1804b1b6b3da7116c084bad2fea Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Fri, 30 Jan 2026 13:49:06 +0000 Subject: [PATCH 06/17] fix: strengthen explore portfolio site condition (#25433) ## **Description** Replaced URL substring check with hostname comparison for portfolio site identification to fix a security vulnerability. This should be pretty safe since we own the underlying data, but still good practice to use stricter url condition. ## **Changelog** CHANGELOG entry: fix: strengthen explore portfolio site condition ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/security/code-scanning/134 ## **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. --- Open in Cursor Open in Web --- > [!NOTE] > **Low Risk** > Low risk: small, localized change to Portfolio site detection logic; the main risk is accidentally missing/duplicating the Portfolio entry if URL parsing/normalization behaves unexpectedly for edge-case inputs. > > **Overview** > Strengthens how the Explore sites list detects whether MetaMask Portfolio is already present by replacing a `url.includes('portfolio.metamask.io')` substring check with strict hostname parsing/comparison. > > Adds `PORTFOLIO_HOSTNAME` and a new `isPortfolioSiteUrl()` normalizer (handles missing schemes/whitespace) and uses it in `mergePortfolioSite` to avoid false matches that could let non-Portfolio URLs bypass or trigger the Portfolio entry logic. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 3214479b364cdb6c8e1bde8e04859f1bc58a6b8e. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../Sites/hooks/useSiteData/useSitesData.ts | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/app/components/UI/Sites/hooks/useSiteData/useSitesData.ts b/app/components/UI/Sites/hooks/useSiteData/useSitesData.ts index 6611502aa1d5..4730fe5e46d8 100644 --- a/app/components/UI/Sites/hooks/useSiteData/useSitesData.ts +++ b/app/components/UI/Sites/hooks/useSiteData/useSitesData.ts @@ -30,6 +30,7 @@ interface UseSitesDataResult { const PORTFOLIO_API_BASE_URL = 'https://portfolio.api.cx.metamask.io/'; const DEFAULT_SITES_LIMIT = 200; +const PORTFOLIO_HOSTNAME = 'portfolio.metamask.io'; /** * Hardcoded Portfolio site entry to ensure it's always included @@ -38,8 +39,8 @@ const DEFAULT_SITES_LIMIT = 200; const PORTFOLIO_SITE: SiteData = { id: 'metamask-portfolio', name: 'MetaMask Portfolio', - url: 'https://portfolio.metamask.io', - displayUrl: 'portfolio.metamask.io', + url: `https://${PORTFOLIO_HOSTNAME}`, + displayUrl: PORTFOLIO_HOSTNAME, logoUrl: 'https://raw.githubusercontent.com/MetaMask/metamask-mobile/main/logo.png', featured: true, @@ -58,6 +59,19 @@ const extractDisplayUrl = (url: string): string => { } }; +const isPortfolioSiteUrl = (url: string): boolean => { + try { + const trimmedUrl = url.trim(); + const normalizedUrl = + trimmedUrl.startsWith('http://') || trimmedUrl.startsWith('https://') + ? trimmedUrl + : `https://${trimmedUrl}`; + return new URL(normalizedUrl).hostname === PORTFOLIO_HOSTNAME; + } catch { + return false; + } +}; + /** * Helper function to merge Portfolio site with API sites, * ensuring Portfolio is always included at the beginning @@ -65,9 +79,7 @@ const extractDisplayUrl = (url: string): string => { const mergePortfolioSite = (sites: SiteData[]): SiteData[] => { // Check if Portfolio is already in the list (by URL match) const portfolioExists = sites.some( - (site) => - site.url.includes('portfolio.metamask.io') || - site.id === PORTFOLIO_SITE.id, + (site) => isPortfolioSiteUrl(site.url) || site.id === PORTFOLIO_SITE.id, ); if (portfolioExists) { From a9c05f5d71494f014c8c6a936eb57ff15e77a0cc Mon Sep 17 00:00:00 2001 From: Juanmi <95381763+juanmigdr@users.noreply.github.com> Date: Fri, 30 Jan 2026 15:29:13 +0100 Subject: [PATCH 07/17] chore: simplify local e2e testing setup (#25402) ## **Description** After trying to debug an e2e test that was failing on the CI, I attempted to set up my local environment to run these same e2e tests. After a lot of trial and error, I managed to get them running, I have updated the Docs that explain how to get started and improved the `install-ios-runway-app` script so that it can be reused for this scenario which simplifies the tester's life and prevents them from having to download the build manually from runway ## **Changelog** CHANGELOG entry: simplify local e2e testing setup ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-2569 ## **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** > Changes are limited to developer documentation and a local iOS Runway install helper script; risk is mainly around breaking existing local workflows due to the new `build/` artifact location and fixed `MetaMask.app` naming. > > **Overview** > Simplifies local E2E setup docs by restructuring the flow into **App Build** (Expo prebuild download vs local build) and **Run the E2E Tests** (explicit two-terminal Metro/test execution steps), with clearer commands for running all tests, a folder, a file, or by tag. > > Updates `install-ios-runway-app.sh` to store Runway artifacts in `build/`, always extract/expect `MetaMask.app`, and adds `--skipInstall` to support download-only runs without requiring a booted simulator. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 3fcfa65ac7373b0ec152f659515cbabfac1258e7. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- docs/readme/e2e-testing.md | 90 +++++++++++++++++-------------- scripts/install-ios-runway-app.sh | 28 ++++++---- 2 files changed, 67 insertions(+), 51 deletions(-) diff --git a/docs/readme/e2e-testing.md b/docs/readme/e2e-testing.md index bbd60ddb6ab7..354d61401f68 100644 --- a/docs/readme/e2e-testing.md +++ b/docs/readme/e2e-testing.md @@ -72,21 +72,32 @@ Ensure that following devices are set up: export PREBUILT_ANDROID_TEST_APK_PATH='build/MetaMask-Test.apk' ``` -3. Create the build directory if it doesn't exist: +### App Build - ```bash - # In root of project - mkdir build - ``` +You can either use prebuilt app files from Expo (iOS only) or build the app locally. + +#### Option 1: Use Expo Prebuilds (iOS Only) -4. Install dependencies +Choose one of the following methods to download the prebuilt iOS app: + +**Method A: Using Runway Script (Recommended)** + +```bash +yarn install:ios:runway --skipInstall +``` + +**Method B: Manual Download from Runway** + +1. Navigate to [Runway builds](https://app.runway.team/bucket/aCddXOkg1p_nDryri-FMyvkC9KRqQeVT_12sf6Nw0u6iGygGo6BlNzjD6bOt-zma260EzAxdpXmlp2GQphp3TN1s6AJE4i6d_9V0Tv5h4pHISU49dFk=) +2. Download the latest version of the app +3. Copy and rename the build: ```bash - # In root of project - yarn setup:expo + # Copy your downloaded .app file to the prebuild path + cp /path/to/your/downloaded/AAA.app build/MetaMask.app ``` -### Build the app (optional) +#### Option 2: Build the App Locally Sometimes it is necessary to build the app locally, for example, to enable build-time feature flags (like GNS), to debug issues more effectively, or to identify and update element locators. @@ -102,57 +113,56 @@ yarn test:e2e:android:debug:build # These commands are hardcoded to build for `main` build type and `e2e` environment based on the .detoxrc.js file ``` -### Use Expo prebuilds (iOS Only) +### Run the E2E Tests -You can use prebuilt app files instead of building the app locally. +Running E2E tests requires two separate terminal sessions: one for the Metro bundler and one for executing the tests. -#### iOS builds +#### Terminal 1: Start the Metro Bundler -1. **Download iOS simulator builds** from Runway/Bitrise/GitHub workflows (build jobs) +First, ensure the build watcher is running in a dedicated terminal for logs: -2. **Copy and rename the build**: Copy your downloaded .app file to the prebuild path +```bash +export METAMASK_ENVIRONMENT='e2e' +export METAMASK_BUILD_TYPE='main' +yarn setup:expo +yarn watch:clean # First time or after dependency changes +yarn watch # Subsequent runs +``` - ```bash - # Copy your downloaded .app file to the prebuild path - cp /path/to/your/downloaded/AAA.app build/MetaMask.app - ``` +#### Terminal 2: Execute Tests -3. **Start the build watcher**: +In a separate terminal, set up and run your tests: - ```bash - source .e2e.env && yarn watch:clean - ``` - -4. **Launch the iPhone 15 Pro simulator** from Xcode or in a new terminal by: +**Initial Setup (First Time Only)** - ```bash - xcrun simctl boot "iPhone 15 Pro" - open -a Simulator # to open the simulator app GUI - ``` +```bash +cp .e2e.env.example .e2e.env +``` -### Run the E2E Tests +**Run All Tests** ```bash -# Firstly, make sure the build watcher is running in a dedicated terminal for the logs -# and the emulators are up and running -# Ensure METAMASK_BUILD_TYPE is set to `main` and METAMASK_ENVIRONMENT is set to `e2e` in .js.env -source .e2e.env # Ensure .js.env is sourced -yarn watch:clean # First time or after dependency changes -yarn watch # Subsequent runs - -# Run all Tests source .e2e.env && yarn test:e2e:ios:debug:run source .e2e.env && yarn test:e2e:android:debug:run +``` + +**Run Specific Test Folder** -# Run specific folder +```bash source .e2e.env && yarn test:e2e:ios:debug:run e2e/specs/your-folder source .e2e.env && yarn test:e2e:android:debug:run e2e/specs/your-folder +``` -# Run specific test +**Run Specific Test File** + +```bash source .e2e.env && yarn test:e2e:ios:debug:run e2e/specs/onboarding/create-wallet.spec.js source .e2e.env && yarn test:e2e:android:debug:run e2e/specs/onboarding/create-wallet.spec.js +``` -# Run tests by tag +**Run Tests by Tag** + +```bash source .e2e.env && yarn test:e2e:ios:debug:run --testNamePattern="Smoke" source .e2e.env && yarn test:e2e:android:debug:run --testNamePattern="Smoke" ``` diff --git a/scripts/install-ios-runway-app.sh b/scripts/install-ios-runway-app.sh index 6e7b6ed3bec4..26a1ad240d1d 100755 --- a/scripts/install-ios-runway-app.sh +++ b/scripts/install-ios-runway-app.sh @@ -13,7 +13,7 @@ BUNDLE_ID="io.metamask.MetaMask" # Get the repo root directory (script is in scripts/, so go up one level) readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" readonly REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -readonly RUNWAY_DIR="$REPO_ROOT/runway-artifacts" +readonly RUNWAY_DIR="$REPO_ROOT/build" RUNWAY_API_URL="https://app.runway.team/api/bucket/aCddXOkg1p_nDryri-FMyvkC9KRqQeVT_12sf6Nw0u6iGygGo6BlNzjD6bOt-zma260EzAxdpXmlp2GQphp3TN1s6AJE4i6d_9V0Tv5h4pHISU49dFk=/builds" # Ensure script is run from the repo root @@ -26,6 +26,7 @@ if [[ "$(pwd)" != "$REPO_ROOT" ]]; then fi UNINSTALL=false SKIP_DOWNLOAD=false +SKIP_INSTALL=false # Track files for cleanup ZIP_PATH="" @@ -70,9 +71,13 @@ while [[ $# -gt 0 ]]; do SKIP_DOWNLOAD=true shift ;; + --skipInstall) + SKIP_INSTALL=true + shift + ;; *) echo -e "${RED}Unknown option: $1${NC}" - echo "Usage: $0 [--skip-download] [--uninstall]" + echo "Usage: $0 [--skip-download] [--skipInstall] [--uninstall]" exit 1 ;; esac @@ -162,13 +167,8 @@ download_latest_app() { # Extract the .app bundle # The zip contains the contents of the .app, so we create the .app directory first - APP_NAME="${ARTIFACT_NAME%.zip}" - - # Validate APP_NAME ends with .app - if [[ ! "$APP_NAME" =~ \.app$ ]]; then - echo -e "${RED}โŒ Invalid app name (must end with .app): $APP_NAME${NC}" - exit 1 - fi + # Always name the app "MetaMask.app" regardless of the artifact filename + APP_NAME="MetaMask.app" EXTRACTED_APP_PATH="$RUNWAY_DIR/$APP_NAME" @@ -205,6 +205,12 @@ if [ "$SKIP_DOWNLOAD" = false ]; then download_latest_app fi +# Skip installation if requested +if [ "$SKIP_INSTALL" = true ]; then + echo -e "${GREEN}โœ“ Download complete. Installation skipped (--skipInstall flag).${NC}" + exit 0 +fi + echo -e "${GREEN}Checking for running iOS simulator...${NC}" # Check if a simulator is booted @@ -219,14 +225,14 @@ fi echo -e "${GREEN}โœ“ Simulator is running:${NC}" echo " $BOOTED_DEVICE" -# Check if runway-artifacts directory exists +# Check if build directory exists if [[ ! -d "$RUNWAY_DIR" ]]; then echo -e "${RED}โŒ Directory $RUNWAY_DIR does not exist${NC}" echo -e "${YELLOW}Run without --skip-download to download an app first${NC}" exit 1 fi -# Find the .app file with the highest version number in runway-artifacts +# Find the .app file with the highest version number in build APP_PATH=$(find "$RUNWAY_DIR" -name "*.app" -type d -maxdepth 1 2>/dev/null | sort -V | tail -1 || true) if [[ -z "$APP_PATH" ]]; then From f43252c9d9c22b20834d459100d9501b867f4870 Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Fri, 30 Jan 2026 14:52:27 +0000 Subject: [PATCH 08/17] =?UTF-8?q?fix(confirmations):=20handle=20batch=20pr?= =?UTF-8?q?edict=20deposits=20and=20quote=E2=80=91based=20alerts=20(#25326?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update pay token selection to use hasTransactionType for predict deposit detection (covers nested/batch txs), and tighten typing/guarding around transactionMeta when updating selectedGasFeeToken. Perps behavior remains unchanged. - Suppress the native insufficient balance alert when the pay token matches the required token and payโ€‘controller quotes are loading/available, letting payโ€‘token balance/fee alerts drive blocking instead. - Add unit coverage for the quoteโ€‘present case and expand mocks for quote/loading state in useInsufficientBalanceAlert tests. ## **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** > Changes blocking alert behavior and updates how `selectedGasFeeToken` is set during pay-token selection for `predictDeposit`, which could impact confirmation gating and gas/token selection in edge cases. > > **Overview** > **Confirmation alerts now defer to transaction-pay UI when pay quotes/source amounts are in play.** `useInsufficientBalanceAlert` stops emitting the native insufficient-balance blocking alert whenever `useTransactionPayHasSourceAmount()` indicates pay source amounts are being used, replacing the prior pay-token/required-token matching logic. > > **Pay-token selection for `predictDeposit` is more robust.** `useTransactionPayToken` uses `hasTransactionType` to detect `predictDeposit` (covering nested/batch transactions) and only updates `selectedGasFeeToken` when `transactionMeta` is present, with tighter typing. > > Tests for `useInsufficientBalanceAlert` were updated to mock `useTransactionPayHasSourceAmount` and cover the new suppression behavior. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 567382ea1d7ebe73e8de1f40c6adaa0f051ff9c6. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../useInsufficientBalanceAlert.test.ts | 63 +++---------------- .../alerts/useInsufficientBalanceAlert.ts | 25 ++------ .../hooks/pay/useTransactionPayToken.ts | 15 +++-- 3 files changed, 23 insertions(+), 80 deletions(-) diff --git a/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.test.ts b/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.test.ts index b9d16cc37645..ff4c7c6e2314 100644 --- a/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.test.ts +++ b/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.test.ts @@ -11,19 +11,13 @@ import { AlertKeys } from '../../constants/alerts'; import { RowAlertKey } from '../../components/UI/info-row/alert-row/constants'; import { Severity } from '../../types/alerts'; import { useConfirmActions } from '../useConfirmActions'; -import { useTransactionPayToken } from '../pay/useTransactionPayToken'; -import { noop } from 'lodash'; import { useConfirmationContext } from '../../context/confirmation-context'; import { useRampNavigation } from '../../../../UI/Ramp/hooks/useRampNavigation'; import { useIsGaslessSupported } from '../gas/useIsGaslessSupported'; -import { useTransactionPayRequiredTokens } from '../pay/useTransactionPayData'; -import { - TransactionPayRequiredToken, - TransactionPaymentToken, -} from '@metamask/transaction-pay-controller'; -import { Hex } from '@metamask/utils'; +import { useTransactionPayHasSourceAmount } from '../pay/useTransactionPayHasSourceAmount'; import { useHasInsufficientBalance } from '../useHasInsufficientBalance'; import { selectUseTransactionSimulations } from '../../../../../selectors/preferencesController'; +import { Hex } from '@metamask/utils'; jest.mock('../../../../../util/navigation/navUtils', () => ({ ...jest.requireActual('../../../../../util/navigation/navUtils'), @@ -53,7 +47,6 @@ jest.mock('../../../../../selectors/preferencesController'); jest.mock('../useHasInsufficientBalance'); jest.mock('../useConfirmActions'); jest.mock('../transactions/useTransactionMetadataRequest'); -jest.mock('../pay/useTransactionPayToken'); jest.mock('../useAccountNativeBalance'); jest.mock('../../../../../../locales/i18n'); jest.mock('../../../../../selectors/networkController'); @@ -62,8 +55,7 @@ jest.mock('../../../../UI/Ramp/hooks/useRampNavigation', () => ({ useRampNavigation: jest.fn(), })); jest.mock('../gas/useIsGaslessSupported'); -jest.mock('../pay/useTransactionPayData'); -jest.mock('../pay/useTransactionPayData'); +jest.mock('../pay/useTransactionPayHasSourceAmount'); describe('useInsufficientBalanceAlert', () => { const mockUseTransactionMetadataRequest = jest.mocked( @@ -74,15 +66,13 @@ describe('useInsufficientBalanceAlert', () => { const mockSelectUseTransactionSimulations = jest.mocked( selectUseTransactionSimulations, ); - const mockUseTransactionPayToken = jest.mocked(useTransactionPayToken); const mockUseConfirmationContext = jest.mocked(useConfirmationContext); const mockUseRampNavigation = jest.mocked(useRampNavigation); const mockGoToBuy = jest.fn(); const useIsGaslessSupportedMock = jest.mocked(useIsGaslessSupported); - const useTransactionPayRequiredTokensMock = jest.mocked( - useTransactionPayRequiredTokens, + const useTransactionPayHasSourceAmountMock = jest.mocked( + useTransactionPayHasSourceAmount, ); - const useTransactionPayTokenMock = jest.mocked(useTransactionPayToken); const useHasInsufficientBalanceMock = jest.mocked(useHasInsufficientBalance); const mockChainId = '0x1' as Hex; @@ -111,10 +101,6 @@ describe('useInsufficientBalanceAlert', () => { } as unknown as ReturnType); mockUseTransactionMetadataRequest.mockReturnValue(mockTransaction); mockSelectUseTransactionSimulations.mockReturnValue(false); - mockUseTransactionPayToken.mockReturnValue({ - payToken: undefined, - setPayToken: noop as never, - }); (strings as jest.Mock).mockImplementation((key, params) => { if (key === 'alert_system.insufficient_balance.buy_action') { @@ -142,12 +128,7 @@ describe('useInsufficientBalanceAlert', () => { goToDeposit: jest.fn(), }); - useTransactionPayRequiredTokensMock.mockReturnValue([]); - - useTransactionPayTokenMock.mockReturnValue({ - payToken: undefined, - setPayToken: jest.fn(), - }); + useTransactionPayHasSourceAmountMock.mockReturnValue(false); useHasInsufficientBalanceMock.mockReturnValue({ hasInsufficientBalance: true, @@ -291,38 +272,12 @@ describe('useInsufficientBalanceAlert', () => { expect(result.current).toStrictEqual([]); }); - it('returns no alert if pay token', () => { - useTransactionPayTokenMock.mockReturnValue({ - payToken: { - address: '0x123' as Hex, - } as TransactionPaymentToken, - setPayToken: jest.fn(), - }); + it('returns empty array when using pay source amounts', () => { + useTransactionPayHasSourceAmountMock.mockReturnValue(true); const { result } = renderHook(() => useInsufficientBalanceAlert()); - expect(result.current).toStrictEqual([]); - }); - - it('returns alert if pay token matches required token', () => { - useTransactionPayTokenMock.mockReturnValue({ - payToken: { - address: '0x123' as Hex, - chainId: mockChainId, - } as TransactionPaymentToken, - setPayToken: jest.fn(), - }); - - useTransactionPayRequiredTokensMock.mockReturnValue([ - { - address: '0x123' as Hex, - chainId: mockChainId, - } as TransactionPayRequiredToken, - ]); - - const { result } = renderHook(() => useInsufficientBalanceAlert()); - - expect(result.current).toHaveLength(1); + expect(result.current).toEqual([]); }); describe('when ignoreGasFeeToken is true', () => { diff --git a/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.ts b/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.ts index f8cda1d25048..fb974dc57790 100644 --- a/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.ts +++ b/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.ts @@ -11,8 +11,7 @@ import { useConfirmationContext } from '../../context/confirmation-context'; import { useIsGaslessSupported } from '../gas/useIsGaslessSupported'; import { TransactionType } from '@metamask/transaction-controller'; import { hasTransactionType } from '../../utils/transaction'; -import { useTransactionPayToken } from '../pay/useTransactionPayToken'; -import { useTransactionPayRequiredTokens } from '../pay/useTransactionPayData'; +import { useTransactionPayHasSourceAmount } from '../pay/useTransactionPayHasSourceAmount'; import { selectUseTransactionSimulations } from '../../../../../selectors/preferencesController'; import { useHasInsufficientBalance } from '../useHasInsufficientBalance'; @@ -29,28 +28,13 @@ export const useInsufficientBalanceAlert = ({ const { onReject } = useConfirmActions(); const { isSupported: isGaslessSupported, pending: isGaslessCheckPending } = useIsGaslessSupported(); - const { payToken } = useTransactionPayToken(); - const requiredTokens = useTransactionPayRequiredTokens(); + const isUsingPay = useTransactionPayHasSourceAmount(); const isSimulationEnabled = useSelector(selectUseTransactionSimulations); const { hasInsufficientBalance, nativeCurrency } = useHasInsufficientBalance(); - const primaryRequiredToken = (requiredTokens ?? []).find( - (token) => !token.skipIfBalance, - ); - - const isPayTokenTarget = - payToken && - payToken.chainId === primaryRequiredToken?.chainId && - payToken.address.toLowerCase() === - primaryRequiredToken?.address.toLowerCase(); - return useMemo(() => { - if ( - !transactionMetadata || - isTransactionValueUpdating || - (payToken && !isPayTokenTarget) - ) { + if (!transactionMetadata || isTransactionValueUpdating || isUsingPay) { return []; } @@ -118,12 +102,11 @@ export const useInsufficientBalanceAlert = ({ }, [ transactionMetadata, isTransactionValueUpdating, - payToken, - isPayTokenTarget, isGaslessCheckPending, isGaslessSupported, isSimulationEnabled, ignoreGasFeeToken, + isUsingPay, hasInsufficientBalance, nativeCurrency, goToBuy, diff --git a/app/components/Views/confirmations/hooks/pay/useTransactionPayToken.ts b/app/components/Views/confirmations/hooks/pay/useTransactionPayToken.ts index 9f3f536aecd1..39690d200c01 100644 --- a/app/components/Views/confirmations/hooks/pay/useTransactionPayToken.ts +++ b/app/components/Views/confirmations/hooks/pay/useTransactionPayToken.ts @@ -1,5 +1,8 @@ import { getNativeTokenAddress } from '@metamask/assets-controllers'; -import { TransactionType } from '@metamask/transaction-controller'; +import { + TransactionMeta, + TransactionType, +} from '@metamask/transaction-controller'; import { TransactionPaymentToken } from '@metamask/transaction-pay-controller'; import { Hex } from '@metamask/utils'; import { noop } from 'lodash'; @@ -12,6 +15,7 @@ import { selectTransactionPaymentTokenByTransactionId } from '../../../../../sel import { updateTransaction } from '../../../../../util/transaction-controller'; import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; import { useTransactionPayRequiredTokens } from './useTransactionPayData'; +import { hasTransactionType } from '../../utils/transaction'; export function useTransactionPayToken(): { isNative?: boolean; @@ -57,16 +61,17 @@ export function useTransactionPayToken(): { } // perps deposits only use relay, so doesn't need gasFeeToken update - const isPredictDepositTransaction = - transactionMeta?.type === TransactionType.predictDeposit; + const isPredictDepositTransaction = hasTransactionType(transactionMeta, [ + TransactionType.predictDeposit, + ]); - if (isPredictDepositTransaction) { + if (isPredictDepositTransaction && transactionMeta) { const isNewPayTokenRequiredToken = newPayToken.chainId === primaryRequiredToken?.chainId && newPayToken.address.toLowerCase() === primaryRequiredToken?.address.toLowerCase(); - const updatedTx = { + const updatedTx: TransactionMeta = { ...transactionMeta, selectedGasFeeToken: isNewPayTokenRequiredToken ? newPayToken.address From fb7cf08afffad80abbb83e6190eb2c5252827162 Mon Sep 17 00:00:00 2001 From: Ramon AC <36987446+racitores@users.noreply.github.com> Date: Fri, 30 Jan 2026 16:14:40 +0100 Subject: [PATCH 09/17] test: add musd conversion feature e2e (#25183) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adds E2E tests for the mUSD conversion happy path, covering the main user flows and aligning with project E2E guidelines. ### Test scenarios - **First-time user:** Education screen โ†’ custom amount โ†’ transaction confirmation โ†’ back to wallet โ†’ Activity tab shows confirmed mUSD conversion. - **Returning user (Token List):** Direct conversion from token list item CTA (โ€œGet X% mUSD bonusโ€ on USDC row); education skipped; same confirmation and Activity verification. - **Asset Overview:** Tap USDC โ†’ Asset Overview โ†’ scroll to mUSD CTA โ†’ tap โ†’ confirmation โ†’ back to wallet โ†’ Activity verification. ### Technical improvements - **Fixture & mocks** - `createMusdFixture(node, options)` moved to `e2e/specs/wallet/helpers/musd-fixture.ts` (Mainnet, ETH/USDC/mUSD, rates, balances, geo/ramp state). - API mocks (feature flags, geolocation, ramp tokens, price APIs, token API, Relay quote/status) moved to `e2e/specs/wallet/helpers/musd-mocks.ts`; feature flag key fixed to `earnMusdConversionAssetOverviewCtaEnabled` for Asset Overview CTA. - **Page Object Model** - **WalletView:** `scrollDownToAssetOverviewMusdCta()` (scroll in Asset Overview until CTA visible, then assert); `tapAssetOverviewMusdCta()` with `checkStability` and delay; token list item CTA getter/tap with stability. - **TransactionPayConfirmation:** `enterAmountAndContinue(amount)` for keyboard amount + continue. - **ActivitiesView:** `verifyMusdConversionConfirmed(rowIndex)` and generic `verifyActivityItemWithStatus(title, status, rowIndex)`; uses `ActivitiesView.testIds` for mUSD conversion label. - **Framework usage** - Assertions, Gestures, Matchers from `tests/framework`; no direct Detox in specs; no `TestHelpers.delay()`; proper timeouts and descriptions. - **Relay / transaction-pay** - Mainnet USDCโ†’mUSD quote mock and `mockRelayQuoteMainnetMusd(mockServer)` in `tests/api-mocking/mock-responses/transaction-pay.ts` so confirmation screen gets a valid quote (avoids โ€œNo quotesโ€). All three tests use local Anvil (no mainnet fork), `setupMusdMocks`, and end with Activity tab verification of the confirmed mUSD conversion. ## **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 modifies shared E2E infrastructure (`FixtureBuilder`, Relay quote mocks, and scrolling helpers), which could introduce flakiness or break unrelated tests despite being non-production code. > > **Overview** > Adds a new E2E suite that validates the **mUSD conversion happy path** across first-time, token-list, and asset-overview entry points, asserting the resulting Activity row is confirmed. > > Introduces dedicated mUSD test infrastructure: a reusable `createMusdFixture` helper plus `FixtureBuilder.withMusdConversion()` to seed Mainnet balances/state, and a full set of API mocks (feature flags, geo/ramp, price/token APIs, Merkl rewards, Relay quote/status) backed by shared `USDC_MAINNET`/`MUSD_MAINNET` constants. > > Extends page objects to support the flow (`WalletView` CTAs + scrolling, `TransactionPayConfirmation.enterAmountAndContinue`, `ActivitiesView.verifyMusdConversionConfirmed`) and tightens `TrendingView` feed scrolling to use `scrollToElement` with configurable timeouts. Also adds a Mainnet-specific Relay quote mock (`mockRelayQuoteMainnetMusd`) to keep Transaction Pay confirmations from failing with missing quotes. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 66af71968fa0431e579f2efd83204435897e31ae. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Javier Garcia Vera --- .../ActivityView/ActivitiesView.testIds.ts | 1 + .../TransactionPayConfirmation.ts | 5 + e2e/pages/Transactions/ActivitiesView.ts | 40 +++ e2e/pages/wallet/WalletView.ts | 82 ++++++ e2e/specs/wallet/helpers/musd-fixture.ts | 75 ++++++ .../wallet/musd-conversion-happy-path.spec.ts | 236 ++++++++++++++++++ .../mock-responses/musd/musd-mocks.ts | 112 +++++++++ .../musd/musd-price-responses.ts | 128 ++++++++++ .../musd/musd-ramp-tokens-response.ts | 37 +++ .../musd/musd-token-response.ts | 15 ++ .../mock-responses/transaction-pay.ts | 100 +++++++- tests/constants/musd-mainnet.ts | 8 + tests/framework/fixtures/FixtureBuilder.ts | 104 ++++++++ tests/page-objects/Trending/TrendingView.ts | 39 ++- 14 files changed, 971 insertions(+), 11 deletions(-) create mode 100644 e2e/specs/wallet/helpers/musd-fixture.ts create mode 100644 e2e/specs/wallet/musd-conversion-happy-path.spec.ts create mode 100644 tests/api-mocking/mock-responses/musd/musd-mocks.ts create mode 100644 tests/api-mocking/mock-responses/musd/musd-price-responses.ts create mode 100644 tests/api-mocking/mock-responses/musd/musd-ramp-tokens-response.ts create mode 100644 tests/api-mocking/mock-responses/musd/musd-token-response.ts create mode 100644 tests/constants/musd-mainnet.ts diff --git a/app/components/Views/ActivityView/ActivitiesView.testIds.ts b/app/components/Views/ActivityView/ActivitiesView.testIds.ts index 764ef14bc219..68e8f9fabad4 100644 --- a/app/components/Views/ActivityView/ActivitiesView.testIds.ts +++ b/app/components/Views/ActivityView/ActivitiesView.testIds.ts @@ -27,6 +27,7 @@ export const ActivitiesViewSelectorsText = { UNSTAKE: enContent.transactions.tx_review_staking_unstake, STAKING_CLAIM: enContent.transactions.tx_review_staking_claim, PREDICT_DEPOSIT: enContent.transactions.tx_review_predict_deposit, + MUSD_CONVERSION: enContent.transactions.tx_review_musd_conversion, }; export const sentMessageTokenIDs = { diff --git a/e2e/pages/Confirmation/TransactionPayConfirmation.ts b/e2e/pages/Confirmation/TransactionPayConfirmation.ts index e264de21f57c..fc3f5882f807 100644 --- a/e2e/pages/Confirmation/TransactionPayConfirmation.ts +++ b/e2e/pages/Confirmation/TransactionPayConfirmation.ts @@ -66,6 +66,11 @@ class TransactionPayConfirmation { } } + async enterAmountAndContinue(amount: string): Promise { + await this.tapKeyboardAmount(amount); + await this.tapKeyboardContinueButton(); + } + async verifyBridgeTime(time: string): Promise { await Assertions.expectElementToHaveText(this.bridgeTime, time, { description: 'Bridge time should be correct', diff --git a/e2e/pages/Transactions/ActivitiesView.ts b/e2e/pages/Transactions/ActivitiesView.ts index 7006632d35aa..ebee8636d303 100644 --- a/e2e/pages/Transactions/ActivitiesView.ts +++ b/e2e/pages/Transactions/ActivitiesView.ts @@ -4,6 +4,7 @@ import { } from '../../../app/components/Views/ActivityView/ActivitiesView.testIds'; import Matchers from '../../../tests/framework/Matchers'; import Gestures from '../../../tests/framework/Gestures'; +import Assertions from '../../../tests/framework/Assertions'; class ActivitiesView { get title(): DetoxElement { @@ -151,6 +152,45 @@ class ActivitiesView { elemDescription: `Tapping Predict Position: ${positionName}`, }); } + + /** + * Verifies that an activity item with the given title is visible and its row status matches. + * Use after TabBarComponent.tapActivity(). Row 0 is the most recent transaction. + * + * @param titleText - Activity title to look for (e.g. "mUSD conversion", "Sent ETH") + * @param statusText - Expected status for the row (e.g. "Confirmed", "Failed") + * @param rowIndex - Row index (default 0 = most recent) + */ + async verifyActivityItemWithStatus( + titleText: string, + statusText: string, + rowIndex = 0, + ): Promise { + await Assertions.expectTextDisplayed(titleText, { + timeout: 20000, + description: `Activity item "${titleText}" should be visible`, + }); + await Assertions.expectElementToHaveText( + this.transactionStatus(rowIndex), + statusText, + { + timeout: 10000, + description: `Activity row (index ${rowIndex}) should show status "${statusText}"`, + }, + ); + } + + /** + * Verifies that the mUSD conversion activity item is visible and its status is Confirmed. + * Delegates to verifyActivityItemWithStatus. + */ + async verifyMusdConversionConfirmed(rowIndex = 0): Promise { + await this.verifyActivityItemWithStatus( + ActivitiesViewSelectorsText.MUSD_CONVERSION, + ActivitiesViewSelectorsText.CONFIRM_TEXT, + rowIndex, + ); + } } export default new ActivitiesView(); diff --git a/e2e/pages/wallet/WalletView.ts b/e2e/pages/wallet/WalletView.ts index 4b1a86b97cc1..8057860b7494 100644 --- a/e2e/pages/wallet/WalletView.ts +++ b/e2e/pages/wallet/WalletView.ts @@ -2,6 +2,8 @@ import { WalletViewSelectorsIDs, WalletViewSelectorsText, } from '../../../app/components/Views/Wallet/WalletView.testIds'; +import { EARN_TEST_IDS } from '../../../app/components/UI/Earn/constants/testIds'; +import { SECONDARY_BALANCE_BUTTON_TEST_ID } from '../../../app/components/UI/AssetElement/index.constants'; import { PredictTabViewSelectorsIDs, PredictPositionsHeaderSelectorsIDs, @@ -622,6 +624,32 @@ class WalletView { return Matchers.getElementByID(WalletViewSelectorsIDs.WALLET_SEND_BUTTON); } + // mUSD conversion (Earn) - asset list CTA, education screen, token list CTA, asset overview CTA + get musdConversionCta(): DetoxElement { + return Matchers.getElementByID( + EARN_TEST_IDS.MUSD.ASSET_LIST_CONVERSION_CTA, + ); + } + + get getMusdButton(): DetoxElement { + return Matchers.getElementByText('Get mUSD'); + } + + get getStartedButton(): DetoxElement { + return Matchers.getElementByText('Get Started'); + } + + /** Token list item CTA: "Get 3% mUSD bonus" on USDC row. Use testID + index (1 = USDC after ETH) to avoid regex/text flakiness. */ + get tokenListItemConvertToMusdCta(): DetoxElement { + return Matchers.getElementByID(SECONDARY_BALANCE_BUTTON_TEST_ID, 1); + } + + get assetOverviewMusdCta(): DetoxElement { + return Matchers.getElementByID( + EARN_TEST_IDS.MUSD.ASSET_OVERVIEW_CONVERSION_CTA, + ); + } + get walletReceiveButton(): DetoxElement { return Matchers.getElementByID( WalletViewSelectorsIDs.WALLET_RECEIVE_BUTTON, @@ -664,6 +692,60 @@ class WalletView { }); } + async tapGetMusdButton(): Promise { + await Gestures.waitAndTap(this.getMusdButton, { + elemDescription: 'Get mUSD button', + }); + } + + async tapGetStartedButton(): Promise { + await Gestures.waitAndTap(this.getStartedButton, { + elemDescription: 'Get Started button on education screen', + }); + } + + /** Tap the "Get X% mUSD bonus" CTA on a token list row (visible when user has mUSD balance). Uses checkStability + delay so list is fully loaded before tap. */ + async tapTokenListItemConvertToMusdCta(): Promise { + await Gestures.waitAndTap(this.tokenListItemConvertToMusdCta, { + checkStability: true, + delay: 1000, + elemDescription: 'Token list item mUSD conversion CTA', + }); + } + + /** + * Scrolls down on the Asset Overview screen until the mUSD conversion CTA is visible, + * then asserts it is visible so the caller can safely tap. Uses the same scroll + * container as the Asset/Transactions screen (transactions-container). + */ + async scrollDownToAssetOverviewMusdCta(): Promise { + const assetOverviewScrollContainer = Matchers.getIdentifier( + 'transactions-container', + ); + await Gestures.scrollToElement( + this.assetOverviewMusdCta as unknown as DetoxElement, + assetOverviewScrollContainer, + { + direction: 'down', + scrollAmount: 200, + elemDescription: 'Asset Overview mUSD CTA', + timeout: 15000, + }, + ); + await Assertions.expectElementToBeVisible(this.assetOverviewMusdCta, { + timeout: 5000, + description: 'Asset Overview mUSD CTA should be visible after scroll', + }); + } + + async tapAssetOverviewMusdCta(): Promise { + await Gestures.waitAndTap(this.assetOverviewMusdCta, { + checkStability: true, + delay: 800, + elemDescription: 'Asset Overview mUSD CTA', + }); + } + async tapWalletReceiveButton(): Promise { await Gestures.waitAndTap(this.walletReceiveButton, { elemDescription: 'Wallet Receive Button', diff --git a/e2e/specs/wallet/helpers/musd-fixture.ts b/e2e/specs/wallet/helpers/musd-fixture.ts new file mode 100644 index 000000000000..95192da67d49 --- /dev/null +++ b/e2e/specs/wallet/helpers/musd-fixture.ts @@ -0,0 +1,75 @@ +import FixtureBuilder, { + type MusdFixtureOptions, +} from '../../../../tests/framework/fixtures/FixtureBuilder'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { toChecksumHexAddress } from '@metamask/controller-utils'; +import { AnvilPort } from '../../../../tests/framework/fixtures/FixtureUtils'; +import { AnvilManager } from '../../../../tests/seeder/anvil-manager'; +import { + USDC_MAINNET, + MUSD_MAINNET, +} from '../../../../tests/constants/musd-mainnet'; + +const USDC_DECIMALS = 6; +const MUSD_DECIMALS = 6; +const ETH_NATIVE_ADDRESS = '0x0000000000000000000000000000000000000000'; + +export type { MusdFixtureOptions }; + +/** + * Builds a fixture for mUSD conversion E2E tests using FixtureBuilder: + * Mainnet, ETH/USDC/mUSD tokens, rates, balances, and mUSD eligibility state. + */ +export function createMusdFixture( + node: AnvilManager, + options: MusdFixtureOptions, +): ReturnType { + const rpcPort = node?.getPort?.() ?? AnvilPort(); + const baseTokens = [ + { + address: toChecksumHexAddress(ETH_NATIVE_ADDRESS), + symbol: 'ETH', + decimals: 18, + name: 'Ethereum', + }, + { + address: toChecksumHexAddress(USDC_MAINNET), + symbol: 'USDC', + decimals: USDC_DECIMALS, + name: 'USDCoin', + }, + ...(options.hasMusdBalance + ? [ + { + address: toChecksumHexAddress(MUSD_MAINNET), + symbol: 'MUSD', + decimals: MUSD_DECIMALS, + name: 'MUSD', + }, + ] + : []), + ]; + + return new FixtureBuilder() + .withNetworkController({ + providerConfig: { + chainId: CHAIN_IDS.MAINNET, + rpcUrl: `http://localhost:${rpcPort}`, + type: 'custom', + nickname: 'Ethereum Mainnet', + ticker: 'ETH', + }, + }) + .withNetworkEnabledMap({ eip155: { [CHAIN_IDS.MAINNET]: true } }) + .withMetaMetricsOptIn() + .withTokensForAllPopularNetworks(baseTokens) + .withTokenRates( + CHAIN_IDS.MAINNET, + toChecksumHexAddress(ETH_NATIVE_ADDRESS), + 3000.0, + ) + .withTokenRates(CHAIN_IDS.MAINNET, toChecksumHexAddress(USDC_MAINNET), 1.0) + .withTokenRates(CHAIN_IDS.MAINNET, toChecksumHexAddress(MUSD_MAINNET), 1.0) + .withMusdConversion(options) + .build(); +} diff --git a/e2e/specs/wallet/musd-conversion-happy-path.spec.ts b/e2e/specs/wallet/musd-conversion-happy-path.spec.ts new file mode 100644 index 000000000000..9f9092a4de5f --- /dev/null +++ b/e2e/specs/wallet/musd-conversion-happy-path.spec.ts @@ -0,0 +1,236 @@ +import { SmokeWalletPlatform } from '../../tags'; +import TestHelpers from '../../helpers'; +import WalletView from '../../pages/wallet/WalletView'; +import { loginToApp } from '../../viewHelper'; +import Assertions from '../../../tests/framework/Assertions'; +import { withFixtures } from '../../../tests/framework/fixtures/FixtureHelper'; +import { + LocalNode, + LocalNodeType, + type WithFixturesOptions, +} from '../../../tests/framework/types'; +import { AnvilManager } from '../../../tests/seeder/anvil-manager'; +import TransactionPayConfirmation from '../../pages/Confirmation/TransactionPayConfirmation'; +import FooterActions from '../../pages/Browser/Confirmations/FooterActions'; +import TabBarComponent from '../../pages/wallet/TabBarComponent'; +import ActivitiesView from '../../pages/Transactions/ActivitiesView'; +import { setupMusdMocks } from '../../../tests/api-mocking/mock-responses/musd/musd-mocks'; +import { + createMusdFixture, + type MusdFixtureOptions, +} from './helpers/musd-fixture'; + +/** + * Returns the shared withFixtures config for mUSD conversion tests. + * Only fixture options vary per scenario; localNodeOptions, restartDevice, and testSpecificMock are centralized here. + */ +function withMusdFixturesOptions( + fixtureOptions: MusdFixtureOptions, +): WithFixturesOptions { + return { + fixture: ({ localNodes }: { localNodes?: LocalNode[] }) => { + const node = localNodes?.[0] as unknown as AnvilManager; + return createMusdFixture(node, fixtureOptions); + }, + localNodeOptions: [ + { + type: LocalNodeType.anvil, + options: { chainId: 1 }, + }, + ], + restartDevice: true, + testSpecificMock: setupMusdMocks, + }; +} + +describe(SmokeWalletPlatform('mUSD Conversion Happy Path'), () => { + beforeAll(async () => { + jest.setTimeout(150000); + await TestHelpers.launchApp(); + }); + + it('converts USDC to mUSD successfully (First Time User)', async () => { + await withFixtures( + withMusdFixturesOptions({ + musdConversionEducationSeen: false, + }), + async () => { + await device.disableSynchronization(); + await loginToApp(); + + // Verify wallet is visible + await Assertions.expectElementToBeVisible(WalletView.container, { + description: 'Wallet view should be visible', + }); + + // Verify mUSD CTA is visible and tap Get mUSD + await Assertions.expectElementToBeVisible( + WalletView.musdConversionCta, + { + description: 'mUSD conversion CTA should be visible', + }, + ); + await WalletView.tapGetMusdButton(); + + // Verify education screen is shown (first time user) and tap Get Started + await Assertions.expectElementToBeVisible(WalletView.getStartedButton, { + timeout: 10000, + description: 'Education screen Get Started button should be visible', + }); + await WalletView.tapGetStartedButton(); + + // Verify custom amount/confirmation screen is shown + await Assertions.expectElementToBeVisible( + TransactionPayConfirmation.payWithRow, + { + timeout: 10000, + description: + 'Pay with row should be visible on confirmation screen', + }, + ); + + // Enter amount ($12) and continue (avoid "0" key to prevent banner blocking) + await TransactionPayConfirmation.enterAmountAndContinue('12'); + + // Verify confirmation details are visible + await Assertions.expectElementToBeVisible( + TransactionPayConfirmation.total, + { + timeout: 10000, + description: 'Total amount should be visible', + }, + ); + + // Confirm the transaction (tap the convert/confirm button) + await FooterActions.tapConfirmButton(); + + // Verify we're back in the wallet after confirmation (ignore processing/completed banners - flaky) + await Assertions.expectElementToBeVisible(WalletView.container, { + timeout: 30000, + description: 'Wallet view should be visible after conversion', + }); + + // Go to Activity and verify mUSD conversion is confirmed (same pattern as send-native-token: no swipeDown) + await TabBarComponent.tapActivity(); + await ActivitiesView.verifyMusdConversionConfirmed(0); + }, + ); + }); + + it('converts USDC to mUSD from Token List (Returning User)', async () => { + await withFixtures( + withMusdFixturesOptions({ + musdConversionEducationSeen: true, + hasMusdBalance: true, + musdBalance: 100, + }), + async () => { + await device.disableSynchronization(); + await loginToApp(); + + // Verify wallet is visible + await Assertions.expectElementToBeVisible(WalletView.container, { + description: 'Wallet view should be visible', + }); + + // Scroll to top then to USDC row (UI shows symbol "USDC" on token row). tapTokenListItemConvertToMusdCta uses checkStability + delay so list is ready before tap. + await WalletView.scrollToTopOfTokensList(); + await WalletView.scrollToToken('USDCoin'); + await Assertions.expectElementToBeVisible( + WalletView.tokenListItemConvertToMusdCta, + { + timeout: 10000, + description: + 'Token list item mUSD CTA (Get X% mUSD bonus) should be visible on USDC row', + }, + ); + await WalletView.tapTokenListItemConvertToMusdCta(); + + // Education skipped (musdConversionEducationSeen: true) - confirmation screen shown (payToken/quote may load) + await Assertions.expectElementToBeVisible( + TransactionPayConfirmation.payWithRow, + { + timeout: 20000, + description: + 'Pay with row should be visible on confirmation screen', + }, + ); + + // Enter amount and continue (avoid "0" key - use 5) + await TransactionPayConfirmation.enterAmountAndContinue('5'); + + // Confirm the transaction + await FooterActions.tapConfirmButton(); + + // Verify we're back in the wallet after confirmation + await Assertions.expectElementToBeVisible(WalletView.container, { + timeout: 30000, + description: + 'Wallet view should be visible after transaction confirmation', + }); + + // Go to Activity and verify mUSD conversion is confirmed + await TabBarComponent.tapActivity(); + await ActivitiesView.verifyMusdConversionConfirmed(0); + }, + ); + }); + + it('converts USDC to mUSD from Asset Overview', async () => { + await withFixtures( + withMusdFixturesOptions({ + musdConversionEducationSeen: true, + }), + async () => { + await device.disableSynchronization(); + await loginToApp(); + + // Verify wallet is visible + await Assertions.expectElementToBeVisible(WalletView.container, { + description: 'Wallet view should be visible', + }); + + // Tap on USDC to go to Asset Overview, scroll to mUSD CTA (ensures loaded), then tap + await WalletView.tapOnToken('USDCoin'); + await WalletView.scrollDownToAssetOverviewMusdCta(); + await WalletView.tapAssetOverviewMusdCta(); + + // Verify confirmation screen (payToken/quote may load) + await Assertions.expectElementToBeVisible( + TransactionPayConfirmation.payWithRow, + { + timeout: 20000, + description: + 'Pay with row should be visible on confirmation screen', + }, + ); + + // Enter amount and continue (avoid "0" key - use 5) + await TransactionPayConfirmation.enterAmountAndContinue('5'); + + // Verify confirmation details are visible + await Assertions.expectElementToBeVisible( + TransactionPayConfirmation.total, + { + timeout: 10000, + description: 'Total amount should be visible', + }, + ); + + // Confirm the transaction + await FooterActions.tapConfirmButton(); + + // Verify we're back in the wallet after confirmation + await Assertions.expectElementToBeVisible(WalletView.container, { + timeout: 30000, + description: + 'Wallet view should be visible after transaction confirmation', + }); + + // Go to Activity and verify mUSD conversion is confirmed + await TabBarComponent.tapActivity(); + await ActivitiesView.verifyMusdConversionConfirmed(0); + }, + ); + }); +}); diff --git a/tests/api-mocking/mock-responses/musd/musd-mocks.ts b/tests/api-mocking/mock-responses/musd/musd-mocks.ts new file mode 100644 index 000000000000..361bb80f01b0 --- /dev/null +++ b/tests/api-mocking/mock-responses/musd/musd-mocks.ts @@ -0,0 +1,112 @@ +/** + * mUSD conversion E2E API mocks. + * Sets up feature flags, geolocation, ramp tokens, price APIs, token API, + * Merkl rewards, and Relay quote/status. + */ + +import { Mockttp } from 'mockttp'; +import { setupRemoteFeatureFlagsMock } from '../../helpers/remoteFeatureFlagsHelper.ts'; +import { setupMockRequest } from '../../helpers/mockHelpers.ts'; +import { getDecodedProxiedURL } from '../../../../e2e/specs/notifications/utils/helpers.ts'; +import { + mockRelayQuoteMainnetMusd, + mockRelayStatus, +} from '../transaction-pay.ts'; +import { MUSD_MAINNET } from '../../../constants/musd-mainnet.ts'; +import { MUSD_RAMP_TOKENS_RESPONSE } from './musd-ramp-tokens-response.ts'; +import { + MUSD_SPOT_PRICES_V3_RESPONSE, + MUSD_CHAINS_SPOT_PRICES_V2_RESPONSE, + MUSD_EXCHANGE_RATES_V1_RESPONSE, + MUSD_HISTORICAL_PRICES_RESPONSE, +} from './musd-price-responses.ts'; +import { MUSD_TOKEN_API_RESPONSE } from './musd-token-response.ts'; + +export async function setupMusdMocks(mockServer: Mockttp): Promise { + await setupRemoteFeatureFlagsMock(mockServer, { + earnMusdConversionFlowEnabled: { enabled: true, minimumVersion: '0.0.0' }, + earnMusdCtaEnabled: { enabled: true, minimumVersion: '0.0.0' }, + earnMusdConversionTokenListItemCtaEnabled: { + enabled: true, + minimumVersion: '0.0.0', + }, + earnMusdConversionAssetOverviewCtaEnabled: { + enabled: true, + minimumVersion: '0.0.0', + }, + earnMusdConversionCtaTokens: { '*': ['USDC'] }, + earnMusdConvertibleTokensAllowlist: { '*': ['USDC'] }, + earnMusdConversionMinAssetBalanceRequired: 0.01, + earnMusdConversionGeoBlockedCountries: { blockedRegions: ['GB'] }, + }); + + await mockServer + .forGet('/proxy') + .matching((request) => { + const url = getDecodedProxiedURL(request.url); + return /on-ramp\.(dev-api|uat-api|api)\.cx\.metamask\.io\/geolocation/.test( + url, + ); + }) + .asPriority(998) + .thenCallback(() => ({ + statusCode: 200, + body: 'US', + headers: { 'content-type': 'text/plain' }, + })); + + await setupMockRequest(mockServer, { + url: /on-ramp-cache\.(uat-api|api)\.cx\.metamask\.io\/regions\/.*\/tokens/, + response: MUSD_RAMP_TOKENS_RESPONSE, + requestMethod: 'GET', + responseCode: 200, + }); + + await setupMockRequest(mockServer, { + url: /price\.api\.cx\.metamask\.io\/v3\/spot-prices/, + response: MUSD_SPOT_PRICES_V3_RESPONSE, + requestMethod: 'GET', + responseCode: 200, + }); + + await setupMockRequest(mockServer, { + url: /price\.api\.cx\.metamask\.io\/v2\/chains\/\d+\/spot-prices/, + response: MUSD_CHAINS_SPOT_PRICES_V2_RESPONSE, + requestMethod: 'GET', + responseCode: 200, + }); + + await setupMockRequest(mockServer, { + url: /price\.api\.cx\.metamask\.io\/v1\/exchange-rates/, + response: MUSD_EXCHANGE_RATES_V1_RESPONSE, + requestMethod: 'GET', + responseCode: 200, + }); + + await setupMockRequest(mockServer, { + url: /price\.api\.cx\.metamask\.io\/v3\/historical-prices/, + response: MUSD_HISTORICAL_PRICES_RESPONSE, + requestMethod: 'GET', + responseCode: 200, + }); + + await setupMockRequest(mockServer, { + url: new RegExp( + `token\\.api\\.cx\\.metamask\\.io/token/1\\?address=${MUSD_MAINNET}`, + 'i', + ), + response: MUSD_TOKEN_API_RESPONSE, + requestMethod: 'GET', + responseCode: 200, + }); + + await setupMockRequest(mockServer, { + url: /api\.merkl\.xyz\/v4\/users\/0x[a-fA-F0-9]+\/rewards\?chainId=/, + response: [], + requestMethod: 'GET', + responseCode: 200, + }); + + await mockRelayQuoteMainnetMusd(mockServer); + await mockRelayStatus(mockServer); +} diff --git a/tests/api-mocking/mock-responses/musd/musd-price-responses.ts b/tests/api-mocking/mock-responses/musd/musd-price-responses.ts new file mode 100644 index 000000000000..fe0f5190780d --- /dev/null +++ b/tests/api-mocking/mock-responses/musd/musd-price-responses.ts @@ -0,0 +1,128 @@ +/** + * Price API mock responses for mUSD conversion E2E. + * v3 spot-prices, v2 chains spot-prices, v1 exchange-rates, historical-prices. + */ + +import { USDC_MAINNET, MUSD_MAINNET } from '../../../constants/musd-mainnet.ts'; + +const ETH_NATIVE = '0x0000000000000000000000000000000000000000'; + +export const MUSD_SPOT_PRICES_V3_RESPONSE = { + 'eip155:1/slip44:60': { + id: 'ethereum', + price: 0.999904095987313, + marketCap: 120707177.900275, + allTimeHigh: 1.6514207089624, + allTimeLow: 0.000144565964182697, + totalVolume: 9910501.61509202, + high1d: 1.01396406829944, + low1d: 0.972789150571307, + circulatingSupply: 120694373.7963051, + dilutedMarketCap: 120707177.900275, + marketCapPercentChange1d: 2.69249, + priceChange1d: 77.51, + pricePercentChange1h: -0.8412978033401519, + pricePercentChange1d: 2.656997542645518, + pricePercentChange7d: 2.280904394391741, + pricePercentChange14d: -9.26474467228875, + pricePercentChange30d: 2.3819062900759485, + pricePercentChange200d: 1.9842726591642341, + pricePercentChange1y: -5.689801242350527, + }, + [`eip155:1/erc20:${USDC_MAINNET}`]: { + id: 'usd-coin', + price: 0.000333804977889164, + marketCap: 23754743.4686059, + allTimeHigh: 0.000390647532775852, + allTimeLow: 0.000293034730938571, + totalVolume: 4928178.91900057, + high1d: 0.000333824677209193, + low1d: 0.000333722507854467, + circulatingSupply: 71166951334.28784, + dilutedMarketCap: 23754727.3558976, + marketCapPercentChange1d: -0.73791, + priceChange1d: 0.00013626, + pricePercentChange1h: 0.013840506207943954, + pricePercentChange1d: 0.013630960582563337, + pricePercentChange7d: -0.0009572939895655817, + pricePercentChange14d: -0.00423420136358611, + pricePercentChange30d: -0.000059529858641097485, + pricePercentChange200d: -0.014687097277342517, + pricePercentChange1y: -0.01926094991611645, + }, + [`eip155:1/erc20:${MUSD_MAINNET}`]: { + id: 'metamask-usd', + price: 0.000333694127478155, + marketCap: 7677.36891681464, + allTimeHigh: 0.000362267156463077, + allTimeLow: 0.000309008208387742, + totalVolume: 4392.14336541057, + high1d: 0.00035425387373947, + low1d: 0.000333370591188189, + circulatingSupply: 22999586.214533, + dilutedMarketCap: 7677.36891681464, + marketCapPercentChange1d: -7.9584, + priceChange1d: -0.000168554433610746, + pricePercentChange1h: -0.04427332716520257, + pricePercentChange1d: -0.01686233770769416, + pricePercentChange7d: 0.07342340725992479, + pricePercentChange14d: -0.09041741955087122, + pricePercentChange30d: -0.10435457604221866, + pricePercentChange200d: null, + pricePercentChange1y: null, + }, +}; + +export const MUSD_CHAINS_SPOT_PRICES_V2_RESPONSE = { + [ETH_NATIVE]: { + id: 'ethereum', + price: 3000.0, + pricePercentChange1d: 2.65, + }, + [USDC_MAINNET]: { + id: 'usd-coin', + price: 1.0, + pricePercentChange1d: 0.01, + }, + [MUSD_MAINNET]: { + id: 'metamask-usd', + price: 1.0, + pricePercentChange1d: -0.01, + }, +}; + +export const MUSD_EXCHANGE_RATES_V1_RESPONSE = { + btc: { + name: 'Bitcoin', + ticker: 'btc', + value: 0.0000112264812935107, + currencyType: 'crypto', + }, + eth: { + name: 'Ether', + ticker: 'eth', + value: 0.000333886780150301, + currencyType: 'crypto', + }, + usd: { name: 'US Dollar', ticker: 'usd', value: 1, currencyType: 'fiat' }, + eur: { + name: 'Euro', + ticker: 'eur', + value: 0.837579007063758, + currencyType: 'fiat', + }, + gbp: { + name: 'British Pound Sterling', + ticker: 'gbp', + value: 0.726810998426553, + currencyType: 'fiat', + }, +}; + +export const MUSD_HISTORICAL_PRICES_RESPONSE = { + prices: [ + [Date.now() - 86400000, 1.0], + [Date.now() - 43200000, 1.0], + [Date.now(), 1.0], + ], +}; diff --git a/tests/api-mocking/mock-responses/musd/musd-ramp-tokens-response.ts b/tests/api-mocking/mock-responses/musd/musd-ramp-tokens-response.ts new file mode 100644 index 000000000000..72760722502f --- /dev/null +++ b/tests/api-mocking/mock-responses/musd/musd-ramp-tokens-response.ts @@ -0,0 +1,37 @@ +/** + * Ramp tokens response for mUSD conversion E2E mocks. + * Used by on-ramp-cache regions/tokens endpoint. + */ + +import { USDC_MAINNET, MUSD_MAINNET } from '../../../constants/musd-mainnet.ts'; + +export const MUSD_RAMP_TOKENS_RESPONSE = { + topTokens: [ + { + assetId: `eip155:1/erc20:${MUSD_MAINNET}`, + chainId: 'eip155:1', + symbol: 'MUSD', + name: 'MetaMask USD', + decimals: 6, + tokenSupported: true, + }, + ], + allTokens: [ + { + assetId: `eip155:1/erc20:${MUSD_MAINNET}`, + chainId: 'eip155:1', + symbol: 'MUSD', + name: 'MetaMask USD', + decimals: 6, + tokenSupported: true, + }, + { + assetId: `eip155:1/erc20:${USDC_MAINNET}`, + chainId: 'eip155:1', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + tokenSupported: true, + }, + ], +}; diff --git a/tests/api-mocking/mock-responses/musd/musd-token-response.ts b/tests/api-mocking/mock-responses/musd/musd-token-response.ts new file mode 100644 index 000000000000..dfa367039389 --- /dev/null +++ b/tests/api-mocking/mock-responses/musd/musd-token-response.ts @@ -0,0 +1,15 @@ +/** + * Token API mock response for mUSD (token metadata). + */ + +import { MUSD_MAINNET } from '../../../constants/musd-mainnet.ts'; + +export const MUSD_TOKEN_API_RESPONSE = { + address: MUSD_MAINNET, + symbol: 'MUSD', + name: 'MetaMask USD', + decimals: 6, + chainId: 1, + logoURI: '', + aggregators: [], +}; diff --git a/tests/api-mocking/mock-responses/transaction-pay.ts b/tests/api-mocking/mock-responses/transaction-pay.ts index 6b3778307f1b..b754fcb54d9c 100644 --- a/tests/api-mocking/mock-responses/transaction-pay.ts +++ b/tests/api-mocking/mock-responses/transaction-pay.ts @@ -1,5 +1,6 @@ import { Mockttp } from 'mockttp'; -import { DEFAULT_FIXTURE_ACCOUNT } from '../../framework/fixtures/FixtureBuilder.ts'; +import { USDC_MAINNET, MUSD_MAINNET } from '../../constants/musd-mainnet'; +import { DEFAULT_FIXTURE_ACCOUNT } from '../../framework/fixtures/FixtureBuilder'; export const RELAY_QUOTE_MOCK = { steps: [ @@ -310,6 +311,86 @@ export const RELAY_QUOTE_MOCK = { }, }; +/** + * Relay quote mock for Mainnet mUSD conversion (chainId 1, USDC โ†’ mUSD). + * TransactionPayController's normalizeQuote expects: + * - details.currencyIn/currencyOut with chainId matching the request (1) + * - steps[].items[].data.chainId = 1 so gas/network lookups use Mainnet + * - details.timeEstimate, details.totalImpact.usd + */ +export const MAINNET_MUSD_RELAY_QUOTE_MOCK = { + steps: [ + { + id: 'deposit', + action: 'Confirm transaction in your wallet', + description: 'Convert USDC to mUSD', + kind: 'transaction', + items: [ + { + status: 'incomplete', + data: { + from: DEFAULT_FIXTURE_ACCOUNT, + to: '0x00000000aa467eba42a3d604b3d74d63b2b6c6cb', + data: '0x470b5f3b22544142d6b2116ec296913046fe06578b495e602ac2fe0c87b843de', + value: '0', + chainId: 1, + gas: '100000', + maxFeePerGas: '30000000000', + maxPriorityFeePerGas: '1000000000', + }, + check: { + endpoint: + '/intents/status?requestId=0x470b5f3b22544142d6b2116ec296913046fe06578b495e602ac2fe0c87b843de', + method: 'GET', + }, + }, + ], + requestId: + '0x470b5f3b22544142d6b2116ec296913046fe06578b495e602ac2fe0c87b843de', + depositAddress: '', + }, + ], + details: { + operation: 'swap', + sender: DEFAULT_FIXTURE_ACCOUNT, + recipient: DEFAULT_FIXTURE_ACCOUNT, + currencyIn: { + currency: { + chainId: 1, + address: USDC_MAINNET, + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + metadata: { logoURI: '', verified: true }, + }, + amount: '100000000', + amountFormatted: '100', + amountUsd: '100', + minimumAmount: '100000000', + }, + currencyOut: { + currency: { + chainId: 1, + address: MUSD_MAINNET, + symbol: 'MUSD', + name: 'MetaMask USD', + decimals: 6, + metadata: { logoURI: '', verified: true }, + }, + amount: '100100000', + amountFormatted: '100.1', + amountUsd: '100.1', + minimumAmount: '100000000', + }, + totalImpact: { usd: '-0.01', percent: '-0.01' }, + timeEstimate: 4, + }, + fees: { + relayer: { amountUsd: '0.01' }, + }, + metamask: { gasLimits: [100000] }, +}; + export const RELAY_STATUS_MOCK = { status: 'success', txHashes: [ @@ -330,6 +411,23 @@ export async function mockRelayQuote(mockServer: Mockttp) { })); } +/** + * Mocks Relay quote API for Mainnet mUSD conversion (chainId 1, USDC โ†’ mUSD). + * Use this in mUSD conversion E2E so normalizeQuote uses Mainnet for gas/rates. + */ +export async function mockRelayQuoteMainnetMusd(mockServer: Mockttp) { + await mockServer + .forPost('/proxy') + .matching((request) => { + const url = new URL(request.url).searchParams.get('url'); + return Boolean(url?.includes('api.relay.link/quote')); + }) + .thenCallback(() => ({ + statusCode: 200, + json: MAINNET_MUSD_RELAY_QUOTE_MOCK, + })); +} + export async function mockRelayStatus(mockServer: Mockttp) { await mockServer .forGet('/proxy') diff --git a/tests/constants/musd-mainnet.ts b/tests/constants/musd-mainnet.ts new file mode 100644 index 000000000000..83e7520eebec --- /dev/null +++ b/tests/constants/musd-mainnet.ts @@ -0,0 +1,8 @@ +/** + * Mainnet token addresses for mUSD conversion E2E and API mocks. + * Single source of truth so fixture, mocks, and transaction-pay stay in sync. + */ +export const USDC_MAINNET = + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as const; +export const MUSD_MAINNET = + '0xaca92e438df0b2401ff60da7e4337b687a2435da' as const; diff --git a/tests/framework/fixtures/FixtureBuilder.ts b/tests/framework/fixtures/FixtureBuilder.ts index da5bb83bea8a..51a9045b9cf2 100644 --- a/tests/framework/fixtures/FixtureBuilder.ts +++ b/tests/framework/fixtures/FixtureBuilder.ts @@ -8,6 +8,7 @@ import { import { merge } from 'lodash'; import { encryptVault } from './helpers.ts'; import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { toChecksumHexAddress } from '@metamask/controller-utils'; import { BtcScope, SolScope, TrxScope } from '@metamask/keyring-api'; import { Caip25CaveatType, @@ -37,6 +38,7 @@ import { MOCK_ENTROPY_SOURCE_3, } from '../../../app/util/test/keyringControllerTestUtils.ts'; import { NetworkEnablementControllerState } from '@metamask/network-enablement-controller'; +import { USDC_MAINNET, MUSD_MAINNET } from '../../constants/musd-mainnet.ts'; export const DEFAULT_FIXTURE_ACCOUNT_CHECKSUM = '0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3'; @@ -68,6 +70,17 @@ export const SIMPLE_KEYRING_SNAP_ID = export const GENERIC_SNAP_WALLET_1_ID = 'snap:npm:@metamask/generic-snap-1'; export const GENERIC_SNAP_WALLET_2_ID = 'snap:npm:@metamask/generic-snap-2'; +/** + * Options for mUSD conversion E2E fixture state. + */ +export interface MusdFixtureOptions { + musdConversionEducationSeen: boolean; + hasUsdcBalance?: boolean; + usdcBalance?: number; + hasMusdBalance?: boolean; + musdBalance?: number; +} + /** * FixtureBuilder class provides a fluent interface for building fixture data. */ @@ -2293,6 +2306,97 @@ class FixtureBuilder { return this; } + /** + * Sets mUSD conversion fixture state: user flags, fiat orders, currency rates, + * and Mainnet token balances (USDC, optional MUSD) and native ETH for the default account. + * Call after withNetworkController, withTokensForAllPopularNetworks([ETH, USDC, MUSD?]), and withTokenRates. + * + * @param options - mUSD conversion options (education seen, USDC/MUSD balances). + * @returns {FixtureBuilder} - The FixtureBuilder instance for method chaining. + */ + withMusdConversion(options: MusdFixtureOptions) { + const USDC_DECIMALS = 6; + const MUSD_DECIMALS = 6; + const ETH_BALANCE_WEI = '0x' + (BigInt(10) * BigInt(10 ** 18)).toString(16); + + merge(this.fixture.state.user, { + musdConversionEducationSeen: options.musdConversionEducationSeen, + }); + + this.fixture.state.fiatOrders = this.fixture.state.fiatOrders ?? {}; + merge(this.fixture.state.fiatOrders, { + detectedGeolocation: 'US', + rampRoutingDecision: 'AGGREGATOR', + }); + + if (!this.fixture.state.engine.backgroundState.CurrencyRateController) { + merge(this.fixture.state.engine.backgroundState, { + CurrencyRateController: { currentCurrency: 'usd', currencyRates: {} }, + }); + } + merge(this.fixture.state.engine.backgroundState.CurrencyRateController, { + currentCurrency: 'usd', + currencyRates: { + ETH: { + conversionDate: Date.now() / 1000, + conversionRate: 3000.0, + usdConversionRate: 3000.0, + }, + }, + }); + + const ac = this.fixture.state.engine.backgroundState.AccountsController; + const accountId = ac?.internalAccounts?.selectedAccount; + const accountAddress = ac?.internalAccounts?.accounts?.[accountId]?.address; + if (!accountAddress) return this; + + const engine = this.fixture.state.engine.backgroundState; + if (!engine.AccountTrackerController) { + merge(engine, { + AccountTrackerController: { accounts: {}, accountsByChainId: {} }, + }); + } + const atc = engine.AccountTrackerController; + atc.accounts = atc.accounts ?? {}; + atc.accountsByChainId = atc.accountsByChainId ?? {}; + atc.accounts[accountAddress] = { balance: ETH_BALANCE_WEI }; + atc.accountsByChainId[CHAIN_IDS.MAINNET] = { + ...atc.accountsByChainId[CHAIN_IDS.MAINNET], + [accountAddress]: { balance: ETH_BALANCE_WEI }, + }; + + if (!engine.TokenBalancesController) { + merge(engine, { TokenBalancesController: { tokenBalances: {} } }); + } + engine.TokenBalancesController.tokenBalances = + engine.TokenBalancesController.tokenBalances ?? {}; + const tb = engine.TokenBalancesController.tokenBalances; + if (!tb[accountAddress]) tb[accountAddress] = {}; + if (!tb[accountAddress][CHAIN_IDS.MAINNET]) + tb[accountAddress][CHAIN_IDS.MAINNET] = {}; + const mainnetBalances = tb[accountAddress][CHAIN_IDS.MAINNET] as Record< + string, + string + >; + + if (options.hasUsdcBalance !== false) { + mainnetBalances[toChecksumHexAddress(USDC_MAINNET.toLowerCase())] = + '0x' + + Math.floor((options.usdcBalance ?? 100) * 10 ** USDC_DECIMALS).toString( + 16, + ); + } + if (options.hasMusdBalance) { + mainnetBalances[toChecksumHexAddress(MUSD_MAINNET.toLowerCase())] = + '0x' + + Math.floor((options.musdBalance ?? 10) * 10 ** MUSD_DECIMALS).toString( + 16, + ); + } + + return this; + } + /** * Build and return the fixture object. * @returns {Object} - The built fixture object. diff --git a/tests/page-objects/Trending/TrendingView.ts b/tests/page-objects/Trending/TrendingView.ts index dca82994412e..bae25f411507 100644 --- a/tests/page-objects/Trending/TrendingView.ts +++ b/tests/page-objects/Trending/TrendingView.ts @@ -1,4 +1,9 @@ -import { Matchers, Gestures, Assertions } from '../../framework'; +import { + Matchers, + Gestures, + Assertions, + type ScrollOptions, +} from '../../framework'; import { TrendingViewSelectorsIDs, SECTION_BACK_BUTTONS, @@ -111,14 +116,14 @@ class TrendingView { } /** - * Generic method to scroll to an element in the trending feed. - * This ensures elements are visible and hittable before interaction. - * Works regardless of section order changes. + * Scrolls the feed until the target element is visible (same pattern as WalletView.scrollToToken). + * Uses Gestures.scrollToElement which retries scroll + visibility check until the element is on screen. */ private async scrollToElementInFeed( targetElement: DetoxElement, description: string, direction: 'up' | 'down' = 'down', + options: Partial = {}, ): Promise { await Gestures.scrollToElement( targetElement, @@ -127,6 +132,7 @@ class TrendingView { direction, scrollAmount: 300, elemDescription: description, + ...options, }, ); } @@ -296,10 +302,8 @@ class TrendingView { } /** - * Generic method to tap on an item row with automatic scrolling. - * @param getElement - Function to get the element - * @param identifier - Item identifier (id, symbol, name, etc.) - * @param itemType - Type of item for description ('token', 'perp', 'prediction', 'site') + * Tap on an item row after scrolling until it is visible (same pattern as WalletView.scrollToToken + tap). + * Gestures.scrollToElement retries scroll until the element is visible. */ private async tapItemRow( getElement: () => DetoxElement, @@ -308,10 +312,15 @@ class TrendingView { ): Promise { const targetElement = getElement(); - // Use generic scroll method to ensure element is visible + // Sites section is typically lower in the feed; give scroll retry more time (same idea as WalletView.scrollDownToAssetOverviewMusdCta) + const scrollOptions: Partial = + itemType === 'site' ? { timeout: 15000 } : {}; + await this.scrollToElementInFeed( targetElement, `Scroll to ${identifier} ${itemType} row`, + 'down', + scrollOptions, ); await Gestures.tap(targetElement, { @@ -352,7 +361,17 @@ class TrendingView { } async verifySiteVisible(name: string): Promise { - await this.verifyItemVisible(() => this.getSiteRow(name), name, 'site'); + const siteRow = () => this.getSiteRow(name); + + // Scroll until Site row is visible (same pattern as WalletView.scrollDownToAssetOverviewMusdCta) + await this.scrollToElementInFeed( + siteRow(), + `Scroll to Site row for ${name}`, + 'down', + { timeout: 15000 }, + ); + + await this.verifyItemVisible(siteRow, name, 'site'); } async tapSiteRow(name: string): Promise { From 3b5ab86ce109638f003f3deb8c4def26bc9f762a Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Fri, 30 Jan 2026 15:26:53 +0000 Subject: [PATCH 10/17] chore: codeowners update for token details (#25440) ## **Description** Code owners update for new token details page. ## **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: only updates CODEOWNERS review ownership with no runtime code changes; impact is limited to PR review routing/enforcement for `TokenDetails`. > > **Overview** > Updates `.github/CODEOWNERS` to add `app/components/UI/TokenDetails` under **Assets Team** (`@MetaMask/metamask-assets`), ensuring code owner review is required for changes in that UI area. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 692dc037c459d64a162f8215250d57c45dce1c36. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9746a050c13b..4ee060b6b452 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -187,6 +187,7 @@ app/components/UI/CollectibleOverview @MetaMask/metamask-assets app/components/UI/ConfirmAddAsset @MetaMask/metamask-assets app/components/UI/DeFiPositions @MetaMask/metamask-assets app/components/UI/Tokens @MetaMask/metamask-assets +app/components/UI/TokenDetails @MetaMask/metamask-assets app/components/Views/AddAsset @MetaMask/metamask-assets app/components/Views/Asset @MetaMask/metamask-assets app/components/Views/AssetDetails @MetaMask/metamask-assets From 2cdcf0a2f0ecdf738588098d7b5679161651252e Mon Sep 17 00:00:00 2001 From: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> Date: Fri, 30 Jan 2026 12:35:05 -0300 Subject: [PATCH 11/17] chore: remove legacy accounts component code (pre BIP-44) (#24886) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR removes the usage of the selector `selectMultichainAccountsState2Enabled` which gets the value for the BIP-44 feature flag. Moving forward, BIP-44 is the default behaviour and any alternative logic branch will be remove alongside tests. The scope of the changes is limited to files inside the `app/components/UI/` directory. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/MUL-1382 ## **Manual testing steps** Not applicable ## **Screenshots/Recordings** Not applicable ## **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** > Removes legacy/flagged UI branches and changes user-visible behavior (e.g., `AddressCopy` no longer copies to clipboard and network selector defaults/sections change). Moderate risk of regressions in navigation, balance display, and tests due to widened unconditional paths. > > **Overview** > **Standardizes UI on multichain/BIP-44 behavior by removing `selectMultichainAccountsState2Enabled` gating.** > > `AddressCopy` is simplified to no longer accept an `account` prop and no longer performs clipboard/toast/metrics/protect-wallet logic; pressing the icon now always navigates to the multichain address list (updated call sites in `Navbar` and `AccountInfo`, plus a simplified test). > > Network selection UI is de-flagged: `NetworkManager`โ€™s initial tab selection is now derived solely from `enabledNetworksByNamespace`, `NetworkMultiSelector` always renders the custom network section for any namespace, and `NetworkMultiSelectorList` always prepends the โ€œselect all networksโ€ row when provided. > > `AssetOverview` drops the state2-specific balance shortcut and always uses `asset.balance` when present; tests are updated accordingly (mock selectors refactor, Solana balance expectation, and an updated โ€œno balanceโ€ scenario). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 0516e6e5042b8d567eca8d3dfe5d9fa31c1fcf88. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../UI/AddressCopy/AddressCopy.test.tsx | 25 +-- app/components/UI/AddressCopy/AddressCopy.tsx | 78 +------ .../UI/AddressCopy/AddressCopy.types.ts | 2 - .../UI/AssetOverview/AssetOverview.test.tsx | 201 +++++++----------- .../UI/AssetOverview/AssetOverview.tsx | 9 +- app/components/UI/Navbar/index.js | 5 +- .../UI/NetworkManager/index.test.tsx | 29 +-- app/components/UI/NetworkManager/index.tsx | 35 +-- .../NetworkMultiSelector.test.tsx | 48 +---- .../NetworkMultiSelector.tsx | 31 +-- .../NetworkMultiSelectorList.test.tsx | 10 +- .../NetworkMultiSelectorList.tsx | 11 +- .../components/AccountInfo/AccountInfo.tsx | 2 +- 13 files changed, 134 insertions(+), 352 deletions(-) diff --git a/app/components/UI/AddressCopy/AddressCopy.test.tsx b/app/components/UI/AddressCopy/AddressCopy.test.tsx index 824212b54814..b6ea48d087a5 100644 --- a/app/components/UI/AddressCopy/AddressCopy.test.tsx +++ b/app/components/UI/AddressCopy/AddressCopy.test.tsx @@ -1,11 +1,8 @@ import React from 'react'; -import { InternalAccount } from '@metamask/keyring-internal-api'; import AddressCopy from './AddressCopy'; import { WalletViewSelectorsIDs } from '../../Views/Wallet/WalletView.testIds'; import renderWithProvider from '../../../util/test/renderWithProvider'; -import { createMockInternalAccount } from '../../../util/test/accountsControllerTestUtils'; -import { ToastContext } from '../../../component-library/components/Toast'; // Mock navigation before importing renderWithProvider jest.mock('@react-navigation/native', () => ({ @@ -15,32 +12,18 @@ jest.mock('@react-navigation/native', () => ({ }), })); -const mockShowToast = jest.fn(); -const mockCloseToast = jest.fn(); -const mockToastRef = { - current: { showToast: mockShowToast, closeToast: mockCloseToast }, -}; - -const renderWithAddressCopy = (account: InternalAccount) => - renderWithProvider( - - - , - ); +const renderAddressCopy = () => renderWithProvider(); describe('AddressCopy', () => { beforeEach(() => { jest.clearAllMocks(); }); - it('renders correctly the component', () => { - const component = renderWithAddressCopy( - createMockInternalAccount('0xaddress', 'Account 1'), - ); + it('renders the copy button', () => { + const { getByTestId } = renderAddressCopy(); - expect(component).toBeDefined(); expect( - component.getByTestId(WalletViewSelectorsIDs.ACCOUNT_COPY_BUTTON), + getByTestId(WalletViewSelectorsIDs.ACCOUNT_COPY_BUTTON), ).toBeDefined(); }); }); diff --git a/app/components/UI/AddressCopy/AddressCopy.tsx b/app/components/UI/AddressCopy/AddressCopy.tsx index 1294670f87d3..20133f35c34d 100644 --- a/app/components/UI/AddressCopy/AddressCopy.tsx +++ b/app/components/UI/AddressCopy/AddressCopy.tsx @@ -1,6 +1,6 @@ // Third parties dependencies -import React, { useCallback, useContext } from 'react'; -import { useSelector, useDispatch } from 'react-redux'; +import React, { useCallback } from 'react'; +import { useSelector } from 'react-redux'; import { View } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { AccountGroupId } from '@metamask/account-api'; @@ -11,27 +11,15 @@ import { ButtonIconSize, IconName, } from '@metamask/design-system-react-native'; -import ClipboardManager from '../../../core/ClipboardManager'; -import { protectWalletModalVisible } from '../../../actions/user'; -import { - ToastContext, - ToastVariants, -} from '../../../component-library/components/Toast'; -import { IconName as ComponentLibraryIconName } from '../../../component-library/components/Icons/Icon'; import { strings } from '../../../../locales/i18n'; -import { MetaMetricsEvents } from '../../../core/Analytics'; import { useStyles } from '../../../component-library/hooks'; import { WalletViewSelectorsIDs } from '../../Views/Wallet/WalletView.testIds'; -import { selectMultichainAccountsState2Enabled } from '../../../selectors/featureFlagController/multichainAccounts/enabledMultichainAccounts'; import { selectSelectedAccountGroupId } from '../../../selectors/multichainAccounts/accountTreeController'; import { createAddressListNavigationDetails } from '../../Views/MultichainAccounts/AddressList'; // Internal dependencies import styleSheet from './AddressCopy.styles'; -import { useMetrics } from '../../../components/hooks/useMetrics'; -import { useTheme } from '../../../util/theme'; -import { getFormattedAddressFromInternalAccount } from '../../../core/Multichain/utils'; import type { AddressCopyProps } from './AddressCopy.types'; import { endTrace, @@ -40,59 +28,13 @@ import { TraceOperation, } from '../../../util/trace'; -const AddressCopy = ({ account, iconColor, hitSlop }: AddressCopyProps) => { +const AddressCopy = ({ iconColor, hitSlop }: AddressCopyProps) => { const { styles } = useStyles(styleSheet, {}); const { navigate } = useNavigation(); - const { colors } = useTheme(); - - const dispatch = useDispatch(); - const { trackEvent, createEventBuilder } = useMetrics(); - const { toastRef } = useContext(ToastContext); - const isMultichainAccountsState2Enabled = useSelector( - selectMultichainAccountsState2Enabled, - ); const selectedAccountGroupId = useSelector(selectSelectedAccountGroupId); - const handleProtectWalletModalVisible = useCallback( - () => dispatch(protectWalletModalVisible()), - [dispatch], - ); - - /** - * A string that represents the selected address - */ - - const copyAccountToClipboard = useCallback(async () => { - await ClipboardManager.setString( - getFormattedAddressFromInternalAccount(account), - ); - toastRef?.current?.showToast({ - variant: ToastVariants.Icon, - iconName: ComponentLibraryIconName.CheckBold, - iconColor: colors.accent03.dark, - backgroundColor: colors.accent03.normal, - labelOptions: [ - { label: strings('account_details.account_copied_to_clipboard') }, - ], - hasNoTimeout: false, - }); - setTimeout(() => handleProtectWalletModalVisible(), 2000); - - trackEvent( - createEventBuilder(MetaMetricsEvents.WALLET_COPIED_ADDRESS).build(), - ); - }, [ - account, - colors.accent03.dark, - colors.accent03.normal, - createEventBuilder, - handleProtectWalletModalVisible, - toastRef, - trackEvent, - ]); - - const navigateToAddressList = useCallback(() => { + const handleOnPress = useCallback(() => { // Start the trace before navigating to the address list to include the // navigation and render times in the trace. trace({ @@ -116,18 +58,6 @@ const AddressCopy = ({ account, iconColor, hitSlop }: AddressCopyProps) => { ); }, [navigate, selectedAccountGroupId]); - const handleOnPress = useCallback(() => { - if (isMultichainAccountsState2Enabled) { - navigateToAddressList(); - } else { - copyAccountToClipboard(); - } - }, [ - copyAccountToClipboard, - isMultichainAccountsState2Enabled, - navigateToAddressList, - ]); - return ( ({ - ...jest.requireActual('../../../selectors/accountsController'), - selectSelectedInternalAccount: jest.fn(), -})); - jest.mock('@metamask/controller-utils', () => ({ ...jest.requireActual('@metamask/controller-utils'), handleFetch: jest.fn(), })); -jest.mock( - '../../../selectors/multichainAccounts/accountTreeController', - () => ({ - ...jest.requireActual( - '../../../selectors/multichainAccounts/accountTreeController', - ), - selectSelectedAccountGroup: jest.fn(), - }), -); - jest.mock('./Balance', () => { /* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */ const React = require('react'); @@ -87,16 +72,6 @@ jest.mock('./Balance', () => { }; }); -jest.mock('../../../selectors/assets/assets-list', () => ({ - ...jest.requireActual('../../../selectors/assets/assets-list'), - selectTronResourcesBySelectedAccountGroup: jest.fn().mockReturnValue([]), -})); - -jest.mock('../../../selectors/multichainAccounts/accounts', () => ({ - ...jest.requireActual('../../../selectors/multichainAccounts/accounts'), - selectSelectedInternalAccountByScope: jest.fn(), -})); - const MOCK_CHAIN_ID = '0x1'; const mockInitialState = { @@ -258,13 +233,6 @@ jest.mock('../../../components/hooks/useMetrics', () => { }; }); -jest.mock( - '../../../selectors/featureFlagController/multichainAccounts', - () => ({ - selectMultichainAccountsState2Enabled: () => false, - }), -); - const mockAddPopularNetwork = jest .fn() .mockImplementation(() => Promise.resolve()); @@ -322,6 +290,37 @@ jest.mock('../Perps', () => ({ selectPerpsEnabledFlag: () => mockSelectPerpsEnabledFlag(), })); +const mockSelectSelectedInternalAccount = jest.fn(); +jest.mock('../../../selectors/accountsController', () => ({ + ...jest.requireActual('../../../selectors/accountsController'), + selectSelectedInternalAccount: () => mockSelectSelectedInternalAccount(), +})); + +const mockSelectSelectedInternalAccountByScope = jest.fn(); +jest.mock('../../../selectors/multichainAccounts/accounts', () => ({ + ...jest.requireActual('../../../selectors/multichainAccounts/accounts'), + selectSelectedInternalAccountByScope: () => + mockSelectSelectedInternalAccountByScope, +})); + +const mockSelectTronResourcesBySelectedAccountGroup = jest.fn(); +jest.mock('../../../selectors/assets/assets-list', () => ({ + ...jest.requireActual('../../../selectors/assets/assets-list'), + selectTronResourcesBySelectedAccountGroup: () => + mockSelectTronResourcesBySelectedAccountGroup(), +})); + +const mockSelectSelectedAccountGroup = jest.fn(); +jest.mock( + '../../../selectors/multichainAccounts/accountTreeController', + () => ({ + ...jest.requireActual( + '../../../selectors/multichainAccounts/accountTreeController', + ), + selectSelectedAccountGroup: () => mockSelectSelectedAccountGroup(), + }), +); + const asset = { balance: '400', balanceFiat: '1500', @@ -364,23 +363,18 @@ describe('AssetOverview', () => { isNonEvmAccount: false, }); - // Default selected internal account to an EVM account so token balance flow uses EVM path - const { selectSelectedInternalAccount } = jest.requireMock( - '../../../selectors/accountsController', - ); - selectSelectedInternalAccount.mockReturnValue({ + mockSelectSelectedInternalAccount.mockReturnValue({ address: MOCK_ADDRESS_2, type: 'eip155:eoa', }); - // Default mock for selectSelectedInternalAccountByScope - const { selectSelectedInternalAccountByScope } = jest.requireMock( - '../../../selectors/multichainAccounts/accounts', - ); - const mockGetAccountByScope = jest.fn().mockReturnValue({ + mockSelectSelectedInternalAccountByScope.mockReturnValue({ address: MOCK_ADDRESS_2, + type: 'eip155:eoa', }); - selectSelectedInternalAccountByScope.mockReturnValue(mockGetAccountByScope); + + // Default mock for tron resources - return empty array + mockSelectTronResourcesBySelectedAccountGroup.mockReturnValue([]); // Default mock for unified V1 flag - disabled mockUseRampsUnifiedV1Enabled.mockReturnValue(false); @@ -760,27 +754,11 @@ describe('AssetOverview', () => { }); it('should handle receive button press for EVM asset with EVM address', async () => { - // Arrange - Mock the selectors directly to ensure conditions are met - const { selectSelectedInternalAccount } = jest.requireMock( - '../../../selectors/accountsController', - ); - const { selectSelectedAccountGroup } = jest.requireMock( - '../../../selectors/multichainAccounts/accountTreeController', - ); - const { selectSelectedInternalAccountByScope } = jest.requireMock( - '../../../selectors/multichainAccounts/accounts', - ); + mockSelectSelectedAccountGroup.mockReturnValue({ id: 'group-id-123' }); - selectSelectedInternalAccount.mockReturnValue({ - address: MOCK_ADDRESS_2, - type: 'eip155:eoa', - }); - selectSelectedAccountGroup.mockReturnValue({ id: 'group-id-123' }); - - const mockGetAccountByScope = jest.fn().mockReturnValue({ + mockSelectSelectedInternalAccountByScope.mockReturnValue({ address: MOCK_ADDRESS_2, }); - selectSelectedInternalAccountByScope.mockReturnValue(mockGetAccountByScope); const { getByTestId } = renderWithProvider( { }), }, ); - - // Cleanup mocks for isolation - selectSelectedInternalAccount.mockReset(); - selectSelectedAccountGroup.mockReset(); - selectSelectedInternalAccountByScope.mockReset(); }); it('should track receive button click analytics with correct properties', async () => { // Arrange - Mock the selectors directly to ensure conditions are met - const { selectSelectedInternalAccount } = jest.requireMock( - '../../../selectors/accountsController', - ); - const { selectSelectedAccountGroup } = jest.requireMock( - '../../../selectors/multichainAccounts/accountTreeController', - ); - selectSelectedInternalAccount.mockReturnValue({ address: MOCK_ADDRESS_2 }); - selectSelectedAccountGroup.mockReturnValue({ id: 'group-id-123' }); + mockSelectSelectedInternalAccount.mockReturnValue({ + address: MOCK_ADDRESS_2, + }); + mockSelectSelectedAccountGroup.mockReturnValue({ id: 'group-id-123' }); const { getByTestId } = renderWithProvider( { // Verify trackEvent was called with the built event expect(mockTrackEvent).toHaveBeenCalledWith({ category: 'test' }); - - // Cleanup mocks for isolation - selectSelectedInternalAccount.mockReset(); - selectSelectedAccountGroup.mockReset(); }); it('should handle receive button press for Solana asset with Solana address', async () => { const SOLANA_ADDRESS = 'HN7cABqLq46Es1jh92dQQisAq662SmxELLLsHHe4YWrH'; const SOLANA_CHAIN_ID = SolScope.Mainnet; - const { selectSelectedInternalAccount } = jest.requireMock( - '../../../selectors/accountsController', - ); - const { selectSelectedAccountGroup } = jest.requireMock( - '../../../selectors/multichainAccounts/accountTreeController', - ); - const { selectSelectedInternalAccountByScope } = jest.requireMock( - '../../../selectors/multichainAccounts/accounts', - ); + mockSelectSelectedAccountGroup.mockReturnValue({ id: 'group-id-123' }); - selectSelectedInternalAccount.mockReturnValue({ - address: MOCK_ADDRESS_2, - type: 'eip155:eoa', - }); - selectSelectedAccountGroup.mockReturnValue({ id: 'group-id-123' }); - - const mockGetAccountByScope = jest.fn().mockReturnValue({ + mockSelectSelectedInternalAccountByScope.mockReturnValue({ address: SOLANA_ADDRESS, type: SolAccountType.DataAccount, }); - selectSelectedInternalAccountByScope.mockReturnValue(mockGetAccountByScope); const solanaAsset = { ...asset, @@ -929,11 +879,9 @@ describe('AssetOverview', () => { }, ); - expect(mockGetAccountByScope).toHaveBeenCalledWith(SOLANA_CHAIN_ID); - - selectSelectedInternalAccount.mockReset(); - selectSelectedAccountGroup.mockReset(); - selectSelectedInternalAccountByScope.mockReset(); + expect(mockSelectSelectedInternalAccountByScope).toHaveBeenCalledWith( + SOLANA_CHAIN_ID, + ); }); it('should not render swap button if displaySwapsButton is false', async () => { @@ -1060,11 +1008,7 @@ describe('AssetOverview', () => { }); it('renders staked TRX details when viewing TRX on Tron', () => { - const { selectTronResourcesBySelectedAccountGroup } = jest.requireMock( - '../../../selectors/assets/assets-list', - ); - - selectTronResourcesBySelectedAccountGroup.mockReturnValue([ + mockSelectTronResourcesBySelectedAccountGroup.mockReturnValue([ { symbol: 'strx-energy', balance: '10' }, { symbol: 'strx-bandwidth', balance: '20' }, ]); @@ -1623,36 +1567,49 @@ describe('AssetOverview', () => { const secondaryBalance = getByTestId(TOKEN_AMOUNT_BALANCE_TEST_ID); - // Should display formatted Solana balance - expect(secondaryBalance.props.children).toBe('123.45679 SOL'); + // Should display the balance directly (no truncation) + expect(secondaryBalance.props.children).toBe('123.456789 SOL'); }); }); it('should not render Balance component when balance is undefined', () => { - // Given an asset with undefined balance - const assetWithNoBalance = { + // Asset on a chain (0x999) that has no account data in AccountTrackerController + const assetOnUnknownChain = { ...asset, balance: undefined as unknown as string, + chainId: '0x999', // Chain not in AccountTrackerController.accountsByChainId + isETH: false, + isNative: false, }; - // Override the mock to enable state2 so balance stays undefined - const mockModule = jest.requireMock( - '../../../selectors/featureFlagController/multichainAccounts', - ); - const originalMock = mockModule.selectMultichainAccountsState2Enabled; - mockModule.selectMultichainAccountsState2Enabled = jest - .fn() - .mockReturnValue(true); + // State without any account data for chain 0x999 + const stateWithNoChainData = { + ...mockInitialState, + engine: { + ...mockInitialState.engine, + backgroundState: { + ...mockInitialState.engine.backgroundState, + AccountTrackerController: { + accountsByChainId: { + // No data for 0x999 + }, + }, + TokenBalancesController: { + tokenBalances: { + // No token balances for this account/chain + }, + }, + }, + }, + }; const { queryByTestId } = renderWithProvider( - , - { state: mockInitialState }, + , + { state: stateWithNoChainData }, ); + // Balance component should not render when balance cannot be determined expect(queryByTestId(BALANCE_TEST_ID)).toBeNull(); - - // Restore original mock - mockModule.selectMultichainAccountsState2Enabled = originalMock; }); describe('Exchange Rate Fetching', () => { diff --git a/app/components/UI/AssetOverview/AssetOverview.tsx b/app/components/UI/AssetOverview/AssetOverview.tsx index dbbdb5f3d6c2..8e9bb0429b17 100644 --- a/app/components/UI/AssetOverview/AssetOverview.tsx +++ b/app/components/UI/AssetOverview/AssetOverview.tsx @@ -101,7 +101,6 @@ import { } from '@metamask/bridge-controller'; import { InitSendLocation } from '../../Views/confirmations/constants/send'; import { useSendNavigation } from '../../Views/confirmations/hooks/useSendNavigation'; -import { selectMultichainAccountsState2Enabled } from '../../../selectors/featureFlagController/multichainAccounts'; import parseRampIntent from '../Ramp/utils/parseRampIntent'; ///: BEGIN:ONLY_INCLUDE_IF(tron) import TronEnergyBandwidthDetail from './TronEnergyBandwidthDetail/TronEnergyBandwidthDetail'; @@ -240,9 +239,6 @@ const AssetOverview: React.FC = ({ ); const multiChainTokenBalance = useSelector(selectTokensBalances); - const isMultichainAccountsState2Enabled = useSelector( - selectMultichainAccountsState2Enabled, - ); const chainId = asset.chainId as Hex; const selectedNetworkClientId = useSelector(selectSelectedNetworkClientId); @@ -604,8 +600,8 @@ const AssetOverview: React.FC = ({ const exchangeRate = marketDataRate ?? fetchedRate; let balance; - const minimumDisplayThreshold = 0.00001; + const minimumDisplayThreshold = 0.00001; const isMultichainAsset = isNonEvmAsset; const isEthOrNative = asset.isETH || asset.isNative; @@ -627,8 +623,7 @@ const AssetOverview: React.FC = ({ } ///: END:ONLY_INCLUDE_IF - if (isMultichainAccountsState2Enabled && balanceSource != null) { - // When state2 is enabled and asset has balance, use it directly + if (balanceSource != null) { balance = balanceSource; } else if (isMultichainAsset) { balance = balanceSource diff --git a/app/components/UI/Navbar/index.js b/app/components/UI/Navbar/index.js index fa1a06a65fa6..424dfc0e44cb 100644 --- a/app/components/UI/Navbar/index.js +++ b/app/components/UI/Navbar/index.js @@ -995,10 +995,7 @@ export function getWalletNavbarOptions( - + {shouldDisplayCardButton && ( ({ ), })); -// Mock feature flag selectors -jest.mock( - '../../../selectors/featureFlagController/multichainAccounts', - () => ({ - selectMultichainAccountsState2Enabled: jest.fn(() => false), - }), -); - jest.mock('../../../../locales/i18n', () => ({ strings: (key: string) => key, })); @@ -765,9 +757,16 @@ describe('NetworkManager Component', () => { expect(getByTestId('custom-network-selector')).toBeOnTheScreen(); }); - it('should set initial tab to popular networks when selectedCount > 0', () => { - (useNetworksByNamespace as jest.Mock).mockReturnValue({ - selectedCount: 3, + it('sets initial tab to popular networks when multiple networks are enabled', () => { + (useNetworkEnablement as jest.Mock).mockReturnValue({ + disableNetwork: mockDisableNetwork, + enableNetwork: mockEnableNetwork, + enabledNetworksByNamespace: { + eip155: { + '0x1': true, + '0x89': true, + }, + }, }); const { getByTestId } = renderComponent(); @@ -776,9 +775,11 @@ describe('NetworkManager Component', () => { expect(tabView.props.initialPage).toBe(0); // Popular tab }); - it('should set initial tab to custom networks when selectedCount is 0', () => { - (useNetworksByNamespace as jest.Mock).mockReturnValue({ - selectedCount: 0, + it('sets initial tab to custom networks when no networks are enabled', () => { + (useNetworkEnablement as jest.Mock).mockReturnValue({ + disableNetwork: mockDisableNetwork, + enableNetwork: mockEnableNetwork, + enabledNetworksByNamespace: {}, }); const { getByTestId } = renderComponent(); diff --git a/app/components/UI/NetworkManager/index.tsx b/app/components/UI/NetworkManager/index.tsx index ab8a1d32c869..1fbdcdae2fde 100644 --- a/app/components/UI/NetworkManager/index.tsx +++ b/app/components/UI/NetworkManager/index.tsx @@ -37,10 +37,6 @@ import Device from '../../../util/device'; import Routes from '../../../constants/navigation/Routes'; import { createNavigationDetails } from '../../../util/navigation/navUtils'; import { selectNetworkConfigurationsByCaipChainId } from '../../../selectors/networkController'; -import { - useNetworksByNamespace, - NetworkType, -} from '../../hooks/useNetworksByNamespace/useNetworksByNamespace'; import { useNetworkEnablement } from '../../hooks/useNetworkEnablement/useNetworkEnablement'; import { NETWORK_MULTI_SELECTOR_TEST_IDS } from '../NetworkMultiSelector/NetworkMultiSelector.constants'; @@ -51,7 +47,6 @@ import { ShowConfirmDeleteModalState, ShowMultiRpcSelectModalState, } from './index.types'; -import { selectMultichainAccountsState2Enabled } from '../../../selectors/featureFlagController/multichainAccounts'; import { POPULAR_NETWORK_CHAIN_IDS } from '../../../constants/popular-networks'; import RpcSelectionModal from '../../Views/NetworkSelector/RpcSelectionModal/RpcSelectionModal'; import { isNonEvmChainId } from '../../../core/Multichain/utils'; @@ -87,15 +82,8 @@ const NetworkManager = () => { const { colors } = useTheme(); const { styles } = useStyles(createStyles, { colors }); const { trackEvent, createEventBuilder, addTraitsToUser } = useMetrics(); - const { selectedCount } = useNetworksByNamespace({ - networkType: NetworkType.Popular, - }); const { disableNetwork, enabledNetworksByNamespace } = useNetworkEnablement(); - const isMultichainAccountsState2Enabled = useSelector( - selectMultichainAccountsState2Enabled, - ); - const enabledNetworks = useMemo(() => { function getEnabledNetworks( obj: Record>, @@ -331,20 +319,17 @@ const NetworkManager = () => { const defaultTabIndex = useMemo(() => { // If no popular networks are selected, default to custom tab (index 1) // Otherwise, show popular tab (index 0) - if (isMultichainAccountsState2Enabled) { - if (enabledNetworks.length === 1) { - const isPopularNetwork = POPULAR_NETWORK_CHAIN_IDS.has( - enabledNetworks[0] as `0x${string}`, - ) - ? 0 - : 1; - return isPopularNetwork; - } - - return enabledNetworks.length > 1 ? 0 : 1; + if (enabledNetworks.length === 1) { + const isPopularNetwork = POPULAR_NETWORK_CHAIN_IDS.has( + enabledNetworks[0] as `0x${string}`, + ) + ? 0 + : 1; + return isPopularNetwork; } - return selectedCount > 0 ? 0 : 1; - }, [selectedCount, isMultichainAccountsState2Enabled, enabledNetworks]); + + return enabledNetworks.length > 1 ? 0 : 1; + }, [enabledNetworks]); // Capture the initial tab index only once on first render // This prevents tab switching when networks are added/deleted diff --git a/app/components/UI/NetworkMultiSelector/NetworkMultiSelector.test.tsx b/app/components/UI/NetworkMultiSelector/NetworkMultiSelector.test.tsx index 1b3e432ee1fa..0eb1876f3a45 100644 --- a/app/components/UI/NetworkMultiSelector/NetworkMultiSelector.test.tsx +++ b/app/components/UI/NetworkMultiSelector/NetworkMultiSelector.test.tsx @@ -14,7 +14,6 @@ import { useNetworkSelection } from '../../hooks/useNetworkSelection/useNetworkS import { useNetworksToUse } from '../../hooks/useNetworksToUse/useNetworksToUse'; import NetworkMultiSelector from './NetworkMultiSelector'; import { NETWORK_MULTI_SELECTOR_TEST_IDS } from './NetworkMultiSelector.constants'; -import { selectMultichainAccountsState2Enabled } from '../../../selectors/featureFlagController/multichainAccounts/enabledMultichainAccounts'; import { selectSelectedInternalAccountByScope } from '../../../selectors/multichainAccounts/accounts'; import { InternalAccount } from '@metamask/keyring-internal-api'; import { useMetrics } from '../../hooks/useMetrics'; @@ -95,13 +94,6 @@ jest.mock('react-redux', () => ({ Provider: jest.requireActual('react-redux').Provider, })); -jest.mock( - '../../../selectors/featureFlagController/multichainAccounts/enabledMultichainAccounts', - () => ({ - selectMultichainAccountsState2Enabled: jest.fn(), - }), -); - jest.mock('../../../selectors/accountsController', () => ({ selectSelectedInternalAccountByScope: jest.fn(() => jest.fn()), selectInternalAccounts: jest.fn(), @@ -289,7 +281,6 @@ describe('NetworkMultiSelector', () => { return evmConfigs; if (selector === selectNonEvmNetworkConfigurationsByChainId) return nonEvmConfigs; - if (selector === selectMultichainAccountsState2Enabled) return true; if (selector === selectSelectedInternalAccountByScope) { return (scope: string) => { if (scope === 'eip155:0') return { id: 'evm-account' }; @@ -401,9 +392,6 @@ describe('NetworkMultiSelector', () => { }); mockUseSelector.mockImplementation((selector) => { - if (selector === selectMultichainAccountsState2Enabled) { - return true; - } if (selector === selectSelectedInternalAccountByScope) { return (scope: string) => { if (scope === 'eip155:0') { @@ -566,7 +554,7 @@ describe('NetworkMultiSelector', () => { ); }); - it('renders custom network component even for non-EIP155 namespace when multichain is enabled', () => { + it('renders custom network component for non-EIP155 namespace', () => { mockUseNetworkEnablement.mockReturnValue({ namespace: 'solana' as KnownCaipNamespace, enabledNetworksByNamespace: { solana: {} }, @@ -586,7 +574,7 @@ describe('NetworkMultiSelector', () => { ); const networkList = getByTestId('mock-network-multi-selector-list'); - // Since multichain is enabled, it should still render the custom network component + // Custom network component should always render expect(networkList.props.additionalNetworksComponent).toBeTruthy(); expect(networkList.props.additionalNetworksComponent.props.testID).toBe( NETWORK_MULTI_SELECTOR_TEST_IDS.CUSTOM_NETWORK_CONTAINER, @@ -800,9 +788,6 @@ describe('NetworkMultiSelector', () => { // Setup selector mock mockUseSelector.mockImplementation((selector) => { - if (selector === selectMultichainAccountsState2Enabled) { - return true; - } if (selector === selectSelectedInternalAccountByScope) { return (scope: string) => { if (scope === 'eip155:0') { @@ -882,9 +867,6 @@ describe('NetworkMultiSelector', () => { }); mockUseSelector.mockImplementation((selector) => { - if (selector === selectMultichainAccountsState2Enabled) { - return true; - } if (selector === selectSelectedInternalAccountByScope) { return (scope: string) => { if (scope.includes('solana')) { @@ -964,9 +946,6 @@ describe('NetworkMultiSelector', () => { }); mockUseSelector.mockImplementation((selector) => { - if (selector === selectMultichainAccountsState2Enabled) { - return true; - } if (selector === selectSelectedInternalAccountByScope) { return () => null; // No accounts selected } @@ -981,7 +960,7 @@ describe('NetworkMultiSelector', () => { expect(networkList.props.networks).toEqual(mockNetworks); }); - it('uses regular networks when multichain is disabled', () => { + it('uses regular networks from hook', () => { // Clear all mocks for clean state jest.clearAllMocks(); @@ -1018,7 +997,7 @@ describe('NetworkMultiSelector', () => { customNetworksToReset: [], }); - // Mock useNetworksToUse to return default networks when multichain disabled + // Mock useNetworksToUse to return default networks mockUseNetworksToUse.mockReturnValue({ networksToUse: mockNetworks, evmNetworks: [], @@ -1029,7 +1008,7 @@ describe('NetworkMultiSelector', () => { selectedSolanaAccount: null, selectedBitcoinAccount: null, selectedTronAccount: null, - isMultichainAccountsState2Enabled: false, + isMultichainAccountsState2Enabled: true, areAllNetworksSelectedCombined: false, areAllEvmNetworksSelected: false, areAllSolanaNetworksSelected: false, @@ -1038,9 +1017,6 @@ describe('NetworkMultiSelector', () => { }); mockUseSelector.mockImplementation((selector) => { - if (selector === selectMultichainAccountsState2Enabled) { - return false; - } if (selector === selectSelectedInternalAccountByScope) { return (scope: string) => { if (scope === 'eip155:0') { @@ -1101,9 +1077,6 @@ describe('NetworkMultiSelector', () => { // Override the selector for this specific test mockUseSelector.mockImplementation((selector) => { - if (selector === selectMultichainAccountsState2Enabled) { - return true; - } if (selector === selectSelectedInternalAccountByScope) { return (scope: string) => { if (scope === 'eip155:0') { @@ -2425,7 +2398,7 @@ describe('NetworkMultiSelector', () => { jest.clearAllMocks(); }); - it('renders custom network component for multichain enabled even with non-EIP155 namespace', () => { + it('always renders custom network component for any namespace', () => { mockUseNetworkEnablement.mockReturnValue({ namespace: 'solana' as KnownCaipNamespace, enabledNetworksByNamespace: { solana: {} }, @@ -2440,12 +2413,6 @@ describe('NetworkMultiSelector', () => { enabledNetworksForAllNamespaces: mockEnabledNetworks, }); - mockUseSelector - .mockReturnValueOnce(true) // isMultichainAccountsState2Enabled - .mockReturnValueOnce(() => null) // selectedEvmAccount - .mockReturnValueOnce(() => null) // selectedSolanaAccount - .mockReturnValueOnce(() => null); // selectedBitcoinAccount - const { getByTestId } = renderWithProvider( , ); @@ -2676,9 +2643,6 @@ describe('NetworkMultiSelector', () => { // Override the selector for this specific test mockUseSelector.mockImplementation((selector) => { - if (selector === selectMultichainAccountsState2Enabled) { - return true; - } if (selector === selectSelectedInternalAccountByScope) { return (scope: string) => { if (scope === 'eip155:0') { diff --git a/app/components/UI/NetworkMultiSelector/NetworkMultiSelector.tsx b/app/components/UI/NetworkMultiSelector/NetworkMultiSelector.tsx index 6914d6215740..8561e92a1962 100644 --- a/app/components/UI/NetworkMultiSelector/NetworkMultiSelector.tsx +++ b/app/components/UI/NetworkMultiSelector/NetworkMultiSelector.tsx @@ -99,11 +99,7 @@ const NetworkMultiSelector = ({ const selectedNonEvmChainId = useSelector(selectSelectedNonEvmNetworkChainId); const currentEvmChainId = useSelector(selectEvmChainId); - const { - networksToUse, - areAllNetworksSelectedCombined, - isMultichainAccountsState2Enabled, - } = useNetworksToUse({ + const { networksToUse, areAllNetworksSelectedCombined } = useNetworksToUse({ networks, networkType: NetworkType.Popular, areAllNetworksSelected, @@ -225,22 +221,15 @@ const NetworkMultiSelector = ({ ); const additionalNetworksComponent = useMemo( - () => - namespace === KnownCaipNamespace.Eip155 || - isMultichainAccountsState2Enabled ? ( - - - - ) : null, - [ - namespace, - customNetworkProps, - isMultichainAccountsState2Enabled, - styles.customNetworkContainer, - ], + () => ( + + + + ), + [customNetworkProps, styles.customNetworkContainer], ); const getNetworkName = useCallback( diff --git a/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.test.tsx b/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.test.tsx index b3eaf8183ee5..3f781a1e4842 100644 --- a/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.test.tsx +++ b/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.test.tsx @@ -92,13 +92,6 @@ jest.mock('../../../selectors/multichainNetworkController', () => ({ selectIsEvmNetworkSelected: jest.fn(), })); -jest.mock( - '../../../selectors/featureFlagController/multichainAccounts/index.ts', - () => ({ - selectMultichainAccountsState2Enabled: jest.fn(), - }), -); - jest.mock('../../../multichain-accounts/remote-feature-flag', () => ({ isMultichainAccountsRemoteFeatureEnabled: jest.fn(), MULTI_CHAIN_ACCOUNTS_FEATURE_VERSION_1: 'v1', @@ -254,8 +247,7 @@ describe('NetworkMultiSelectorList', () => { if (selector === mockSelectIsEvmNetworkSelected) { return true; } - // Default return for selectMultichainAccountsState2Enabled - return false; + return undefined; }); mockUseSafeAreaInsets.mockReturnValue({ diff --git a/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.tsx b/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.tsx index a9c0fdff13e5..d7a2bb58589b 100644 --- a/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.tsx +++ b/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.tsx @@ -59,7 +59,6 @@ import { import { selectEvmChainId } from '../../../selectors/networkController'; import { formatChainIdToCaip } from '@metamask/bridge-controller'; import { NETWORK_MULTI_SELECTOR_TEST_IDS } from '../NetworkMultiSelector/NetworkMultiSelector.constants'; -import { selectMultichainAccountsState2Enabled } from '../../../selectors/featureFlagController/multichainAccounts/index.ts'; import { getGasFeesSponsoredNetworkEnabled } from '../../../selectors/featureFlagController/gasFeesSponsored/index.ts'; import { strings } from '../../../../locales/i18n'; @@ -102,9 +101,6 @@ const NetworkMultiSelectList = ({ const selectedChainIdCaip = isEvmSelected ? formatChainIdToCaip(evmChainId) : (nonEvmChainId ?? formatChainIdToCaip(evmChainId)); - const isMultichainAccountsState2Enabled = useSelector( - selectMultichainAccountsState2Enabled, - ); const isGasFeesSponsoredNetworkEnabled = useSelector( getGasFeesSponsoredNetworkEnabled, ); @@ -142,10 +138,7 @@ const NetworkMultiSelectList = ({ data.push(...filteredNetworks); } - if ( - (selectAllNetworksComponent && isEvmSelected) || - isMultichainAccountsState2Enabled - ) { + if (selectAllNetworksComponent) { data.unshift({ id: SELECT_ALL_NETWORKS_SECTION_ID, type: NetworkListItemType.SelectAllNetworksListItem, @@ -166,8 +159,6 @@ const NetworkMultiSelectList = ({ processedNetworks, additionalNetworksComponent, selectAllNetworksComponent, - isEvmSelected, - isMultichainAccountsState2Enabled, ]); const debouncedSelectNetwork = useMemo( diff --git a/app/components/Views/MultichainAccounts/AccountDetails/components/AccountInfo/AccountInfo.tsx b/app/components/Views/MultichainAccounts/AccountDetails/components/AccountInfo/AccountInfo.tsx index 5963cd920470..b1518102ad69 100644 --- a/app/components/Views/MultichainAccounts/AccountDetails/components/AccountInfo/AccountInfo.tsx +++ b/app/components/Views/MultichainAccounts/AccountDetails/components/AccountInfo/AccountInfo.tsx @@ -48,7 +48,7 @@ export const AccountInfo = ({ account }: AccountInfoProps) => { {formatAddress(formattedAddress, 'short')} - + ); From a44c0c53ba5fd4cc450254246b33db1763b6be1a Mon Sep 17 00:00:00 2001 From: Juanmi <95381763+juanmigdr@users.noreply.github.com> Date: Fri, 30 Jan 2026 16:41:06 +0100 Subject: [PATCH 12/17] chore: removed usage of token-search-discovery-controller (#25435) ## **Description** The token-search-discovery-controller was used by mobile at some point to get data from the portfolio API, after investigating its use I concluded that this controller could be fully removed from the codebase. I will follow up with a PR that removes it from [core](https://github.com/MetaMask/core/pull/7789) ## **Changelog** CHANGELOG entry: removed usage of token-search-discovery-controller ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-2534 ## **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 cleanup that removes an unused controller and associated hook/tests; main risk is any remaining hidden runtime dependency on `TokenSearchDiscoveryController` now removed from Engine context/state and dependencies. > > **Overview** > Removes the legacy `TokenSearchDiscoveryController` integration end-to-end: its Engine initialization/context wiring, messenger + init module, selectors, and the `useTokenSearchDiscovery` hook/tests. > > Cleans up persisted/background state and logging snapshots to no longer include `TokenSearchDiscoveryController`, and drops the `@metamask/token-search-discovery-controller` dependency from `package.json`/`yarn.lock` (while keeping `TokenSearchDiscoveryDataController` in place). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 91b82ee6f56ea548f45662d468cb71808916c959. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .github/CODEOWNERS | 4 - .../useTokenSearch/constants.ts | 1 - .../useTokenSearch/useTokenSearch.test.ts | 141 ------------------ .../useTokenSearch/useTokenSearch.ts | 74 --------- app/core/Engine/Engine.ts | 7 - app/core/Engine/constants.ts | 1 - ...n-search-discovery-controller-init.test.ts | 51 ------- .../token-search-discovery-controller-init.ts | 54 ------- app/core/Engine/messengers/index.ts | 5 - ...rch-discovery-controller-messenger.test.ts | 30 ---- ...n-search-discovery-controller-messenger.ts | 29 ---- app/core/Engine/types.ts | 11 -- app/core/EngineService/EngineService.test.ts | 1 - .../tokenSearchDiscoveryController.test.ts | 46 ------ .../tokenSearchDiscoveryController.ts | 10 -- app/selectors/types.ts | 2 - .../logs/__snapshots__/index.test.ts.snap | 8 - app/util/test/initial-background-state.json | 4 - package.json | 1 - yarn.lock | 12 -- 20 files changed, 492 deletions(-) delete mode 100644 app/components/hooks/TokenSearchDiscovery/useTokenSearch/constants.ts delete mode 100644 app/components/hooks/TokenSearchDiscovery/useTokenSearch/useTokenSearch.test.ts delete mode 100644 app/components/hooks/TokenSearchDiscovery/useTokenSearch/useTokenSearch.ts delete mode 100644 app/core/Engine/controllers/token-search-discovery-controller-init.test.ts delete mode 100644 app/core/Engine/controllers/token-search-discovery-controller-init.ts delete mode 100644 app/core/Engine/messengers/token-search-discovery-controller-messenger.test.ts delete mode 100644 app/core/Engine/messengers/token-search-discovery-controller-messenger.ts delete mode 100644 app/selectors/tokenSearchDiscoveryController.test.ts delete mode 100644 app/selectors/tokenSearchDiscoveryController.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4ee060b6b452..9663c7a2fe6d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -123,10 +123,6 @@ app/components/Views/Settings/NotificationsSettings @MetaMask/notifications ses.cjs @MetaMask/supply-chain patches/react-native+0.*.patch @MetaMask/supply-chain -# Portfolio Team -app/components/hooks/useTokenSearchDiscovery @MetaMask/portfolio -app/core/Engine/controllers/TokenSearchDiscoveryController @MetaMask/portfolio - # Core Platform Team **/snaps/** @MetaMask/core-platform **/Snaps/** @MetaMask/core-platform diff --git a/app/components/hooks/TokenSearchDiscovery/useTokenSearch/constants.ts b/app/components/hooks/TokenSearchDiscovery/useTokenSearch/constants.ts deleted file mode 100644 index b28cb76199a6..000000000000 --- a/app/components/hooks/TokenSearchDiscovery/useTokenSearch/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const DISCOVERY_TOKENS_LIMIT = '50'; diff --git a/app/components/hooks/TokenSearchDiscovery/useTokenSearch/useTokenSearch.test.ts b/app/components/hooks/TokenSearchDiscovery/useTokenSearch/useTokenSearch.test.ts deleted file mode 100644 index 6de17a9b69b8..000000000000 --- a/app/components/hooks/TokenSearchDiscovery/useTokenSearch/useTokenSearch.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { act } from '@testing-library/react-hooks'; -import Engine from '../../../../core/Engine'; -import useTokenSearchDiscovery, { MAX_RESULTS } from './useTokenSearch'; -import { renderHookWithProvider } from '../../../../util/test/renderWithProvider'; - -jest.mock('../../../../core/Engine', () => ({ - context: { - TokenSearchDiscoveryController: { - searchSwappableTokens: jest.fn(), - }, - }, -})); - -const mockInitialState = { - engine: { - backgroundState: { - TokenSearchDiscoveryController: { - recentSearches: [], - lastSearchTimestamp: 0, - }, - RemoteFeatureFlagController: { - remoteFeatureFlags: { - tokenSearchDiscoveryEnabled: true, - }, - }, - }, - }, -}; - -describe('useTokenSearchDiscovery', () => { - beforeEach(() => { - jest.clearAllMocks(); - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useFakeTimers({ legacyFakeTimers: true }); - }); - - it('updates states correctly when searching tokens', async () => { - const mockSearchQuery = 'DAI'; - const mockSearchResult = [ - { name: 'DAI', tokenAddress: '0x123', chainId: '0x1' }, - ]; - - ( - Engine.context.TokenSearchDiscoveryController - .searchSwappableTokens as jest.Mock - ).mockResolvedValueOnce(mockSearchResult); - - const { result } = renderHookWithProvider(() => useTokenSearchDiscovery(), { - state: mockInitialState, - }); - - // Initial state - expect(result.current.isLoading).toBe(false); - expect(result.current.error).toBe(null); - expect(result.current.results).toEqual([]); - - // Call search - await act(async () => { - result.current.searchTokens(mockSearchQuery); - jest.advanceTimersByTime(300); - await Promise.resolve(); - }); - - // Final state - expect(result.current.isLoading).toBe(false); - expect(result.current.error).toBe(null); - expect(result.current.results).toEqual(mockSearchResult); - expect( - Engine.context.TokenSearchDiscoveryController.searchSwappableTokens, - ).toHaveBeenCalledWith({ query: mockSearchQuery, limit: MAX_RESULTS }); - }); - - it('does not search when less than two characters are queried', async () => { - const { result } = renderHookWithProvider(() => useTokenSearchDiscovery(), { - state: mockInitialState, - }); - await act(async () => { - result.current.searchTokens('a'); - jest.advanceTimersByTime(300); - await Promise.resolve(); - }); - - expect( - Engine.context.TokenSearchDiscoveryController.searchSwappableTokens, - ).not.toHaveBeenCalled(); - }); - - it('resets the state when reset() is called', async () => { - const mockSearchResult = [ - { name: 'DAI', tokenAddress: '0x123', chainId: '0x1' }, - ]; - - ( - Engine.context.TokenSearchDiscoveryController - .searchSwappableTokens as jest.Mock - ).mockResolvedValueOnce(mockSearchResult); - - const { result } = renderHookWithProvider(() => useTokenSearchDiscovery(), { - state: mockInitialState, - }); - - await act(async () => { - result.current.searchTokens('doge'); - jest.advanceTimersByTime(300); - await Promise.resolve(); - }); - - expect(result.current.results).toEqual(mockSearchResult); - - await act(async () => { - result.current.reset(); - }); - - expect(result.current.results).toEqual([]); - }); - - it('returns error and empty results if search failed', async () => { - const mockError = new Error('Search failed'); - ( - Engine.context.TokenSearchDiscoveryController - .searchSwappableTokens as jest.Mock - ).mockRejectedValueOnce(mockError); - - const { result } = renderHookWithProvider(() => useTokenSearchDiscovery(), { - state: mockInitialState, - }); - - await act(async () => { - result.current.searchTokens('doge'); - jest.advanceTimersByTime(300); - await Promise.resolve(); - }); - - expect(result.current.isLoading).toBe(false); - expect(result.current.error).toEqual(mockError); - expect(result.current.results).toEqual([]); - }); -}); diff --git a/app/components/hooks/TokenSearchDiscovery/useTokenSearch/useTokenSearch.ts b/app/components/hooks/TokenSearchDiscovery/useTokenSearch/useTokenSearch.ts deleted file mode 100644 index 73d4b6a8bcfc..000000000000 --- a/app/components/hooks/TokenSearchDiscovery/useTokenSearch/useTokenSearch.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { useState, useRef, useMemo, useCallback } from 'react'; -import { useSelector } from 'react-redux'; -import { debounce } from 'lodash'; -import Engine from '../../../../core/Engine'; -import { selectRecentTokenSearches } from '../../../../selectors/tokenSearchDiscoveryController'; -import { TokenSearchResponseItem } from '@metamask/token-search-discovery-controller'; -import { tokenSearchDiscoveryEnabled } from '../../../../selectors/featureFlagController/tokenSearchDiscovery'; - -const SEARCH_DEBOUNCE_DELAY = 250; -const MINIMUM_QUERY_LENGTH = 2; -export const MAX_RESULTS = '20'; - -export const useTokenSearchDiscovery = () => { - const recentSearches = useSelector(selectRecentTokenSearches); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const [results, setResults] = useState([]); - const latestRequestId = useRef(0); - const tokenSearchEnabled = useSelector(tokenSearchDiscoveryEnabled); - - const searchTokens = useMemo( - () => - debounce(async (query: string) => { - setIsLoading(true); - setError(null); - - if (query.length < MINIMUM_QUERY_LENGTH || !tokenSearchEnabled) { - setResults([]); - setIsLoading(false); - return; - } - - const requestId = ++latestRequestId.current; - - try { - const { TokenSearchDiscoveryController } = Engine.context; - const result = - await TokenSearchDiscoveryController.searchSwappableTokens({ - query, - limit: MAX_RESULTS, - }); - if (requestId === latestRequestId.current) { - setResults(result); - } - } catch (err) { - if (requestId === latestRequestId.current) { - setError(err as Error); - } - } finally { - if (requestId === latestRequestId.current) { - setIsLoading(false); - } - } - }, SEARCH_DEBOUNCE_DELAY), - [tokenSearchEnabled], - ); - - const reset = useCallback(() => { - setResults([]); - setError(null); - setIsLoading(false); - }, []); - - return { - searchTokens, - recentSearches, - isLoading, - error, - results, - reset, - }; -}; - -export default useTokenSearchDiscovery; diff --git a/app/core/Engine/Engine.ts b/app/core/Engine/Engine.ts index 6661aa8fc3cc..f414d48fc48c 100644 --- a/app/core/Engine/Engine.ts +++ b/app/core/Engine/Engine.ts @@ -148,7 +148,6 @@ import { tokenSearchDiscoveryDataControllerInit } from './controllers/token-sear import { assetsContractControllerInit } from './controllers/assets-contract-controller-init'; import { tokensControllerInit } from './controllers/tokens-controller-init'; import { tokenListControllerInit } from './controllers/token-list-controller-init'; -import { tokenSearchDiscoveryControllerInit } from './controllers/token-search-discovery-controller-init'; import { tokenDetectionControllerInit } from './controllers/token-detection-controller-init'; import { tokenBalancesControllerInit } from './controllers/token-balances-controller-init'; import { tokenRatesControllerInit } from './controllers/token-rates-controller-init'; @@ -316,7 +315,6 @@ export class Engine { TokenRatesController: tokenRatesControllerInit, TokenListController: tokenListControllerInit, TokenDetectionController: tokenDetectionControllerInit, - TokenSearchDiscoveryController: tokenSearchDiscoveryControllerInit, TokenSearchDiscoveryDataController: tokenSearchDiscoveryDataControllerInit, DeFiPositionsController: defiPositionsControllerInit, @@ -423,8 +421,6 @@ export class Engine { const tokenRatesController = controllersByName.TokenRatesController; const tokenListController = controllersByName.TokenListController; const tokenDetectionController = controllersByName.TokenDetectionController; - const tokenSearchDiscoveryController = - controllersByName.TokenSearchDiscoveryController; const tokenSearchDiscoveryDataController = controllersByName.TokenSearchDiscoveryDataController; const bridgeController = controllersByName.BridgeController; @@ -511,7 +507,6 @@ export class Engine { RemoteFeatureFlagController: remoteFeatureFlagController, SelectedNetworkController: selectedNetworkController, SignatureController: signatureController, - TokenSearchDiscoveryController: tokenSearchDiscoveryController, LoggingController: loggingController, ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) CronjobController: cronjobController, @@ -1306,7 +1301,6 @@ export default { TokenListController, TokenRatesController, TokensController, - TokenSearchDiscoveryController, TokenSearchDiscoveryDataController, TransactionController, TransactionPayController, @@ -1373,7 +1367,6 @@ export default { TokenListController: TokenListController.state, TokenRatesController: TokenRatesController.state, TokensController: TokensController.state, - TokenSearchDiscoveryController: TokenSearchDiscoveryController.state, TokenSearchDiscoveryDataController: TokenSearchDiscoveryDataController.state, TransactionController: TransactionController.state, diff --git a/app/core/Engine/constants.ts b/app/core/Engine/constants.ts index ecbca20acec8..ef506dade9a2 100644 --- a/app/core/Engine/constants.ts +++ b/app/core/Engine/constants.ts @@ -47,7 +47,6 @@ export const BACKGROUND_STATE_CHANGE_EVENT_NAMES = [ 'TokenListController:stateChange', 'TokenRatesController:stateChange', 'TokensController:stateChange', - 'TokenSearchDiscoveryController:stateChange', 'TokenSearchDiscoveryDataController:stateChange', 'TransactionController:stateChange', 'TransactionPayController:stateChange', diff --git a/app/core/Engine/controllers/token-search-discovery-controller-init.test.ts b/app/core/Engine/controllers/token-search-discovery-controller-init.test.ts deleted file mode 100644 index b2801ce7337b..000000000000 --- a/app/core/Engine/controllers/token-search-discovery-controller-init.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { buildControllerInitRequestMock } from '../utils/test-utils'; -import { ExtendedMessenger } from '../../ExtendedMessenger'; -import { getTokenSearchDiscoveryControllerMessenger } from '../messengers/token-search-discovery-controller-messenger'; -import { ControllerInitRequest } from '../types'; -import { tokenSearchDiscoveryControllerInit } from './token-search-discovery-controller-init'; -import { - TokenSearchDiscoveryController, - TokenDiscoveryApiService, - TokenSearchApiService, - TokenSearchDiscoveryControllerMessenger, -} from '@metamask/token-search-discovery-controller'; -import { MOCK_ANY_NAMESPACE, MockAnyNamespace } from '@metamask/messenger'; - -jest.mock('@metamask/token-search-discovery-controller'); - -function getInitRequestMock(): jest.Mocked< - ControllerInitRequest -> { - const baseMessenger = new ExtendedMessenger({ - namespace: MOCK_ANY_NAMESPACE, - }); - - const requestMock = { - ...buildControllerInitRequestMock(baseMessenger), - controllerMessenger: - getTokenSearchDiscoveryControllerMessenger(baseMessenger), - initMessenger: undefined, - }; - - return requestMock; -} - -describe('TokenSearchDiscoveryControllerInit', () => { - it('initializes the controller', () => { - const { controller } = - tokenSearchDiscoveryControllerInit(getInitRequestMock()); - expect(controller).toBeInstanceOf(TokenSearchDiscoveryController); - }); - - it('passes the proper arguments to the controller', () => { - tokenSearchDiscoveryControllerInit(getInitRequestMock()); - - const controllerMock = jest.mocked(TokenSearchDiscoveryController); - expect(controllerMock).toHaveBeenCalledWith({ - messenger: expect.any(Object), - state: undefined, - tokenSearchService: expect.any(TokenSearchApiService), - tokenDiscoveryService: expect.any(TokenDiscoveryApiService), - }); - }); -}); diff --git a/app/core/Engine/controllers/token-search-discovery-controller-init.ts b/app/core/Engine/controllers/token-search-discovery-controller-init.ts deleted file mode 100644 index ff7a8c994669..000000000000 --- a/app/core/Engine/controllers/token-search-discovery-controller-init.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { ControllerInitFunction } from '../types'; -import { - TokenDiscoveryApiService, - TokenSearchApiService, - TokenSearchDiscoveryController, - type TokenSearchDiscoveryControllerMessenger, -} from '@metamask/token-search-discovery-controller'; - -const PORTFOLIO_API_URL = { - dev: 'https://portfolio.dev-api.cx.metamask.io/', - prod: 'https://portfolio.api.cx.metamask.io/', -}; - -const getPortfolioApiBaseUrl = () => { - const env = process.env.METAMASK_ENVIRONMENT; - switch (env) { - case 'dev': - case 'e2e': - return PORTFOLIO_API_URL.dev; - case 'pre-release': - case 'production': - case 'beta': - case 'rc': - case 'exp': - return PORTFOLIO_API_URL.prod; - default: - return PORTFOLIO_API_URL.dev; - } -}; - -/** - * Initialize the token search discovery controller. - * - * @param request - The request object. - * @param request.controllerMessenger - The messenger to use for the controller. - * @returns The initialized controller. - */ -export const tokenSearchDiscoveryControllerInit: ControllerInitFunction< - TokenSearchDiscoveryController, - TokenSearchDiscoveryControllerMessenger -> = ({ controllerMessenger, persistedState }) => { - const baseUrl = getPortfolioApiBaseUrl(); - - const controller = new TokenSearchDiscoveryController({ - messenger: controllerMessenger, - state: persistedState.TokenSearchDiscoveryController, - tokenSearchService: new TokenSearchApiService(baseUrl), - tokenDiscoveryService: new TokenDiscoveryApiService(baseUrl), - }); - - return { - controller, - }; -}; diff --git a/app/core/Engine/messengers/index.ts b/app/core/Engine/messengers/index.ts index 6347b1a9948f..8ebe67bf76b1 100644 --- a/app/core/Engine/messengers/index.ts +++ b/app/core/Engine/messengers/index.ts @@ -86,7 +86,6 @@ import { getTokenListControllerInitMessenger, getTokenListControllerMessenger, } from './token-list-controller-messenger'; -import { getTokenSearchDiscoveryControllerMessenger } from './token-search-discovery-controller-messenger'; import { getTokenDetectionControllerInitMessenger, getTokenDetectionControllerMessenger, @@ -408,10 +407,6 @@ export const CONTROLLER_MESSENGERS = { getMessenger: getTokenRatesControllerMessenger, getInitMessenger: noop, }, - TokenSearchDiscoveryController: { - getMessenger: getTokenSearchDiscoveryControllerMessenger, - getInitMessenger: noop, - }, TokenSearchDiscoveryDataController: { getMessenger: getTokenSearchDiscoveryDataControllerMessenger, getInitMessenger: noop, diff --git a/app/core/Engine/messengers/token-search-discovery-controller-messenger.test.ts b/app/core/Engine/messengers/token-search-discovery-controller-messenger.test.ts deleted file mode 100644 index 7b86637619ad..000000000000 --- a/app/core/Engine/messengers/token-search-discovery-controller-messenger.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { - Messenger, - type MessengerActions, - type MessengerEvents, - type MockAnyNamespace, - MOCK_ANY_NAMESPACE, -} from '@metamask/messenger'; -import { TokenSearchDiscoveryControllerMessenger } from '@metamask/token-search-discovery-controller'; -import { getTokenSearchDiscoveryControllerMessenger } from './token-search-discovery-controller-messenger'; - -type RootMessenger = Messenger< - MockAnyNamespace, - MessengerActions, - MessengerEvents ->; - -const getRootMessenger = (): RootMessenger => - new Messenger({ - namespace: MOCK_ANY_NAMESPACE, - }); - -describe('getTokenSearchDiscoveryControllerMessenger', () => { - it('returns a messenger', () => { - const messenger = getRootMessenger(); - const tokenSearchDiscoveryControllerMessenger = - getTokenSearchDiscoveryControllerMessenger(messenger); - - expect(tokenSearchDiscoveryControllerMessenger).toBeInstanceOf(Messenger); - }); -}); diff --git a/app/core/Engine/messengers/token-search-discovery-controller-messenger.ts b/app/core/Engine/messengers/token-search-discovery-controller-messenger.ts deleted file mode 100644 index 19e27fdcbc8b..000000000000 --- a/app/core/Engine/messengers/token-search-discovery-controller-messenger.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { - Messenger, - MessengerActions, - MessengerEvents, -} from '@metamask/messenger'; -import { type TokenSearchDiscoveryControllerMessenger } from '@metamask/token-search-discovery-controller'; -import { RootMessenger } from '../types'; - -/** - * Get the messenger for the token search discovery controller. This is scoped to the - * actions and events that the token search discovery controller is allowed to handle. - * - * @param rootMessenger - The root messenger. - * @returns The TokenSearchDiscoveryControllerMessenger. - */ -export function getTokenSearchDiscoveryControllerMessenger( - rootMessenger: RootMessenger, -): TokenSearchDiscoveryControllerMessenger { - const messenger = new Messenger< - 'TokenSearchDiscoveryController', - MessengerActions, - MessengerEvents, - RootMessenger - >({ - namespace: 'TokenSearchDiscoveryController', - parent: rootMessenger, - }); - return messenger; -} diff --git a/app/core/Engine/types.ts b/app/core/Engine/types.ts index c694aecbce97..9e9197151b99 100644 --- a/app/core/Engine/types.ts +++ b/app/core/Engine/types.ts @@ -267,12 +267,6 @@ import { ActionConstraint, EventConstraint, } from '@metamask/messenger'; -import { - TokenSearchDiscoveryController, - TokenSearchDiscoveryControllerState, - TokenSearchDiscoveryControllerActions, - TokenSearchDiscoveryControllerEvents, -} from '@metamask/token-search-discovery-controller'; import { SnapKeyringEvents } from '@metamask/eth-snap-keyring'; import { MultichainNetworkController, @@ -500,7 +494,6 @@ type GlobalActions = | SmartTransactionsControllerActions | AssetsContractControllerActions | RemoteFeatureFlagControllerActions - | TokenSearchDiscoveryControllerActions | TokenSearchDiscoveryDataControllerActions | MultichainNetworkControllerActions | BridgeControllerActions @@ -577,7 +570,6 @@ type GlobalEvents = | SmartTransactionsControllerEvents | AssetsContractControllerEvents | RemoteFeatureFlagControllerEvents - | TokenSearchDiscoveryControllerEvents | TokenSearchDiscoveryDataControllerEvents | SnapKeyringEvents | MultichainNetworkControllerEvents @@ -665,7 +657,6 @@ export type Controllers = { TokenListController: TokenListController; TokenDetectionController: TokenDetectionController; TokenRatesController: TokenRatesController; - TokenSearchDiscoveryController: TokenSearchDiscoveryController; TokensController: TokensController; DeFiPositionsController: DeFiPositionsController; TransactionController: TransactionController; @@ -743,7 +734,6 @@ export type EngineState = { PhishingController: PhishingControllerState; TokenBalancesController: TokenBalancesControllerState; TokenRatesController: TokenRatesControllerState; - TokenSearchDiscoveryController: TokenSearchDiscoveryControllerState; TransactionController: TransactionControllerState; TransactionPayController: TransactionPayControllerState; SmartTransactionsController: SmartTransactionsControllerState; @@ -877,7 +867,6 @@ export type ControllersToInitialize = | 'TokenListController' | 'TokenRatesController' | 'TokensController' - | 'TokenSearchDiscoveryController' | 'TokenSearchDiscoveryDataController' | 'TransactionController' | 'TransactionPayController' diff --git a/app/core/EngineService/EngineService.test.ts b/app/core/EngineService/EngineService.test.ts index 3698d79e5c56..bb8b11a7755f 100644 --- a/app/core/EngineService/EngineService.test.ts +++ b/app/core/EngineService/EngineService.test.ts @@ -164,7 +164,6 @@ jest.mock('../Engine', () => { SelectedNetworkController: { subscribe: jest.fn() }, SnapInterfaceController: { subscribe: jest.fn() }, SignatureController: { subscribe: jest.fn() }, - TokenSearchDiscoveryController: { subscribe: jest.fn() }, MultichainBalancesController: { subscribe: jest.fn() }, RatesController: { subscribe: jest.fn() }, }, diff --git a/app/selectors/tokenSearchDiscoveryController.test.ts b/app/selectors/tokenSearchDiscoveryController.test.ts deleted file mode 100644 index a9b946526ca7..000000000000 --- a/app/selectors/tokenSearchDiscoveryController.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { RootState } from '../reducers'; -import { selectRecentTokenSearches } from './tokenSearchDiscoveryController'; - -describe('Token Search Discovery Controller Selectors', () => { - const mockRecentSearches = ['ETH', 'USDC', 'DAI']; - - const mockState = { - engine: { - backgroundState: { - TokenSearchDiscoveryController: { - recentSearches: mockRecentSearches, - }, - }, - }, - } as unknown as RootState; - - describe('selectRecentTokenSearches', () => { - it('returns recent token searches from state', () => { - expect(selectRecentTokenSearches(mockState)).toEqual(mockRecentSearches); - }); - - it('returns empty array when no recent searches exist', () => { - const stateWithoutSearches = { - engine: { - backgroundState: { - TokenSearchDiscoveryController: { - recentSearches: [], - }, - }, - }, - } as unknown as RootState; - - expect(selectRecentTokenSearches(stateWithoutSearches)).toEqual([]); - }); - - it('returns empty array when TokenSearchDiscoveryController is not initialized', () => { - const stateWithoutController = { - engine: { - backgroundState: {}, - }, - } as unknown as RootState; - - expect(selectRecentTokenSearches(stateWithoutController)).toEqual([]); - }); - }); -}); diff --git a/app/selectors/tokenSearchDiscoveryController.ts b/app/selectors/tokenSearchDiscoveryController.ts deleted file mode 100644 index 4aff774c7b58..000000000000 --- a/app/selectors/tokenSearchDiscoveryController.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createSelector } from 'reselect'; -import { RootState } from '../reducers'; - -const selectTokenSearchDiscoveryControllerState = (state: RootState) => - state.engine.backgroundState.TokenSearchDiscoveryController; - -export const selectRecentTokenSearches = createSelector( - selectTokenSearchDiscoveryControllerState, - (state) => state?.recentSearches ?? [], -); diff --git a/app/selectors/types.ts b/app/selectors/types.ts index a22867dc35a8..49bd50376125 100644 --- a/app/selectors/types.ts +++ b/app/selectors/types.ts @@ -18,7 +18,6 @@ import { TransactionControllerState } from '@metamask/transaction-controller'; import { GasFeeController } from '@metamask/gas-fee-controller'; import { ApprovalControllerState } from '@metamask/approval-controller'; import { AccountsControllerState } from '@metamask/accounts-controller'; -import { TokenSearchDiscoveryControllerState } from '@metamask/token-search-discovery-controller'; import { AccountTreeControllerState } from '@metamask/account-tree-controller'; ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) import { SnapController } from '@metamask/snaps-controllers'; @@ -49,7 +48,6 @@ export interface EngineState { ApprovalController: ApprovalControllerState; AccountsController: AccountsControllerState; AccountTreeController: AccountTreeControllerState; - TokenSearchDiscoveryController: TokenSearchDiscoveryControllerState; }; }; } diff --git a/app/util/logs/__snapshots__/index.test.ts.snap b/app/util/logs/__snapshots__/index.test.ts.snap index b9d0a1275c4e..7a78989b518a 100644 --- a/app/util/logs/__snapshots__/index.test.ts.snap +++ b/app/util/logs/__snapshots__/index.test.ts.snap @@ -765,10 +765,6 @@ exports[`logs :: generateStateLogs Sanitized SeedlessOnboardingController State "TokenRatesController": { "marketData": {}, }, - "TokenSearchDiscoveryController": { - "lastSearchTimestamp": null, - "recentSearches": [], - }, "TokenSearchDiscoveryDataController": { "tokenDisplayData": [], }, @@ -1544,10 +1540,6 @@ exports[`logs :: generateStateLogs generates a valid json export 1`] = ` "TokenRatesController": { "marketData": {}, }, - "TokenSearchDiscoveryController": { - "lastSearchTimestamp": null, - "recentSearches": [], - }, "TokenSearchDiscoveryDataController": { "tokenDisplayData": [], }, diff --git a/app/util/test/initial-background-state.json b/app/util/test/initial-background-state.json index 293f8a854240..60746f2f9b81 100644 --- a/app/util/test/initial-background-state.json +++ b/app/util/test/initial-background-state.json @@ -342,10 +342,6 @@ "TokenRatesController": { "marketData": {} }, - "TokenSearchDiscoveryController": { - "lastSearchTimestamp": null, - "recentSearches": [] - }, "TokenSearchDiscoveryDataController": { "tokenDisplayData": [] }, diff --git a/package.json b/package.json index 525279c9752e..4cf9f0dfe420 100644 --- a/package.json +++ b/package.json @@ -297,7 +297,6 @@ "@metamask/storage-service": "^0.0.1", "@metamask/swappable-obj-proxy": "^2.1.0", "@metamask/swaps-controller": "^15.0.0", - "@metamask/token-search-discovery-controller": "^4.0.0", "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.9.0#~/.yarn/patches/@metamask-transaction-controller-npm-62.9.0-5c8f871530.patch", "@metamask/transaction-pay-controller": "^12.0.2", "@metamask/tron-wallet-snap": "^1.19.2", diff --git a/yarn.lock b/yarn.lock index 9ff79e9c8d24..98a08efec24a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9678,17 +9678,6 @@ __metadata: languageName: node linkType: hard -"@metamask/token-search-discovery-controller@npm:^4.0.0": - version: 4.0.0 - resolution: "@metamask/token-search-discovery-controller@npm:4.0.0" - dependencies: - "@metamask/base-controller": "npm:^9.0.0" - "@metamask/messenger": "npm:^0.3.0" - "@metamask/utils": "npm:^11.8.1" - checksum: 10/0e4f067acc872bd538db41ec07e55104d641caa522565c2a55c8be85bffcf6368ac2c24205c2e0b55230c73f36e9793c4366f3551edad69291e375f18b84a64f - languageName: node - linkType: hard - "@metamask/toprf-secure-backup@npm:^0.10.0": version: 0.10.1 resolution: "@metamask/toprf-secure-backup@npm:0.10.1" @@ -34826,7 +34815,6 @@ __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/token-search-discovery-controller": "npm:^4.0.0" "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.9.0#~/.yarn/patches/@metamask-transaction-controller-npm-62.9.0-5c8f871530.patch" "@metamask/transaction-pay-controller": "npm:^12.0.2" "@metamask/tron-wallet-snap": "npm:^1.19.2" From 971cfc4919f16a5f85875ff20d54cb0b87101138 Mon Sep 17 00:00:00 2001 From: Juanmi <95381763+juanmigdr@users.noreply.github.com> Date: Fri, 30 Jan 2026 16:42:39 +0100 Subject: [PATCH 13/17] feat: added deeplinking to the NFT screen (#25426) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The marketing team has asked for a deeplink to the NFTs screen/tab. After researching other components I found two kind of behaviors (taking users to the NFTs tab or taking users to the NFTs screen) and raised the question to the marketing team: image Here is the video I attached them (Sound ON): https://github.com/user-attachments/assets/f7b563c2-8d54-4e77-8297-662469828a6e Here is their response: image Therefore we will be navigating to the NFTs screen (not tab) whenever the deeplink is opened ## **Changelog** CHANGELOG entry: added deeplinking to the NFT screen ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-2570 ## **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/a826ef60-6f5c-4493-8616-c68d739d69c8 ## **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 deeplink route and handler that only performs in-app navigation plus analytics route mapping, with tests covering the new handlerโ€™s error paths. > > **Overview** > Adds support for the `/nft` universal link action so marketing links like `https://link.metamask.io/nft` navigate directly to the NFTs full view. > > Wires the new `ACTIONS.NFT` through `handleUniversalLink`, the supported-action/type lists, and deep-link analytics route extraction/mapping (no sensitive params extracted). Includes unit tests for `handleNftUrl`, including fallback navigation to `Routes.WALLET.HOME` on errors. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit c77e32ba62815e1c37bae355bcb2a52f2a01133e. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/constants/deeplinks.ts | 2 + .../legacy/__tests__/handleNftUrl.test.ts | 91 +++++++++++++++++++ .../handlers/legacy/handleNftUrl.ts | 31 +++++++ .../handlers/legacy/handleUniversalLink.ts | 6 ++ .../DeeplinkManager/types/deepLink.types.ts | 1 + .../types/deepLinkAnalytics.types.ts | 1 + .../util/deeplinks/deepLinkAnalytics.ts | 17 ++++ 7 files changed, 149 insertions(+) create mode 100644 app/core/DeeplinkManager/handlers/legacy/__tests__/handleNftUrl.test.ts create mode 100644 app/core/DeeplinkManager/handlers/legacy/handleNftUrl.ts diff --git a/app/constants/deeplinks.ts b/app/constants/deeplinks.ts index 6aeaea1bd4d8..f1947fa14992 100644 --- a/app/constants/deeplinks.ts +++ b/app/constants/deeplinks.ts @@ -45,6 +45,7 @@ export enum ACTIONS { ONBOARDING = 'onboarding', TRENDING = 'trending', EARN_MUSD = 'earn-musd', + NFT = 'nft', } export const PREFIXES = { @@ -77,5 +78,6 @@ export const PREFIXES = { [ACTIONS.CARD_HOME]: '', [ACTIONS.TRENDING]: '', [ACTIONS.EARN_MUSD]: '', + [ACTIONS.NFT]: '', METAMASK: 'metamask://', }; diff --git a/app/core/DeeplinkManager/handlers/legacy/__tests__/handleNftUrl.test.ts b/app/core/DeeplinkManager/handlers/legacy/__tests__/handleNftUrl.test.ts new file mode 100644 index 000000000000..b17708f7824a --- /dev/null +++ b/app/core/DeeplinkManager/handlers/legacy/__tests__/handleNftUrl.test.ts @@ -0,0 +1,91 @@ +import { handleNftUrl } from '../handleNftUrl'; +import NavigationService from '../../../../NavigationService'; +import Routes from '../../../../../constants/navigation/Routes'; +import DevLogger from '../../../../SDKConnect/utils/DevLogger'; +import Logger from '../../../../../util/Logger'; + +jest.mock('../../../../NavigationService'); +jest.mock('../../../../SDKConnect/utils/DevLogger'); +jest.mock('../../../../../util/Logger'); + +describe('handleNftUrl', () => { + let mockNavigate: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + mockNavigate = jest.fn(); + NavigationService.navigation = { + navigate: mockNavigate, + } as unknown as typeof NavigationService.navigation; + + (DevLogger.log as jest.Mock) = jest.fn(); + (Logger.error as jest.Mock) = jest.fn(); + }); + + it('navigates to NFTS_FULL_VIEW', () => { + handleNftUrl(); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.WALLET.NFTS_FULL_VIEW); + }); + + it('logs start of deeplink handling', () => { + handleNftUrl(); + + expect(DevLogger.log).toHaveBeenCalledWith( + '[handleNftUrl] Starting NFT deeplink handling', + ); + }); + + it('falls back to WALLET.HOME on navigation error', () => { + mockNavigate.mockImplementationOnce(() => { + throw new Error('Navigation error'); + }); + + handleNftUrl(); + + expect(mockNavigate).toHaveBeenCalledTimes(2); + expect(mockNavigate).toHaveBeenLastCalledWith(Routes.WALLET.HOME); + }); + + it('logs error when navigation fails', () => { + const error = new Error('Navigation error'); + mockNavigate.mockImplementationOnce(() => { + throw error; + }); + + handleNftUrl(); + + expect(DevLogger.log).toHaveBeenCalledWith( + '[handleNftUrl] Failed to handle NFT deeplink:', + error, + ); + expect(Logger.error).toHaveBeenCalledWith( + error, + '[handleNftUrl] Error handling NFT deeplink', + ); + }); + + it('logs error when fallback navigation also fails', () => { + const primaryError = new Error('Primary navigation error'); + const fallbackError = new Error('Fallback navigation error'); + mockNavigate + .mockImplementationOnce(() => { + throw primaryError; + }) + .mockImplementationOnce(() => { + throw fallbackError; + }); + + handleNftUrl(); + + expect(Logger.error).toHaveBeenCalledWith( + primaryError, + '[handleNftUrl] Error handling NFT deeplink', + ); + expect(Logger.error).toHaveBeenCalledWith( + fallbackError, + '[handleNftUrl] Failed to navigate to fallback screen', + ); + }); +}); diff --git a/app/core/DeeplinkManager/handlers/legacy/handleNftUrl.ts b/app/core/DeeplinkManager/handlers/legacy/handleNftUrl.ts new file mode 100644 index 000000000000..91f92176395b --- /dev/null +++ b/app/core/DeeplinkManager/handlers/legacy/handleNftUrl.ts @@ -0,0 +1,31 @@ +import NavigationService from '../../../NavigationService'; +import Routes from '../../../../constants/navigation/Routes'; +import DevLogger from '../../../SDKConnect/utils/DevLogger'; +import Logger from '../../../../util/Logger'; + +/** + * NFT deeplink handler + * + * Supported URL formats: + * - https://link.metamask.io/nft + * - https://metamask.io/nft (mapped via Branch) + */ +export const handleNftUrl = () => { + DevLogger.log('[handleNftUrl] Starting NFT deeplink handling'); + + try { + NavigationService.navigation.navigate(Routes.WALLET.NFTS_FULL_VIEW); + } catch (error) { + DevLogger.log('[handleNftUrl] Failed to handle NFT deeplink:', error); + Logger.error(error as Error, '[handleNftUrl] Error handling NFT deeplink'); + + try { + NavigationService.navigation.navigate(Routes.WALLET.HOME); + } catch (navError) { + Logger.error( + navError as Error, + '[handleNftUrl] Failed to navigate to fallback screen', + ); + } + } +}; diff --git a/app/core/DeeplinkManager/handlers/legacy/handleUniversalLink.ts b/app/core/DeeplinkManager/handlers/legacy/handleUniversalLink.ts index 40794504d71f..45425f62dde6 100644 --- a/app/core/DeeplinkManager/handlers/legacy/handleUniversalLink.ts +++ b/app/core/DeeplinkManager/handlers/legacy/handleUniversalLink.ts @@ -32,6 +32,7 @@ import { handleCardOnboarding } from './handleCardOnboarding'; import { handleCardHome } from './handleCardHome'; import { handleTrendingUrl } from './handleTrendingUrl'; import { handleEarnMusd } from './handleEarnMusd'; +import { handleNftUrl } from './handleNftUrl'; import { RampType } from '../../../../reducers/fiatOrders/types'; import { SHIELD_WEBSITE_URL } from '../../../../constants/shield'; import { @@ -82,6 +83,7 @@ const SUPPORTED_ACTIONS = { TRENDING: ACTIONS.TRENDING, SHIELD: ACTIONS.SHIELD, EARN_MUSD: ACTIONS.EARN_MUSD, + NFT: ACTIONS.NFT, // MetaMask SDK specific actions ANDROID_SDK: ACTIONS.ANDROID_SDK, CONNECT: ACTIONS.CONNECT, @@ -597,6 +599,10 @@ async function handleUniversalLink({ handleEarnMusd(); break; } + case SUPPORTED_ACTIONS.NFT: { + handleNftUrl(); + break; + } } } diff --git a/app/core/DeeplinkManager/types/deepLink.types.ts b/app/core/DeeplinkManager/types/deepLink.types.ts index 203621b4467e..68ee60879b6f 100644 --- a/app/core/DeeplinkManager/types/deepLink.types.ts +++ b/app/core/DeeplinkManager/types/deepLink.types.ts @@ -136,6 +136,7 @@ export const SUPPORTED_ACTIONS = [ ACTIONS.CARD_ONBOARDING, ACTIONS.CARD_HOME, ACTIONS.SHIELD, + ACTIONS.NFT, ] as const satisfies readonly ACTIONS[]; export type SupportedAction = (typeof SUPPORTED_ACTIONS)[number]; diff --git a/app/core/DeeplinkManager/types/deepLinkAnalytics.types.ts b/app/core/DeeplinkManager/types/deepLinkAnalytics.types.ts index 4886d5fd0e36..922670d53efa 100644 --- a/app/core/DeeplinkManager/types/deepLinkAnalytics.types.ts +++ b/app/core/DeeplinkManager/types/deepLinkAnalytics.types.ts @@ -54,6 +54,7 @@ export enum DeepLinkRoute { ENABLE_CARD_BUTTON = 'enable-card-button', CARD_ONBOARDING = 'card-onboarding', CARD_HOME = 'card-home', + NFT = 'nft', INVALID = 'invalid', } diff --git a/app/core/DeeplinkManager/util/deeplinks/deepLinkAnalytics.ts b/app/core/DeeplinkManager/util/deeplinks/deepLinkAnalytics.ts index 0f0878d639ad..2ce11508907d 100644 --- a/app/core/DeeplinkManager/util/deeplinks/deepLinkAnalytics.ts +++ b/app/core/DeeplinkManager/util/deeplinks/deepLinkAnalytics.ts @@ -444,6 +444,18 @@ const extractShieldProperties = ( // SHIELD route doesn't have sensitive parameters to extract }; +/** + * Extract properties for NFT route + * @param urlParams - URL parameters + * @param sensitiveProps - Object to add properties to + */ +const extractNftProperties = ( + _urlParams: UrlParamValues, + _sensitiveProps: Record, +): void => { + // NFT route doesn't have sensitive parameters to extract +}; + /** * Extract properties for INVALID route * No properties to extract, this function is a placeholder @@ -483,6 +495,7 @@ const routeExtractors: Record< [DeepLinkRoute.ENABLE_CARD_BUTTON]: extractEnableCardButtonProperties, [DeepLinkRoute.CARD_ONBOARDING]: extractCardOnboardingProperties, [DeepLinkRoute.CARD_HOME]: extractCardHomeProperties, + [DeepLinkRoute.NFT]: extractNftProperties, [DeepLinkRoute.INVALID]: extractInvalidProperties, }; @@ -615,6 +628,8 @@ export const mapSupportedActionToRoute = ( return DeepLinkRoute.CARD_ONBOARDING; case ACTIONS.CARD_HOME: return DeepLinkRoute.CARD_HOME; + case ACTIONS.NFT: + return DeepLinkRoute.NFT; default: return DeepLinkRoute.INVALID; } @@ -667,6 +682,8 @@ export const extractRouteFromUrl = (url: string): DeepLinkRoute => { return DeepLinkRoute.CARD_ONBOARDING; case 'card-home': return DeepLinkRoute.CARD_HOME; + case 'nft': + return DeepLinkRoute.NFT; case undefined: // Empty path (no segments after filtering) return DeepLinkRoute.HOME; default: From 910d769f738747026def31881bb99deb0687e656 Mon Sep 17 00:00:00 2001 From: cryptodev-2s <109512101+cryptodev-2s@users.noreply.github.com> Date: Fri, 30 Jan 2026 16:44:18 +0100 Subject: [PATCH 14/17] feat: return actual host for known public domains in analytics cp-7.64.0 (#25385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Improves analytics data quality by returning the actual domain host for known public RPC providers instead of masking them as 'custom'. - Add `isPublicRpcDomain` helper in `rpc-domain-utils.ts` that checks if an RPC URL has a known public domain - Simplify `isPublicEndpointUrl` by using the new helper - `sanitizeRpcUrl` now returns the actual host (e.g., `mainnet.infura.io`, `eth-mainnet.alchemyapi.io` or any RPC from chainid.network) for known public domains, improving the accuracy of `rpc_domain` in analytics events ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/WPC-342 ## **Manual testing steps** ```gherkin Feature: RPC domain analytics Scenario: Verify rpc_domain shows actual host when switching to public RPC via banner # Setup - Add Ink network with local RPC Given user navigates to Settings โ†’ Networks โ†’ Add Network And user adds Ink network (Chain ID: 57073) with local RPC endpoint: http://127.0.0.1:8545 And user also adds public RPC endpoint: https://rpc-qnd.inkonchain.com And user sets the local RPC as the default endpoint And user switches to Ink network # Trigger degraded state When user disconnects local RPC (or it becomes unavailable) And user waits for banner showing "Still connecting to Ink..." # Trigger RPC update from banner Then the "Update RPC" button appears on the banner When user clicks "Update RPC" on the banner And user is navigated to Edit Network screen And user switches default RPC to https://rpc-qnd.inkonchain.com # Verify analytics in Segment When user checks Segment dashboard for "Network Connection Banner RPC Updated" event Then the event property from_rpc_domain should be "custom" (local RPC is private) And the event property to_rpc_domain should be "rpc-qnd.inkonchain.com" (known public domain) Scenario: Verify rpc_domain for Infura networks using Switch to MetaMask default # Setup - Configure Arbitrum with local RPC Given user starts a local Ganache server: npx ganache --chain.chainId 42161 And user navigates to Settings โ†’ Networks โ†’ Arbitrum One And user adds a new RPC endpoint: http://127.0.0.1:8545 And user sets the local RPC as the default endpoint # Trigger degraded state When user stops the Ganache server (Ctrl+C) And user waits for banner showing "Still connecting to Arbitrum One..." # Switch to Infura via banner button Then the "Switch to MetaMask default RPC" button appears on the banner When user clicks "Switch to MetaMask default RPC" Then the toast "Updated to MetaMask default" appears # Verify analytics When user checks Segment for "Network Connection Banner Switch To MetaMask Default RPC Clicked" Then rpc_domain should be "custom" (the local RPC being switched from) ``` ## **Screenshots/Recordings** N/A - Internal analytics improvement, no UI changes. ### **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** > Changes the logic that decides whether an RPC URL is safe to include in analytics, which could affect privacy/data reporting if domains are misclassified. Also adds a new async initialization step during `Engine` startup (non-blocking) that reports failures to Sentry. > > **Overview** > Improves RPC-domain analytics by recognizing *known public RPC provider domains* (e.g., Infura/Alchemy and domains learned from cached safe-chain RPC lists) as safe to report, so metrics can record the real host instead of lumping these under `custom`. > > Adds `isPublicRpcDomain` to `rpc-domain-utils` and wires it into `isPublicEndpointUrl` in network-controller utilities; `Engine` now asynchronously preloads the provider-domain cache on startup and captures init errors via Sentry. Updates unit tests to cover invalid/localhost URLs and known public provider domains. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 7d9f601027f59b12fc47997cb07c0c51c595fbf4. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/core/Engine/Engine.ts | 8 +++++ .../network-controller/utils.test.ts | 30 +++++++++++++++++++ .../controllers/network-controller/utils.ts | 16 ++++------ app/util/rpc-domain-utils.test.ts | 23 ++++++++++++++ app/util/rpc-domain-utils.ts | 12 ++++++++ 5 files changed, 78 insertions(+), 11 deletions(-) diff --git a/app/core/Engine/Engine.ts b/app/core/Engine/Engine.ts index f414d48fc48c..6d452d6cf35a 100644 --- a/app/core/Engine/Engine.ts +++ b/app/core/Engine/Engine.ts @@ -49,6 +49,7 @@ import { import NotificationManager from '../NotificationManager'; import Logger from '../../util/Logger'; import { isZero } from '../../util/lodash'; +import { initializeRpcProviderDomains } from '../../util/rpc-domain-utils'; ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) import { notificationServicesControllerInit } from './controllers/notifications/notification-services-controller-init'; @@ -68,6 +69,7 @@ import { parseCaipAssetType, } from '@metamask/utils'; import { providerErrors } from '@metamask/rpc-errors'; +import { captureException } from '@sentry/react-native'; import { networkIdUpdated, @@ -469,6 +471,12 @@ export class Engine { controllersByName.NetworkEnablementController; networkEnablementController.init(); + // Initialize RPC domain validation cache for analytics + // This runs asynchronously and doesn't block Engine initialization + initializeRpcProviderDomains().catch((error) => { + captureException(error); + }); + ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) snapController.init(); cronjobController.init(); diff --git a/app/core/Engine/controllers/network-controller/utils.test.ts b/app/core/Engine/controllers/network-controller/utils.test.ts index eb1346516646..2012abf43654 100644 --- a/app/core/Engine/controllers/network-controller/utils.test.ts +++ b/app/core/Engine/controllers/network-controller/utils.test.ts @@ -371,6 +371,36 @@ describe('isPublicEndpointUrl', () => { ), ).toBe(false); }); + + it('returns false for localhost URLs', () => { + expect( + isPublicEndpointUrl( + 'http://localhost:8545', + MOCK_METAMASK_INFURA_PROJECT_ID, + ), + ).toBe(false); + expect( + isPublicEndpointUrl( + 'http://127.0.0.1:8545', + MOCK_METAMASK_INFURA_PROJECT_ID, + ), + ).toBe(false); + }); + + it('returns false for invalid URLs', () => { + expect( + isPublicEndpointUrl(':::invalid-url', MOCK_METAMASK_INFURA_PROJECT_ID), + ).toBe(false); + }); + + it('returns true for known public provider domains like Alchemy', () => { + expect( + isPublicEndpointUrl( + 'https://eth-mainnet.alchemyapi.io/v2/some-key', + MOCK_METAMASK_INFURA_PROJECT_ID, + ), + ).toBe(true); + }); }); /** diff --git a/app/core/Engine/controllers/network-controller/utils.ts b/app/core/Engine/controllers/network-controller/utils.ts index 1f1fb7fb4825..f7fd40420ea6 100644 --- a/app/core/Engine/controllers/network-controller/utils.ts +++ b/app/core/Engine/controllers/network-controller/utils.ts @@ -6,6 +6,7 @@ import { PopularList, } from '../../../../util/networks/customNetworks'; import { BUILT_IN_CUSTOM_NETWORKS_RPC } from '@metamask/controller-utils'; +import { isPublicRpcDomain } from '../../../../util/rpc-domain-utils'; /** * We capture Segment events for degraded or unavailable RPC endpoints for 1% @@ -150,17 +151,10 @@ export function isPublicEndpointUrl( endpointUrl: string, infuraProjectId: string, ) { - const isMetaMaskInfuraEndpointUrl = getIsMetaMaskInfuraEndpointUrl( - endpointUrl, - infuraProjectId, - ); - const isQuicknodeEndpointUrl = getIsQuicknodeEndpointUrl(endpointUrl); - const isKnownCustomEndpointUrl = - KNOWN_CUSTOM_ENDPOINT_URLS.includes(endpointUrl); - return ( - isMetaMaskInfuraEndpointUrl || - isQuicknodeEndpointUrl || - isKnownCustomEndpointUrl + getIsMetaMaskInfuraEndpointUrl(endpointUrl, infuraProjectId) || + getIsQuicknodeEndpointUrl(endpointUrl) || + KNOWN_CUSTOM_ENDPOINT_URLS.includes(endpointUrl) || + isPublicRpcDomain(endpointUrl) ); } diff --git a/app/util/rpc-domain-utils.test.ts b/app/util/rpc-domain-utils.test.ts index 862914b1d0ff..841815429da1 100644 --- a/app/util/rpc-domain-utils.test.ts +++ b/app/util/rpc-domain-utils.test.ts @@ -7,6 +7,7 @@ import { getKnownDomains, isKnownDomain, extractRpcDomain, + isPublicRpcDomain, getNetworkRpcUrl, getModuleState, } from './rpc-domain-utils'; @@ -419,6 +420,28 @@ describe('rpc-domain-utils', () => { }); }); + describe('isPublicRpcDomain', () => { + it('returns false for invalid URLs', () => { + expect(isPublicRpcDomain(':::invalid-url')).toBe(false); + }); + + it('returns false for private/localhost URLs', () => { + expect(isPublicRpcDomain('http://localhost:8545')).toBe(false); + expect(isPublicRpcDomain('http://127.0.0.1:8545')).toBe(false); + }); + + it('returns false for unknown private domains', () => { + expect(isPublicRpcDomain('https://unknown-domain.com')).toBe(false); + }); + + it('returns true for known public provider URLs', () => { + expect(isPublicRpcDomain('https://mainnet.infura.io/v3/key')).toBe(true); + expect( + isPublicRpcDomain('https://eth-mainnet.alchemyapi.io/v2/key'), + ).toBe(true); + }); + }); + describe('getNetworkRpcUrl', () => { describe('when retrieving RPC URLs', () => { it('returns RPC URL from legacy format', () => { diff --git a/app/util/rpc-domain-utils.ts b/app/util/rpc-domain-utils.ts index cb0269926450..2d80c4d42eec 100644 --- a/app/util/rpc-domain-utils.ts +++ b/app/util/rpc-domain-utils.ts @@ -107,6 +107,18 @@ export const RpcDomainStatus = { export type RpcDomainStatus = (typeof RpcDomainStatus)[keyof typeof RpcDomainStatus]; +/** + * Checks if an RPC endpoint URL has a valid public domain. + * Extracts the domain from the URL and verifies it's not private, invalid, or unknown. + * + * @param endpointUrl - The RPC endpoint URL to check + * @returns True if the URL has a valid public domain, false otherwise + */ +export function isPublicRpcDomain(endpointUrl: string): boolean { + const rpcDomain = extractRpcDomain(endpointUrl); + return !Object.values(RpcDomainStatus).includes(rpcDomain as RpcDomainStatus); +} + function parseDomain(url: string): string | undefined { try { const normalizedUrl = url.includes('://') ? url : `https://${url}`; From 58eab7606a9b4682242c8c0d010c15763722e0ca Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Fri, 30 Jan 2026 17:12:39 +0100 Subject: [PATCH 15/17] chore: add more metrics to trending flow (#25375) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR extends the `TRENDING_FEED_VIEWED` analytics event with an `interaction_type` discriminator pattern to track detailed user interactions within the Trending feed. Related: https://github.com/Consensys/segment-schema/pull/440 ### Background The existing `TRENDING_FEED_VIEWED` event (PR #23674) tracks session-level metrics (session_id, session_time, entry_point, is_session_end). This PR adds granular interaction tracking. ### Changes 1. **Added `interaction_type` discriminator** - All events now include an `interaction_type` field to distinguish between: `session_start`, `session_end`, `token_click`, `search`, `filter_change` 2. **Token Click Tracking** - Tracks when users tap on tokens from the Trending feed, including position, price data, and active filter context 3. **Search Tracking** - Tracks search queries with debounce (500ms), including result counts and filter context 4. **Filter Change Tracking** - Tracks when users change time, sort, or network filters, including previous and new values 5. **Filter Context Propagation** - Added `TrendingFilterContext` interface and propagated it through `TrendingTokensList` โ†’ `TrendingTokenRowItem` for analytics context --- # Debug Console Logs for Testing Temporary console.logs have been added to help verify the events fire correctly. **File:** `app/components/UI/Trending/services/TrendingFeedSessionManager.ts` ### Session Start/End (in `trackEvent` method, after line 256): ```typescript // Add after: const analyticsProperties = { ... }; console.log( `${isSessionEnd ? '๐Ÿ›‘' : '๐Ÿš€'} TRENDING_FEED_VIEWED [${interactionType}]`, analyticsProperties, ); ``` ### Token Click / Search / Filter Change (in `trackInteraction` method, after line 296): ```typescript // Add after: const analyticsProperties = { ... }; const emoji = { token_click: '๐Ÿ”ฅ', search: '๐Ÿ”', filter_change: 'โš™๏ธ', }[interactionType] || '๐Ÿ“Š'; console.log(`${emoji} TRENDING_FEED_VIEWED [${interactionType}]`, analyticsProperties); ``` ## New Analytics Attributes ### Interaction Types | `interaction_type` | Description | |--------------------|-------------| | `session_start` | Fired when user enters Trending feed | | `session_end` | Fired when user leaves Trending feed | | `token_click` | Fired when user taps on a token | | `search` | Fired when user searches (debounced 500ms) | | `filter_change` | Fired when user changes time/sort/network filter | ### Token Click Properties (when `interaction_type = token_click`) | Property | Type | Description | |----------|------|-------------| | `token_symbol` | string | Token symbol clicked (e.g., "ETH") | | `token_address` | string | Token contract address | | `token_name` | string | Token display name | | `chain_id` | string | Network chain ID (hex format) | | `position` | integer | 0-indexed position in list | | `price_usd` | number | Token price at click time (USD) | | `price_change_pct` | number | Price change percentage | | `time_filter` | string | Active time filter (e.g., "24h", "6h", "1h", "5m") | | `sort_option` | string | Active sort option (e.g., "price_change", "volume") | | `network_filter` | string | Active network filter ("all" or chain ID) | | `is_search_result` | boolean | Was this from search results? | ### Search Properties (when `interaction_type = search`) | Property | Type | Description | |----------|------|-------------| | `search_query` | string | The search query entered | | `results_count` | integer | Number of results returned | | `has_results` | boolean | Whether search returned any results | | `time_filter` | string | Active time filter | | `sort_option` | string | Active sort option | | `network_filter` | string | Active network filter | ### Filter Change Properties (when `interaction_type = filter_change`) | Property | Type | Description | |----------|------|-------------| | `filter_type` | string | Type of filter changed: "time", "sort", or "network" | | `previous_value` | string | Previous filter value | | `new_value` | string | New filter value | | `time_filter` | string | Active time filter | | `sort_option` | string | Active sort option | | `network_filter` | string | Active network filter | --- ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Trending Feed Analytics Tracking Scenario: User clicks on a token from Trending feed Given the user is on the Trending feed (via homepage or Trade button) When user taps on a token in the list Then a TRENDING_FEED_VIEWED event fires with interaction_type="token_click" And the event includes token_symbol, position, price data, and filter context And console shows "๐Ÿ”ฅ TRENDING_FEED_VIEWED [token_click]" with properties Scenario: User searches for a token Given the user is on the Trending full view When user types a search query and waits 500ms Then a TRENDING_FEED_VIEWED event fires with interaction_type="search" And the event includes search_query, results_count, and has_results And console shows "๐Ÿ” TRENDING_FEED_VIEWED [search]" with properties Scenario: User changes the time filter Given the user is on the Trending full view with 24h filter active When user changes the time filter to 6h Then a TRENDING_FEED_VIEWED event fires with interaction_type="filter_change" And filter_type="time", previous_value="24h", new_value="6h" And console shows "โš™๏ธ TRENDING_FEED_VIEWED [filter_change]" with properties Scenario: User changes the network filter Given the user is on the Trending full view with "all" networks When user selects a specific network (e.g., Ethereum) Then a TRENDING_FEED_VIEWED event fires with interaction_type="filter_change" And filter_type="network" with previous and new chain IDs And console shows "โš™๏ธ TRENDING_FEED_VIEWED [filter_change]" with properties Scenario: Session tracking includes interaction_type Given the user opens the Trending feed Then a TRENDING_FEED_VIEWED event fires with interaction_type="session_start" And console shows "๐Ÿš€ TRENDING_FEED_VIEWED [session_start]" When user navigates away from Trending Then a TRENDING_FEED_VIEWED event fires with interaction_type="session_end" And console shows "๐Ÿ›‘ TRENDING_FEED_VIEWED [session_end]" ``` ## **Screenshots/Recordings** ### **Before** ### **After** Uploading Screen Recording 2026-01-29 at 17.35.57.movโ€ฆ ## **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 tab-switching behavior via a new `onLeave` hook and adds new analytics side effects (session start/end, search debounce) across multiple Trending entry points, which could impact navigation timing and event volume. > > **Overview** > **Expands Trending analytics from session-only to interaction-level tracking.** `TrendingFeedSessionManager` now emits `TRENDING_FEED_VIEWED` events with an `interaction_type` discriminator (start/end, token_click, search, filter_change) and exposes new tracking helpers. > > Trending UI now propagates a `TrendingFilterContext` through `TrendingTokensList`/`TrendingTokenRowItem` to record token click position, pricing, and active filters, adds a debounced `useSearchTracking` hook for search events (used in both the full Trending tokens view and Explore search), and tracks filter changes when time/sort/network selections change. > > Navigation updates add a tab-level `onLeave` callback and invoke it on tab switches; the Trending tab uses this to start sessions on press and end sessions when leaving the tab. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit dbfeda08b20db319e260c8c9ee408f5007a95969. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../components/Navigation/TabBar/TabBar.tsx | 8 + .../Navigation/TabBar/TabBar.types.ts | 5 + app/components/Nav/Main/MainNavigator.js | 14 + .../TrendingTokenRowItem.test.tsx | 397 +++++++++++++++++- .../TrendingTokenRowItem.tsx | 33 +- .../TrendingTokensList/TrendingTokensList.tsx | 28 +- .../useSearchTracking.test.ts | 278 ++++++++++++ .../useSearchTracking/useSearchTracking.ts | 104 +++++ .../TrendingFeedSessionManager.test.ts | 320 +++++++++++++- .../services/TrendingFeedSessionManager.ts | 190 ++++++++- .../TrendingTokensFullView.test.tsx | 240 +++++++++++ .../TrendingTokensFullView.tsx | 121 +++++- .../ExploreSearchResults.tsx | 23 +- .../Sections/SectionTypes/SectionCard.tsx | 4 +- .../SectionTypes/SectionCarrousel.tsx | 6 +- .../Views/TrendingView/sections.config.tsx | 50 ++- 16 files changed, 1793 insertions(+), 28 deletions(-) create mode 100644 app/components/UI/Trending/hooks/useSearchTracking/useSearchTracking.test.ts create mode 100644 app/components/UI/Trending/hooks/useSearchTracking/useSearchTracking.ts diff --git a/app/component-library/components/Navigation/TabBar/TabBar.tsx b/app/component-library/components/Navigation/TabBar/TabBar.tsx index e4682f3a54ba..f9d9d1d17ff3 100644 --- a/app/component-library/components/Navigation/TabBar/TabBar.tsx +++ b/app/component-library/components/Navigation/TabBar/TabBar.tsx @@ -37,6 +37,7 @@ const TabBar = ({ state, descriptors, navigation }: TabBarProps) => { selectAssetsTrendingTokensEnabled, ); const tabBarRef = useRef(null); + const previousTabIndexRef = useRef(state.index); const tw = useTailwind(); const renderTabBarItem = useCallback( @@ -54,6 +55,13 @@ const TabBar = ({ state, descriptors, navigation }: TabBarProps) => { const labelKey = LABEL_BY_TAB_BAR_ICON_KEY[tabBarIconKey]; const labelText = labelKey ? strings(labelKey) : ''; const onPress = () => { + // Call onLeave callback for the previous tab before switching + if (previousTabIndexRef.current !== index) { + const previousRoute = state.routes[previousTabIndexRef.current]; + const previousOptions = descriptors[previousRoute?.key]?.options; + previousOptions?.onLeave?.(); + previousTabIndexRef.current = index; + } callback?.(); switch (rootScreenName) { case Routes.WALLET_VIEW: diff --git a/app/component-library/components/Navigation/TabBar/TabBar.types.ts b/app/component-library/components/Navigation/TabBar/TabBar.types.ts index e9a41f03581e..8a69ad062709 100644 --- a/app/component-library/components/Navigation/TabBar/TabBar.types.ts +++ b/app/component-library/components/Navigation/TabBar/TabBar.types.ts @@ -39,6 +39,11 @@ export interface ExtendedBottomTabDescriptor extends BottomTabDescriptor { rootScreenName: string; isSelected?: (rootScreenName: string) => boolean; isHidden?: boolean; + /** + * Callback fired when leaving this tab (switching to another tab). + * Useful for cleanup actions like ending analytics sessions. + */ + onLeave?: () => void; }; } diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index 616fcbb95a0a..19123a792a6c 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -50,6 +50,7 @@ import ActivityView from '../../Views/ActivityView'; import RewardsNavigator from '../../UI/Rewards/RewardsNavigator'; import { ExploreFeed } from '../../Views/TrendingView/TrendingView'; import ExploreSearchScreen from '../../Views/TrendingView/Views/ExploreSearchScreen/ExploreSearchScreen'; +import TrendingFeedSessionManager from '../../UI/Trending/services/TrendingFeedSessionManager'; import CollectiblesDetails from '../../UI/CollectibleModal'; import OptinMetrics from '../../UI/OptinMetrics'; @@ -595,6 +596,19 @@ const HomeTabs = () => { MetaMetricsEvents.NAVIGATION_TAPS_TRENDING, ).build(), ); + // Re-enable AppState listener when returning to trending tab + // (it was disabled when leaving to prevent phantom sessions) + TrendingFeedSessionManager.getInstance().enableAppStateListener(); + // Start a new session when returning to trending tab + // The session manager will ignore if a session is already active + TrendingFeedSessionManager.getInstance().startSession('tab_press'); + }, + onLeave: () => { + // End trending session when user switches to another tab + TrendingFeedSessionManager.getInstance().endSession(); + // Disable AppState listener to prevent phantom sessions when app backgrounds/foregrounds + // while user is on a different tab (since TrendingView stays mounted with unmountOnBlur: false) + TrendingFeedSessionManager.getInstance().disableAppStateListener(); }, rootScreenName: Routes.TRENDING_VIEW, unmountOnBlur: false, diff --git a/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx index c003e03c3c22..8533a750bd0f 100644 --- a/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx +++ b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx @@ -3,13 +3,25 @@ import { fireEvent, waitFor } from '@testing-library/react-native'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; import TrendingTokenRowItem from './TrendingTokenRowItem'; import type { TrendingAsset } from '@metamask/assets-controllers'; -import { TimeOption } from '../TrendingTokensBottomSheet'; +import { TimeOption, PriceChangeOption } from '../TrendingTokensBottomSheet'; +import type { TrendingFilterContext } from '../TrendingTokensList/TrendingTokensList'; // Mock the trendingNetworksList module to avoid getNetworkImageSource errors jest.mock('../../utils/trendingNetworksList', () => ({ TRENDING_NETWORKS_LIST: [], })); +const mockTrackTokenClick = jest.fn(); + +jest.mock('../../services/TrendingFeedSessionManager', () => ({ + __esModule: true, + default: { + getInstance: () => ({ + trackTokenClick: mockTrackTokenClick, + }), + }, +})); + const mockNavigate = jest.fn(); const mockAddPopularNetwork = jest.fn(); @@ -1184,4 +1196,387 @@ describe('TrendingTokenRowItem', () => { }); }); }); + + describe('token click analytics tracking', () => { + const mockFilterContext: TrendingFilterContext = { + timeFilter: TimeOption.TwentyFourHours, + sortOption: PriceChangeOption.PriceChange, + networkFilter: 'all', + isSearchResult: false, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const networkAddedState: any = { + engine: { + backgroundState: { + NetworkController: { + networkConfigurations: {}, + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + caipChainId: 'eip155:1', + name: 'Ethereum Mainnet', + }, + }, + }, + MultichainNetworkController: { + selectedMultichainNetworkChainId: undefined, + multichainNetworkConfigurationsByChainId: {}, + }, + }, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockIsCaipChainId.mockReturnValue(true); + }); + + it('tracks token click with correct properties when position and filterContext are provided', () => { + const token = createMockToken({ + assetId: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + name: 'USD Coin', + price: '1.00135763432467', + priceChangePct: { + h24: '+3.44', + h6: '+1.23', + h1: '+0.56', + m5: '+0.12', + }, + }); + + const { getByTestId } = renderWithProvider( + , + { state: networkAddedState }, + false, + ); + + const tokenRow = getByTestId( + 'trending-token-row-item-eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + ); + fireEvent.press(tokenRow); + + expect(mockTrackTokenClick).toHaveBeenCalledWith({ + token_symbol: 'USDC', + token_address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + token_name: 'USD Coin', + chain_id: '0x1', + position: 2, + price_usd: 1.00135763432467, + price_change_pct: 3.44, + time_filter: TimeOption.TwentyFourHours, + sort_option: PriceChangeOption.PriceChange, + network_filter: 'all', + is_search_result: false, + }); + }); + + it('tracks token click with search result flag when isSearchResult is true', () => { + const token = createMockToken({ + assetId: 'eip155:1/erc20:0x123', + symbol: 'TEST', + name: 'Test Token', + price: '50.00', + }); + + const searchFilterContext: TrendingFilterContext = { + ...mockFilterContext, + isSearchResult: true, + }; + + const { getByTestId } = renderWithProvider( + , + { state: networkAddedState }, + false, + ); + + const tokenRow = getByTestId( + 'trending-token-row-item-eip155:1/erc20:0x123', + ); + fireEvent.press(tokenRow); + + expect(mockTrackTokenClick).toHaveBeenCalledWith( + expect.objectContaining({ + is_search_result: true, + }), + ); + }); + + it('tracks token click with correct network filter when specific network is selected', () => { + const token = createMockToken({ + assetId: 'eip155:1/erc20:0x123', + symbol: 'TEST', + name: 'Test Token', + }); + + const networkFilterContext: TrendingFilterContext = { + ...mockFilterContext, + networkFilter: 'eip155:1', + }; + + const { getByTestId } = renderWithProvider( + , + { state: networkAddedState }, + false, + ); + + const tokenRow = getByTestId( + 'trending-token-row-item-eip155:1/erc20:0x123', + ); + fireEvent.press(tokenRow); + + expect(mockTrackTokenClick).toHaveBeenCalledWith( + expect.objectContaining({ + position: 5, + network_filter: 'eip155:1', + }), + ); + }); + + it('tracks token click with different time filters', () => { + const token = createMockToken({ + assetId: 'eip155:1/erc20:0x123', + symbol: 'TEST', + name: 'Test Token', + priceChangePct: { + h24: '+3.44', + h6: '+1.23', + h1: '+0.56', + m5: '+0.12', + }, + }); + + const sixHourFilterContext: TrendingFilterContext = { + ...mockFilterContext, + timeFilter: TimeOption.SixHours, + }; + + const { getByTestId } = renderWithProvider( + , + { state: networkAddedState }, + false, + ); + + const tokenRow = getByTestId( + 'trending-token-row-item-eip155:1/erc20:0x123', + ); + fireEvent.press(tokenRow); + + expect(mockTrackTokenClick).toHaveBeenCalledWith( + expect.objectContaining({ + time_filter: TimeOption.SixHours, + price_change_pct: 1.23, + }), + ); + }); + + it('does not track token click when position is undefined', () => { + const token = createMockToken({ + assetId: 'eip155:1/erc20:0x123', + symbol: 'TEST', + name: 'Test Token', + }); + + const { getByTestId } = renderWithProvider( + , + { state: networkAddedState }, + false, + ); + + const tokenRow = getByTestId( + 'trending-token-row-item-eip155:1/erc20:0x123', + ); + fireEvent.press(tokenRow); + + expect(mockTrackTokenClick).not.toHaveBeenCalled(); + }); + + it('does not track token click when filterContext is undefined', () => { + const token = createMockToken({ + assetId: 'eip155:1/erc20:0x123', + symbol: 'TEST', + name: 'Test Token', + }); + + const { getByTestId } = renderWithProvider( + , + { state: networkAddedState }, + false, + ); + + const tokenRow = getByTestId( + 'trending-token-row-item-eip155:1/erc20:0x123', + ); + fireEvent.press(tokenRow); + + expect(mockTrackTokenClick).not.toHaveBeenCalled(); + }); + + it('does not track token click when both position and filterContext are undefined', () => { + const token = createMockToken({ + assetId: 'eip155:1/erc20:0x123', + symbol: 'TEST', + name: 'Test Token', + }); + + const { getByTestId } = renderWithProvider( + , + { state: networkAddedState }, + false, + ); + + const tokenRow = getByTestId( + 'trending-token-row-item-eip155:1/erc20:0x123', + ); + fireEvent.press(tokenRow); + + expect(mockTrackTokenClick).not.toHaveBeenCalled(); + }); + + it('tracks token click with zero position (first item in list)', () => { + const token = createMockToken({ + assetId: 'eip155:1/erc20:0x123', + symbol: 'TEST', + name: 'Test Token', + }); + + const { getByTestId } = renderWithProvider( + , + { state: networkAddedState }, + false, + ); + + const tokenRow = getByTestId( + 'trending-token-row-item-eip155:1/erc20:0x123', + ); + fireEvent.press(tokenRow); + + expect(mockTrackTokenClick).toHaveBeenCalledWith( + expect.objectContaining({ + position: 0, + }), + ); + }); + + it('uses default sort option when sortOption is undefined in filterContext', () => { + const token = createMockToken({ + assetId: 'eip155:1/erc20:0x123', + symbol: 'TEST', + name: 'Test Token', + }); + + const noSortFilterContext: TrendingFilterContext = { + timeFilter: TimeOption.TwentyFourHours, + sortOption: undefined, + networkFilter: 'all', + isSearchResult: false, + }; + + const { getByTestId } = renderWithProvider( + , + { state: networkAddedState }, + false, + ); + + const tokenRow = getByTestId( + 'trending-token-row-item-eip155:1/erc20:0x123', + ); + fireEvent.press(tokenRow); + + expect(mockTrackTokenClick).toHaveBeenCalledWith( + expect.objectContaining({ + sort_option: PriceChangeOption.PriceChange, + }), + ); + }); + + it('handles zero price correctly', () => { + const token = createMockToken({ + assetId: 'eip155:1/erc20:0x123', + symbol: 'TEST', + name: 'Test Token', + price: '0', + }); + + const { getByTestId } = renderWithProvider( + , + { state: networkAddedState }, + false, + ); + + const tokenRow = getByTestId( + 'trending-token-row-item-eip155:1/erc20:0x123', + ); + fireEvent.press(tokenRow); + + expect(mockTrackTokenClick).toHaveBeenCalledWith( + expect.objectContaining({ + price_usd: 0, + }), + ); + }); + + it('handles null price change percentage correctly', () => { + const token = createMockToken({ + assetId: 'eip155:1/erc20:0x123', + symbol: 'TEST', + name: 'Test Token', + priceChangePct: undefined, + }); + + const { getByTestId } = renderWithProvider( + , + { state: networkAddedState }, + false, + ); + + const tokenRow = getByTestId( + 'trending-token-row-item-eip155:1/erc20:0x123', + ); + fireEvent.press(tokenRow); + + expect(mockTrackTokenClick).toHaveBeenCalledWith( + expect.objectContaining({ + price_change_pct: 0, + }), + ); + }); + }); }); diff --git a/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx index b9b8ce05d5cc..2f1013e727cc 100644 --- a/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx +++ b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx @@ -37,12 +37,14 @@ import { import { AvatarSize } from '../../../../../component-library/components/Avatars/Avatar'; import { formatMarketStats } from './utils'; import { formatPriceWithSubscriptNotation } from '../../../Predict/utils/format'; -import { TimeOption } from '../TrendingTokensBottomSheet'; +import { TimeOption, PriceChangeOption } from '../TrendingTokensBottomSheet'; import { selectNetworkConfigurationsByCaipChainId } from '../../../../../selectors/networkController'; import { getTrendingTokenImageUrl } from '../../utils/getTrendingTokenImageUrl'; import { useRWAToken } from '../../../Bridge/hooks/useRWAToken'; import StockBadge from '../../../shared/StockBadge'; import { useAddPopularNetwork } from '../../../../hooks/useAddPopularNetwork'; +import TrendingFeedSessionManager from '../../services/TrendingFeedSessionManager'; +import type { TrendingFilterContext } from '../TrendingTokensList/TrendingTokensList'; /** * Extracts CAIP chain ID from asset ID @@ -146,6 +148,10 @@ export const getPriceChangeFieldKey = ( interface TrendingTokenRowItemProps { token: TrendingAsset; selectedTimeOption?: TimeOption; + /** 0-indexed position in the list for analytics */ + position?: number; + /** Filter context for analytics tracking */ + filterContext?: TrendingFilterContext; } /** @@ -183,6 +189,8 @@ const getAssetNavigationParams = (token: TrendingAsset) => { const TrendingTokenRowItem = ({ token, selectedTimeOption = TimeOption.TwentyFourHours, + position, + filterContext, }: TrendingTokenRowItemProps) => { const { styles } = useStyles(styleSheet, {}); const navigation = useNavigation(); @@ -191,6 +199,7 @@ const TrendingTokenRowItem = ({ ); const { addPopularNetwork } = useAddPopularNetwork(); const { isStockToken } = useRWAToken(); + const sessionManager = TrendingFeedSessionManager.getInstance(); // Memoize derived values const caipChainId = useMemo( @@ -222,6 +231,23 @@ const TrendingTokenRowItem = ({ const handlePress = useCallback(async () => { if (!assetParams) return; + // Track token click event BEFORE navigation to ensure capture + if (position !== undefined && filterContext) { + sessionManager.trackTokenClick({ + token_symbol: token.symbol, + token_address: assetParams.address, + token_name: token.name, + chain_id: assetParams.chainId, + position, + price_usd: parseFloat(token.price) || 0, + price_change_pct: pricePercentChange ?? 0, + time_filter: filterContext.timeFilter, + sort_option: filterContext.sortOption || PriceChangeOption.PriceChange, + network_filter: filterContext.networkFilter, + is_search_result: filterContext.isSearchResult, + }); + } + const isNetworkAdded = Boolean(networkConfigurations[caipChainId]); if (!isNetworkAdded) { @@ -250,6 +276,11 @@ const TrendingTokenRowItem = ({ navigation, networkConfigurations, addPopularNetwork, + position, + filterContext, + pricePercentChange, + token, + sessionManager, ]); return ( diff --git a/app/components/UI/Trending/components/TrendingTokensList/TrendingTokensList.tsx b/app/components/UI/Trending/components/TrendingTokensList/TrendingTokensList.tsx index ae890d714da3..fe00c4ef8f83 100644 --- a/app/components/UI/Trending/components/TrendingTokensList/TrendingTokensList.tsx +++ b/app/components/UI/Trending/components/TrendingTokensList/TrendingTokensList.tsx @@ -3,7 +3,21 @@ import { RefreshControl } from 'react-native'; import { FlashList } from '@shopify/flash-list'; import { TrendingAsset } from '@metamask/assets-controllers'; import TrendingTokenRowItem from '../TrendingTokenRowItem/TrendingTokenRowItem'; -import { TimeOption } from '../TrendingTokensBottomSheet'; +import { TimeOption, PriceChangeOption } from '../TrendingTokensBottomSheet'; + +/** + * Filter context for analytics tracking + */ +export interface TrendingFilterContext { + /** Active time filter (e.g., '24h', '6h', '1h', '5m') */ + timeFilter: TimeOption; + /** Active sort option */ + sortOption: PriceChangeOption | undefined; + /** Active network filter (chain ID or 'all') */ + networkFilter: string; + /** Whether results are from search */ + isSearchResult: boolean; +} export interface TrendingTokensListProps { /** @@ -18,6 +32,10 @@ export interface TrendingTokensListProps { * Refresh control for pull-to-refresh functionality */ refreshControl?: React.ReactElement; + /** + * Filter context for analytics tracking + */ + filterContext?: TrendingFilterContext; } /** @@ -27,15 +45,17 @@ export interface TrendingTokensListProps { * (renderItem and keyExtractor) to avoid recreating them on every render */ const TrendingTokensList: React.FC = React.memo( - ({ trendingTokens, selectedTimeOption, refreshControl }) => { + ({ trendingTokens, selectedTimeOption, refreshControl, filterContext }) => { const renderItem = useCallback( - ({ item }: { item: TrendingAsset }) => ( + ({ item, index }: { item: TrendingAsset; index: number }) => ( ), - [selectedTimeOption], + [selectedTimeOption, filterContext], ); const keyExtractor = useCallback( diff --git a/app/components/UI/Trending/hooks/useSearchTracking/useSearchTracking.test.ts b/app/components/UI/Trending/hooks/useSearchTracking/useSearchTracking.test.ts new file mode 100644 index 000000000000..aaf63f828f00 --- /dev/null +++ b/app/components/UI/Trending/hooks/useSearchTracking/useSearchTracking.test.ts @@ -0,0 +1,278 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useSearchTracking } from './useSearchTracking'; +import TrendingFeedSessionManager from '../../services/TrendingFeedSessionManager'; + +// Mock the TrendingFeedSessionManager +jest.mock('../../services/TrendingFeedSessionManager', () => ({ + __esModule: true, + default: { + getInstance: jest.fn(), + }, +})); + +describe('useSearchTracking', () => { + const mockTrackSearch = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + + (TrendingFeedSessionManager.getInstance as jest.Mock).mockReturnValue({ + trackSearch: mockTrackSearch, + }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const defaultProps = { + searchQuery: '', + resultsCount: 0, + isLoading: false, + timeFilter: '24h', + sortOption: 'price_change', + networkFilter: 'all', + }; + + it('does not track when search query is empty', () => { + renderHook(() => + useSearchTracking({ + ...defaultProps, + searchQuery: '', + }), + ); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(mockTrackSearch).not.toHaveBeenCalled(); + }); + + it('does not track when results are loading', () => { + renderHook(() => + useSearchTracking({ + ...defaultProps, + searchQuery: 'ethereum', + isLoading: true, + }), + ); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(mockTrackSearch).not.toHaveBeenCalled(); + }); + + it('tracks search after debounce when query is non-empty and not loading', () => { + renderHook(() => + useSearchTracking({ + ...defaultProps, + searchQuery: 'ethereum', + resultsCount: 5, + isLoading: false, + }), + ); + + // Should not track immediately + expect(mockTrackSearch).not.toHaveBeenCalled(); + + // Advance timers past debounce + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(mockTrackSearch).toHaveBeenCalledTimes(1); + expect(mockTrackSearch).toHaveBeenCalledWith({ + search_query: 'ethereum', + results_count: 5, + has_results: true, + time_filter: '24h', + sort_option: 'price_change', + network_filter: 'all', + }); + }); + + it('tracks has_results as false when resultsCount is 0', () => { + renderHook(() => + useSearchTracking({ + ...defaultProps, + searchQuery: 'nonexistent', + resultsCount: 0, + isLoading: false, + }), + ); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(mockTrackSearch).toHaveBeenCalledWith( + expect.objectContaining({ + search_query: 'nonexistent', + results_count: 0, + has_results: false, + }), + ); + }); + + it('does not track the same query twice', () => { + const { rerender } = renderHook((props) => useSearchTracking(props), { + initialProps: { + ...defaultProps, + searchQuery: 'bitcoin', + resultsCount: 3, + }, + }); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(mockTrackSearch).toHaveBeenCalledTimes(1); + + // Rerender with same query + rerender({ ...defaultProps, searchQuery: 'bitcoin', resultsCount: 3 }); + + act(() => { + jest.advanceTimersByTime(500); + }); + + // Should still be 1 (not tracked again) + expect(mockTrackSearch).toHaveBeenCalledTimes(1); + }); + + it('tracks when query changes', () => { + const { rerender } = renderHook((props) => useSearchTracking(props), { + initialProps: { + ...defaultProps, + searchQuery: 'bitcoin', + resultsCount: 3, + }, + }); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(mockTrackSearch).toHaveBeenCalledTimes(1); + + // Rerender with different query + rerender({ ...defaultProps, searchQuery: 'ethereum', resultsCount: 5 }); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(mockTrackSearch).toHaveBeenCalledTimes(2); + expect(mockTrackSearch).toHaveBeenLastCalledWith( + expect.objectContaining({ + search_query: 'ethereum', + results_count: 5, + }), + ); + }); + + it('debounces rapid query changes', () => { + const { rerender } = renderHook((props) => useSearchTracking(props), { + initialProps: { ...defaultProps, searchQuery: 'e', resultsCount: 10 }, + }); + + // Simulate rapid typing + rerender({ ...defaultProps, searchQuery: 'et', resultsCount: 8 }); + act(() => { + jest.advanceTimersByTime(100); + }); + + rerender({ ...defaultProps, searchQuery: 'eth', resultsCount: 5 }); + act(() => { + jest.advanceTimersByTime(100); + }); + + rerender({ ...defaultProps, searchQuery: 'ethe', resultsCount: 3 }); + act(() => { + jest.advanceTimersByTime(100); + }); + + // Should not have tracked yet (still within debounce) + expect(mockTrackSearch).not.toHaveBeenCalled(); + + // Complete the debounce + act(() => { + jest.advanceTimersByTime(500); + }); + + // Should only track the final query + expect(mockTrackSearch).toHaveBeenCalledTimes(1); + expect(mockTrackSearch).toHaveBeenCalledWith( + expect.objectContaining({ + search_query: 'ethe', + results_count: 3, + }), + ); + }); + + it('resets tracking when search is cleared', () => { + const { rerender } = renderHook((props) => useSearchTracking(props), { + initialProps: { + ...defaultProps, + searchQuery: 'bitcoin', + resultsCount: 3, + }, + }); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(mockTrackSearch).toHaveBeenCalledTimes(1); + + // Clear search + rerender({ ...defaultProps, searchQuery: '', resultsCount: 0 }); + + act(() => { + jest.advanceTimersByTime(500); + }); + + // Should not track empty query + expect(mockTrackSearch).toHaveBeenCalledTimes(1); + + // Search again with same query + rerender({ ...defaultProps, searchQuery: 'bitcoin', resultsCount: 3 }); + + act(() => { + jest.advanceTimersByTime(500); + }); + + // Should track again after clearing + expect(mockTrackSearch).toHaveBeenCalledTimes(2); + }); + + it('passes all filter values correctly', () => { + renderHook(() => + useSearchTracking({ + searchQuery: 'test', + resultsCount: 10, + isLoading: false, + timeFilter: '7d', + sortOption: 'volume', + networkFilter: '0x1', + }), + ); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(mockTrackSearch).toHaveBeenCalledWith({ + search_query: 'test', + results_count: 10, + has_results: true, + time_filter: '7d', + sort_option: 'volume', + network_filter: '0x1', + }); + }); +}); diff --git a/app/components/UI/Trending/hooks/useSearchTracking/useSearchTracking.ts b/app/components/UI/Trending/hooks/useSearchTracking/useSearchTracking.ts new file mode 100644 index 000000000000..52a72549479b --- /dev/null +++ b/app/components/UI/Trending/hooks/useSearchTracking/useSearchTracking.ts @@ -0,0 +1,104 @@ +import { useEffect, useRef } from 'react'; +import TrendingFeedSessionManager from '../../services/TrendingFeedSessionManager'; + +interface UseSearchTrackingOptions { + /** + * The search query string + */ + searchQuery: string; + /** + * Number of results found + */ + resultsCount: number; + /** + * Whether results are still loading + */ + isLoading: boolean; + /** + * Time filter value (e.g., '24h', '7d') + */ + timeFilter: string; + /** + * Sort option value (e.g., 'price_change', 'relevance') + */ + sortOption: string; + /** + * Network filter value (e.g., 'all', '0x1') + */ + networkFilter: string; + /** + * Debounce delay in milliseconds (default: 500) + */ + debounceMs?: number; +} + +/** + * Hook to track search events with debouncing. + * Fires a search analytics event when the user searches for tokens. + * + * @param options - Configuration options for search tracking + */ +export const useSearchTracking = ({ + searchQuery, + resultsCount, + isLoading, + timeFilter, + sortOption, + networkFilter, + debounceMs = 500, +}: UseSearchTrackingOptions): void => { + const lastTrackedSearchQuery = useRef(''); + const searchDebounceTimer = useRef | null>( + null, + ); + const sessionManager = TrendingFeedSessionManager.getInstance(); + + useEffect(() => { + // Clear any existing debounce timer + if (searchDebounceTimer.current) { + clearTimeout(searchDebounceTimer.current); + } + + const trimmedQuery = searchQuery?.trim() || ''; + + // Only track if query is non-empty, results are loaded, and different from last tracked + if ( + trimmedQuery && + !isLoading && + trimmedQuery !== lastTrackedSearchQuery.current + ) { + // Debounce search tracking to avoid tracking every keystroke + searchDebounceTimer.current = setTimeout(() => { + sessionManager.trackSearch({ + search_query: trimmedQuery, + results_count: resultsCount, + has_results: resultsCount > 0, + time_filter: timeFilter, + sort_option: sortOption, + network_filter: networkFilter, + }); + lastTrackedSearchQuery.current = trimmedQuery; + }, debounceMs); + } + + // Reset last tracked query when search is cleared + if (!trimmedQuery) { + lastTrackedSearchQuery.current = ''; + } + + return () => { + if (searchDebounceTimer.current) { + clearTimeout(searchDebounceTimer.current); + } + }; + }, [ + searchQuery, + resultsCount, + isLoading, + timeFilter, + sortOption, + networkFilter, + debounceMs, + sessionManager, + ]); +}; diff --git a/app/components/UI/Trending/services/TrendingFeedSessionManager.test.ts b/app/components/UI/Trending/services/TrendingFeedSessionManager.test.ts index 40e72ccfe207..b901f2b495c8 100644 --- a/app/components/UI/Trending/services/TrendingFeedSessionManager.test.ts +++ b/app/components/UI/Trending/services/TrendingFeedSessionManager.test.ts @@ -1,5 +1,10 @@ import { AppState } from 'react-native'; -import TrendingFeedSessionManager from './TrendingFeedSessionManager'; +import TrendingFeedSessionManager, { + TrendingInteractionType, + TokenClickProperties, + SearchProperties, + FilterChangeProperties, +} from './TrendingFeedSessionManager'; import { MetaMetricsEvents } from '../../../../core/Analytics'; import { MetricsEventBuilder } from '../../../../core/Analytics/MetricsEventBuilder'; import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; @@ -97,6 +102,7 @@ describe('TrendingFeedSessionManager', () => { ); expect(mockEventBuilder.addProperties).toHaveBeenCalledWith({ session_id: 'mock-session-id', + interaction_type: TrendingInteractionType.SessionStart, session_time: 0, is_session_end: false, entry_point: entryPoint, @@ -138,6 +144,7 @@ describe('TrendingFeedSessionManager', () => { expect(mockEventBuilder.addProperties).toHaveBeenCalledWith({ session_id: 'mock-session-id', + interaction_type: TrendingInteractionType.SessionStart, session_time: 0, is_session_end: false, entry_point: 'main_trade_button', @@ -159,6 +166,7 @@ describe('TrendingFeedSessionManager', () => { expect(mockEventBuilder.addProperties).toHaveBeenCalledWith({ session_id: 'mock-session-id', + interaction_type: TrendingInteractionType.SessionEnd, session_time: 5, is_session_end: true, entry_point: 'homepage_balance', @@ -239,6 +247,7 @@ describe('TrendingFeedSessionManager', () => { expect(mockEventBuilder.addProperties).toHaveBeenCalledWith( expect.objectContaining({ + interaction_type: TrendingInteractionType.SessionEnd, session_time: 3, is_session_end: true, }), @@ -254,6 +263,7 @@ describe('TrendingFeedSessionManager', () => { expect(mockEventBuilder.addProperties).toHaveBeenCalledWith( expect.objectContaining({ + interaction_type: TrendingInteractionType.SessionEnd, session_time: 2, is_session_end: true, }), @@ -276,6 +286,7 @@ describe('TrendingFeedSessionManager', () => { expect(mockEventBuilder.addProperties).toHaveBeenCalledWith( expect.objectContaining({ session_id: 'mock-session-id', + interaction_type: TrendingInteractionType.SessionStart, entry_point: 'background', is_session_end: false, }), @@ -363,6 +374,7 @@ describe('TrendingFeedSessionManager', () => { expect(mockEventBuilder.addProperties).toHaveBeenCalledWith( expect.objectContaining({ session_id: 'mock-session-id', + interaction_type: TrendingInteractionType.SessionStart, session_time: 0, entry_point: 'homepage_trending', is_session_end: false, @@ -383,6 +395,7 @@ describe('TrendingFeedSessionManager', () => { expect(mockEventBuilder.addProperties).toHaveBeenCalledWith( expect.objectContaining({ session_id: 'mock-session-id', + interaction_type: TrendingInteractionType.SessionEnd, session_time: 5, is_session_end: true, }), @@ -431,4 +444,309 @@ describe('TrendingFeedSessionManager', () => { expect(sessionManager.isFromTrending).toBe(true); }); }); + + describe('trackTokenClick', () => { + const mockTokenClickProperties: TokenClickProperties = { + token_symbol: 'ETH', + token_address: '0x0000000000000000000000000000000000000000', + token_name: 'Ethereum', + chain_id: '0x1', + position: 0, + price_usd: 2500.5, + price_change_pct: 5.25, + time_filter: '24h', + sort_option: 'price_change', + network_filter: 'all', + is_search_result: false, + }; + + it('tracks token click event with correct properties when session is active', () => { + sessionManager.startSession('trending_feed'); + mockTrackEvent.mockClear(); + (mockEventBuilder.addProperties as jest.Mock).mockClear(); + + sessionManager.trackTokenClick(mockTokenClickProperties); + + expect(MetricsEventBuilder.createEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.TRENDING_FEED_VIEWED, + ); + expect(mockEventBuilder.addProperties).toHaveBeenCalledWith({ + session_id: 'mock-session-id', + interaction_type: TrendingInteractionType.TokenClick, + ...mockTokenClickProperties, + }); + expect(mockTrackEvent).toHaveBeenCalled(); + }); + + it('does not track token click when no session is active', () => { + mockTrackEvent.mockClear(); + + sessionManager.trackTokenClick(mockTokenClickProperties); + + expect(mockTrackEvent).not.toHaveBeenCalled(); + expect(DevLogger.log).toHaveBeenCalledWith( + 'TrendingFeedSessionManager: Cannot track token_click - no active session', + ); + }); + + it('tracks token click with search result flag', () => { + sessionManager.startSession('trending_feed'); + mockTrackEvent.mockClear(); + (mockEventBuilder.addProperties as jest.Mock).mockClear(); + + const searchResultProperties: TokenClickProperties = { + ...mockTokenClickProperties, + is_search_result: true, + }; + + sessionManager.trackTokenClick(searchResultProperties); + + expect(mockEventBuilder.addProperties).toHaveBeenCalledWith( + expect.objectContaining({ + interaction_type: TrendingInteractionType.TokenClick, + is_search_result: true, + }), + ); + }); + + it('tracks token click with different positions', () => { + sessionManager.startSession('trending_feed'); + mockTrackEvent.mockClear(); + (mockEventBuilder.addProperties as jest.Mock).mockClear(); + + const positionProperties: TokenClickProperties = { + ...mockTokenClickProperties, + position: 5, + }; + + sessionManager.trackTokenClick(positionProperties); + + expect(mockEventBuilder.addProperties).toHaveBeenCalledWith( + expect.objectContaining({ + interaction_type: TrendingInteractionType.TokenClick, + position: 5, + }), + ); + }); + + it('logs token click details to DevLogger', () => { + sessionManager.startSession('trending_feed'); + (DevLogger.log as jest.Mock).mockClear(); + + sessionManager.trackTokenClick(mockTokenClickProperties); + + expect(DevLogger.log).toHaveBeenCalledWith( + 'TrendingFeedSessionManager: Token click tracked', + expect.objectContaining({ + sessionId: 'mock-session-id', + token_symbol: 'ETH', + position: 0, + }), + ); + }); + }); + + describe('trackSearch', () => { + const mockSearchProperties: SearchProperties = { + search_query: 'ethereum', + results_count: 5, + has_results: true, + time_filter: '24h', + sort_option: 'price_change', + network_filter: 'all', + }; + + it('tracks search event with correct properties when session is active', () => { + sessionManager.startSession('trending_feed'); + mockTrackEvent.mockClear(); + (mockEventBuilder.addProperties as jest.Mock).mockClear(); + + sessionManager.trackSearch(mockSearchProperties); + + expect(MetricsEventBuilder.createEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.TRENDING_FEED_VIEWED, + ); + expect(mockEventBuilder.addProperties).toHaveBeenCalledWith({ + session_id: 'mock-session-id', + interaction_type: TrendingInteractionType.Search, + ...mockSearchProperties, + }); + expect(mockTrackEvent).toHaveBeenCalled(); + }); + + it('does not track search when no session is active', () => { + mockTrackEvent.mockClear(); + + sessionManager.trackSearch(mockSearchProperties); + + expect(mockTrackEvent).not.toHaveBeenCalled(); + expect(DevLogger.log).toHaveBeenCalledWith( + 'TrendingFeedSessionManager: Cannot track search - no active session', + ); + }); + + it('tracks search with zero results', () => { + sessionManager.startSession('trending_feed'); + mockTrackEvent.mockClear(); + (mockEventBuilder.addProperties as jest.Mock).mockClear(); + + const noResultsProperties: SearchProperties = { + ...mockSearchProperties, + results_count: 0, + has_results: false, + }; + + sessionManager.trackSearch(noResultsProperties); + + expect(mockEventBuilder.addProperties).toHaveBeenCalledWith( + expect.objectContaining({ + interaction_type: TrendingInteractionType.Search, + results_count: 0, + has_results: false, + }), + ); + }); + + it('logs search details to DevLogger', () => { + sessionManager.startSession('trending_feed'); + (DevLogger.log as jest.Mock).mockClear(); + + sessionManager.trackSearch(mockSearchProperties); + + expect(DevLogger.log).toHaveBeenCalledWith( + 'TrendingFeedSessionManager: Search tracked', + expect.objectContaining({ + sessionId: 'mock-session-id', + search_query: 'ethereum', + results_count: 5, + }), + ); + }); + }); + + describe('trackFilterChange', () => { + const mockFilterChangeProperties: FilterChangeProperties = { + filter_type: 'time', + previous_value: '24h', + new_value: '6h', + time_filter: '6h', + sort_option: 'price_change', + network_filter: 'all', + }; + + it('tracks filter change event with correct properties when session is active', () => { + sessionManager.startSession('trending_feed'); + mockTrackEvent.mockClear(); + (mockEventBuilder.addProperties as jest.Mock).mockClear(); + + sessionManager.trackFilterChange(mockFilterChangeProperties); + + expect(MetricsEventBuilder.createEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.TRENDING_FEED_VIEWED, + ); + expect(mockEventBuilder.addProperties).toHaveBeenCalledWith({ + session_id: 'mock-session-id', + interaction_type: TrendingInteractionType.FilterChange, + ...mockFilterChangeProperties, + }); + expect(mockTrackEvent).toHaveBeenCalled(); + }); + + it('does not track filter change when no session is active', () => { + mockTrackEvent.mockClear(); + + sessionManager.trackFilterChange(mockFilterChangeProperties); + + expect(mockTrackEvent).not.toHaveBeenCalled(); + expect(DevLogger.log).toHaveBeenCalledWith( + 'TrendingFeedSessionManager: Cannot track filter_change - no active session', + ); + }); + + it('tracks time filter changes', () => { + sessionManager.startSession('trending_feed'); + mockTrackEvent.mockClear(); + (mockEventBuilder.addProperties as jest.Mock).mockClear(); + + sessionManager.trackFilterChange(mockFilterChangeProperties); + + expect(mockEventBuilder.addProperties).toHaveBeenCalledWith( + expect.objectContaining({ + interaction_type: TrendingInteractionType.FilterChange, + filter_type: 'time', + previous_value: '24h', + new_value: '6h', + }), + ); + }); + + it('tracks sort filter changes', () => { + sessionManager.startSession('trending_feed'); + mockTrackEvent.mockClear(); + (mockEventBuilder.addProperties as jest.Mock).mockClear(); + + const sortFilterChange: FilterChangeProperties = { + filter_type: 'sort', + previous_value: 'price_change', + new_value: 'volume', + time_filter: '24h', + sort_option: 'volume', + network_filter: 'all', + }; + + sessionManager.trackFilterChange(sortFilterChange); + + expect(mockEventBuilder.addProperties).toHaveBeenCalledWith( + expect.objectContaining({ + interaction_type: TrendingInteractionType.FilterChange, + filter_type: 'sort', + previous_value: 'price_change', + new_value: 'volume', + }), + ); + }); + + it('tracks network filter changes', () => { + sessionManager.startSession('trending_feed'); + mockTrackEvent.mockClear(); + (mockEventBuilder.addProperties as jest.Mock).mockClear(); + + const networkFilterChange: FilterChangeProperties = { + filter_type: 'network', + previous_value: 'all', + new_value: 'eip155:1', + time_filter: '24h', + sort_option: 'price_change', + network_filter: 'eip155:1', + }; + + sessionManager.trackFilterChange(networkFilterChange); + + expect(mockEventBuilder.addProperties).toHaveBeenCalledWith( + expect.objectContaining({ + interaction_type: TrendingInteractionType.FilterChange, + filter_type: 'network', + previous_value: 'all', + new_value: 'eip155:1', + }), + ); + }); + + it('logs filter change details to DevLogger', () => { + sessionManager.startSession('trending_feed'); + (DevLogger.log as jest.Mock).mockClear(); + + sessionManager.trackFilterChange(mockFilterChangeProperties); + + expect(DevLogger.log).toHaveBeenCalledWith( + 'TrendingFeedSessionManager: Filter change tracked', + expect.objectContaining({ + sessionId: 'mock-session-id', + filter_type: 'time', + previous_value: '24h', + new_value: '6h', + }), + ); + }); + }); }); diff --git a/app/components/UI/Trending/services/TrendingFeedSessionManager.ts b/app/components/UI/Trending/services/TrendingFeedSessionManager.ts index 37fccca647f6..ab6755cc8962 100644 --- a/app/components/UI/Trending/services/TrendingFeedSessionManager.ts +++ b/app/components/UI/Trending/services/TrendingFeedSessionManager.ts @@ -4,6 +4,86 @@ import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; import { MetaMetrics, MetaMetricsEvents } from '../../../../core/Analytics'; import { MetricsEventBuilder } from '../../../../core/Analytics/MetricsEventBuilder'; +/** + * Interaction types for Trending Feed analytics events + */ +export enum TrendingInteractionType { + SessionStart = 'session_start', + SessionEnd = 'session_end', + TokenClick = 'token_click', + Search = 'search', + FilterChange = 'filter_change', +} + +/** + * Properties for token click tracking + */ +export interface TokenClickProperties { + /** Token symbol clicked */ + token_symbol: string; + /** Token contract address */ + token_address: string; + /** Token display name */ + token_name: string; + /** Network chain ID (hex format) */ + chain_id: string; + /** 0-indexed position in list */ + position: number; + /** Token price at click time (USD) */ + price_usd: number; + /** Price change percentage */ + price_change_pct: number; + /** Active time filter (e.g., '24h', '6h', '1h', '5m') */ + time_filter: string; + /** Active sort option (e.g., 'price_change', 'volume', 'market_cap') */ + sort_option: string; + /** Active network filter (e.g., 'all' or specific chain ID) */ + network_filter: string; + /** Was this from search results? */ + is_search_result: boolean; +} + +/** + * Properties for search tracking + */ +export interface SearchProperties { + /** The search query entered */ + search_query: string; + /** Number of results returned */ + results_count: number; + /** Whether search returned any results */ + has_results: boolean; + /** Active time filter (e.g., '24h', '6h', '1h', '5m') */ + time_filter: string; + /** Active sort option (e.g., 'price_change', 'volume', 'market_cap') */ + sort_option: string; + /** Active network filter (e.g., 'all' or specific chain ID) */ + network_filter: string; +} + +/** + * Filter types for filter change tracking + */ +type FilterType = 'time' | 'sort' | 'network'; + +/** + * Properties for filter change tracking + */ +export interface FilterChangeProperties { + /** Type of filter that changed */ + filter_type: FilterType; + /** Previous filter value */ + previous_value: string; + /** New filter value */ + new_value: string; + /** Active time filter (e.g., '24h', '6h', '1h', '5m') */ + time_filter: string; + /** Active sort option (e.g., 'price_change', 'volume', 'market_cap') */ + sort_option: string; + /** Active network filter (e.g., 'all' or specific chain ID) */ + network_filter: string; +} + /** * Singleton manager for Trending Feed session tracking * Handles session lifecycle, timing, and analytics events @@ -159,13 +239,17 @@ class TrendingFeedSessionManager { } /** - * Track feed viewed event + * Track feed viewed event with interaction type */ - private trackEvent(isSessionEnd: boolean = false): void { + private trackEvent( + interactionType: TrendingInteractionType, + isSessionEnd: boolean = false, + ): void { if (!this.sessionId) return; const analyticsProperties = { session_id: this.sessionId, + interaction_type: interactionType, session_time: this.getElapsedTime(), is_session_end: isSessionEnd, entry_point: this.entryPoint, @@ -180,6 +264,100 @@ class TrendingFeedSessionManager { ); } + /** + * Private helper to track interaction events with shared logic + * Encapsulates session validation, analytics properties building, logging, and event tracking + * + * @param interactionType - The type of interaction being tracked + * @param properties - Additional properties to include in the event + * @param logMessage - Message for DevLogger + * @param logContext - Additional context for DevLogger (merged with sessionId) + */ + private trackInteraction( + interactionType: TrendingInteractionType, + properties: + | TokenClickProperties + | SearchProperties + | FilterChangeProperties, + logMessage: string, + logContext: Record, + ): void { + if (!this.sessionId) { + DevLogger.log( + `TrendingFeedSessionManager: Cannot track ${interactionType} - no active session`, + ); + return; + } + + const analyticsProperties = { + session_id: this.sessionId, + interaction_type: interactionType, + ...properties, + }; + + DevLogger.log(logMessage, { + sessionId: this.sessionId, + ...logContext, + }); + + MetaMetrics.getInstance().trackEvent( + MetricsEventBuilder.createEventBuilder( + MetaMetricsEvents.TRENDING_FEED_VIEWED, + ) + .addProperties(analyticsProperties) + .build(), + ); + } + + /** + * Track token click event + * @param properties - Token click properties including position, filters, etc. + */ + public trackTokenClick(properties: TokenClickProperties): void { + this.trackInteraction( + TrendingInteractionType.TokenClick, + properties, + 'TrendingFeedSessionManager: Token click tracked', + { + token_symbol: properties.token_symbol, + position: properties.position, + }, + ); + } + + /** + * Track search event + * @param properties - Search properties including query, results count, etc. + */ + public trackSearch(properties: SearchProperties): void { + this.trackInteraction( + TrendingInteractionType.Search, + properties, + 'TrendingFeedSessionManager: Search tracked', + { + search_query: properties.search_query, + results_count: properties.results_count, + }, + ); + } + + /** + * Track filter change event + * @param properties - Filter change properties including type, previous/new values + */ + public trackFilterChange(properties: FilterChangeProperties): void { + this.trackInteraction( + TrendingInteractionType.FilterChange, + properties, + 'TrendingFeedSessionManager: Filter change tracked', + { + filter_type: properties.filter_type, + previous_value: properties.previous_value, + new_value: properties.new_value, + }, + ); + } + /** * Start a new session * @param entryPoint - How the user entered the feed @@ -213,8 +391,8 @@ class TrendingFeedSessionManager { entryPoint, }); - // Track initial event - this.trackEvent(false); + // Track initial event with session_start interaction type + this.trackEvent(TrendingInteractionType.SessionStart, false); } /** @@ -234,8 +412,8 @@ class TrendingFeedSessionManager { finalTime: this.getElapsedTime(), }); - // Send final event - this.trackEvent(true); + // Send final event with session_end interaction type + this.trackEvent(TrendingInteractionType.SessionEnd, true); // Mark as ended (but keep state for debugging until next startSession) this.sessionEnded = true; diff --git a/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.test.tsx b/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.test.tsx index bfeba8be636f..087d4289c649 100644 --- a/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.test.tsx +++ b/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.test.tsx @@ -557,4 +557,244 @@ describe('TrendingTokensFullView', () => { expect(mockFetchTrendingTokens).toHaveBeenCalledTimes(1); }); + + describe('trendingTokens sorting logic', () => { + it('returns search results in relevance order when search query is present', async () => { + const mockTokens = [ + createMockToken({ + name: 'Ethereum', + symbol: 'ETH', + assetId: 'eip155:1/erc20:0x111', + aggregatedUsdVolume: 1000, + }), + createMockToken({ + name: 'Bitcoin', + symbol: 'BTC', + assetId: 'eip155:1/erc20:0x222', + aggregatedUsdVolume: 5000, + }), + ]; + + mockUseTrendingSearch.mockReturnValue({ + data: mockTokens, + isLoading: false, + refetch: jest.fn(), + }); + + const { getByTestId, getByText } = renderWithProvider( + , + { state: mockState }, + false, + ); + + // Open search and type a query + const searchToggle = getByTestId('trending-tokens-header-search-toggle'); + fireEvent.press(searchToggle); + + const searchInput = getByTestId('trending-tokens-header-search-bar'); + fireEvent.changeText(searchInput, 'eth'); + + // Tokens should be displayed in original order (relevance), not sorted + // Even if we select a sort option, search results should maintain relevance order + expect(getByText('Ethereum')).toBeOnTheScreen(); + expect(getByText('Bitcoin')).toBeOnTheScreen(); + }); + + it('returns results without sorting when no price change option is selected', () => { + const mockTokens = [ + createMockToken({ + name: 'Token A', + assetId: 'eip155:1/erc20:0xaaa', + aggregatedUsdVolume: 100, + }), + createMockToken({ + name: 'Token B', + assetId: 'eip155:1/erc20:0xbbb', + aggregatedUsdVolume: 500, + }), + createMockToken({ + name: 'Token C', + assetId: 'eip155:1/erc20:0xccc', + aggregatedUsdVolume: 300, + }), + ]; + + mockUseTrendingSearch.mockReturnValue({ + data: mockTokens, + isLoading: false, + refetch: jest.fn(), + }); + + const { getByTestId, getByText } = renderWithProvider( + , + { state: mockState }, + false, + ); + + // No price change option selected by default, tokens should be in original order + expect(getByTestId('trending-tokens-list')).toBeOnTheScreen(); + expect(getByText('Token A')).toBeOnTheScreen(); + expect(getByText('Token B')).toBeOnTheScreen(); + expect(getByText('Token C')).toBeOnTheScreen(); + }); + + it('returns empty array when search results are empty', () => { + mockUseTrendingSearch.mockReturnValue({ + data: [], + isLoading: false, + refetch: jest.fn(), + }); + + const { getByTestId } = renderWithProvider( + , + { state: mockState }, + false, + ); + + // Should show empty state, not the tokens list + expect(getByTestId('empty-error-trending-state')).toBeOnTheScreen(); + }); + + it('applies sorting when price change option is selected and no search query', async () => { + const mockTokens = [ + createMockToken({ + name: 'Low Volume Token', + assetId: 'eip155:1/erc20:0x111', + aggregatedUsdVolume: 100, + }), + createMockToken({ + name: 'High Volume Token', + assetId: 'eip155:1/erc20:0x222', + aggregatedUsdVolume: 10000, + }), + createMockToken({ + name: 'Medium Volume Token', + assetId: 'eip155:1/erc20:0x333', + aggregatedUsdVolume: 1000, + }), + ]; + + mockUseTrendingSearch.mockReturnValue({ + data: mockTokens, + isLoading: false, + refetch: jest.fn(), + }); + + const { getByTestId, getByText } = renderWithProvider( + , + { state: mockState }, + false, + ); + + // Open price change bottom sheet + const priceChangeButton = getByTestId('price-change-button'); + fireEvent.press(priceChangeButton); + + // Select Volume option + const volumeOption = getByTestId('price-change-select-volume'); + await act(async () => { + fireEvent(volumeOption, 'touchEnd'); + }); + + // The tokens should now be sorted by volume + // Note: The actual sorting is done by sortTrendingTokens utility + expect(getByText('Low Volume Token')).toBeOnTheScreen(); + expect(getByText('High Volume Token')).toBeOnTheScreen(); + expect(getByText('Medium Volume Token')).toBeOnTheScreen(); + }); + + it('does not apply sorting when search query is present even with price change option', async () => { + const mockTokens = [ + createMockToken({ + name: 'Ethereum Classic', + symbol: 'ETC', + assetId: 'eip155:1/erc20:0x111', + aggregatedUsdVolume: 100, + }), + createMockToken({ + name: 'Ethereum', + symbol: 'ETH', + assetId: 'eip155:1/erc20:0x222', + aggregatedUsdVolume: 50000, + }), + ]; + + mockUseTrendingSearch.mockReturnValue({ + data: mockTokens, + isLoading: false, + refetch: jest.fn(), + }); + + const { getByTestId, getByText } = renderWithProvider( + , + { state: mockState }, + false, + ); + + // First select a price change option + const priceChangeButton = getByTestId('price-change-button'); + fireEvent.press(priceChangeButton); + + const volumeOption = getByTestId('price-change-select-volume'); + await act(async () => { + fireEvent(volumeOption, 'touchEnd'); + }); + + // Now open search and type a query + const searchToggle = getByTestId('trending-tokens-header-search-toggle'); + fireEvent.press(searchToggle); + + const searchInput = getByTestId('trending-tokens-header-search-bar'); + fireEvent.changeText(searchInput, 'eth'); + + // Even with volume sort selected, search results should maintain relevance order + // (Ethereum Classic first because that's the order returned by mock) + expect(getByText('Ethereum Classic')).toBeOnTheScreen(); + expect(getByText('Ethereum')).toBeOnTheScreen(); + }); + + it('clears search and shows sorted results when search is dismissed', async () => { + const mockTokens = [ + createMockToken({ + name: 'Token X', + assetId: 'eip155:1/erc20:0xaaa', + }), + createMockToken({ + name: 'Token Y', + assetId: 'eip155:1/erc20:0xbbb', + }), + ]; + + mockUseTrendingSearch.mockReturnValue({ + data: mockTokens, + isLoading: false, + refetch: jest.fn(), + }); + + const { getByTestId, getByText, queryByTestId } = renderWithProvider( + , + { state: mockState }, + false, + ); + + // Open search + const searchToggle = getByTestId('trending-tokens-header-search-toggle'); + fireEvent.press(searchToggle); + + // Type search query + const searchInput = getByTestId('trending-tokens-header-search-bar'); + fireEvent.changeText(searchInput, 'token'); + + // Verify search is active + expect(searchInput.props.value).toBe('token'); + + // Clear search by changing text to empty + fireEvent.changeText(searchInput, ''); + + // Results should still be displayed + expect(getByText('Token X')).toBeOnTheScreen(); + expect(getByText('Token Y')).toBeOnTheScreen(); + expect(queryByTestId('empty-search-result-state')).not.toBeOnTheScreen(); + }); + }); }); diff --git a/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.tsx b/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.tsx index 1396185d0d84..746e76cbe4d4 100644 --- a/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.tsx +++ b/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.tsx @@ -23,7 +23,9 @@ import Icon, { } from '../../../../component-library/components/Icons/Icon'; import { strings } from '../../../../../locales/i18n'; import { TrendingListHeader } from '../../../UI/Trending/components/TrendingListHeader'; -import TrendingTokensList from '../../../UI/Trending/components/TrendingTokensList/TrendingTokensList'; +import TrendingTokensList, { + TrendingFilterContext, +} from '../../../UI/Trending/components/TrendingTokensList/TrendingTokensList'; import TrendingTokensSkeleton from '../../../UI/Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton'; import { SortTrendingBy, @@ -44,6 +46,8 @@ import { sortTrendingTokens } from '../../../UI/Trending/utils/sortTrendingToken import { useTrendingSearch } from '../../../UI/Trending/hooks/useTrendingSearch/useTrendingSearch'; import EmptyErrorTrendingState from '../../TrendingView/components/EmptyErrorState/EmptyErrorTrendingState'; import EmptySearchResultState from '../../TrendingView/components/EmptyErrorState/EmptySearchResultState'; +import TrendingFeedSessionManager from '../../../UI/Trending/services/TrendingFeedSessionManager'; +import { useSearchTracking } from '../../../UI/Trending/hooks/useSearchTracking/useSearchTracking'; interface TrendingTokensNavigationParamList { [key: string]: undefined | object; @@ -135,6 +139,7 @@ const TrendingTokensFullView = () => { const theme = useAppThemeFromContext(); const styles = useMemo(() => createStyles(theme), [theme]); const insets = useSafeAreaInsets(); + const sessionManager = TrendingFeedSessionManager.getInstance(); const [sortBy, setSortBy] = useState(undefined); const [selectedTimeOption, setSelectedTimeOption] = useState( TimeOption.TwentyFourHours, @@ -253,21 +258,102 @@ const TrendingTokensFullView = () => { selectedTimeOption, ]); + // Compute filter context for analytics tracking + const filterContext: TrendingFilterContext = useMemo( + () => ({ + timeFilter: selectedTimeOption, + sortOption: selectedPriceChangeOption, + networkFilter: + selectedNetwork && selectedNetwork.length > 0 + ? selectedNetwork[0] + : 'all', + isSearchResult: Boolean(searchQuery?.trim()), + }), + [ + selectedTimeOption, + selectedPriceChangeOption, + selectedNetwork, + searchQuery, + ], + ); + + // Track search events with debounce + useSearchTracking({ + searchQuery, + resultsCount: trendingTokens.length, + isLoading, + timeFilter: selectedTimeOption, + sortOption: selectedPriceChangeOption || PriceChangeOption.PriceChange, + networkFilter: + selectedNetwork && selectedNetwork.length > 0 + ? selectedNetwork[0] + : 'all', + }); + const handlePriceChangeSelect = useCallback( (option: PriceChangeOption, sortDirection: SortDirection) => { + const previousValue = + selectedPriceChangeOption || PriceChangeOption.PriceChange; setSelectedPriceChangeOption(option); setPriceChangeSortDirection(sortDirection); + + // Track filter change if value actually changed + if (option !== previousValue) { + sessionManager.trackFilterChange({ + filter_type: 'sort', + previous_value: previousValue, + new_value: option, + time_filter: selectedTimeOption, + sort_option: option, + network_filter: + selectedNetwork && selectedNetwork.length > 0 + ? selectedNetwork[0] + : 'all', + }); + } }, - [], + [ + selectedPriceChangeOption, + selectedTimeOption, + selectedNetwork, + sessionManager, + ], ); const handlePriceChangePress = useCallback(() => { setShowPriceChangeBottomSheet(true); }, []); - const handleNetworkSelect = useCallback((chainIds: CaipChainId[] | null) => { - setSelectedNetwork(chainIds); - }, []); + const handleNetworkSelect = useCallback( + (chainIds: CaipChainId[] | null) => { + const previousValue = + selectedNetwork && selectedNetwork.length > 0 + ? selectedNetwork[0] + : 'all'; + const newValue = chainIds && chainIds.length > 0 ? chainIds[0] : 'all'; + + setSelectedNetwork(chainIds); + + // Track filter change if value actually changed + if (newValue !== previousValue) { + sessionManager.trackFilterChange({ + filter_type: 'network', + previous_value: previousValue, + new_value: newValue, + time_filter: selectedTimeOption, + sort_option: + selectedPriceChangeOption || PriceChangeOption.PriceChange, + network_filter: newValue, + }); + } + }, + [ + selectedNetwork, + selectedTimeOption, + selectedPriceChangeOption, + sessionManager, + ], + ); const handleAllNetworksPress = useCallback(() => { setShowNetworkBottomSheet(true); @@ -275,10 +361,32 @@ const TrendingTokensFullView = () => { const handleTimeSelect = useCallback( (selectedSortBy: SortTrendingBy, timeOption: TimeOption) => { + const previousValue = selectedTimeOption; setSortBy(selectedSortBy); setSelectedTimeOption(timeOption); + + // Track filter change if value actually changed + if (timeOption !== previousValue) { + sessionManager.trackFilterChange({ + filter_type: 'time', + previous_value: previousValue, + new_value: timeOption, + time_filter: timeOption, + sort_option: + selectedPriceChangeOption || PriceChangeOption.PriceChange, + network_filter: + selectedNetwork && selectedNetwork.length > 0 + ? selectedNetwork[0] + : 'all', + }); + } }, - [], + [ + selectedTimeOption, + selectedPriceChangeOption, + selectedNetwork, + sessionManager, + ], ); const handle24hPress = useCallback(() => { @@ -424,6 +532,7 @@ const TrendingTokensFullView = () => { = ({ } }, [searchQuery, flatData.length]); + // Track search events for tokens section + useSearchTracking({ + searchQuery, + resultsCount: data.tokens?.length || 0, + isLoading: isLoading.tokens, + timeFilter: TimeOption.TwentyFourHours, + sortOption: 'relevance', + networkFilter: 'all', + }); + const renderFooter = useMemo(() => { if (searchQuery.length === 0) return null; @@ -118,7 +130,7 @@ const ExploreSearchResults: React.FC = ({ }, [searchQuery]); const renderFlatItem: ListRenderItem = useCallback( - ({ item }) => { + ({ item, index }) => { if (item.type === 'header') { return renderSectionHeader(item.data); } @@ -137,13 +149,20 @@ const ExploreSearchResults: React.FC = ({ return ( ); } // Cast navigation to 'never' to satisfy different navigation param list types - return ; + return ( + + ); }, [navigation, renderSectionHeader], ); diff --git a/app/components/Views/TrendingView/components/Sections/SectionTypes/SectionCard.tsx b/app/components/Views/TrendingView/components/Sections/SectionTypes/SectionCard.tsx index 560304917a95..d90737b01889 100644 --- a/app/components/Views/TrendingView/components/Sections/SectionTypes/SectionCard.tsx +++ b/app/components/Views/TrendingView/components/Sections/SectionTypes/SectionCard.tsx @@ -36,7 +36,9 @@ const SectionCard: React.FC = ({ const section = SECTIONS_CONFIG[sectionId]; const renderFlatItem: ListRenderItem = useCallback( - ({ item }) => , + ({ item, index }) => ( + + ), [navigation, section], ); diff --git a/app/components/Views/TrendingView/components/Sections/SectionTypes/SectionCarrousel.tsx b/app/components/Views/TrendingView/components/Sections/SectionTypes/SectionCarrousel.tsx index e16aa318d446..a2335019260f 100644 --- a/app/components/Views/TrendingView/components/Sections/SectionTypes/SectionCarrousel.tsx +++ b/app/components/Views/TrendingView/components/Sections/SectionTypes/SectionCarrousel.tsx @@ -49,7 +49,11 @@ const SectionCarrousel: React.FC = ({ {isLoading ? ( ) : ( - + )} diff --git a/app/components/Views/TrendingView/sections.config.tsx b/app/components/Views/TrendingView/sections.config.tsx index 265d5c60d4f3..475093e1a40f 100644 --- a/app/components/Views/TrendingView/sections.config.tsx +++ b/app/components/Views/TrendingView/sections.config.tsx @@ -22,6 +22,11 @@ import SiteRowItemWrapper from '../../UI/Sites/components/SiteRowItemWrapper/Sit import SiteSkeleton from '../../UI/Sites/components/SiteSkeleton/SiteSkeleton'; import { useSitesData } from '../../UI/Sites/hooks/useSiteData/useSitesData'; import { useTrendingSearch } from '../../UI/Trending/hooks/useTrendingSearch/useTrendingSearch'; +import { + TimeOption, + PriceChangeOption, +} from '../../UI/Trending/components/TrendingTokensBottomSheet'; +import type { TrendingFilterContext } from '../../UI/Trending/components/TrendingTokensList/TrendingTokensList'; import { filterMarketsByQuery } from '../../UI/Perps/utils/marketUtils'; import PredictMarketRowItem from '../../UI/Predict/components/PredictMarketRowItem'; import SectionCard from './components/Sections/SectionTypes/SectionCard'; @@ -41,10 +46,12 @@ interface SectionConfig { viewAllAction: (navigation: NavigationProp) => void; RowItem: React.ComponentType<{ item: unknown; + index: number; navigation: NavigationProp; }>; OverrideRowItemSearch?: React.ComponentType<{ item: unknown; + index?: number; navigation: NavigationProp; }>; Skeleton: React.ComponentType; @@ -123,6 +130,28 @@ const PREDICTIONS_FUSE_OPTIONS: FuseOptions = { * - Section headers with "View All" navigation */ +/** + * Default filter context for tokens in the Trending View home section. + * Used for analytics tracking of token clicks from the home page. + */ +const DEFAULT_TOKENS_FILTER_CONTEXT: TrendingFilterContext = { + timeFilter: TimeOption.TwentyFourHours, + sortOption: PriceChangeOption.PriceChange, + networkFilter: 'all', + isSearchResult: false, +}; + +/** + * Filter context for tokens in search results on the Explore page. + * Used for analytics tracking of token clicks from search results. + */ +const SEARCH_TOKENS_FILTER_CONTEXT: TrendingFilterContext = { + timeFilter: TimeOption.TwentyFourHours, + sortOption: PriceChangeOption.PriceChange, + networkFilter: 'all', + isSearchResult: true, +}; + export const SECTIONS_CONFIG: Record = { tokens: { id: 'tokens', @@ -131,8 +160,19 @@ export const SECTIONS_CONFIG: Record = { viewAllAction: (navigation) => { navigation.navigate(Routes.WALLET.TRENDING_TOKENS_FULL_VIEW); }, - RowItem: ({ item }) => ( - + RowItem: ({ item, index }) => ( + + ), + OverrideRowItemSearch: ({ item, index }) => ( + ), Skeleton: TrendingTokensSkeleton, Section: SectionCard, @@ -167,7 +207,7 @@ export const SECTIONS_CONFIG: Record = { }, }); }, - RowItem: ({ item, navigation }) => ( + RowItem: ({ item, index: _index, navigation }) => ( { @@ -217,7 +257,7 @@ export const SECTIONS_CONFIG: Record = { screen: Routes.PREDICT.MARKET_LIST, }); }, - RowItem: ({ item }) => ( + RowItem: ({ item, index: _index }) => ( = { viewAllAction: (navigation) => { navigation.navigate(Routes.SITES_FULL_VIEW); }, - RowItem: ({ item, navigation }) => ( + RowItem: ({ item, index: _index, navigation }) => ( ), Skeleton: SiteSkeleton, From 62ccea03fd5d3c4f83e167c8aaf0d2d4ea433846 Mon Sep 17 00:00:00 2001 From: Brian August Nguyen Date: Fri, 30 Jan 2026 08:23:56 -0800 Subject: [PATCH 16/17] chore: Updated headers for settings general page (#25356) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR updates the General Settings page and SelectComponent modal to use the `HeaderCenter` component for consistent header styling across the app. **Changes:** 1. **GeneralSettings page**: Replaced the dynamic navigation header (`getNavigationOptionsTitle`) with an inline `HeaderCenter` component, following the pattern established in the parent Settings page 2. **Navigation config**: Set `headerShown: false` at the stack level for GeneralSettings screens in MainNavigator.js 3. **SafeAreaView**: Wrapped GeneralSettings content in `SafeAreaView` for proper safe area handling 4. **SelectComponent modal**: Updated the "Base currency" (and other select) modal header to use `HeaderCenter` with a close button ## **Changelog** CHANGELOG entry: Updated General Settings page and select modal headers to use consistent HeaderCenter component styling ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/jira/software/c/projects/MDP/boards/2972?assignee=62afb43d33a882e2be47c36f&quickFilter=3325&selectedIssue=MDP-697 ## **Manual testing steps** ```gherkin Feature: General Settings Header Scenario: User navigates to General Settings Given the user is on the Settings page When user taps on "General" Then the General Settings page opens with a centered "General" title header And a back arrow button is visible on the left Scenario: User opens currency selector modal Given the user is on the General Settings page When user taps on the currency dropdown Then a modal opens with "Base currency" as the centered title And a close (X) button is visible on the right When user taps the close button Then the modal closes ``` ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/b75d366c-2479-42da-a9b0-459eef386c99 ## **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 refactor that mainly changes header rendering and related tests; minimal impact to underlying settings behavior. > > **Overview** > **Unifies header UI for General Settings and select modals.** `GeneralSettings` now renders its own `HeaderCenter` (wrapped in `SafeAreaView`) instead of configuring a React Navigation header, and `MainNavigator` forces `headerShown: false` for `GeneralSettings` screens. > > `SelectComponent`โ€™s modal header is replaced with `HeaderCenter` (adds close button handling). Tests were migrated from Enzyme to Testing Library and snapshots updated to reflect the new header structure and navigation interactions. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 95f4c98085e06d99a9fa3b98715a099c725109c8. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/components/Nav/Main/MainNavigator.js | 7 +- .../__snapshots__/MainNavigator.test.tsx.snap | 6 +- app/components/UI/SelectComponent/index.js | 20 +- .../__snapshots__/index.test.tsx.snap | 1034 ++++++++++++++++- .../Views/Settings/GeneralSettings/index.js | 431 ++++--- .../Settings/GeneralSettings/index.test.tsx | 71 +- 6 files changed, 1292 insertions(+), 277 deletions(-) diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index 19123a792a6c..f0d0b3b394ff 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -363,7 +363,7 @@ const SettingsFlow = () => ( { {process.env.METAMASK_ENVIRONMENT !== 'production' && ( @@ -593,7 +593,7 @@ exports[`MainNavigator Tab Bar Visibility shows tab bar when not in browser 1`] name="GeneralSettings" options={ { - "headerShown": true, + "headerShown": false, } } /> @@ -911,7 +911,7 @@ exports[`MainNavigator matches rendered snapshot 1`] = ` name="GeneralSettings" options={ { - "headerShown": true, + "headerShown": false, } } /> diff --git a/app/components/UI/SelectComponent/index.js b/app/components/UI/SelectComponent/index.js index 250bc6dd6ded..1b13a8ff9255 100644 --- a/app/components/UI/SelectComponent/index.js +++ b/app/components/UI/SelectComponent/index.js @@ -14,6 +14,7 @@ import dismissKeyboard from 'react-native/Libraries/Utilities/dismissKeyboard'; import IconCheck from 'react-native-vector-icons/MaterialCommunityIcons'; import Device from '../../../util/device'; import { ThemeContext, mockTheme } from '../../../util/theme'; +import HeaderCenter from '../../../component-library/components-temp/HeaderCenter'; const ROW_HEIGHT = 35; const createStyles = (colors) => @@ -38,21 +39,6 @@ const createStyles = (colors) => paddingBottom: 10, ...fontStyles.normal, }, - accesoryBar: { - width: '100%', - paddingTop: 5, - height: 50, - borderBottomColor: colors.border.muted, - borderBottomWidth: 1, - }, - label: { - textAlign: 'center', - flex: 1, - paddingVertical: 10, - fontSize: 17, - ...fontStyles.bold, - color: colors.text.default, - }, modal: { margin: 0, width: '100%', @@ -196,9 +182,7 @@ export default class SelectComponent extends PureComponent { backdropOpacity={1} > - - {this.props.label} - + {this.props.options.map((option) => ( diff --git a/app/components/Views/Settings/GeneralSettings/__snapshots__/index.test.tsx.snap b/app/components/Views/Settings/GeneralSettings/__snapshots__/index.test.tsx.snap index f1621253752b..9d6bbe30ec8c 100644 --- a/app/components/Views/Settings/GeneralSettings/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/Settings/GeneralSettings/__snapshots__/index.test.tsx.snap @@ -1,32 +1,1018 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`GeneralSettings should render correctly 1`] = ` - - - + + + + + + + + + + + + + + General + + + + + + + + + + + + + Currency conversion + + + Display fiat values in using a specific currency throughout the application. + + + + + + + + + USD - United States Dollar + + + ๎—… + + + + + + + + + + + + Primary currency + + + Select Native to prioritize displaying values in the native currency of the chain (e.g. ETH). Select Fiat to prioritize displaying values in your selected fiat currency. + + + + + + + + + Native + + + + + + + + + + Fiat + + + + + + + + + + Current language + + + Translate the application to a different supported language. + + + + + + + + + English + + + ๎—… + + + + + + + + + + + + Search engine + + + Change the default search engine used when entering search terms in the URL bar. + + + + + + + + + Google + + + ๎—… + + + + + + + + + + + + + Hide tokens without balance + + + + + + + Prevents tokens with no balance from displaying in your token listing. + + + + + Account icon + + + Choose from three different styles of unique icons that can help you identify accounts at a glance. + + + + + + + Polycons + + + + + + Jazzicons + + + + + + Blockies + + + + + + + + + `; diff --git a/app/components/Views/Settings/GeneralSettings/index.js b/app/components/Views/Settings/GeneralSettings/index.js index 647549c92a50..55edf7e3df70 100644 --- a/app/components/Views/Settings/GeneralSettings/index.js +++ b/app/components/Views/Settings/GeneralSettings/index.js @@ -7,6 +7,7 @@ import { View, TouchableOpacity, } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; import { connect } from 'react-redux'; import Engine from '../../../../core/Engine'; @@ -17,7 +18,7 @@ import I18n, { } from '../../../../../locales/i18n'; import SelectComponent from '../../../UI/SelectComponent'; import infuraCurrencies from '../../../../util/infura-conversion.json'; -import { getNavigationOptionsTitle } from '../../../UI/Navbar'; +import HeaderCenter from '../../../../component-library/components-temp/HeaderCenter'; import { setSearchEngine, setPrimaryCurrency, @@ -83,8 +84,10 @@ const createStyles = (colors) => wrapper: { backgroundColor: colors.background.default, flex: 1, + }, + content: { padding: 16, - zIndex: 99999999999999, + flex: 1, }, titleContainer: { flexDirection: 'row', @@ -242,21 +245,7 @@ class Settings extends PureComponent { this.props.setHideZeroBalanceTokens(toggleHideZeroBalanceTokens); }; - updateNavBar = () => { - const { navigation } = this.props; - const colors = this.context.colors || mockTheme.colors; - navigation.setOptions( - getNavigationOptionsTitle( - strings('app_settings.general_title'), - navigation, - false, - colors, - ), - ); - }; - componentDidMount = () => { - this.updateNavBar(); const languages = getLanguages(); this.setState({ languages }); this.languageOptions = Object.keys(languages).map((key) => ({ @@ -282,10 +271,6 @@ class Settings extends PureComponent { ]; }; - componentDidUpdate = () => { - this.updateNavBar(); - }; - // TODO - Reintroduce once we enable manual theme settings // goToThemeSettings = () => { // const { navigation } = this.props; @@ -322,228 +307,236 @@ class Settings extends PureComponent { setAvatarAccountType, selectedAddress, hideZeroBalanceTokens, + navigation, } = this.props; const themeTokens = this.context || mockTheme; const { colors } = themeTokens; const styles = createStyles(colors); return ( - - - - - {strings('app_settings.conversion_title')} - - - {strings('app_settings.conversion_desc')} - - - - - - - - - - {strings('app_settings.primary_currency_title')} - - - {strings('app_settings.primary_currency_desc')} - - {this.primaryCurrencyOptions && ( - - - - )} - - - - {strings('app_settings.current_language')} - - - {strings('app_settings.language_desc')} - - {this.languageOptions && ( + + navigation.goBack()} + includesTopInset + /> + + + + + {strings('app_settings.conversion_title')} + + + {strings('app_settings.conversion_desc')} + - )} - - - - {strings('app_settings.search_engine')} - - - {strings('app_settings.engine_desc')} - - {this.searchEngineOptions && ( - - - + + + {strings('app_settings.primary_currency_title')} + + + {strings('app_settings.primary_currency_desc')} + + {this.primaryCurrencyOptions && ( + + - - )} - - - - - {strings('app_settings.hide_zero_balance_tokens_title')} - - - - + )} - - {strings('app_settings.hide_zero_balance_tokens_desc')} - - - - - {strings('app_settings.accounts_identicon_title')} - - - {strings('app_settings.accounts_identicon_desc')} - - - - - setAvatarAccountType(AvatarAccountType.Maskicon) - } - style={styles.identicon_row} - > - - + + {strings('app_settings.current_language')} + + + {strings('app_settings.language_desc')} + + {this.languageOptions && ( + + + - Polycons - - - setAvatarAccountType(AvatarAccountType.JazzIcon) - } - style={styles.identicon_row} - > - - + )} + + + + {strings('app_settings.search_engine')} + + + {strings('app_settings.engine_desc')} + + {this.searchEngineOptions && ( + + + - - {strings('app_settings.jazzicons')} - - - - setAvatarAccountType(AvatarAccountType.Blockies) - } - style={styles.identicon_row} - > - + )} + + + + + {strings('app_settings.hide_zero_balance_tokens_title')} + + + + + + + {strings('app_settings.hide_zero_balance_tokens_desc')} + + + + + {strings('app_settings.accounts_identicon_title')} + + + {strings('app_settings.accounts_identicon_desc')} + + + + + setAvatarAccountType(AvatarAccountType.Maskicon) + } + style={styles.identicon_row} > - - - - {strings('app_settings.blockies')} - - + + + + Polycons + + + setAvatarAccountType(AvatarAccountType.JazzIcon) + } + style={styles.identicon_row} + > + + + + + {strings('app_settings.jazzicons')} + + + + setAvatarAccountType(AvatarAccountType.Blockies) + } + style={styles.identicon_row} + > + + + + + {strings('app_settings.blockies')} + + + + {/* {this.renderThemeSettingsSection()} */} - {/* {this.renderThemeSettingsSection()} */} - - + + ); } } diff --git a/app/components/Views/Settings/GeneralSettings/index.test.tsx b/app/components/Views/Settings/GeneralSettings/index.test.tsx index 7efbe1fe45cd..bc4c92be3cef 100644 --- a/app/components/Views/Settings/GeneralSettings/index.test.tsx +++ b/app/components/Views/Settings/GeneralSettings/index.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { shallow } from 'enzyme'; +import { render, fireEvent } from '@testing-library/react-native'; import GeneralSettings, { updateUserTraitsWithCurrentCurrency, updateUserTraitsWithCurrencyType, @@ -11,9 +11,37 @@ import { backgroundState } from '../../../../util/test/initial-root-state'; import { MetaMetricsEvents } from '../../../../core/Analytics'; import { UserProfileProperty } from '../../../../util/metrics/UserSettingsAnalyticsMetaData/UserProfileAnalyticsMetaData.types'; import { MetricsEventBuilder } from '../../../../core/Analytics/MetricsEventBuilder'; -import { AvatarAccountType } from '../../../../component-library/components/Avatars/Avatar'; +import { AvatarAccountType } from '../../../../component-library/components/Avatars/Avatar/variants/AvatarAccount'; +import { ThemeContext, mockTheme } from '../../../../util/theme'; jest.mock('../../../../core/Analytics'); +jest.mock('../../../hooks/useMetrics', () => ({ + useMetrics: () => ({ + isEnabled: jest.fn().mockReturnValue(true), + enable: jest.fn(), + addTraitsToUser: jest.fn(), + createEventBuilder: jest.fn(), + trackEvent: jest.fn(), + trackAnonymousEvent: jest.fn(), + getMetaMetricsId: jest.fn(), + }), + withMetricsAwareness: + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (Component: React.ComponentType) => + (props: Record) => , +})); +jest.mock( + '../../../../component-library/components/Avatars/Avatar/variants/AvatarAccount', + () => ({ + __esModule: true, + default: () => null, + AvatarAccountType: { + JazzIcon: 'JazzIcon', + Blockies: 'Blockies', + Maskicon: 'Maskicon', + }, + }), +); const mockStore = configureMockStore(); const initialState = { @@ -31,14 +59,41 @@ const initialState = { }; const store = mockStore(initialState); +const mockNavigation = { + goBack: jest.fn(), + navigate: jest.fn(), +}; + +const renderComponent = () => + render( + + + + + , + ); + describe('GeneralSettings', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should render correctly', () => { - const wrapper = shallow( - - - , - ); - expect(wrapper).toMatchSnapshot(); + const { toJSON } = renderComponent(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders header with correct title', () => { + const { getByText } = renderComponent(); + expect(getByText('General')).toBeTruthy(); + }); + + it('calls navigation.goBack when back button is pressed', () => { + const { getByTestId } = renderComponent(); + const backButton = getByTestId('button-icon'); + fireEvent.press(backButton); + + expect(mockNavigation.goBack).toHaveBeenCalledTimes(1); }); }); From 6f89dec7469bdedc689f2fe016bb4e1dee9f356e Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Sat, 31 Jan 2026 01:26:16 +0800 Subject: [PATCH 17/17] fix(perps): potential rate limit on close positions cp-7.63.0 cp-7.64.0 cp-7.62.2 (#25438) ## **Description** Complete the 429 rate limiting fix for position management operations. The previous fix (commit `425beaead7`) only addressed `updatePositionTPSL()`. This PR extends the fix to `closePosition()`, `closePositions()`, and `updateMargin()` methods. **Problem:** These methods were using `skipCache: true` which forced REST API calls on every operation, leading to 429 rate limiting errors during prolonged app usage. **Solution:** - Remove `skipCache: true` from `closePositions()` and `updateMargin()` to use WebSocket cache - For `closePosition()`, add optional `position` parameter so callers can pass the live WebSocket position directly, avoiding the need to fetch positions entirely - Update `usePerpsClosePosition` hook to pass the position it already has ## **Changelog** CHANGELOG entry: Fixed rate limiting errors (429) when closing positions or updating margin after prolonged app usage ## **Related issues** Fixes: Rate limiting issues during position close/margin update operations ## **Manual testing steps** ```gherkin Feature: Position close without rate limiting Scenario: User closes position after prolonged usage Given user has the app open for extended period (>30 minutes) And user has an open perps position When user closes the position Then the position closes successfully without 429 errors Scenario: User updates margin after prolonged usage Given user has the app open for extended period (>30 minutes) And user has an open perps position with isolated margin When user adjusts the margin Then the margin updates successfully without 429 errors Scenario: User closes all positions Given user has multiple open perps positions When user uses "close all positions" feature Then all positions close successfully without rate limiting errors ``` ## **Screenshots/Recordings** ### **Before** N/A - Bug fix for rate limiting, no UI changes ### **After** N/A - Bug fix for rate limiting, 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** > Touches perps position-management flows (`closePosition`, `closePositions`, `updateMargin`) and changes them to rely on cached/WebSocket position data, which could surface edge cases if the cache is stale or mismatched, but does not alter core order-placement logic. > > **Overview** > Reduces 429 errors in perps position management by stopping forced REST position refreshes during `closePositions` and `updateMargin`, and by updating `closePosition` to accept an optional live `position` payload (with a cache-based fallback). > > Updates `usePerpsClosePosition` (and its tests) to pass the already-available position through, avoiding extra `getPositions()` calls during close flows. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit ccfc17e18d9d37707b3278b86d091d91c1384b30. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Michal Szorad --- .../providers/HyperLiquidProvider.ts | 20 ++++++++++--------- .../UI/Perps/controllers/types/index.ts | 7 +++++++ .../Perps/hooks/usePerpsClosePosition.test.ts | 4 ++++ .../UI/Perps/hooks/usePerpsClosePosition.ts | 2 ++ 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts index 99dcae138200..9ae800796cab 100644 --- a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts +++ b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts @@ -3448,9 +3448,8 @@ export class HyperLiquidProvider implements PerpsProvider { // Ensure provider is ready for trading (includes signing operations) await this.ensureReadyForTrading(); - // Get all current positions - // Force fresh API data (not WebSocket cache) since we're about to mutate positions - const positions = await this.getPositions({ skipCache: true }); + // Get all current positions from cache (avoids 429 rate limiting) + const positions = await this.getPositions(); // Filter positions based on params positionsToClose = @@ -3980,9 +3979,13 @@ export class HyperLiquidProvider implements PerpsProvider { // Ensure provider is ready for trading (includes signing operations) await this.ensureReadyForTrading(); - // Force fresh API data (not WebSocket cache) since we're about to mutate the position - const positions = await this.getPositions({ skipCache: true }); - const position = positions.find((pos) => pos.symbol === params.symbol); + // Use provided position (from WebSocket) or fetch from cache + // This avoids unnecessary API calls and prevents 429 rate limiting + let position = params.position; + if (!position) { + const positions = await this.getPositions(); + position = positions.find((pos) => pos.symbol === params.symbol); + } if (!position) { throw new Error(`No position found for ${params.symbol}`); @@ -4119,9 +4122,8 @@ export class HyperLiquidProvider implements PerpsProvider { // Ensure provider is ready await this.ensureReady(); - // Get current position to determine direction - // Force fresh API data since we're about to mutate the position - const positions = await this.getPositions({ skipCache: true }); + // Get current position to determine direction (from cache to avoid 429 rate limiting) + const positions = await this.getPositions(); const position = positions.find((pos) => pos.symbol === symbol); if (!position) { diff --git a/app/components/UI/Perps/controllers/types/index.ts b/app/components/UI/Perps/controllers/types/index.ts index ec669e5f8845..24e1285d299d 100644 --- a/app/components/UI/Perps/controllers/types/index.ts +++ b/app/components/UI/Perps/controllers/types/index.ts @@ -239,6 +239,13 @@ export type ClosePositionParams = { // Multi-provider routing (optional: defaults to active/default provider) providerId?: PerpsProviderType; // Optional: override active provider for routing + + /** + * Optional live position data from WebSocket. + * If provided, skips the REST API position fetch (avoids rate limiting issues). + * If not provided, falls back to fetching positions via REST API cache. + */ + position?: Position; }; export type ClosePositionsParams = { diff --git a/app/components/UI/Perps/hooks/usePerpsClosePosition.test.ts b/app/components/UI/Perps/hooks/usePerpsClosePosition.test.ts index 9ff822c15b46..3fb05643cbe1 100644 --- a/app/components/UI/Perps/hooks/usePerpsClosePosition.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsClosePosition.test.ts @@ -121,6 +121,7 @@ describe('usePerpsClosePosition', () => { usdAmount: undefined, priceAtCalculation: undefined, maxSlippageBps: undefined, + position: mockPosition, }); expect(onSuccess).toHaveBeenCalledWith(successResult); @@ -168,6 +169,7 @@ describe('usePerpsClosePosition', () => { usdAmount: undefined, priceAtCalculation: undefined, maxSlippageBps: undefined, + position: mockPosition, }); expect(onSuccess).toHaveBeenCalledWith(successResult); @@ -325,6 +327,7 @@ describe('usePerpsClosePosition', () => { usdAmount: undefined, priceAtCalculation: undefined, maxSlippageBps: undefined, + position: mockPosition, }); }); @@ -392,6 +395,7 @@ describe('usePerpsClosePosition', () => { usdAmount: undefined, priceAtCalculation: undefined, maxSlippageBps: undefined, + position: positionWithTPSL, }); }); diff --git a/app/components/UI/Perps/hooks/usePerpsClosePosition.ts b/app/components/UI/Perps/hooks/usePerpsClosePosition.ts index 12ce5cc850d0..6c512487fd2c 100644 --- a/app/components/UI/Perps/hooks/usePerpsClosePosition.ts +++ b/app/components/UI/Perps/hooks/usePerpsClosePosition.ts @@ -126,6 +126,8 @@ export const usePerpsClosePosition = ( usdAmount: slippage?.usdAmount, priceAtCalculation: slippage?.priceAtCalculation, maxSlippageBps: slippage?.maxSlippageBps, + // Pass live position to avoid getPositions() API call (prevents 429 rate limiting) + position, }); DevLogger.log('usePerpsClosePosition: Close result', result);