From 79fdb3416d42d19961f56fd2d5ce3a38bd053f70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Tani=C3=A7a?= Date: Wed, 12 Nov 2025 22:46:05 -0700 Subject: [PATCH 01/12] fix(predict): sell order error messages (#22596) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Right now, we're always showing a generic error message for **all** errors when placing a Sell order. This fix makes sure we show the user the correct parsed error message. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** Screenshot 2025-11-12 at 9 34 44 PM ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Show parsed sell order error in `PredictSellPreview` instead of a generic failure message. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 4a603dee8abce118336253d6adffd2a763f7b543. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../UI/Predict/views/PredictSellPreview/PredictSellPreview.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.tsx b/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.tsx index b4cab129cb7..25b01df6f4b 100644 --- a/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.tsx +++ b/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.tsx @@ -215,7 +215,7 @@ const PredictSellPreview = () => { color={TextColor.Error} style={tw.style('text-center')} > - {strings('predict.order.order_failed_generic')} + {placeOrderError} )} From da48e65ae496ec10c426aa2d16057f6576715fe9 Mon Sep 17 00:00:00 2001 From: VGR Date: Thu, 13 Nov 2025 09:23:13 +0100 Subject: [PATCH 02/12] chore: prefer side effect acc group for opt in & link active acc group (#22550) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Right now when opting in, we will first attempt to opt in your active account, and then link the first account of your SRP. This PR changes this so that we opt in your first account of your SRP and then link your active account. Reasoning here is that we're heading towards a situation where both on extension and mobile users will be able to opt in for the same SRP. If for some reason on one of their devices their cache is still stale, it will be correctly updated when during op-tin we detect that the first account of their SRP is already tied to a subscription. The active account group will then be tied/linked to this subscription. Instead of creating a new subscription in the old implementation. ## **Changelog** CHANGELOG entry: null ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Opt-in now prefers side-effect (first wallet) accounts, links the other group afterward, and `RewardsController:optIn` takes explicit accounts with updated tests and metrics. > > - **Rewards opt-in flow (hook)**: > - Prefer `sideEffectAccounts` for `RewardsController:optIn`; otherwise use active group accounts. > - After successful opt-in, link the opposite group (`linkAccountGroup`) based on which accounts were used. > - Track additional metrics/traits on referral usage; throw error if no subscription ID returned. > - **Controller API**: > - Change `RewardsController:optIn` signature to `(accounts: InternalAccount[], referralCode?)` and remove internal account-group lookup. > - Update log message for empty accounts; keep retry/ordering logic via `sortAccounts`. > - Update action type `RewardsControllerOptInAction` to new signature. > - **Tests**: > - Rewrite `useOptIn` tests to cover side-effect vs active group paths, linking behavior, referral traits, and failure cases. > - Update controller tests to pass accounts explicitly and validate new behaviors across edge cases. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 41bcb71d4c55e5d91f58f8b4511795dcc2b71e99. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../UI/Rewards/hooks/useOptIn.test.ts | 284 ++++++++++++++---- app/components/UI/Rewards/hooks/useOptIn.ts | 74 +++-- .../RewardsController.test.ts | 269 +++++------------ .../rewards-controller/RewardsController.ts | 14 +- .../controllers/rewards-controller/types.ts | 5 +- 5 files changed, 352 insertions(+), 294 deletions(-) diff --git a/app/components/UI/Rewards/hooks/useOptIn.test.ts b/app/components/UI/Rewards/hooks/useOptIn.test.ts index 9ba3525cea6..4d4be0af21e 100644 --- a/app/components/UI/Rewards/hooks/useOptIn.test.ts +++ b/app/components/UI/Rewards/hooks/useOptIn.test.ts @@ -4,14 +4,17 @@ import useOptin from './useOptIn'; import Engine from '../../../../core/Engine'; import { setCandidateSubscriptionId } from '../../../../reducers/rewards'; import { useMetrics } from '../../../hooks/useMetrics'; -import { selectMultichainAccountsState2Enabled } from '../../../../selectors/featureFlagController/multichainAccounts/enabledMultichainAccounts'; import { selectSelectedAccountGroup, selectAccountGroupsByWallet, selectWalletByAccount, + selectSelectedAccountGroupInternalAccounts, } from '../../../../selectors/multichainAccounts/accountTreeController'; +import { selectInternalAccountsByGroupId } from '../../../../selectors/multichainAccounts/accounts'; +import { selectSelectedInternalAccount } from '../../../../selectors/accountsController'; import { useLinkAccountGroup } from './useLinkAccountGroup'; import { AccountGroupId } from '@metamask/account-api'; +import { InternalAccount } from '@metamask/keyring-internal-api'; // Mock dependencies jest.mock('react-redux', () => ({ @@ -38,28 +41,32 @@ jest.mock('../../../hooks/useMetrics', () => ({ useMetrics: jest.fn(), })); -jest.mock( - '../../../../selectors/featureFlagController/multichainAccounts/enabledMultichainAccounts', - () => ({ - selectMultichainAccountsState2Enabled: jest.fn(), - }), -); - jest.mock( '../../../../selectors/multichainAccounts/accountTreeController', () => ({ selectSelectedAccountGroup: jest.fn(), selectAccountGroupsByWallet: jest.fn(), selectWalletByAccount: jest.fn(), + selectSelectedAccountGroupInternalAccounts: jest.fn(), }), ); +jest.mock('../../../../selectors/multichainAccounts/accounts', () => ({ + selectInternalAccountsByGroupId: jest.fn(), +})); + +jest.mock('../../../../selectors/accountsController', () => ({ + selectSelectedInternalAccount: jest.fn(), +})); + jest.mock( '../../../../util/metrics/UserSettingsAnalyticsMetaData/UserProfileAnalyticsMetaData.types', () => ({ UserProfileProperty: { HAS_REWARDS_OPTED_IN: 'has_rewards_opted_in', ON: 'on', + REWARDS_REFERRED: 'rewards_referred', + REWARDS_REFERRAL_CODE_USED: 'rewards_referral_code_used', }, }), ); @@ -98,13 +105,19 @@ describe('useOptIn', () => { const mockSelectSelectedAccountGroup = jest.mocked( selectSelectedAccountGroup, ); - const mockSelectMultichainAccountsState2Enabled = jest.mocked( - selectMultichainAccountsState2Enabled, - ); const mockSelectAccountGroupsByWallet = jest.mocked( selectAccountGroupsByWallet, ); const mockSelectWalletByAccount = jest.mocked(selectWalletByAccount); + const mockSelectSelectedAccountGroupInternalAccounts = jest.mocked( + selectSelectedAccountGroupInternalAccounts, + ); + const mockSelectInternalAccountsByGroupId = jest.mocked( + selectInternalAccountsByGroupId, + ); + const mockSelectSelectedInternalAccount = jest.mocked( + selectSelectedInternalAccount, + ); const mockUseLinkAccountGroup = jest.mocked(useLinkAccountGroup); const mockTrackEvent = jest.fn(); @@ -155,6 +168,24 @@ describe('useOptIn', () => { metadata: { name: 'Account 1' }, } as never; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mockActiveGroupAccounts: InternalAccount[] = [ + { + id: 'account-1', + address: '0x123', + metadata: { name: 'Account 1' }, + } as InternalAccount, + ]; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mockSideEffectAccounts: InternalAccount[] = [ + { + id: 'account-2', + address: '0x456', + metadata: { name: 'Account 2' }, + } as InternalAccount, + ]; + const mockLinkAccountGroup = jest.fn(); beforeEach(() => { @@ -168,20 +199,29 @@ describe('useOptIn', () => { // Setup default selector values mockSelectSelectedAccountGroup.mockReturnValue(mockAccountGroup); - mockSelectMultichainAccountsState2Enabled.mockReturnValue(false); mockSelectAccountGroupsByWallet.mockReturnValue([mockWalletSection]); mockSelectWalletByAccount.mockReturnValue( (_accountId: string) => mockWallet, ); + mockSelectSelectedAccountGroupInternalAccounts.mockReturnValue( + mockActiveGroupAccounts, + ); + mockSelectInternalAccountsByGroupId.mockReturnValue( + (_groupId: string) => [], + ); + mockSelectSelectedInternalAccount.mockReturnValue(mockActiveAccount); mockUseSelector.mockImplementation((selector) => { if (selector === selectSelectedAccountGroup) return mockAccountGroup; - if (selector === selectMultichainAccountsState2Enabled) return false; if (selector === selectAccountGroupsByWallet) return [mockWalletSection]; if (selector === selectWalletByAccount) return (_accountId: string) => mockWallet; - // Return mockActiveAccount for any other selector (e.g., selectSelectedInternalAccount) - return mockActiveAccount; + if (selector === selectSelectedAccountGroupInternalAccounts) + return mockActiveGroupAccounts; + if (selector === selectInternalAccountsByGroupId) + return (_groupId: string) => []; + if (selector === selectSelectedInternalAccount) return mockActiveAccount; + return undefined; }); // Setup useMetrics mock @@ -214,7 +254,7 @@ describe('useOptIn', () => { }); }); - it('should handle successful optin without multichain accounts', async () => { + it('should handle successful optin with active group accounts', async () => { const { result } = renderHook(() => useOptin()); await act(async () => { @@ -223,6 +263,7 @@ describe('useOptIn', () => { expect(mockEngineCall).toHaveBeenCalledWith( 'RewardsController:optIn', + mockActiveGroupAccounts, undefined, ); expect(mockDispatch).toHaveBeenCalledWith( @@ -255,12 +296,18 @@ describe('useOptIn', () => { expect(mockEngineCall).toHaveBeenCalledWith( 'RewardsController:optIn', + mockActiveGroupAccounts, 'ABC123', ); expect(mockCreateEventBuilder).toHaveBeenCalledWith( 'Rewards Opt-in Started', ); expect(mockTrackEvent).toHaveBeenCalledTimes(2); // Started and Completed + expect(mockAddTraitsToUser).toHaveBeenCalledWith({ + has_rewards_opted_in: 'on', + rewards_referred: true, + rewards_referral_code_used: 'ABC123', + }); }); it('should handle optin failure', async () => { @@ -295,12 +342,17 @@ describe('useOptIn', () => { mockSelectSelectedAccountGroup.mockReturnValue(null); mockUseSelector.mockImplementation((selector) => { if (selector === selectSelectedAccountGroup) return null; - if (selector === selectMultichainAccountsState2Enabled) return false; if (selector === selectAccountGroupsByWallet) return [mockWalletSection]; if (selector === selectWalletByAccount) return (_accountId: string) => mockWallet; - return mockActiveAccount; + if (selector === selectSelectedAccountGroupInternalAccounts) + return mockActiveGroupAccounts; + if (selector === selectInternalAccountsByGroupId) + return (_groupId: string) => []; + if (selector === selectSelectedInternalAccount) + return mockActiveAccount; + return undefined; }); const { result } = renderHook(() => useOptin()); @@ -323,12 +375,17 @@ describe('useOptIn', () => { mockUseSelector.mockImplementation((selector) => { if (selector === selectSelectedAccountGroup) return accountGroupWithoutId; - if (selector === selectMultichainAccountsState2Enabled) return false; if (selector === selectAccountGroupsByWallet) return [mockWalletSection]; if (selector === selectWalletByAccount) return (_accountId: string) => mockWallet; - return mockActiveAccount; + if (selector === selectSelectedAccountGroupInternalAccounts) + return mockActiveGroupAccounts; + if (selector === selectInternalAccountsByGroupId) + return (_groupId: string) => []; + if (selector === selectSelectedInternalAccount) + return mockActiveAccount; + return undefined; }); const { result } = renderHook(() => useOptin()); @@ -339,25 +396,47 @@ describe('useOptIn', () => { expect(mockEngineCall).not.toHaveBeenCalled(); }); + + it('should throw error when subscriptionId is null', async () => { + mockEngineCall.mockResolvedValue(null); + + const { result } = renderHook(() => useOptin()); + + await act(async () => { + await result.current.optin({}); + }); + + expect(result.current.optinError).toBe( + 'Error: Failed to opt in any account from the account group', + ); + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'Rewards Opt-in Failed', + }), + ); + expect(mockDispatch).not.toHaveBeenCalled(); + }); }); - describe('Multichain accounts behavior', () => { + describe('Side effect account group behavior', () => { beforeEach(() => { - // Enable multichain accounts state 2 - mockSelectMultichainAccountsState2Enabled.mockReturnValue(true); - mockUseSelector.mockImplementation((selector) => { if (selector === selectSelectedAccountGroup) return mockAccountGroup; - if (selector === selectMultichainAccountsState2Enabled) return true; if (selector === selectAccountGroupsByWallet) return [mockWalletSection]; if (selector === selectWalletByAccount) return (_accountId: string) => mockWallet; - return mockActiveAccount; + if (selector === selectSelectedAccountGroupInternalAccounts) + return mockActiveGroupAccounts; + if (selector === selectInternalAccountsByGroupId) + return (_groupId: string) => []; + if (selector === selectSelectedInternalAccount) + return mockActiveAccount; + return undefined; }); }); - it('should link side effect account group when multichain accounts is enabled', async () => { + it('should use side effect accounts and link selected account group when side effect accounts exist', async () => { // Setup wallet section with a different group ID for side effect const sideEffectWalletSection = { title: 'Test Wallet', @@ -366,7 +445,7 @@ describe('useOptIn', () => { { type: 'single' as const, id: 'side-effect-group-1', // Different from current account group - accounts: ['account-1'], + accounts: ['account-2'], metadata: { name: 'Side Effect Group' }, }, ], @@ -375,15 +454,31 @@ describe('useOptIn', () => { mockSelectAccountGroupsByWallet.mockReturnValue([ sideEffectWalletSection, ]); + mockSelectInternalAccountsByGroupId.mockReturnValue((groupId: string) => { + if (groupId === 'side-effect-group-1') { + return mockSideEffectAccounts; + } + return []; + }); mockUseSelector.mockImplementation((selector) => { if (selector === selectSelectedAccountGroup) return mockAccountGroup; - if (selector === selectMultichainAccountsState2Enabled) return true; if (selector === selectAccountGroupsByWallet) return [sideEffectWalletSection]; if (selector === selectWalletByAccount) return (_accountId: string) => mockWallet; - return mockActiveAccount; + if (selector === selectSelectedAccountGroupInternalAccounts) + return mockActiveGroupAccounts; + if (selector === selectInternalAccountsByGroupId) + return (groupId: string) => { + if (groupId === 'side-effect-group-1') { + return mockSideEffectAccounts; + } + return []; + }; + if (selector === selectSelectedInternalAccount) + return mockActiveAccount; + return undefined; }); const { result } = renderHook(() => useOptin()); @@ -392,14 +487,15 @@ describe('useOptIn', () => { await result.current.optin({}); }); - // Should call optin first + // Should call optin with side effect accounts expect(mockEngineCall).toHaveBeenCalledWith( 'RewardsController:optIn', + mockSideEffectAccounts, undefined, ); - // Then should link the side effect account group after optin completes - expect(mockLinkAccountGroup).toHaveBeenCalledWith('side-effect-group-1'); + // Then should link the selected account group (group-1) after optin completes + expect(mockLinkAccountGroup).toHaveBeenCalledWith('group-1'); // Should dispatch subscription ID after linkAccountGroup expect(mockDispatch).toHaveBeenCalledWith( @@ -407,31 +503,42 @@ describe('useOptIn', () => { ); }); - it('should not link side effect account group when it is the same as current account group', async () => { - // Mock side effect account group with same ID as current account group - const sameGroupWalletSection = { + it('should use active group accounts and link side effect account group when side effect accounts are empty', async () => { + // Setup wallet section with a different group ID for side effect + const sideEffectWalletSection = { title: 'Test Wallet', wallet: mockWallet, data: [ { type: 'single' as const, - id: 'group-1', // Same as current account group - accounts: ['account-1'], - metadata: { name: 'Same Group' }, + id: 'side-effect-group-1', // Different from current account group + accounts: ['account-2'], + metadata: { name: 'Side Effect Group' }, }, ], } as never; - mockSelectAccountGroupsByWallet.mockReturnValue([sameGroupWalletSection]); + mockSelectAccountGroupsByWallet.mockReturnValue([ + sideEffectWalletSection, + ]); + // Return empty array for side effect accounts + mockSelectInternalAccountsByGroupId.mockReturnValue( + (_groupId: string) => [], + ); mockUseSelector.mockImplementation((selector) => { if (selector === selectSelectedAccountGroup) return mockAccountGroup; - if (selector === selectMultichainAccountsState2Enabled) return true; if (selector === selectAccountGroupsByWallet) - return [sameGroupWalletSection]; + return [sideEffectWalletSection]; if (selector === selectWalletByAccount) return (_accountId: string) => mockWallet; - return mockActiveAccount; + if (selector === selectSelectedAccountGroupInternalAccounts) + return mockActiveGroupAccounts; + if (selector === selectInternalAccountsByGroupId) + return (_groupId: string) => []; + if (selector === selectSelectedInternalAccount) + return mockActiveAccount; + return undefined; }); const { result } = renderHook(() => useOptin()); @@ -440,13 +547,20 @@ describe('useOptIn', () => { await result.current.optin({}); }); - // Should only call optin, not link account group - expect(mockEngineCall).toHaveBeenCalledTimes(1); + // Should call optin with active group accounts expect(mockEngineCall).toHaveBeenCalledWith( 'RewardsController:optIn', + mockActiveGroupAccounts, undefined, ); - expect(mockLinkAccountGroup).not.toHaveBeenCalled(); + + // Should link the side effect account group + expect(mockLinkAccountGroup).toHaveBeenCalledWith('side-effect-group-1'); + + // Should dispatch subscription ID + expect(mockDispatch).toHaveBeenCalledWith( + mockSetCandidateSubscriptionId('subscription-123'), + ); }); it('should handle case when side effect account group is not found', async () => { @@ -454,11 +568,16 @@ describe('useOptIn', () => { mockUseSelector.mockImplementation((selector) => { if (selector === selectSelectedAccountGroup) return mockAccountGroup; - if (selector === selectMultichainAccountsState2Enabled) return true; if (selector === selectAccountGroupsByWallet) return []; if (selector === selectWalletByAccount) return (_accountId: string) => mockWallet; - return mockActiveAccount; + if (selector === selectSelectedAccountGroupInternalAccounts) + return mockActiveGroupAccounts; + if (selector === selectInternalAccountsByGroupId) + return (_groupId: string) => []; + if (selector === selectSelectedInternalAccount) + return mockActiveAccount; + return undefined; }); const { result } = renderHook(() => useOptin()); @@ -467,10 +586,11 @@ describe('useOptIn', () => { await result.current.optin({}); }); - // Should only call optin, not link account group + // Should only call optin with active group accounts, not link account group expect(mockEngineCall).toHaveBeenCalledTimes(1); expect(mockEngineCall).toHaveBeenCalledWith( 'RewardsController:optIn', + mockActiveGroupAccounts, undefined, ); expect(mockLinkAccountGroup).not.toHaveBeenCalled(); @@ -494,7 +614,6 @@ describe('useOptIn', () => { mockUseSelector.mockImplementation((selector) => { if (selector === selectSelectedAccountGroup) return mockAccountGroup; - if (selector === selectMultichainAccountsState2Enabled) return true; if (selector === selectAccountGroupsByWallet) return [ { @@ -512,7 +631,13 @@ describe('useOptIn', () => { ]; if (selector === selectWalletByAccount) return (_accountId: string) => mockWallet; - return mockActiveAccount; + if (selector === selectSelectedAccountGroupInternalAccounts) + return mockActiveGroupAccounts; + if (selector === selectInternalAccountsByGroupId) + return (_groupId: string) => []; + if (selector === selectSelectedInternalAccount) + return mockActiveAccount; + return undefined; }); const { result } = renderHook(() => useOptin()); @@ -521,10 +646,11 @@ describe('useOptIn', () => { await result.current.optin({}); }); - // Should only call optin, not link account group + // Should only call optin with active group accounts, not link account group expect(mockEngineCall).toHaveBeenCalledTimes(1); expect(mockEngineCall).toHaveBeenCalledWith( 'RewardsController:optIn', + mockActiveGroupAccounts, undefined, ); expect(mockLinkAccountGroup).not.toHaveBeenCalled(); @@ -539,7 +665,7 @@ describe('useOptIn', () => { { type: 'single' as const, id: 'side-effect-group-1', // Different from current account group - accounts: ['account-1'], + accounts: ['account-2'], metadata: { name: 'Side Effect Group' }, }, ], @@ -548,15 +674,31 @@ describe('useOptIn', () => { mockSelectAccountGroupsByWallet.mockReturnValue([ sideEffectWalletSection, ]); + mockSelectInternalAccountsByGroupId.mockReturnValue((groupId: string) => { + if (groupId === 'side-effect-group-1') { + return mockSideEffectAccounts; + } + return []; + }); mockUseSelector.mockImplementation((selector) => { if (selector === selectSelectedAccountGroup) return mockAccountGroup; - if (selector === selectMultichainAccountsState2Enabled) return true; if (selector === selectAccountGroupsByWallet) return [sideEffectWalletSection]; if (selector === selectWalletByAccount) return (_accountId: string) => mockWallet; - return mockActiveAccount; + if (selector === selectSelectedAccountGroupInternalAccounts) + return mockActiveGroupAccounts; + if (selector === selectInternalAccountsByGroupId) + return (groupId: string) => { + if (groupId === 'side-effect-group-1') { + return mockSideEffectAccounts; + } + return []; + }; + if (selector === selectSelectedInternalAccount) + return mockActiveAccount; + return undefined; }); // Mock linkAccountGroup to throw an error @@ -570,14 +712,15 @@ describe('useOptIn', () => { await result.current.optin({}); }); - // Should call optin + // Should call optin with side effect accounts expect(mockEngineCall).toHaveBeenCalledWith( 'RewardsController:optIn', + mockSideEffectAccounts, undefined, ); - // Should attempt to link the side effect account group - expect(mockLinkAccountGroup).toHaveBeenCalledWith('side-effect-group-1'); + // Should attempt to link the selected account group (group-1) + expect(mockLinkAccountGroup).toHaveBeenCalledWith('group-1'); // Should still dispatch subscription ID even though linkAccountGroup failed expect(mockDispatch).toHaveBeenCalledWith( @@ -607,7 +750,7 @@ describe('useOptIn', () => { { type: 'single' as const, id: 'side-effect-group-1', // Different from current account group - accounts: ['account-1'], + accounts: ['account-2'], metadata: { name: 'Side Effect Group' }, }, ], @@ -616,15 +759,31 @@ describe('useOptIn', () => { mockSelectAccountGroupsByWallet.mockReturnValue([ sideEffectWalletSection, ]); + mockSelectInternalAccountsByGroupId.mockReturnValue((groupId: string) => { + if (groupId === 'side-effect-group-1') { + return mockSideEffectAccounts; + } + return []; + }); mockUseSelector.mockImplementation((selector) => { if (selector === selectSelectedAccountGroup) return mockAccountGroup; - if (selector === selectMultichainAccountsState2Enabled) return true; if (selector === selectAccountGroupsByWallet) return [sideEffectWalletSection]; if (selector === selectWalletByAccount) return (_accountId: string) => mockWallet; - return mockActiveAccount; + if (selector === selectSelectedAccountGroupInternalAccounts) + return mockActiveGroupAccounts; + if (selector === selectInternalAccountsByGroupId) + return (groupId: string) => { + if (groupId === 'side-effect-group-1') { + return mockSideEffectAccounts; + } + return []; + }; + if (selector === selectSelectedInternalAccount) + return mockActiveAccount; + return undefined; }); // Mock optin to fail @@ -637,9 +796,10 @@ describe('useOptIn', () => { await result.current.optin({}); }); - // Should have attempted optin + // Should have attempted optin with side effect accounts expect(mockEngineCall).toHaveBeenCalledWith( 'RewardsController:optIn', + mockSideEffectAccounts, undefined, ); diff --git a/app/components/UI/Rewards/hooks/useOptIn.ts b/app/components/UI/Rewards/hooks/useOptIn.ts index e16bb33c2fe..f1032c1c8b5 100644 --- a/app/components/UI/Rewards/hooks/useOptIn.ts +++ b/app/components/UI/Rewards/hooks/useOptIn.ts @@ -1,18 +1,21 @@ import { useCallback, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { handleRewardsErrorMessage } from '../utils'; -import Engine from '../../../../core/Engine'; import { setCandidateSubscriptionId } from '../../../../reducers/rewards'; import { MetaMetricsEvents, useMetrics } from '../../../hooks/useMetrics'; import { UserProfileProperty } from '../../../../util/metrics/UserSettingsAnalyticsMetaData/UserProfileAnalyticsMetaData.types'; -import { selectMultichainAccountsState2Enabled } from '../../../../selectors/featureFlagController/multichainAccounts/enabledMultichainAccounts'; import { selectSelectedAccountGroup, selectAccountGroupsByWallet, selectWalletByAccount, + selectSelectedAccountGroupInternalAccounts, } from '../../../../selectors/multichainAccounts/accountTreeController'; -import { useLinkAccountGroup } from './useLinkAccountGroup'; +import { selectInternalAccountsByGroupId } from '../../../../selectors/multichainAccounts/accounts'; import { selectSelectedInternalAccount } from '../../../../selectors/accountsController'; +import Engine from '../../../../core/Engine'; +import { useLinkAccountGroup } from './useLinkAccountGroup'; +import { InternalAccount } from '@metamask/keyring-internal-api'; +import { AccountGroupId } from '@metamask/account-api'; export interface UseOptinResult { /** @@ -61,9 +64,20 @@ export const useOptin = (): UseOptinResult => { )?.data?.[0]?.id, [accountGroupsByWallet, currentAccountWalletId], ); - const multichainAccountsState2Enabled = useSelector( - selectMultichainAccountsState2Enabled, + const activeGroupAccounts = useSelector( + selectSelectedAccountGroupInternalAccounts, + ); + const selectInternalAccountsByGroupIdSelector = useSelector( + selectInternalAccountsByGroupId, ); + const sideEffectAccounts = useMemo(() => { + if (!sideEffectAccountGroupIdToLink) { + return []; + } + return selectInternalAccountsByGroupIdSelector( + sideEffectAccountGroupIdToLink, + ); + }, [sideEffectAccountGroupIdToLink, selectInternalAccountsByGroupIdSelector]); const handleOptin = useCallback( async ({ @@ -76,6 +90,7 @@ export const useOptin = (): UseOptinResult => { if (!accountGroup?.id) { return; } + const selectedAccountGroupId = accountGroup.id; const referred = Boolean(referralCode); const metricsProps = { referred, @@ -94,11 +109,32 @@ export const useOptin = (): UseOptinResult => { setOptinLoading(true); setOptinError(null); + const accountsToOptIn = + sideEffectAccountGroupIdToLink && sideEffectAccounts.length > 0 + ? sideEffectAccounts + : activeGroupAccounts; + + const accountGroupToLinkAfterOptIn = + sideEffectAccountGroupIdToLink && sideEffectAccounts.length > 0 + ? selectedAccountGroupId + : sideEffectAccountGroupIdToLink; + subscriptionId = await Engine.controllerMessenger.call( - 'RewardsController:optIn', // for active account group + 'RewardsController:optIn', + accountsToOptIn as InternalAccount[], referralCode || undefined, ); + if (subscriptionId) { + if (accountGroupToLinkAfterOptIn) { + try { + await linkAccountGroup( + accountGroupToLinkAfterOptIn as AccountGroupId, + ); + } catch { + // Failed to link first account group in same wallet. + } + } addTraitsToUser({ [UserProfileProperty.HAS_REWARDS_OPTED_IN]: UserProfileProperty.ON, ...(referralCode && { @@ -111,6 +147,10 @@ export const useOptin = (): UseOptinResult => { .addProperties(metricsProps) .build(), ); + } else { + throw new Error( + 'Failed to opt in any account from the account group', + ); } } catch (error) { trackEvent( @@ -122,23 +162,6 @@ export const useOptin = (): UseOptinResult => { setOptinError(errorMessage); } - if ( - multichainAccountsState2Enabled && - sideEffectAccountGroupIdToLink && - subscriptionId - ) { - if ( - sideEffectAccountGroupIdToLink && - sideEffectAccountGroupIdToLink !== accountGroup?.id - ) { - try { - await linkAccountGroup(sideEffectAccountGroupIdToLink); - } catch { - // Failed to link first account group in same wallet. - } - } - } - if (subscriptionId) { dispatch(setCandidateSubscriptionId(subscriptionId)); } @@ -150,10 +173,11 @@ export const useOptin = (): UseOptinResult => { trackEvent, createEventBuilder, sideEffectAccountGroupIdToLink, - multichainAccountsState2Enabled, - dispatch, + sideEffectAccounts, + activeGroupAccounts, addTraitsToUser, linkAccountGroup, + dispatch, ], ); diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts index cb83724ef58..5d6557c9ec4 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts @@ -7348,11 +7348,7 @@ describe('RewardsController', () => { // Arrange const mockAccounts = [mockEvmInternalAccount, mockEvmInternalAccount2]; mockMessenger.call.mockImplementation((method, ..._args): any => { - if ( - method === 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return mockAccounts; - } else if (method === 'KeyringController:signPersonalMessage') { + if (method === 'KeyringController:signPersonalMessage') { return Promise.resolve('0xsignature123'); } else if (method === 'RewardsDataService:mobileOptin') { return Promise.resolve(mockOptinResponse); @@ -7368,13 +7364,10 @@ describe('RewardsController', () => { })); // Act - const result = await controller.optIn(); + const result = await controller.optIn(mockAccounts); // Assert expect(result).toBe(mockSubscriptionId); - expect(mockMessenger.call).toHaveBeenCalledWith( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ); expect(mockMessenger.call).toHaveBeenCalledWith( 'RewardsDataService:mobileOptin', expect.objectContaining({ @@ -7397,11 +7390,7 @@ describe('RewardsController', () => { const referralCode = 'REF123'; const mockAccounts = [mockEvmInternalAccount]; mockMessenger.call.mockImplementation((method, ..._args): any => { - if ( - method === 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return mockAccounts; - } else if (method === 'KeyringController:signPersonalMessage') { + if (method === 'KeyringController:signPersonalMessage') { return Promise.resolve('0xsignature123'); } else if (method === 'RewardsDataService:mobileOptin') { const params = _args[0] as any; @@ -7417,7 +7406,7 @@ describe('RewardsController', () => { })); // Act - const result = await controller.optIn(referralCode); + const result = await controller.optIn(mockAccounts, referralCode); // Assert expect(result).toBe(mockSubscriptionId); @@ -7433,9 +7422,10 @@ describe('RewardsController', () => { it('should return null when rewards feature is disabled', async () => { // Arrange mockSelectRewardsEnabledFlag.mockReturnValue(false); + const mockAccounts = [mockEvmInternalAccount]; // Act - const result = await controller.optIn(); + const result = await controller.optIn(mockAccounts); // Assert expect(result).toBeNull(); @@ -7444,22 +7434,15 @@ describe('RewardsController', () => { it('should return null when no accounts found in selected account group', async () => { // Arrange - mockMessenger.call.mockImplementation((method, ..._args): any => { - if ( - method === 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return []; - } - return Promise.resolve(); - }); + const mockAccounts: InternalAccount[] = []; // Act - const result = await controller.optIn(); + const result = await controller.optIn(mockAccounts); // Assert expect(result).toBeNull(); - expect(mockMessenger.call).toHaveBeenCalledWith( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', + expect(mockLogger.log).toHaveBeenCalledWith( + 'RewardsController: No accounts provided, skipping optin', ); }); @@ -7467,11 +7450,7 @@ describe('RewardsController', () => { // Arrange const mockAccounts = [mockEvmInternalAccount, mockEvmInternalAccount2]; mockMessenger.call.mockImplementation((method, ..._args): any => { - if ( - method === 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return mockAccounts; - } else if (method === 'KeyringController:signPersonalMessage') { + if (method === 'KeyringController:signPersonalMessage') { return Promise.reject(new Error('Signature failed')); } return Promise.resolve(); @@ -7483,7 +7462,7 @@ describe('RewardsController', () => { })); // Act & Assert - await expect(controller.optIn()).rejects.toThrow( + await expect(controller.optIn(mockAccounts)).rejects.toThrow( 'Failed to opt in any account from the account group', ); }); @@ -7495,11 +7474,7 @@ describe('RewardsController', () => { let callCount = 0; mockMessenger.call.mockImplementation((method, ..._args): any => { - if ( - method === 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return mockAccounts; - } else if (method === 'KeyringController:signPersonalMessage') { + if (method === 'KeyringController:signPersonalMessage') { return Promise.resolve('0xsignature123'); } else if (method === 'RewardsDataService:mobileOptin') { callCount++; @@ -7517,7 +7492,7 @@ describe('RewardsController', () => { })); // Act - const result = await controller.optIn(); + const result = await controller.optIn(mockAccounts); // Assert expect(result).toBe(mockSubscriptionId); @@ -7528,11 +7503,7 @@ describe('RewardsController', () => { // Arrange const mockAccounts = [mockEvmInternalAccount]; mockMessenger.call.mockImplementation((method, ..._args): any => { - if ( - method === 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return mockAccounts; - } else if (method === 'KeyringController:signPersonalMessage') { + if (method === 'KeyringController:signPersonalMessage') { return Promise.resolve('0xsignature123'); } else if (method === 'RewardsDataService:mobileOptin') { return Promise.reject(new Error('Optin service error')); @@ -7546,7 +7517,7 @@ describe('RewardsController', () => { })); // Act & Assert - await expect(controller.optIn()).rejects.toThrow( + await expect(controller.optIn(mockAccounts)).rejects.toThrow( 'Failed to opt in any account from the account group', ); }); @@ -7555,11 +7526,7 @@ describe('RewardsController', () => { // Arrange const mockAccounts = [mockEvmInternalAccount]; mockMessenger.call.mockImplementation((method, ..._args): any => { - if ( - method === 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return mockAccounts; - } else if (method === 'KeyringController:signPersonalMessage') { + if (method === 'KeyringController:signPersonalMessage') { return Promise.reject(new Error('Signature failed')); } return Promise.resolve(); @@ -7571,7 +7538,7 @@ describe('RewardsController', () => { })); // Act & Assert - await expect(controller.optIn()).rejects.toThrow( + await expect(controller.optIn(mockAccounts)).rejects.toThrow( 'Failed to opt in any account from the account group', ); }); @@ -7580,11 +7547,7 @@ describe('RewardsController', () => { // Arrange const mockAccounts = [mockEvmInternalAccount]; mockMessenger.call.mockImplementation((method, ..._args): any => { - if ( - method === 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return mockAccounts; - } else if (method === 'KeyringController:signPersonalMessage') { + if (method === 'KeyringController:signPersonalMessage') { return Promise.resolve('0xsignature123'); } else if (method === 'RewardsDataService:mobileOptin') { return Promise.resolve(mockOptinResponse); @@ -7598,7 +7561,7 @@ describe('RewardsController', () => { })); // Act - const result = await controller.optIn(); + const result = await controller.optIn(mockAccounts); // Assert expect(result).toBe(mockSubscriptionId); @@ -7615,11 +7578,7 @@ describe('RewardsController', () => { // Arrange const mockAccounts = [mockEvmInternalAccount, mockEvmInternalAccount2]; mockMessenger.call.mockImplementation((method, ..._args): any => { - if ( - method === 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return mockAccounts; - } else if (method === 'KeyringController:signPersonalMessage') { + if (method === 'KeyringController:signPersonalMessage') { return Promise.resolve('0xsignature123'); } else if (method === 'RewardsDataService:mobileOptin') { // Only succeed for the first account @@ -7640,7 +7599,7 @@ describe('RewardsController', () => { })); // Act - const result = await controller.optIn(); + const result = await controller.optIn(mockAccounts); // Assert expect(result).toBe(mockSubscriptionId); @@ -7658,11 +7617,7 @@ describe('RewardsController', () => { // Arrange const mockAccounts = [mockEvmInternalAccount, mockEvmInternalAccount2]; mockMessenger.call.mockImplementation((method, ..._args): any => { - if ( - method === 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return mockAccounts; - } else if (method === 'KeyringController:signPersonalMessage') { + if (method === 'KeyringController:signPersonalMessage') { return Promise.resolve('0xsignature123'); } else if (method === 'RewardsDataService:mobileOptin') { // Only succeed for the first account @@ -7683,7 +7638,7 @@ describe('RewardsController', () => { })); // Act - const result = await controller.optIn(); + const result = await controller.optIn(mockAccounts); // Assert expect(result).toBe(mockSubscriptionId); // Should still return the subscription ID even if linking fails @@ -7699,11 +7654,7 @@ describe('RewardsController', () => { })); mockMessenger.call.mockImplementation((method, ..._args): any => { - if ( - method === 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return mockAccounts; - } else if (method === 'KeyringController:signPersonalMessage') { + if (method === 'KeyringController:signPersonalMessage') { return Promise.resolve('0xsignature123'); } else if (method === 'RewardsDataService:mobileOptin') { return Promise.resolve(mockOptinResponse); @@ -7717,7 +7668,7 @@ describe('RewardsController', () => { })); // Act - const result = await controller.optIn(); + const result = await controller.optIn(mockAccounts); // Assert expect(result).toBe(mockSubscriptionId); // Should still succeed even if token storage fails @@ -7742,11 +7693,7 @@ describe('RewardsController', () => { const mockAccounts = [mockSolanaAccount, mockEvmInternalAccount]; const sortedAccounts = [mockEvmInternalAccount, mockSolanaAccount]; // EVM first mockMessenger.call.mockImplementation((method, ..._args): any => { - if ( - method === 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return mockAccounts; - } else if (method === 'KeyringController:signPersonalMessage') { + if (method === 'KeyringController:signPersonalMessage') { return Promise.resolve('0xsignature123'); } else if (method === 'RewardsDataService:mobileOptin') { return Promise.resolve(mockOptinResponse); @@ -7760,7 +7707,7 @@ describe('RewardsController', () => { })); // Act - const result = await controller.optIn(); + const result = await controller.optIn(mockAccounts); // Assert expect(result).toBe(mockSubscriptionId); // Should try EVM account first due to sorting @@ -7776,11 +7723,7 @@ describe('RewardsController', () => { // Arrange const mockAccounts = [mockEvmInternalAccount]; mockMessenger.call.mockImplementation((method, ..._args): any => { - if ( - method === 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return mockAccounts; - } else if (method === 'KeyringController:signPersonalMessage') { + if (method === 'KeyringController:signPersonalMessage') { return Promise.resolve('0xsignature123'); } else if (method === 'RewardsDataService:mobileOptin') { return Promise.resolve(mockOptinResponse); @@ -7794,7 +7737,7 @@ describe('RewardsController', () => { })); // Act - const result = await controller.optIn(); + const result = await controller.optIn(mockAccounts); // Assert expect(result).toBe(mockSubscriptionId); // Should not call mobileJoin since there are no remaining accounts @@ -7812,11 +7755,7 @@ describe('RewardsController', () => { let callCount = 0; mockMessenger.call.mockImplementation((method, ..._args): any => { - if ( - method === 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return mockAccounts; - } else if (method === 'KeyringController:signPersonalMessage') { + if (method === 'KeyringController:signPersonalMessage') { return Promise.resolve('0xsignature123'); } else if (method === 'RewardsDataService:mobileOptin') { callCount++; @@ -7834,7 +7773,7 @@ describe('RewardsController', () => { })); // Act - const result = await controller.optIn(); + const result = await controller.optIn(mockAccounts); // Assert expect(result).toBe(mockSubscriptionId); @@ -7847,11 +7786,7 @@ describe('RewardsController', () => { const mockError = new InvalidTimestampError('Invalid timestamp', 12345); mockMessenger.call.mockImplementation((method, ..._args): any => { - if ( - method === 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return mockAccounts; - } else if (method === 'KeyringController:signPersonalMessage') { + if (method === 'KeyringController:signPersonalMessage') { return Promise.resolve('0xsignature123'); } else if (method === 'RewardsDataService:mobileOptin') { throw mockError; // Always throw InvalidTimestampError @@ -7865,7 +7800,7 @@ describe('RewardsController', () => { })); // Act & Assert - await expect(controller.optIn()).rejects.toThrow( + await expect(controller.optIn(mockAccounts)).rejects.toThrow( 'Failed to opt in any account from the account group', ); }); @@ -7876,11 +7811,7 @@ describe('RewardsController', () => { const mockError = new Error('Network error'); mockMessenger.call.mockImplementation((method, ..._args): any => { - if ( - method === 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return mockAccounts; - } else if (method === 'KeyringController:signPersonalMessage') { + if (method === 'KeyringController:signPersonalMessage') { return Promise.resolve('0xsignature123'); } else if (method === 'RewardsDataService:mobileOptin') { throw mockError; @@ -7894,7 +7825,7 @@ describe('RewardsController', () => { })); // Act & Assert - await expect(controller.optIn()).rejects.toThrow( + await expect(controller.optIn(mockAccounts)).rejects.toThrow( 'Failed to opt in any account from the account group', ); }); @@ -7910,14 +7841,9 @@ describe('RewardsController', () => { return callCount === 1; }); - mockMessenger.call.mockImplementation((method, ..._args): any => { - if ( - method === 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return mockAccounts; - } - return Promise.resolve(); - }); + mockMessenger.call.mockImplementation((_, ..._args): any => + Promise.resolve(), + ); // Mock sortAccounts jest.doMock('./utils/sortAccounts', () => ({ @@ -7925,7 +7851,7 @@ describe('RewardsController', () => { })); // Act & Assert - await expect(controller.optIn()).rejects.toThrow( + await expect(controller.optIn(mockAccounts)).rejects.toThrow( 'Failed to opt in any account from the account group', ); }); @@ -7940,11 +7866,7 @@ describe('RewardsController', () => { .mockReturnValue(null); mockMessenger.call.mockImplementation((method, ..._args): any => { - if ( - method === 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return mockAccounts; - } else if (method === 'KeyringController:signPersonalMessage') { + if (method === 'KeyringController:signPersonalMessage') { return Promise.resolve('0xsignature123'); } else if (method === 'RewardsDataService:mobileOptin') { return Promise.resolve(mockOptinResponse); @@ -7958,7 +7880,7 @@ describe('RewardsController', () => { })); // Act - const result = await controller.optIn(); + const result = await controller.optIn(mockAccounts); // Assert expect(result).toBe(mockSubscriptionId); // Should still succeed even if convertInternalAccountToCaipAccountId returns null @@ -7973,11 +7895,7 @@ describe('RewardsController', () => { }; mockMessenger.call.mockImplementation((method, ..._args): any => { - if ( - method === 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return mockAccounts; - } else if (method === 'KeyringController:signPersonalMessage') { + if (method === 'KeyringController:signPersonalMessage') { return Promise.resolve('0xsignature123'); } else if (method === 'RewardsDataService:mobileOptin') { return Promise.resolve(incompleteResponse); @@ -7991,7 +7909,7 @@ describe('RewardsController', () => { })); // Act - const result = await controller.optIn(); + const result = await controller.optIn(mockAccounts); // Assert expect(result).toBe(mockSubscriptionId); // Should still succeed even if sessionId is missing @@ -8006,11 +7924,7 @@ describe('RewardsController', () => { }; mockMessenger.call.mockImplementation((method, ..._args): any => { - if ( - method === 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return mockAccounts; - } else if (method === 'KeyringController:signPersonalMessage') { + if (method === 'KeyringController:signPersonalMessage') { return Promise.resolve('0xsignature123'); } else if (method === 'RewardsDataService:mobileOptin') { return Promise.resolve(incompleteResponse); @@ -8024,7 +7938,7 @@ describe('RewardsController', () => { })); // Act - const result = await controller.optIn(); + const result = await controller.optIn(mockAccounts); // Assert expect(result).toBeNull(); // Should return null when subscription id is missing @@ -8059,11 +7973,7 @@ describe('RewardsController', () => { .mockResolvedValue(mockSubscriptionId); mockMessenger.call.mockImplementation((method, ..._args): any => { - if ( - method === 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return mockAccounts; - } else if (method === 'KeyringController:signPersonalMessage') { + if (method === 'KeyringController:signPersonalMessage') { return Promise.resolve('0xsignature123'); } else if (method === 'RewardsDataService:mobileOptin') { return Promise.reject(mockError); @@ -8087,7 +7997,7 @@ describe('RewardsController', () => { })); // Act - const result = await testController.optIn(); + const result = await testController.optIn(mockAccounts); // Assert expect(result).toBe(mockSubscriptionId); @@ -8648,67 +8558,43 @@ describe('RewardsController', () => { describe('optIn edge cases', () => { it('should handle empty account group gracefully', async () => { // Arrange - mockMessenger.call.mockImplementation((method, ..._args): any => { - if ( - method === - 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return []; - } - return Promise.resolve(); - }); + const mockAccounts: InternalAccount[] = []; // Act - const result = await controller.optIn(); + const result = await controller.optIn(mockAccounts); // Assert expect(result).toBeNull(); - expect(mockMessenger.call).toHaveBeenCalledWith( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', + expect(mockLogger.log).toHaveBeenCalledWith( + 'RewardsController: No accounts provided, skipping optin', ); }); it('should handle null account group gracefully', async () => { // Arrange - mockMessenger.call.mockImplementation((method, ..._args): any => { - if ( - method === - 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return null; - } - return Promise.resolve(); - }); + const mockAccounts = null as unknown as InternalAccount[]; // Act - const result = await controller.optIn(); + const result = await controller.optIn(mockAccounts); // Assert expect(result).toBeNull(); - expect(mockMessenger.call).toHaveBeenCalledWith( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', + expect(mockLogger.log).toHaveBeenCalledWith( + 'RewardsController: No accounts provided, skipping optin', ); }); it('should handle undefined account group gracefully', async () => { // Arrange - mockMessenger.call.mockImplementation((method, ..._args): any => { - if ( - method === - 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return undefined; - } - return Promise.resolve(); - }); + const mockAccounts = undefined as unknown as InternalAccount[]; // Act - const result = await controller.optIn(); + const result = await controller.optIn(mockAccounts); // Assert expect(result).toBeNull(); - expect(mockMessenger.call).toHaveBeenCalledWith( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', + expect(mockLogger.log).toHaveBeenCalledWith( + 'RewardsController: No accounts provided, skipping optin', ); }); @@ -8729,15 +8615,9 @@ describe('RewardsController', () => { } as InternalAccount; const mockAccounts = [unsupportedAccount]; - mockMessenger.call.mockImplementation((method, ..._args): any => { - if ( - method === - 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return mockAccounts; - } - return Promise.resolve(); - }); + mockMessenger.call.mockImplementation((_, ..._args): any => + Promise.resolve(), + ); // Mock sortAccounts jest.doMock('./utils/sortAccounts', () => ({ @@ -8745,7 +8625,7 @@ describe('RewardsController', () => { })); // Act & Assert - await expect(controller.optIn()).rejects.toThrow( + await expect(controller.optIn(mockAccounts)).rejects.toThrow( 'Failed to opt in any account from the account group', ); }); @@ -8768,12 +8648,7 @@ describe('RewardsController', () => { } as InternalAccount; const mockAccounts = [mockEvmInternalAccount]; mockMessenger.call.mockImplementation((method, ..._args): any => { - if ( - method === - 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return mockAccounts; - } else if (method === 'KeyringController:signPersonalMessage') { + if (method === 'KeyringController:signPersonalMessage') { return Promise.resolve('0xsignature123'); } else if (method === 'RewardsDataService:mobileOptin') { const params = _args[0] as any; @@ -8792,7 +8667,7 @@ describe('RewardsController', () => { })); // Act - const result = await controller.optIn(longReferralCode); + const result = await controller.optIn(mockAccounts, longReferralCode); // Assert expect(result).toBe('test-subscription-id'); @@ -8816,12 +8691,7 @@ describe('RewardsController', () => { } as InternalAccount; const mockAccounts = [mockEvmInternalAccount]; mockMessenger.call.mockImplementation((method, ..._args): any => { - if ( - method === - 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return mockAccounts; - } else if (method === 'KeyringController:signPersonalMessage') { + if (method === 'KeyringController:signPersonalMessage') { return Promise.resolve('0xsignature123'); } else if (method === 'RewardsDataService:mobileOptin') { const params = _args[0] as any; @@ -8840,7 +8710,10 @@ describe('RewardsController', () => { })); // Act - const result = await controller.optIn(specialReferralCode); + const result = await controller.optIn( + mockAccounts, + specialReferralCode, + ); // Assert expect(result).toBe('test-subscription-id'); diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.ts index 606c3245a17..b35b4db9700 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.ts @@ -1927,9 +1927,13 @@ export class RewardsController extends BaseController< /** * Perform the complete opt-in process for rewards + * @param accounts - Array of internal accounts to opt in * @param referralCode - Optional referral code */ - async optIn(referralCode?: string): Promise { + async optIn( + accounts: InternalAccount[], + referralCode?: string, + ): Promise { const rewardsEnabled = this.isRewardsFeatureEnabled(); if (!rewardsEnabled) { Logger.log( @@ -1938,14 +1942,8 @@ export class RewardsController extends BaseController< return null; } - const accounts = await this.messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ); - if (!accounts || accounts.length === 0) { - Logger.log( - 'RewardsController: No accounts found in selected account group, skipping optin', - ); + Logger.log('RewardsController: No accounts provided, skipping optin'); return null; } diff --git a/app/core/Engine/controllers/rewards-controller/types.ts b/app/core/Engine/controllers/rewards-controller/types.ts index 6e46a7ec15d..a64913f552d 100644 --- a/app/core/Engine/controllers/rewards-controller/types.ts +++ b/app/core/Engine/controllers/rewards-controller/types.ts @@ -717,7 +717,10 @@ export interface Patch { */ export interface RewardsControllerOptInAction { type: 'RewardsController:optIn'; - handler: (referralCode?: string) => Promise; + handler: ( + accounts: InternalAccount[], + referralCode?: string, + ) => Promise; } /** From fa7f1147a7b6c0bff256cd33a7bc889311a63461 Mon Sep 17 00:00:00 2001 From: cmd-ob Date: Thu, 13 Nov 2025 08:38:13 +0000 Subject: [PATCH 03/12] test: fix snap test by using assigned port (#22551) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit … tests ## **Description** - Since we added a port manager to allocate random ports we need to remove the reliance on AnvilPort() in this test ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Adds a platform-aware Anvil port helper and updates the Snap network access test to use the allocated port instead of a hardcoded one. > > - **E2E Tests**: > - Update `e2e/specs/snaps/test-snap-network-access.spec.ts` to use `getAnvilPortForTest()` for WebSocket URL and remove explicit `port` option in `localNodeOptions`. > - **Fixtures/Utils**: > - Add `getAnvilPortForTest()` in `e2e/framework/fixtures/FixtureUtils.ts` to return `DEFAULT_ANVIL_PORT` on Android and the allocated `ANVIL` port on iOS, with JSDoc examples. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit ccb55b4953a6f31a90851c0218a85d12960a2f8b. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- e2e/framework/fixtures/FixtureUtils.ts | 16 ++++++++++++++++ e2e/specs/snaps/test-snap-network-access.spec.ts | 5 ++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/e2e/framework/fixtures/FixtureUtils.ts b/e2e/framework/fixtures/FixtureUtils.ts index 724f6e7aea1..f6d49fc27af 100644 --- a/e2e/framework/fixtures/FixtureUtils.ts +++ b/e2e/framework/fixtures/FixtureUtils.ts @@ -476,6 +476,22 @@ export function getTestDappLocalUrl() { return getDappUrl(0); } +/** + * Gets the Anvil port for use during test execution. + * Automatically handles platform differences (Android uses fallback port, iOS uses actual allocated port). + * + * @returns The Anvil port to use in tests (8545 on Android, allocated port on iOS) + * + * @example + * // Get Anvil WebSocket URL + * const wsUrl = `ws://localhost:${getAnvilPortForTest()}`; + */ +export function getAnvilPortForTest(): number { + return device.getPlatform() === 'android' + ? DEFAULT_ANVIL_PORT + : getServerPort(ResourceType.ANVIL); +} + export function getGanachePort(): number { return getServerPort(ResourceType.GANACHE); } diff --git a/e2e/specs/snaps/test-snap-network-access.spec.ts b/e2e/specs/snaps/test-snap-network-access.spec.ts index 2cfe6d3d145..4e32d214d52 100644 --- a/e2e/specs/snaps/test-snap-network-access.spec.ts +++ b/e2e/specs/snaps/test-snap-network-access.spec.ts @@ -4,7 +4,7 @@ import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; import { withFixtures } from '../../framework/fixtures/FixtureHelper'; import TabBarComponent from '../../pages/wallet/TabBarComponent'; import TestSnaps from '../../pages/Browser/TestSnaps'; -import { AnvilPort } from '../../framework/fixtures/FixtureUtils'; +import { getAnvilPortForTest } from '../../framework/fixtures/FixtureUtils'; import { LocalNodeType } from '../../framework'; import { defaultOptions } from '../../seeder/anvil-manager'; @@ -22,7 +22,6 @@ describe(FlaskBuildTests('Network Access Snap Tests'), () => { type: LocalNodeType.anvil, options: { ...defaultOptions, - port: AnvilPort(), blockTime: 2, }, }, @@ -43,7 +42,7 @@ describe(FlaskBuildTests('Network Access Snap Tests'), () => { ); // Use WebSockets - const webSocketUrl = `ws://localhost:${AnvilPort()}`; + const webSocketUrl = `ws://localhost:${getAnvilPortForTest()}`; await TestSnaps.fillMessage('webSocketUrlInput', webSocketUrl); await TestSnaps.tapButton('startWebSocket'); From 2e04809e0ba7b25f7c9739d4d6f7cfb5312ad207 Mon Sep 17 00:00:00 2001 From: cmd-ob Date: Thu, 13 Nov 2025 09:10:21 +0000 Subject: [PATCH 04/12] fix(e2e): add Android delay handling for confirm button tap (#22601) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** - Addresses some flakiness in the send asset test. - iOS handles element obstruction more gracefully so we dont need the same checks. ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Adds an Android-only delay and consolidated platform check before tapping the confirm button in e2e `FooterActions` to mitigate element obstruction. > > - **E2E (Browser Confirmations)** > - In `e2e/pages/Browser/Confirmations/FooterActions.ts`: > - Add Android-only `TestHelpers.delay(3000)` before `confirm` tap to avoid bottom toast obstruction. > - Refactor platform check to `isAndroid` and use it for `waitForElementToDisappear`. > - Import `TestHelpers` to support the delay. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 342eb25a1cc43e50d8e05947cbe4343b3f4ee805. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- e2e/pages/Browser/Confirmations/FooterActions.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/e2e/pages/Browser/Confirmations/FooterActions.ts b/e2e/pages/Browser/Confirmations/FooterActions.ts index b304b9c80e4..7db96e3f8df 100644 --- a/e2e/pages/Browser/Confirmations/FooterActions.ts +++ b/e2e/pages/Browser/Confirmations/FooterActions.ts @@ -1,6 +1,7 @@ import { ConfirmationFooterSelectorIDs } from '../../../selectors/Confirmation/ConfirmationView.selectors'; import Matchers from '../../../framework/Matchers'; import Gestures from '../../../framework/Gestures'; +import TestHelpers from '../../../helpers'; class FooterActions { get confirmButton(): DetoxElement { @@ -14,10 +15,14 @@ class FooterActions { } async tapConfirmButton(): Promise { + const isAndroid = device.getPlatform() === 'android'; + // Android needs extra delay to avoid element being obscured by bottom toast notifications + // eslint-disable-next-line no-restricted-syntax + if (isAndroid) await TestHelpers.delay(3000); await Gestures.waitAndTap(this.confirmButton, { elemDescription: 'Confirm button', delay: 1800, - waitForElementToDisappear: device.getPlatform() === 'android', + waitForElementToDisappear: isAndroid, }); } From 9d7a0b8f309be0a2fd60c5a659c7066444bdf35e Mon Sep 17 00:00:00 2001 From: Salim TOUBAL Date: Thu, 13 Nov 2025 10:10:51 +0100 Subject: [PATCH 05/12] fix: add prediction carousel (#22537) ## **Description** This PR adds a new prediction markets carousel to the Trending tab, allowing users to discover and engage with prediction markets directly from the trending feed. **Reason for change:** - Improve user discoverability of prediction markets - Provide a seamless entry point to predictions from the trending view - Showcase trending prediction markets alongside trending tokens **Implementation:** - Created new `PredictionSection` component with horizontal carousel displaying up to 6 prediction markets - Integrated the prediction section into `TrendingView` above the trending tokens section - Implemented loading states with skeleton loaders - Added pagination dots for carousel navigation - Included "View all" button for future navigation to full predictions list - Fetches trending prediction markets using existing `usePredictMarketData` hook - Fully responsive with proper spacing and design system components ## **Changelog** CHANGELOG entry: Added prediction markets carousel to Trending tab ## **Related issues** Fixes: ## **Manual testing steps** ### **Before** ### **After** https://github.com/user-attachments/assets/37365918-d0d1-4db8-b240-365907baee6b ## **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 a prediction markets carousel to the Trending view with pagination and loading states, introduces a reusable section header, updates the tokens section, and wires the Predict modal route. > > - **Trending view**: > - Add `PredictionSection` (`PredictionSection.tsx/.styles.ts`) with horizontal carousel (FlashList), pagination dots, and skeleton loading; fetches up to 6 markets via `usePredictMarketData`. > - Integrate `PredictionSection` into `TrendingView.tsx` above tokens. > - Register `Routes.PREDICT.MODALS.ROOT` with `PredictModalStack` in navigator. > - **Shared UI**: > - Create reusable `SectionHeader` (`SectionHeader.tsx`, `index.ts`) and replace inline header in `TrendingTokensSection.tsx`. > - **Tests**: > - Add tests for `PredictionSection` and `SectionHeader` covering loading, empty, header, button press, and hook params. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit ea85dfb677d458aec6fb798f925a8c31a2d4ac06. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../PredictionSection.styles.ts | 44 ++++ .../PredictionSection.test.tsx | 220 ++++++++++++++++++ .../PredictionSection/PredictionSection.tsx | 173 ++++++++++++++ .../SectionHeader/SectionHeader.test.tsx | 48 ++++ .../SectionHeader/SectionHeader.tsx | 49 ++++ .../Views/TrendingView/SectionHeader/index.ts | 1 + .../TrendingTokensSection.tsx | 49 +--- .../Views/TrendingView/TrendingView.tsx | 14 ++ 8 files changed, 561 insertions(+), 37 deletions(-) create mode 100644 app/components/Views/TrendingView/PredictionSection/PredictionSection.styles.ts create mode 100644 app/components/Views/TrendingView/PredictionSection/PredictionSection.test.tsx create mode 100644 app/components/Views/TrendingView/PredictionSection/PredictionSection.tsx create mode 100644 app/components/Views/TrendingView/SectionHeader/SectionHeader.test.tsx create mode 100644 app/components/Views/TrendingView/SectionHeader/SectionHeader.tsx create mode 100644 app/components/Views/TrendingView/SectionHeader/index.ts diff --git a/app/components/Views/TrendingView/PredictionSection/PredictionSection.styles.ts b/app/components/Views/TrendingView/PredictionSection/PredictionSection.styles.ts new file mode 100644 index 00000000000..1df3cec1516 --- /dev/null +++ b/app/components/Views/TrendingView/PredictionSection/PredictionSection.styles.ts @@ -0,0 +1,44 @@ +import { StyleSheet } from 'react-native'; +import { Theme } from '../../../../util/theme/models'; + +interface PredictionSectionStylesVars { + activeIndex: number; + cardWidth: number; +} + +const styleSheet = (params: { + theme: Theme; + vars: PredictionSectionStylesVars; +}) => { + const { theme } = params; + const { colors } = theme; + + return StyleSheet.create({ + carouselItem: { + width: params.vars.cardWidth * 0.85, + borderRadius: 16, + paddingHorizontal: 8, + overflow: 'hidden', + borderColor: colors.border.default, + shadowColor: colors.shadow.default, + }, + paginationContainer: { + marginTop: 16, + gap: 8, + }, + dot: { + height: 8, + width: 8, + borderRadius: 4, + backgroundColor: colors.border.muted, + }, + dotActive: { + height: 8, + width: 24, + borderRadius: 4, + backgroundColor: colors.text.default, + }, + }); +}; + +export default styleSheet; diff --git a/app/components/Views/TrendingView/PredictionSection/PredictionSection.test.tsx b/app/components/Views/TrendingView/PredictionSection/PredictionSection.test.tsx new file mode 100644 index 00000000000..2279317ebcd --- /dev/null +++ b/app/components/Views/TrendingView/PredictionSection/PredictionSection.test.tsx @@ -0,0 +1,220 @@ +import React from 'react'; +import { fireEvent } from '@testing-library/react-native'; +import renderWithProvider from '../../../../util/test/renderWithProvider'; +import { backgroundState } from '../../../../util/test/initial-root-state'; +import PredictionSection from './PredictionSection'; +import { usePredictMarketData } from '../../../UI/Predict/hooks/usePredictMarketData'; +import { + PredictMarket as PredictMarketType, + Recurrence, +} from '../../../UI/Predict/types'; + +// Mock dependencies +jest.mock('../../../UI/Predict/hooks/usePredictMarketData'); +jest.mock('../../../UI/Predict/components/PredictMarket', () => { + const { View, Text } = jest.requireActual('react-native'); + return jest.fn(({ market, testID }) => ( + + PredictMarket: {market.title} + + )); +}); +jest.mock('../../../UI/Predict/components/PredictMarketSkeleton', () => { + const { View, Text } = jest.requireActual('react-native'); + return jest.fn(({ testID }) => ( + + Loading... + + )); +}); +jest.mock('@shopify/flash-list', () => { + const { FlatList } = jest.requireActual('react-native'); + return { + FlashList: FlatList, + }; +}); + +const mockUsePredictMarketData = usePredictMarketData as jest.MockedFunction< + typeof usePredictMarketData +>; + +const initialState = { + engine: { + backgroundState, + }, +}; + +describe('PredictionSection', () => { + const createMockMarket = (id: string): PredictMarketType => ({ + id, + providerId: 'test-provider', + slug: `market-${id}`, + title: `Market ${id}`, + description: `Description for market ${id}`, + image: `https://example.com/image-${id}.png`, + status: 'open', + recurrence: Recurrence.NONE, + category: 'crypto', + tags: [], + outcomes: [], + liquidity: 10000, + volume: 50000, + }); + + const mockMarketData: PredictMarketType[] = [ + createMockMarket('1'), + createMockMarket('2'), + createMockMarket('3'), + createMockMarket('4'), + createMockMarket('5'), + createMockMarket('6'), + ]; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('loading state', () => { + it('renders skeleton loaders when fetching data', () => { + mockUsePredictMarketData.mockReturnValue({ + marketData: [], + isFetching: true, + isFetchingMore: false, + error: null, + hasMore: false, + refetch: jest.fn(), + fetchMore: jest.fn(), + }); + + const { getByText, getAllByTestId } = renderWithProvider( + , + { state: initialState }, + ); + + expect(getByText('Predictions')).toBeOnTheScreen(); + expect(getByText('View all')).toBeOnTheScreen(); + expect( + getAllByTestId('prediction-carousel-skeleton').length, + ).toBeGreaterThan(0); + }); + + it('renders header with view all button during loading', () => { + mockUsePredictMarketData.mockReturnValue({ + marketData: [], + isFetching: true, + isFetchingMore: false, + error: null, + hasMore: false, + refetch: jest.fn(), + fetchMore: jest.fn(), + }); + + const { getByText } = renderWithProvider(, { + state: initialState, + }); + + expect(getByText('Predictions')).toBeOnTheScreen(); + expect(getByText('View all')).toBeOnTheScreen(); + }); + }); + + describe('empty state', () => { + it('renders nothing when not fetching and data is empty', () => { + mockUsePredictMarketData.mockReturnValue({ + marketData: [], + isFetching: false, + isFetchingMore: false, + error: null, + hasMore: false, + refetch: jest.fn(), + fetchMore: jest.fn(), + }); + + const { toJSON } = renderWithProvider(, { + state: initialState, + }); + + expect(toJSON()).toBeNull(); + }); + }); + + describe('carousel with data', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + mockUsePredictMarketData.mockReturnValue({ + marketData: mockMarketData, + isFetching: false, + isFetchingMore: false, + error: null, + hasMore: false, + refetch: jest.fn(), + fetchMore: jest.fn(), + }); + }); + + it('renders section header with title and view all button', () => { + const { getByText } = renderWithProvider(, { + state: initialState, + }); + + expect(getByText('Predictions')).toBeOnTheScreen(); + expect(getByText('View all')).toBeOnTheScreen(); + }); + }); + + describe('view all button', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + mockUsePredictMarketData.mockReturnValue({ + marketData: mockMarketData, + isFetching: false, + isFetchingMore: false, + error: null, + hasMore: false, + refetch: jest.fn(), + fetchMore: jest.fn(), + }); + }); + + it('logs to console when view all button is pressed', () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + const { getByText } = renderWithProvider(, { + state: initialState, + }); + + fireEvent.press(getByText('View all')); + + expect(consoleSpy).toHaveBeenCalledWith('View all predictions'); + consoleSpy.mockRestore(); + }); + }); + + describe('data fetching', () => { + it('calls usePredictMarketData with correct parameters', () => { + mockUsePredictMarketData.mockReturnValue({ + marketData: mockMarketData, + isFetching: false, + isFetchingMore: false, + error: null, + hasMore: false, + refetch: jest.fn(), + fetchMore: jest.fn(), + }); + + renderWithProvider(, { + state: initialState, + }); + + expect(mockUsePredictMarketData).toHaveBeenCalledWith({ + category: 'trending', + pageSize: 6, + }); + }); + }); +}); diff --git a/app/components/Views/TrendingView/PredictionSection/PredictionSection.tsx b/app/components/Views/TrendingView/PredictionSection/PredictionSection.tsx new file mode 100644 index 00000000000..775321658df --- /dev/null +++ b/app/components/Views/TrendingView/PredictionSection/PredictionSection.tsx @@ -0,0 +1,173 @@ +import { + Box, + BoxFlexDirection, + BoxAlignItems, + BoxJustifyContent, +} from '@metamask/design-system-react-native'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { + Dimensions, + NativeScrollEvent, + NativeSyntheticEvent, + Pressable, +} from 'react-native'; +import { FlashList, FlashListRef } from '@shopify/flash-list'; +import { strings } from '../../../../../locales/i18n'; +import { usePredictMarketData } from '../../../UI/Predict/hooks/usePredictMarketData'; +import PredictMarket from '../../../UI/Predict/components/PredictMarket'; +import { PredictMarket as PredictMarketType } from '../../../UI/Predict/types'; +import { PredictEventValues } from '../../../UI/Predict/constants/eventNames'; +import PredictMarketSkeleton from '../../../UI/Predict/components/PredictMarketSkeleton'; +import { useStyles } from '../../../../component-library/hooks'; +import styleSheet from './PredictionSection.styles'; +import SectionHeader from '../SectionHeader'; + +const { width: SCREEN_WIDTH } = Dimensions.get('window'); +const CARD_WIDTH = SCREEN_WIDTH - 32; // 16px padding on each side +const CARD_SPACING = 16; +const ACTUAL_CARD_WIDTH = CARD_WIDTH * 0.85; // Actual rendered card width +const SNAP_INTERVAL = ACTUAL_CARD_WIDTH + CARD_SPACING; + +const PredictionSection = () => { + const [activeIndex, setActiveIndex] = useState(0); + const flashListRef = useRef>(null); + + const { styles } = useStyles(styleSheet, { + activeIndex, + cardWidth: CARD_WIDTH, + }); + + // Fetch prediction market data with limit of 6 + const { marketData, isFetching } = usePredictMarketData({ + category: 'trending', + pageSize: 6, + }); + + // Limit to 6 items + const carouselData = useMemo( + () => (marketData ? marketData.slice(0, 6) : []), + [marketData], + ); + + const handleScroll = useCallback( + (event: NativeSyntheticEvent) => { + const scrollPosition = event.nativeEvent.contentOffset.x; + const index = Math.round(scrollPosition / SNAP_INTERVAL); + setActiveIndex(index); + }, + [], + ); + + const scrollToIndex = useCallback((index: number) => { + flashListRef.current?.scrollToIndex({ + index, + animated: true, + }); + setActiveIndex(index); + }, []); + + const handleViewAll = useCallback(() => { + // TODO: Navigate to predictions view all screen + // eslint-disable-next-line no-console + console.log('View all predictions'); + }, []); + + const renderCarouselItem = useCallback( + ({ item, index }: { item: PredictMarketType; index: number }) => ( + + + + ), + [styles], + ); + + const renderPaginationDots = useCallback( + () => ( + + {carouselData.map((_, index) => { + const isActive = activeIndex === index; + return ( + scrollToIndex(index)} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + > + + + ); + })} + + ), + [carouselData, activeIndex, scrollToIndex, styles], + ); + + // Show loading state while fetching + if (isFetching) { + return ( + + + + ( + + + + )} + keyExtractor={(item) => `skeleton-${item}`} + /> + + + ); + } + + // Show empty state when no data + if (carouselData.length === 0) { + return null; // Don't show the section if there are no predictions + } + + return ( + + + + + item.id} + horizontal + pagingEnabled={false} + showsHorizontalScrollIndicator={false} + snapToInterval={SNAP_INTERVAL} + decelerationRate="fast" + onScroll={handleScroll} + scrollEventThrottle={16} + /> + + + {renderPaginationDots()} + + ); +}; + +export default PredictionSection; diff --git a/app/components/Views/TrendingView/SectionHeader/SectionHeader.test.tsx b/app/components/Views/TrendingView/SectionHeader/SectionHeader.test.tsx new file mode 100644 index 00000000000..cc2df13b02d --- /dev/null +++ b/app/components/Views/TrendingView/SectionHeader/SectionHeader.test.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { fireEvent } from '@testing-library/react-native'; +import renderWithProvider from '../../../../util/test/renderWithProvider'; +import { backgroundState } from '../../../../util/test/initial-root-state'; +import SectionHeader from './SectionHeader'; + +const initialState = { + engine: { + backgroundState, + }, +}; + +describe('SectionHeader', () => { + const mockOnViewAll = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders title and view all text correctly', () => { + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + expect(getByText('Predictions')).toBeOnTheScreen(); + expect(getByText('View all')).toBeOnTheScreen(); + }); + + it('calls onViewAll when view all button is pressed', () => { + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + fireEvent.press(getByText('View all')); + + expect(mockOnViewAll).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/components/Views/TrendingView/SectionHeader/SectionHeader.tsx b/app/components/Views/TrendingView/SectionHeader/SectionHeader.tsx new file mode 100644 index 00000000000..2d1a4af5b0a --- /dev/null +++ b/app/components/Views/TrendingView/SectionHeader/SectionHeader.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { TouchableOpacity, StyleSheet } from 'react-native'; +import { + Box, + BoxFlexDirection, + BoxAlignItems, + BoxJustifyContent, +} from '@metamask/design-system-react-native'; +import Text, { + TextColor, + TextVariant, +} from '../../../../component-library/components/Texts/Text'; + +interface SectionHeaderProps { + title: string; + viewAllText: string; + onViewAll: () => void; +} + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: 4, + marginBottom: 8, + }, +}); + +const SectionHeader: React.FC = ({ + title, + viewAllText, + onViewAll, +}) => ( + + + {title} + + + + {viewAllText} + + + +); + +export default SectionHeader; diff --git a/app/components/Views/TrendingView/SectionHeader/index.ts b/app/components/Views/TrendingView/SectionHeader/index.ts new file mode 100644 index 00000000000..96dda554fb2 --- /dev/null +++ b/app/components/Views/TrendingView/SectionHeader/index.ts @@ -0,0 +1 @@ +export { default } from './SectionHeader'; diff --git a/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensSection.tsx b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensSection.tsx index 424b031aa2e..99935a1771c 100644 --- a/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensSection.tsx +++ b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensSection.tsx @@ -1,33 +1,17 @@ import React, { useCallback, useMemo } from 'react'; -import { View, TouchableOpacity, StyleSheet } from 'react-native'; +import { View, StyleSheet } from 'react-native'; import { strings } from '../../../../../locales/i18n'; import { TrendingAsset } from '@metamask/assets-controllers'; import { useAppThemeFromContext } from '../../../../util/theme'; import { Theme } from '../../../../util/theme/models'; -import Text, { - TextColor, - TextVariant, -} from '../../../../component-library/components/Texts/Text'; import TrendingTokensSkeleton from './TrendingTokenSkeleton/TrendingTokensSkeleton'; import TrendingTokensList from './TrendingTokensList'; import Card from '../../../../component-library/components/Cards/Card'; import { useTrendingRequest } from '../../../UI/Assets/hooks/useTrendingRequest'; +import SectionHeader from '../SectionHeader'; const createStyles = (theme: Theme) => StyleSheet.create({ - header: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingHorizontal: 4, - marginBottom: 8, - }, - contentContainer: { - marginHorizontal: 16, - borderRadius: 16, - paddingTop: 12, - backgroundColor: theme.colors.background.muted, - }, cardContainer: { borderRadius: 12, paddingVertical: 16, @@ -54,28 +38,15 @@ const TrendingTokensSection = () => { // TODO: Implement token press logic }, []); - // Header component - const SectionHeader = useCallback( - () => ( - - - {strings('trending.tokens')} - - - - {strings('trending.view_all')} - - - - ), - [styles.header, handleViewAll], - ); - // Show skeleton during initial load or when there are no tokens if (isLoading || trendingTokens.length === 0) { return ( - + @@ -85,7 +56,11 @@ const TrendingTokensSection = () => { return ( - + { style={styles.scrollView} showsVerticalScrollIndicator={false} > + @@ -147,6 +150,17 @@ const TrendingView: React.FC = () => { name={Routes.EXPLORE_SEARCH} component={ExploreSearchScreen} /> + ); From 492befcc7cacfc8a2f108c027ec76e7cda742d7e Mon Sep 17 00:00:00 2001 From: VGR Date: Thu, 13 Nov 2025 10:59:03 +0100 Subject: [PATCH 06/12] chore: use dedicated ff for predict rewards estimation (#22603) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Use a dedicated feature flag for predict reward point estimations. ## **Changelog** CHANGELOG entry: null ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Switches PredictBuyPreview to use dedicated `rewardsEnablePredict` flag via `selectRewardsPredictEnabledFlag` and adds selector + tests; rewards display now gated by this flag. > > - **Predict UI**: > - Update `PredictBuyPreview.tsx` to use `selectRewardsPredictEnabledFlag` (replacing `selectRewardsEnabledFlag`) to gate rewards estimation display. > - Continue calculating `estimatedPoints` from `metamaskFee`; show rewards row only when flag enabled and amount > 0. > - **Feature Flags**: > - Add `REWARDS_PREDICT_ENABLED_FLAG_NAME` (`rewardsEnablePredict`) and new selector `selectRewardsPredictEnabledFlag` in `selectors/featureFlagController/rewards`. > - Extend tests in `selectors/.../rewards/index.test.ts` to cover valid/invalid/version cases for the new flag. > - **Tests**: > - Modify `PredictBuyPreview.test.tsx` to mock `selectRewardsPredictEnabledFlag` and add tests for rewards calculation/display behavior based on the flag. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 31951c1558a9c71d89d403861256291ea906da68. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../PredictBuyPreview.test.tsx | 23 +++++++++ .../PredictBuyPreview/PredictBuyPreview.tsx | 4 +- .../rewards/index.test.ts | 50 +++++++++++++++++++ .../featureFlagController/rewards/index.ts | 15 ++++++ 4 files changed, 90 insertions(+), 2 deletions(-) diff --git a/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.test.tsx b/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.test.tsx index 74a2b55a1df..859db01a949 100644 --- a/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.test.tsx +++ b/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.test.tsx @@ -103,6 +103,20 @@ jest.mock('../../hooks/usePredictDeposit', () => ({ }), })); +// Mock rewards feature flag selector +const mockRewardsPredictEnabledState = { value: false }; +jest.mock('../../../../../selectors/featureFlagController/rewards', () => { + const actual = jest.requireActual( + '../../../../../selectors/featureFlagController/rewards', + ); + return { + ...actual, + selectRewardsPredictEnabledFlag: jest.fn( + () => mockRewardsPredictEnabledState.value, + ), + }; +}); + // Mock Skeleton component jest.mock( '../../../../../component-library/components/Skeleton/Skeleton', @@ -312,6 +326,7 @@ describe('PredictBuyPreview', () => { mockBalanceLoading = false; mockMetamaskFee = 0.5; mockProviderFee = 1.0; + mockRewardsPredictEnabledState.value = false; // Setup default mocks mockUseNavigation.mockReturnValue(mockNavigation); @@ -2188,6 +2203,7 @@ describe('PredictBuyPreview', () => { describe('Rewards Calculation', () => { it('calculates estimated points as metamask fee times 100 rounded', () => { + mockRewardsPredictEnabledState.value = true; mockMetamaskFee = 0.5; const mockStore = { ...initialState, @@ -2212,6 +2228,7 @@ describe('PredictBuyPreview', () => { }); it('rounds estimated points to nearest integer', () => { + mockRewardsPredictEnabledState.value = true; mockMetamaskFee = 1.234; renderWithProvider(, { @@ -2222,6 +2239,7 @@ describe('PredictBuyPreview', () => { }); it('calculates zero points when metamask fee is zero', () => { + mockRewardsPredictEnabledState.value = true; mockMetamaskFee = 0; renderWithProvider(, { @@ -2232,6 +2250,7 @@ describe('PredictBuyPreview', () => { }); it('recalculates points when metamask fee changes', () => { + mockRewardsPredictEnabledState.value = true; mockMetamaskFee = 0.5; const { rerender } = renderWithProvider(, { @@ -2249,6 +2268,7 @@ describe('PredictBuyPreview', () => { describe('Rewards Display', () => { it('shows rewards when feature flag is enabled and amount is entered', () => { + mockRewardsPredictEnabledState.value = true; mockMetamaskFee = 0.5; renderWithProvider(, { @@ -2267,6 +2287,7 @@ describe('PredictBuyPreview', () => { }); it('does not show rewards when feature flag is disabled', () => { + mockRewardsPredictEnabledState.value = false; mockMetamaskFee = 0.5; renderWithProvider(, { @@ -2285,6 +2306,8 @@ describe('PredictBuyPreview', () => { }); it('does not show rewards when amount is zero', () => { + mockRewardsPredictEnabledState.value = true; + renderWithProvider(, { state: initialState, }); diff --git a/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.tsx b/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.tsx index 773a1071416..6aa28bf056e 100644 --- a/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.tsx +++ b/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.tsx @@ -63,7 +63,7 @@ import { usePredictDeposit } from '../../hooks/usePredictDeposit'; import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; import { strings } from '../../../../../../locales/i18n'; import ButtonHero from '../../../../../component-library/components-temp/Buttons/ButtonHero'; -import { selectRewardsEnabledFlag } from '../../../../../selectors/featureFlagController/rewards'; +import { selectRewardsPredictEnabledFlag } from '../../../../../selectors/featureFlagController/rewards'; const PredictBuyPreview = () => { const tw = useTailwind(); @@ -77,7 +77,7 @@ const PredictBuyPreview = () => { const { market, outcome, outcomeToken, entryPoint } = route.params; // Rewards feature flag - const rewardsEnabled = useSelector(selectRewardsEnabledFlag); + const rewardsEnabled = useSelector(selectRewardsPredictEnabledFlag); // Prepare analytics properties const analyticsProperties = useMemo( diff --git a/app/selectors/featureFlagController/rewards/index.test.ts b/app/selectors/featureFlagController/rewards/index.test.ts index 0d87ac9d72b..894eb1d7c68 100644 --- a/app/selectors/featureFlagController/rewards/index.test.ts +++ b/app/selectors/featureFlagController/rewards/index.test.ts @@ -2,6 +2,7 @@ import { selectRewardsEnabledFlag, selectRewardsAnnouncementModalEnabledFlag, selectRewardsCardSpendFeatureFlags, + selectRewardsPredictEnabledFlag, } from '.'; import { VersionGatedFeatureFlag, @@ -211,6 +212,55 @@ describe('Rewards Feature Flag Selectors', () => { expect(result).toBe(false); }); }); + + describe('selectRewardsPredictEnabledFlag', () => { + it('returns true when remote flag is valid and enabled', () => { + const result = selectRewardsPredictEnabledFlag.resultFunc({ + rewardsEnablePredict: { + enabled: true, + minimumVersion: '1.0.0', + }, + }); + expect(result).toBe(true); + }); + + it('returns false when remote flag is valid but disabled', () => { + const result = selectRewardsPredictEnabledFlag.resultFunc({ + rewardsEnablePredict: { + enabled: false, + minimumVersion: '1.0.0', + }, + }); + expect(result).toBe(false); + }); + + it('returns false when version check fails', () => { + mockHasMinimumRequiredVersion.mockReturnValue(false); + const result = selectRewardsPredictEnabledFlag.resultFunc({ + rewardsEnablePredict: { + enabled: true, + minimumVersion: '99.0.0', + }, + }); + expect(result).toBe(false); + }); + + it('returns false when remote flag is invalid', () => { + const result = selectRewardsPredictEnabledFlag.resultFunc({ + rewardsEnablePredict: { + enabled: 'invalid', + minimumVersion: 123, + }, + }); + expect(result).toBe(false); + }); + + it('returns false when remote feature flags are empty', () => { + const result = selectRewardsPredictEnabledFlag.resultFunc({}); + expect(result).toBe(false); + }); + }); + describe('validatedVersionGatedFeatureFlag', () => { const validRemoteFlag: VersionGatedFeatureFlag = { enabled: true, diff --git a/app/selectors/featureFlagController/rewards/index.ts b/app/selectors/featureFlagController/rewards/index.ts index c102788952e..985f464e3d9 100644 --- a/app/selectors/featureFlagController/rewards/index.ts +++ b/app/selectors/featureFlagController/rewards/index.ts @@ -12,6 +12,7 @@ const DEFAULT_CARD_SPEND_ENABLED = false; export const FEATURE_FLAG_NAME = 'rewardsEnabled'; export const ANNOUNCEMENT_MODAL_FLAG_NAME = 'rewardsAnnouncementModalEnabled'; export const CARD_SPEND_FLAG_NAME = 'rewardsEnableCardSpend'; +export const REWARDS_PREDICT_ENABLED_FLAG_NAME = 'rewardsEnablePredict'; export const selectRewardsEnabledFlag = createSelector( selectRemoteFeatureFlags, @@ -67,3 +68,17 @@ export const selectRewardsCardSpendFeatureFlags = createSelector( ); }, ); + +export const selectRewardsPredictEnabledFlag = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => { + if (!hasProperty(remoteFeatureFlags, REWARDS_PREDICT_ENABLED_FLAG_NAME)) { + return false; + } + const remoteFlag = remoteFeatureFlags[ + REWARDS_PREDICT_ENABLED_FLAG_NAME + ] as unknown as VersionGatedFeatureFlag; + + return validatedVersionGatedFeatureFlag(remoteFlag) ?? false; + }, +); From 3a3be7dd439f9710dad2aa9bbd76ae48bd6ed09b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20=C5=81ucka?= <5708018+PatrykLucka@users.noreply.github.com> Date: Thu, 13 Nov 2025 11:31:28 +0100 Subject: [PATCH 07/12] fix: swaps navigation issue when changing source token (#22545) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR fixes issue with swaps navigation from token view, when the source token is changed to different network. ## **Changelog** CHANGELOG entry: Fixed a bug with swaps navigation from token view, when the source token is changed to different network. ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TMCU-7 ## **Manual testing steps** ```gherkin Feature: Swap Solana Scenario: user swaps solana token Given user onboarded When user goes to Solana token page Then Clicks Swap Then Changes source token to Ethereum EVM Then clicks back button Then the Solana token page should be displayed correctly (before it was stuck at loading screen) ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/e58c0455-9600-4161-a126-8f3717cf05f5 ### **After** https://github.com/user-attachments/assets/310b593e-6708-486c-9dce-eb5ccabe5bc2 ## **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] > Ensures the asset view reliably clears the loading state by considering `state.loading` in transaction update conditions for both EVM and non-EVM flows. > > - **Asset view (`app/components/Views/Asset/index.js`)**: > - **Transaction normalization**: > - Include `this.state.loading` in update conditions for non-EVM and EVM paths to force state updates when only loading needs reset. > - Clear loading even when no new transactions by expanding the fallback condition. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 15d58f97e037795c8667a8f63a4c2611548bbc25. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/components/Views/Asset/index.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/components/Views/Asset/index.js b/app/components/Views/Asset/index.js index b2ae8b071c1..e65e5eee300 100644 --- a/app/components/Views/Asset/index.js +++ b/app/components/Views/Asset/index.js @@ -431,7 +431,8 @@ class Asset extends PureComponent { if ( (this.txs.length === 0 && !this.state.transactionsUpdated) || this.txs.length !== filteredTransactions.length || - this.chainId !== chainId + this.chainId !== chainId || + this.state.loading // Ensure loading is reset even if nothing else changed ) { this.txs = filteredTransactions; this.txsPending = []; @@ -512,7 +513,8 @@ class Asset extends PureComponent { (this.txs.length === 0 && !this.state.transactionsUpdated) || this.txs.length !== filteredTransactions.length || this.chainId !== chainId || - this.didTxStatusesChange(newPendingTxs) + this.didTxStatusesChange(newPendingTxs) || + this.state.loading // Ensure loading is reset even if nothing else changed ) { this.txs = filteredTransactions; this.txsPending = newPendingTxs; @@ -525,7 +527,7 @@ class Asset extends PureComponent { }); } } - } else if (!this.state.transactionsUpdated) { + } else if (!this.state.transactionsUpdated || this.state.loading) { this.setState({ transactionsUpdated: true, loading: false }); } this.isNormalizing = false; From cd83552340c5f51dae85a2d94f177de1d9d46793 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 13 Nov 2025 10:34:16 +0000 Subject: [PATCH 08/12] feat: metamask pay buy button (#22367) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Add button to Predict and Perps deposit confirmations to redirect to ramps if account has no token balances. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes:[ #6006](https://github.com/MetaMask/MetaMask-planning/issues/6006) ## **Manual testing steps** ## **Screenshots/Recordings** ### **Before** ### **After** Buy Perps Buy Predictions ## **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] > Shows a Buy Crypto button on Perps/Predictions deposit confirmations when no eligible pay tokens are available, disables amount/pay UI, and centralizes token availability logic used by the Pay With modal. > > - **Confirmations UI**: > - Add `BuySection` with "Buy crypto" CTA in `CustomAmountInfo` when `useTransactionPayAvailableTokens()` returns none; navigates to Ramp Aggregator with inferred `assetId`. > - Disable `CustomAmount`, `PayTokenAmount`, and `DepositKeyboard` when no tokens; greyed styling and fixed "0 ETH" display. > - Gate "Pay with" row and keyboard behind token availability. > - **Token availability refactor**: > - Introduce `getAvailableTokens` in `utils/transaction-pay` to filter/select tokens (handles required/pay token inclusion, ERC-20/EVM checks, gas availability, disabled messaging). > - New hook `useTransactionPayAvailableTokens` to expose filtered tokens; used in `CustomAmountInfo`. > - Update `PayWithModal` to consume `getAvailableTokens` via `tokenFilter`. > - **Tests**: > - Add unit tests for `useTransactionPayAvailableTokens` and `getAvailableTokens` scenarios. > - Update `custom-amount-info` and `pay-token-amount` tests for new disabled and Buy CTA behaviors; simplify `pay-with-modal` tests. > - **i18n**: > - Add `confirm.custom_amount.buy_button`, `buy_predict`, and `buy_perps` strings. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit fa99fa2e3dab662b7a2d237f9a41253ec6a8eaad. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../custom-amount-info.test.tsx | 82 +++++++++++ .../custom-amount-info/custom-amount-info.tsx | 97 ++++++++++++- .../pay-with-modal/pay-with-modal.test.tsx | 67 --------- .../modals/pay-with-modal/pay-with-modal.tsx | 71 ++-------- .../pay-token-amount.test.tsx | 9 +- .../pay-token-amount/pay-token-amount.tsx | 15 +- .../custom-amount/custom-amount.styles.ts | 6 +- .../custom-amount/custom-amount.tsx | 10 +- .../useTransactionPayAvailableTokens.test.ts | 35 +++++ .../pay/useTransactionPayAvailableTokens.ts | 17 +++ .../utils/transaction-pay.test.ts | 131 ++++++++++++++++++ .../confirmations/utils/transaction-pay.ts | 73 ++++++++++ locales/languages/en.json | 5 + 13 files changed, 479 insertions(+), 139 deletions(-) create mode 100644 app/components/Views/confirmations/hooks/pay/useTransactionPayAvailableTokens.test.ts create mode 100644 app/components/Views/confirmations/hooks/pay/useTransactionPayAvailableTokens.ts diff --git a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx index d3256dc2392..4bd572196a7 100644 --- a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx +++ b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx @@ -16,6 +16,15 @@ import { useAlerts, } from '../../../context/alert-system-context'; import { useTransactionCustomAmountAlerts } from '../../../hooks/transactions/useTransactionCustomAmountAlerts'; +import { useAccountTokens } from '../../../hooks/send/useAccountTokens'; +import { useTransactionPayAvailableTokens } from '../../../hooks/pay/useTransactionPayAvailableTokens'; +import { AssetType } from '../../../types/token'; +import { useTransactionPayRequiredTokens } from '../../../hooks/pay/useTransactionPayData'; +import { strings } from '../../../../../../../locales/i18n'; +import { Hex } from '@metamask/utils'; +import { TransactionPayRequiredToken } from '@metamask/transaction-pay-controller'; +import { RampMode } from '../../../../../UI/Ramp/hooks/useRampNavigation'; +import { RampType } from '../../../../../UI/Ramp/Aggregator/types'; jest.mock('../../../hooks/ui/useClearConfirmationOnBackSwipe'); jest.mock('../../../hooks/pay/useAutomaticTransactionPayToken'); @@ -25,12 +34,27 @@ jest.mock('../../../context/confirmation-context'); jest.mock('../../../context/alert-system-context'); jest.mock('../../../hooks/transactions/useTransactionCustomAmountAlerts'); jest.mock('../../../hooks/pay/useTransactionPayMetrics'); +jest.mock('../../../hooks/send/useAccountTokens'); +jest.mock('../../../hooks/pay/useTransactionPayAvailableTokens'); +jest.mock('../../../hooks/pay/useTransactionPayData'); + +const mockGoToRamps = jest.fn(); jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), useNavigation: jest.fn(), })); +jest.mock('../../../../../UI/Ramp/hooks/useRampNavigation', () => ({ + ...jest.requireActual('../../../../../UI/Ramp/hooks/useRampNavigation'), + useRampNavigation: () => ({ + goToRamps: mockGoToRamps, + }), +})); + +const TOKEN_ADDRESS_MOCK = '0x123' as Hex; +const CHAIN_ID_MOCK = '0x1' as Hex; + function render(props: CustomAmountInfoProps = {}) { return renderWithProvider(, { state: merge( @@ -46,6 +70,16 @@ describe('CustomAmountInfo', () => { const useTransactionPayTokenMock = jest.mocked(useTransactionPayToken); const useConfirmationContextMock = jest.mocked(useConfirmationContext); const useAlertsMock = jest.mocked(useAlerts); + const useAccountTokensMock = jest.mocked(useAccountTokens); + + const useTransactionPayRequiredTokensMock = jest.mocked( + useTransactionPayRequiredTokens, + ); + + const useTransactionPayAvailableTokensMock = jest.mocked( + useTransactionPayAvailableTokens, + ); + const useTransactionCustomAmountAlertsMock = jest.mocked( useTransactionCustomAmountAlerts, ); @@ -97,6 +131,10 @@ describe('CustomAmountInfo', () => { keyboardAlertMessage: undefined, excludeBannerKeys: [], }); + + useAccountTokensMock.mockReturnValue([]); + useTransactionPayAvailableTokensMock.mockReturnValue([{}] as AssetType[]); + useTransactionPayRequiredTokensMock.mockReturnValue([]); }); it('renders amount', () => { @@ -154,4 +192,48 @@ describe('CustomAmountInfo', () => { const { getByTestId } = render(); expect(getByTestId('deposit-keyboard')).toBeDefined(); }); + + it('renders buy button if no available tokens', () => { + useTransactionPayAvailableTokensMock.mockReturnValue([]); + + const { getByText } = render(); + + expect( + getByText(strings('confirm.custom_amount.buy_button')), + ).toBeDefined(); + }); + + it('navigates to ramps if buy button pressed', () => { + useTransactionPayAvailableTokensMock.mockReturnValue([]); + + useAccountTokensMock.mockReturnValue([ + { + address: TOKEN_ADDRESS_MOCK, + assetId: TOKEN_ADDRESS_MOCK, + chainId: CHAIN_ID_MOCK, + } as AssetType, + ]); + + useTransactionPayRequiredTokensMock.mockReturnValue([ + { + address: TOKEN_ADDRESS_MOCK, + chainId: CHAIN_ID_MOCK, + }, + ] as TransactionPayRequiredToken[]); + + const { getByText } = render(); + + fireEvent.press(getByText(strings('confirm.custom_amount.buy_button'))); + + expect(mockGoToRamps).toHaveBeenCalledTimes(1); + expect(mockGoToRamps).toHaveBeenCalledWith({ + mode: RampMode.AGGREGATOR, + params: { + rampType: RampType.BUY, + intent: { + assetId: 'eip155:1/erc20:0x123', + }, + }, + }); + }); }); diff --git a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx index 32178449fa4..14bd03fb06e 100644 --- a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx +++ b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx @@ -32,9 +32,32 @@ import { import { useIsTransactionPayLoading, useTransactionPayQuotes, + useTransactionPayRequiredTokens, useTransactionPaySourceAmounts, } from '../../../hooks/pay/useTransactionPayData'; import { useTransactionPayMetrics } from '../../../hooks/pay/useTransactionPayMetrics'; +import { useTransactionPayAvailableTokens } from '../../../hooks/pay/useTransactionPayAvailableTokens'; +import Button, { + ButtonVariants, + ButtonWidthTypes, +} from '../../../../../../component-library/components/Buttons/Button'; +import Text, { + TextColor, + TextVariant, +} from '../../../../../../component-library/components/Texts/Text'; +import { + RampMode, + useRampNavigation, +} from '../../../../../UI/Ramp/hooks/useRampNavigation'; +import { RampType } from '../../../../../../reducers/fiatOrders/types'; +import { useAccountTokens } from '../../../hooks/send/useAccountTokens'; +import { getNativeTokenAddress } from '../../../utils/asset'; +import { toCaipAssetType } from '@metamask/utils'; +import { AlignItems } from '../../../../../UI/Box/box.types'; +import { strings } from '../../../../../../../locales/i18n'; +import { hasTransactionType } from '../../../utils/transaction'; +import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest'; +import { TransactionType } from '@metamask/transaction-controller'; export interface CustomAmountInfoProps { children?: ReactNode; @@ -51,6 +74,8 @@ export const CustomAmountInfo: React.FC = memo( const { styles } = useStyles(styleSheet, {}); const [isKeyboardVisible, setKeyboardVisible] = useState(true); const { setIsFooterVisible } = useConfirmationContext(); + const availableTokens = useTransactionPayAvailableTokens(); + const hasTokens = availableTokens.length > 0; const isResultReady = useIsResultReady({ isKeyboardVisible, @@ -94,8 +119,11 @@ export const CustomAmountInfo: React.FC = memo( currency={currency} hasAlert={Boolean(keyboardAlertMessage)} onPress={handleAmountPress} + disabled={!hasTokens} /> - {disablePay !== true && } + {disablePay !== true && ( + + )} {children} {!isKeyboardVisible && ( = memo( inline /> )} - {disablePay !== true && ( + {disablePay !== true && hasTokens && ( @@ -121,7 +149,7 @@ export const CustomAmountInfo: React.FC = memo( )} - {isKeyboardVisible && ( + {isKeyboardVisible && hasTokens && ( = memo( hasInput={hasInput} /> )} + {!hasTokens && } ); }, @@ -153,6 +182,68 @@ export function CustomAmountInfoSkeleton() { ); } +function BuySection() { + const transactionMeta = useTransactionMetadataRequest(); + const tokens = useAccountTokens({ includeNoBalance: true }); + const requiredTokens = useTransactionPayRequiredTokens(); + + const primaryRequiredToken = requiredTokens.find( + (token) => token.address !== getNativeTokenAddress(token.chainId), + ); + + const asset = tokens.find( + (token) => + token.address?.toLowerCase() === + primaryRequiredToken?.address.toLowerCase() && + token.chainId === primaryRequiredToken?.chainId, + ); + + const assetId = toCaipAssetType( + 'eip155', + Number(primaryRequiredToken?.chainId ?? '0x0').toString(), + 'erc20', + asset?.assetId ?? '0x0', + ); + + const { goToRamps } = useRampNavigation(); + + const handleBuyPress = useCallback(() => { + goToRamps({ + mode: RampMode.AGGREGATOR, + params: { + rampType: RampType.BUY, + intent: { assetId }, + }, + }); + }, [assetId, goToRamps]); + + let message: string | undefined; + + if (hasTransactionType(transactionMeta, [TransactionType.perpsDeposit])) { + message = strings('confirm.custom_amount.buy_perps'); + } + + if (hasTransactionType(transactionMeta, [TransactionType.predictDeposit])) { + message = strings('confirm.custom_amount.buy_predict'); + } + + return ( + + {message && ( + + {message} + + )} +