diff --git a/.github/workflows/performance-test-runner.yml b/.github/workflows/performance-test-runner.yml index 4f19f89c729..5fde4ba4eb1 100644 --- a/.github/workflows/performance-test-runner.yml +++ b/.github/workflows/performance-test-runner.yml @@ -183,7 +183,7 @@ jobs: SELECTED_SENTRY_DSN="" SENTRY_ENVIRONMENT="github-actions-performance-e2e" FEATURE_FLAGS_ENV="${{ inputs.feature_flags_environment }}" - BROWSERSTACK_GEO_LOCATION="${BROWSERSTACK_GEO_LOCATION:-ES}" + BROWSERSTACK_GEO_LOCATION="${BROWSERSTACK_GEO_LOCATION:-SE}" if [[ "$FEATURE_FLAGS_ENV" != "rc" && "$FEATURE_FLAGS_ENV" != "exp" && "$FEATURE_FLAGS_ENV" != "test" && "$FEATURE_FLAGS_ENV" != "dev" && "$FEATURE_FLAGS_ENV" != "prod" ]]; then echo "❌ Invalid feature_flags_environment '$FEATURE_FLAGS_ENV'. Expected rc, exp, test, dev, or prod." @@ -258,7 +258,7 @@ jobs: echo "QA App Version: ${{ inputs.app_version }}" echo "BrowserStack Build Name: $BROWSERSTACK_BUILD_NAME" echo "BrowserStack Local: $BROWSERSTACK_LOCAL" - echo "BrowserStack Geo Location: ${BROWSERSTACK_GEO_LOCATION:-ES}" + echo "BrowserStack Geo Location: ${BROWSERSTACK_GEO_LOCATION:-SE}" if [ -n "$GREP_TAGS" ]; then echo "Grep filter: $GREP_TAGS" fi diff --git a/.github/workflows/run-e2e-workflow.yml b/.github/workflows/run-e2e-workflow.yml index 08ad6dc85b6..c3f96c0764f 100644 --- a/.github/workflows/run-e2e-workflow.yml +++ b/.github/workflows/run-e2e-workflow.yml @@ -197,16 +197,30 @@ jobs: mkdir -p ${{ steps.determine-target-paths.outputs.apk-target-path }} mkdir -p ${{ steps.determine-target-paths.outputs.test-apk-target-path }} - - name: Download Android build artifacts (Namespace) + - name: Download Android app APK (Namespace) if: ${{ inputs.platform == 'android' && inputs.runner_provider == 'namespace' }} uses: namespace-actions/download-artifact@7cbad919e4b0e09f17e9d6311a444ff002992b5b # v2.0.1 with: - path: artifacts/ - - name: Download Android build artifacts (current) + name: ${{ inputs.build_type }}-${{ inputs.metamask_environment }}-release.apk + path: artifacts/${{ inputs.build_type }}-${{ inputs.metamask_environment }}-release.apk + - name: Download Android test APK (Namespace) + if: ${{ inputs.platform == 'android' && inputs.runner_provider == 'namespace' }} + uses: namespace-actions/download-artifact@7cbad919e4b0e09f17e9d6311a444ff002992b5b # v2.0.1 + with: + name: ${{ inputs.build_type }}-${{ inputs.metamask_environment }}-release-androidTest.apk + path: artifacts/${{ inputs.build_type }}-${{ inputs.metamask_environment }}-release-androidTest.apk + - name: Download Android app APK (current) + if: ${{ inputs.platform == 'android' && inputs.runner_provider != 'namespace' }} + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.build_type }}-${{ inputs.metamask_environment }}-release.apk + path: artifacts/${{ inputs.build_type }}-${{ inputs.metamask_environment }}-release.apk + - name: Download Android test APK (current) if: ${{ inputs.platform == 'android' && inputs.runner_provider != 'namespace' }} uses: actions/download-artifact@v4 with: - path: artifacts/ + name: ${{ inputs.build_type }}-${{ inputs.metamask_environment }}-release-androidTest.apk + path: artifacts/${{ inputs.build_type }}-${{ inputs.metamask_environment }}-release-androidTest.apk - name: Move Android artifacts to expected locations if: ${{ inputs.platform == 'android' }} @@ -229,12 +243,14 @@ jobs: if: ${{ inputs.platform == 'ios' && inputs.runner_provider == 'namespace' }} uses: namespace-actions/download-artifact@7cbad919e4b0e09f17e9d6311a444ff002992b5b # v2.0.1 with: - path: artifacts/ + name: ${{ inputs.build_type }}-${{ inputs.metamask_environment }}-MetaMask.app + path: artifacts/${{ inputs.build_type }}-${{ inputs.metamask_environment }}-MetaMask.app - name: Download iOS build artifacts (current) if: ${{ inputs.platform == 'ios' && inputs.runner_provider != 'namespace' }} uses: actions/download-artifact@v4 with: - path: artifacts/ + name: ${{ inputs.build_type }}-${{ inputs.metamask_environment }}-MetaMask.app + path: artifacts/${{ inputs.build_type }}-${{ inputs.metamask_environment }}-MetaMask.app # actions/upload-artifact strips execute permissions from the ZIP. # Also fixes any residual case mismatch as a safety net. diff --git a/CHANGELOG.md b/CHANGELOG.md index 590b4fc2bc9..30e59a5abdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [7.81.1] + +### Added + +- Adds Swap/Bridge feature to ARC mainnet. (#31413) + +### Fixed + +- Fixed the MetaMask Card section on the Money screen so the "Link card" option and funding token placeholders are only shown when the card spending token is enabled in the card feature configuration. (#31611) +- Removed the trailing slash from the Arc explorer URL defined in app/util/networks/customNetworks.tsx so now the URL is correctly generated. (#31718) +- Fixed Arc native token support, included patches for MetaMask/core#9006 and MetaMask/core#9007 (#31662) + ## [7.81.0] ### Added @@ -84,8 +96,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added a new Positions screen for Predict users to view active positions and history (#30718) - Track explore conversions in swaps (#30720) - ### Changed + - Updated the Sei native token icon to Sei's maroon token mark. (#31038) - Updated the Sei network and token logo to Sei's new brand mark. (#30892) - Improve crypto movers section in explore (#30809) @@ -368,10 +380,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [7.78.1] -### Fixed - -- Fixed a crash caused by CloseEvent dispatch on WebSocket failing instanceof validation (#30612) - ### Added - Updated the onboarding interest questionnaire layout to a two-column grid with refreshed option labels. (#30753) @@ -469,6 +477,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Fixed a crash caused by CloseEvent dispatch on WebSocket failing instanceof validation (#30612) - Fixed the "Add mUSD" option in the Money account "Add money" sheet so it opens the deposit flow with mUSD pre-selected, and (#30741) corrected the in-progress / success / failed toast copy for both the Convert and Add flows. @@ -12078,8 +12087,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#957](https://github.com/MetaMask/metamask-mobile/pull/957): fix timeouts (#957) - [#954](https://github.com/MetaMask/metamask-mobile/pull/954): Bugfix: onboarding navigation (#954) -[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.81.0...HEAD -[7.81.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.79.1...v7.81.0 +[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.81.1...HEAD +[7.81.1]: https://github.com/MetaMask/metamask-mobile/compare/v7.81.0...v7.81.1 +[7.81.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.80.0...v7.81.0 +[7.80.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.79.1...v7.80.0 [7.79.1]: https://github.com/MetaMask/metamask-mobile/compare/v7.79.0...v7.79.1 [7.79.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.78.1...v7.79.0 [7.78.1]: https://github.com/MetaMask/metamask-mobile/compare/v7.78.0...v7.78.1 diff --git a/app/components/Nav/App/App.test.tsx b/app/components/Nav/App/App.test.tsx index 411822e52fd..e53d2a58e74 100644 --- a/app/components/Nav/App/App.test.tsx +++ b/app/components/Nav/App/App.test.tsx @@ -940,10 +940,6 @@ describe('App', () => { expect(Routes.SHEET.SUCCESS_ERROR_SHEET).toBeDefined(); }); - it('has add account route defined', () => { - expect(Routes.SHEET.ADD_ACCOUNT).toBeDefined(); - }); - it('has experience enhancer route defined', () => { expect(Routes.SHEET.EXPERIENCE_ENHANCER).toBeDefined(); }); @@ -1183,10 +1179,6 @@ describe('App', () => { expect(Routes.SHEET.ADDRESS_SELECTOR).toBeDefined(); }); - it('has add account route defined', () => { - expect(Routes.SHEET.ADD_ACCOUNT).toBeDefined(); - }); - it('has account actions route defined', () => { expect(Routes.SHEET.ACCOUNT_ACTIONS).toBeDefined(); }); diff --git a/app/components/Nav/App/App.tsx b/app/components/Nav/App/App.tsx index 75a4a71fa8b..5d5a35e0a2e 100644 --- a/app/components/Nav/App/App.tsx +++ b/app/components/Nav/App/App.tsx @@ -126,7 +126,6 @@ import SeedphraseModal from '../../UI/SeedphraseModal'; import SkipAccountSecurityModal from '../../UI/SkipAccountSecurityModal'; import SuccessErrorSheet from '../../Views/SuccessErrorSheet'; import ConfirmTurnOnBackupAndSyncModal from '../../UI/Identity/ConfirmTurnOnBackupAndSyncModal/ConfirmTurnOnBackupAndSyncModal'; -import AddNewAccountBottomSheet from '../../Views/AddNewAccount/AddNewAccountBottomSheet'; import EligibilityFailedModal from '../../UI/Ramp/components/EligibilityFailedModal'; import RampUnsupportedModal from '../../UI/Ramp/components/RampUnsupportedModal'; import RampsBootstrap from '../../UI/Ramp/RampsBootstrap'; @@ -521,10 +520,6 @@ const RootModalFlow = (props: RootModalFlowProps) => ( name={Routes.SHEET.ADDRESS_SELECTOR} component={AddressSelector} /> - { ({ + ...fullScreenModalSlideFromBottomNativeOptions, + animation: route.params?.animation ?? 'slide_from_right', + })} /> { render(); - expect( - screen.getByTestId(CardHomeSelectors.LINK_MONEY_ACCOUNT_DIVIDER_TOP), - ).toBeOnTheScreen(); expect( screen.getByTestId(CardHomeSelectors.LINK_MONEY_ACCOUNT_DIVIDER_BOTTOM), ).toBeOnTheScreen(); @@ -6339,7 +6336,9 @@ describe('CardHome Component', () => { render(); expect( - screen.queryByTestId(CardHomeSelectors.LINK_MONEY_ACCOUNT_DIVIDER_TOP), + screen.queryByTestId( + CardHomeSelectors.LINK_MONEY_ACCOUNT_DIVIDER_BOTTOM, + ), ).not.toBeOnTheScreen(); expect( screen.queryByText(strings('money.metamask_card.link_title')), @@ -6353,7 +6352,7 @@ describe('CardHome Component', () => { render(); expect( - screen.getByTestId(CardHomeSelectors.LINK_MONEY_ACCOUNT_DIVIDER_TOP), + screen.getByTestId(CardHomeSelectors.LINK_MONEY_ACCOUNT_DIVIDER_BOTTOM), ).toBeOnTheScreen(); }); diff --git a/app/components/UI/Card/Views/CardHome/CardHome.testIds.ts b/app/components/UI/Card/Views/CardHome/CardHome.testIds.ts index 0283ba73f04..d7336bf4cce 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.testIds.ts +++ b/app/components/UI/Card/Views/CardHome/CardHome.testIds.ts @@ -31,6 +31,5 @@ export const CardHomeSelectors = { FREEZE_CARD_TOGGLE: 'freeze-card-toggle', VIEW_PIN_BUTTON: 'view-pin-button', CARD_WALLET_ADDRESS: 'card-wallet-address', - LINK_MONEY_ACCOUNT_DIVIDER_TOP: 'link-money-account-divider-top', LINK_MONEY_ACCOUNT_DIVIDER_BOTTOM: 'link-money-account-divider-bottom', }; diff --git a/app/components/UI/Card/Views/CardHome/CardHome.tsx b/app/components/UI/Card/Views/CardHome/CardHome.tsx index 302162808f5..54c1da289c2 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.tsx @@ -422,11 +422,7 @@ const CardHome = () => { {canLinkMoneyAccount && ( <> - - + { limitType: 'full' as const, customLimit: '', isLoading: false, + isUiInteractionLocked: false, setSelectedToken: mockSetSelectedToken, handleAccountSelect: mockHandleAccountSelect, handleOtherSelect: mockHandleOtherSelect, @@ -769,10 +770,11 @@ describe('SpendingLimit Component', () => { ); }); - it('blocks navigation when isLoading is true', () => { + it('blocks navigation when UI interaction is locked', () => { mockUseSpendingLimit.mockReturnValue({ ...getDefaultUseSpendingLimitMock(), isLoading: true, + isUiInteractionLocked: true, }); render(); @@ -785,6 +787,42 @@ describe('SpendingLimit Component', () => { expect(mockEvent.preventDefault).toHaveBeenCalled(); }); + it('allows navigation when Money Account linkage is processing outside onboarding', () => { + mockUseSpendingLimit.mockReturnValue({ + ...getDefaultUseSpendingLimitMock(), + isLoading: true, + isMoneyAccountSource: true, + isUiInteractionLocked: false, + }); + + render(); + + const mockEvent = { preventDefault: jest.fn() }; + const beforeRemoveCallback = mockAddListener.mock.calls[0][1]; + + beforeRemoveCallback(mockEvent); + + expect(mockEvent.preventDefault).not.toHaveBeenCalled(); + }); + + it('blocks navigation when Money Account linkage is processing during onboarding', () => { + mockUseSpendingLimit.mockReturnValue({ + ...getDefaultUseSpendingLimitMock(), + isLoading: true, + isMoneyAccountSource: true, + isUiInteractionLocked: true, + }); + + render({ params: { flow: 'onboarding' } }); + + const mockEvent = { preventDefault: jest.fn() }; + const beforeRemoveCallback = mockAddListener.mock.calls[0][1]; + + beforeRemoveCallback(mockEvent); + + expect(mockEvent.preventDefault).toHaveBeenCalled(); + }); + it('allows navigation when isLoading is false', () => { render(); @@ -853,12 +891,28 @@ describe('SpendingLimit Component', () => { ...getDefaultUseSpendingLimitMock(), isLoading: true, isMoneyAccountSource: true, + isUiInteractionLocked: false, }); render(); expect(screen.queryByTestId('button-loading-indicator')).toBeNull(); }); + + it('disables cancel when Money Account linkage is processing outside onboarding', () => { + mockUseSpendingLimit.mockReturnValue({ + ...getDefaultUseSpendingLimitMock(), + isLoading: true, + isMoneyAccountSource: true, + isUiInteractionLocked: false, + }); + + render(); + + const cancelButton = screen.getByText('Cancel'); + + expect(cancelButton).toBeDisabled(); + }); }); describe('Onboarding Flow', () => { diff --git a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx index 46a9b8dec4e..54de6a975e8 100644 --- a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx +++ b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx @@ -108,6 +108,7 @@ const SpendingLimit: React.FC = ({ route }) => { limitType, customLimit, isLoading, + isUiInteractionLocked, handleAccountSelect, handleOtherSelect, handleLimitSelect, @@ -129,14 +130,14 @@ const SpendingLimit: React.FC = ({ route }) => { routeParams: route?.params as Record | undefined, }); - const isLoadingRef = useRef(isLoading); + const isUiInteractionLockedRef = useRef(isUiInteractionLocked); useEffect(() => { - isLoadingRef.current = isLoading; - }, [isLoading]); + isUiInteractionLockedRef.current = isUiInteractionLocked; + }, [isUiInteractionLocked]); useEffect(() => { const unsubscribe = navigation.addListener('beforeRemove', (e) => { - if (!isLoadingRef.current) return; + if (!isUiInteractionLockedRef.current) return; e.preventDefault(); }); return unsubscribe; diff --git a/app/components/UI/Card/components/CardMessageBox/CardMessageBox.tsx b/app/components/UI/Card/components/CardMessageBox/CardMessageBox.tsx index af988c2aaac..8691e1a58fc 100644 --- a/app/components/UI/Card/components/CardMessageBox/CardMessageBox.tsx +++ b/app/components/UI/Card/components/CardMessageBox/CardMessageBox.tsx @@ -9,6 +9,7 @@ import { } from '@metamask/design-system-react-native'; import { CardMessageBoxType, CardMessageBoxVariant } from '../../types'; import { strings } from '../../../../../../locales/i18n'; +import { FLAT_BANNER_ALERT_STYLE } from '../../../shared/flatBannerAlertStyle'; interface CardMessageBoxProps { messageType: CardMessageBoxType; @@ -84,6 +85,7 @@ const CardMessageBox = ({ severity={SEVERITY_MAP[config.variant]} title={config.title} description={config.description} + style={FLAT_BANNER_ALERT_STYLE} testID="card-message-box" > {(onConfirm || onDismiss) && ( diff --git a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx index 8a8b9655343..8cb4ff321d0 100644 --- a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx +++ b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx @@ -278,6 +278,7 @@ describe('useMoneyAccountCardLinkage', () => { expect(result.current.canLink).toBe(true); expect(result.current.hasMoneyAccountRequirements).toBe(true); + expect(result.current.hasMoneyAccountBaseRequirements).toBe(true); expect(result.current.isCardAuthenticated).toBe(true); expect(result.current.isCardLinkedToMoneyAccount).toBe(false); expect(result.current.moneyAccountCardToken).toBe(MOCK_TOKEN); @@ -353,9 +354,37 @@ describe('useMoneyAccountCardLinkage', () => { const { result } = renderLinkageHook(); expect(result.current.canLink).toBe(false); expect(result.current.hasMoneyAccountRequirements).toBe(false); + expect(result.current.hasMoneyAccountBaseRequirements).toBe(true); expect(result.current.moneyAccountCardToken).toBeNull(); }); + it('reports hasMoneyAccountBaseRequirements=true when VEDA is not allowlisted but base requirements are met', () => { + applySelectorMocks( + buildSelectors({ + cardFeatureFlag: { + chains: { + 'eip155:143': { + enabled: true, + tokens: [ + { + address: '0xusdc', + symbol: 'USDC', + decimals: 6, + enabled: true, + name: 'USD Coin', + }, + ], + }, + }, + }, + }), + ); + const { result } = renderLinkageHook(); + expect(result.current.hasMoneyAccountBaseRequirements).toBe(true); + expect(result.current.hasMoneyAccountRequirements).toBe(false); + expect(result.current.canLink).toBe(false); + }); + it('reports canLink=true when VEDA is allowlisted by address under the mUSD display symbol', () => { applySelectorMocks( buildSelectors({ @@ -380,6 +409,7 @@ describe('useMoneyAccountCardLinkage', () => { const { result } = renderLinkageHook(); expect(result.current.canLink).toBe(true); expect(result.current.hasMoneyAccountRequirements).toBe(true); + expect(result.current.hasMoneyAccountBaseRequirements).toBe(true); expect(result.current.moneyAccountCardToken).toBe(MOCK_TOKEN); }); @@ -700,6 +730,50 @@ describe('useMoneyAccountCardLinkage', () => { expect(mockShowToast).not.toHaveBeenCalled(); }); + it('routes an unauthenticated cardholder to CardAuthentication when base requirements are met but VEDA is not allowlisted', () => { + applySelectorMocks( + buildSelectors({ + isCardAuthenticated: false, + isCardholder: true, + cardFeatureFlag: { + chains: { + 'eip155:143': { + enabled: true, + tokens: [ + { + address: '0xusdc', + symbol: 'USDC', + decimals: 6, + enabled: true, + name: 'USD Coin', + }, + ], + }, + }, + }, + }), + ); + const { result } = renderLinkageHook(); + + act(() => { + result.current.startLinkFlow(ORIGIN); + }); + + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith( + setPendingMoneyAccountCardLink(CardEntryPoint.MONEY_LINK_CARD_SHEET), + ); + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, { + screen: Routes.CARD.HOME, + params: { + screen: Routes.CARD.AUTHENTICATION, + params: { postAuthRedirect: ORIGIN, showAuthPrompt: true }, + }, + }); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + it('stores the origin entrypoint for the post-auth sheet resume', () => { applySelectorMocks( buildSelectors({ isCardAuthenticated: false, isCardholder: true }), @@ -926,6 +1000,74 @@ describe('useMoneyAccountCardLinkage', () => { expect(mockNavigate).not.toHaveBeenCalled(); }); + it.each(['idle', 'loading'] as const)( + 'keeps the pending flag while VEDA support is unresolved and card home data is still %s', + (cardHomeDataStatus) => { + applySelectorMocks( + buildSelectors({ + pendingMoneyAccountCardLink: CardEntryPoint.MONEY_LINK_CARD_SHEET, + vedaConfig: undefined, + cardHomeDataStatus, + }), + ); + renderLinkageHook(); + + expect(mockDispatch).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockShowToast).not.toHaveBeenCalled(); + }, + ); + + it.each(['success', 'error'] as const)( + 'clears the flag silently once card home data has %s but VEDA support is unresolved', + (cardHomeDataStatus) => { + applySelectorMocks( + buildSelectors({ + pendingMoneyAccountCardLink: CardEntryPoint.MONEY_LINK_CARD_SHEET, + vedaConfig: undefined, + cardHomeDataStatus, + }), + ); + renderLinkageHook(); + + expect(mockDispatch).toHaveBeenCalledWith( + setPendingMoneyAccountCardLink(null), + ); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockShowToast).not.toHaveBeenCalled(); + }, + ); + + it('opens the sheet on rerender once VEDA support resolves after card home data succeeds', () => { + applySelectorMocks( + buildSelectors({ + pendingMoneyAccountCardLink: CardEntryPoint.MONEY_LINK_CARD_SHEET, + vedaConfig: undefined, + cardHomeDataStatus: 'loading', + }), + ); + const { rerender } = renderLinkageHook(); + + // First render: VEDA config not yet loaded, flag must stay set. + expect(mockDispatch).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + + applySelectorMocks( + buildSelectors({ + pendingMoneyAccountCardLink: CardEntryPoint.MONEY_LINK_CARD_SHEET, + cardHomeDataStatus: 'success', + }), + ); + rerender(); + + expect(mockDispatch).toHaveBeenCalledWith( + setPendingMoneyAccountCardLink(null), + ); + expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.MODALS.ROOT, { + ...expectedLinkCardSheetRoute(), + }); + }); + it('keeps the flag set when the funding token is null but card home data is still loading', () => { mockResolveMoneyAccountCardToken.mockReturnValue(null); applySelectorMocks( diff --git a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx index 579b21614d0..118172e1271 100644 --- a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx +++ b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx @@ -80,6 +80,7 @@ export interface LinkFlowOrigin { export interface UseMoneyAccountCardLinkageReturn { hasMoneyAccountRequirements: boolean; + hasMoneyAccountBaseRequirements: boolean; isCardAuthenticated: boolean; isCardVerified: boolean; isCardLinkedToMoneyAccount: boolean; @@ -159,12 +160,14 @@ export const useMoneyAccountCardLinkage = ? rawMoneyAccountCardToken : null; + const hasMoneyAccountBaseRequirements = hasMoneyAccountCardRequirements({ + isMoneyAccountEnabled, + vaultConfig, + moneyAccountAddress: primaryMoneyAccount?.address, + }); + const hasRequirements = - hasMoneyAccountCardRequirements({ - isMoneyAccountEnabled, - vaultConfig, - moneyAccountAddress: primaryMoneyAccount?.address, - }) && isMoneyAccountCardSupported; + hasMoneyAccountBaseRequirements && isMoneyAccountCardSupported; const canSubmitDelegation = Boolean( hasRequirements && @@ -323,7 +326,7 @@ export const useMoneyAccountCardLinkage = if (linkInProgress) { return; } - if (!hasRequirements || !primaryMoneyAccount?.address) { + if (!hasMoneyAccountBaseRequirements || !primaryMoneyAccount?.address) { showErrorToast(); return; } @@ -384,7 +387,7 @@ export const useMoneyAccountCardLinkage = }, [ linkInProgress, - hasRequirements, + hasMoneyAccountBaseRequirements, moneyAccountCardToken, primaryMoneyAccount?.address, isCardAuthenticated, @@ -414,11 +417,21 @@ export const useMoneyAccountCardLinkage = return; } - if (!hasRequirements || !primaryMoneyAccount?.address) { + if (!hasMoneyAccountBaseRequirements || !primaryMoneyAccount?.address) { dispatch(setPendingMoneyAccountCardLink(null)); return; } + if (!isMoneyAccountCardSupported) { + if ( + cardHomeDataStatus === 'success' || + cardHomeDataStatus === 'error' + ) { + dispatch(setPendingMoneyAccountCardLink(null)); + } + return; + } + if (isResidencyBlocked) { trackMoneyAccountLinkingEvent( MetaMetricsEvents.CARD_MONEY_ACCOUNT_LINKING_FAILED, @@ -454,7 +467,8 @@ export const useMoneyAccountCardLinkage = pendingMoneyAccountCardLinkEntryPoint, isCardAuthenticated, isCardVerified, - hasRequirements, + hasMoneyAccountBaseRequirements, + isMoneyAccountCardSupported, moneyAccountCardToken, primaryMoneyAccount?.address, isAlreadyDelegated, @@ -598,6 +612,7 @@ export const useMoneyAccountCardLinkage = return { hasMoneyAccountRequirements: hasRequirements, + hasMoneyAccountBaseRequirements, isCardAuthenticated, isCardVerified, isCardLinkedToMoneyAccount: isAlreadyDelegated, diff --git a/app/components/UI/Card/hooks/useSpendingLimit.test.ts b/app/components/UI/Card/hooks/useSpendingLimit.test.ts index 541935e0586..7f56f1f7e5b 100644 --- a/app/components/UI/Card/hooks/useSpendingLimit.test.ts +++ b/app/components/UI/Card/hooks/useSpendingLimit.test.ts @@ -258,6 +258,7 @@ describe('useSpendingLimit', () => { goBack: jest.Mock; dispatch: jest.Mock; setParams: jest.Mock; + isFocused: jest.Mock; }; let mockSubmitDelegation: jest.Mock; let mockTrackEvent: jest.Mock; @@ -279,6 +280,7 @@ describe('useSpendingLimit', () => { goBack: jest.fn(), dispatch: jest.fn(), setParams: jest.fn(), + isFocused: jest.fn().mockReturnValue(true), }; mockUseNavigation.mockReturnValue(mockNavigation as never); @@ -985,6 +987,71 @@ describe('useSpendingLimit', () => { expect(mockNavigation.goBack).not.toHaveBeenCalled(); }); + it('does not navigate when Money Account linkage is processing', async () => { + const MONEY_ACCOUNT_TOKEN: CardFundingToken = { + address: '0xMonadUsdc', + symbol: 'USDC', + name: 'USDC', + decimals: 6, + caipChainId: 'eip155:143', + walletAddress: undefined, + fundingStatus: FundingStatus.NotEnabled, + spendableBalance: '0', + delegationContract: '0xMonadDelegation', + priority: undefined, + isMoneyAccountEntry: true, + }; + + mockUseMoneyAccountCardLinkage.mockReturnValue( + buildLinkageReturn({ + hasMoneyAccountRequirements: true, + isCardAuthenticated: true, + moneyAccountCardToken: MONEY_ACCOUNT_TOKEN, + canLink: true, + }), + ); + mockUseMoneyAccountBalance.mockReturnValue( + buildBalanceReturn({ + tokenTotal: new BigNumber('12.34'), + totalFiatFormatted: '$12.34', + }), + ); + + let resolveLink: (value: boolean) => void = () => undefined; + mockConfirmLinkInBackground.mockImplementation( + () => + new Promise((resolve) => { + resolveLink = resolve; + }), + ); + + const priorityToken = createMockToken({ + ...MONEY_ACCOUNT_TOKEN, + isMoneyAccountEntry: true, + }); + + const { result } = renderHook(() => + useSpendingLimit( + createDefaultParams({ flow: 'manage', priorityToken }), + ), + ); + + act(() => { + result.current.submit().catch(() => undefined); + }); + + act(() => { + result.current.cancel(); + }); + + expect(mockNavigation.goBack).not.toHaveBeenCalled(); + + await act(async () => { + resolveLink(true); + await jest.runAllTimersAsync(); + }); + }); + it('tracks button click event', () => { const { result } = renderHook(() => useSpendingLimit(createDefaultParams()), @@ -1623,6 +1690,153 @@ describe('useSpendingLimit', () => { }); }); + describe('isUiInteractionLocked', () => { + it('returns false when not loading', () => { + const { result } = renderHook(() => + useSpendingLimit(createDefaultParams()), + ); + + expect(result.current.isUiInteractionLocked).toBe(false); + }); + + it('returns true when delegation is loading for regular funding assets', () => { + mockUseCardDelegation.mockReturnValue({ + submitDelegation: mockSubmitDelegation, + isLoading: true, + error: null, + needsFaucet: false, + isFaucetCheckLoading: false, + refetchFaucetCheck: jest.fn(), + }); + + const { result } = renderHook(() => + useSpendingLimit(createDefaultParams()), + ); + + expect(result.current.isUiInteractionLocked).toBe(true); + }); + + it('returns false when Money Account linkage is processing outside onboarding', async () => { + const MONEY_ACCOUNT_TOKEN: CardFundingToken = { + address: '0xMonadUsdc', + symbol: 'USDC', + name: 'USDC', + decimals: 6, + caipChainId: 'eip155:143', + walletAddress: undefined, + fundingStatus: FundingStatus.NotEnabled, + spendableBalance: '0', + delegationContract: '0xMonadDelegation', + priority: undefined, + isMoneyAccountEntry: true, + }; + + mockUseMoneyAccountCardLinkage.mockReturnValue( + buildLinkageReturn({ + hasMoneyAccountRequirements: true, + isCardAuthenticated: true, + moneyAccountCardToken: MONEY_ACCOUNT_TOKEN, + canLink: true, + }), + ); + mockUseMoneyAccountBalance.mockReturnValue( + buildBalanceReturn({ + tokenTotal: new BigNumber('12.34'), + totalFiatFormatted: '$12.34', + }), + ); + + let resolveLink: (value: boolean) => void = () => undefined; + mockConfirmLinkInBackground.mockImplementation( + () => + new Promise((resolve) => { + resolveLink = resolve; + }), + ); + + const priorityToken = createMockToken({ + ...MONEY_ACCOUNT_TOKEN, + isMoneyAccountEntry: true, + }); + + const { result } = renderHook(() => + useSpendingLimit( + createDefaultParams({ flow: 'manage', priorityToken }), + ), + ); + + expect(result.current.isMoneyAccountSource).toBe(true); + + act(() => { + result.current.submit().catch(() => undefined); + }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.isUiInteractionLocked).toBe(false); + + await act(async () => { + resolveLink(true); + await jest.runAllTimersAsync(); + }); + }); + + it('returns true when Money Account linkage is processing during onboarding', async () => { + const MONEY_ACCOUNT_TOKEN: CardFundingToken = { + address: '0xMonadUsdc', + symbol: 'USDC', + name: 'USDC', + decimals: 6, + caipChainId: 'eip155:143', + walletAddress: undefined, + fundingStatus: FundingStatus.NotEnabled, + spendableBalance: '0', + delegationContract: '0xMonadDelegation', + priority: undefined, + }; + + mockUseMoneyAccountCardLinkage.mockReturnValue( + buildLinkageReturn({ + hasMoneyAccountRequirements: true, + isCardAuthenticated: true, + moneyAccountCardToken: MONEY_ACCOUNT_TOKEN, + canLink: true, + }), + ); + mockUseMoneyAccountBalance.mockReturnValue( + buildBalanceReturn({ + tokenTotal: new BigNumber('12.34'), + totalFiatFormatted: '$12.34', + }), + ); + + let resolveLink: (value: boolean) => void = () => undefined; + mockConfirmLinkInBackground.mockImplementation( + () => + new Promise((resolve) => { + resolveLink = resolve; + }), + ); + + const { result } = renderHook(() => + useSpendingLimit(createDefaultParams({ flow: 'onboarding' })), + ); + + expect(result.current.isMoneyAccountSource).toBe(true); + + act(() => { + result.current.submit().catch(() => undefined); + }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.isUiInteractionLocked).toBe(true); + + await act(async () => { + resolveLink(true); + await jest.runAllTimersAsync(); + }); + }); + }); + describe('Money Account source (onboarding flow)', () => { const MONEY_ACCOUNT_TOKEN: CardFundingToken = { address: '0xMonadUsdc', diff --git a/app/components/UI/Card/hooks/useSpendingLimit.ts b/app/components/UI/Card/hooks/useSpendingLimit.ts index cda3f8664ca..6fe259614b6 100644 --- a/app/components/UI/Card/hooks/useSpendingLimit.ts +++ b/app/components/UI/Card/hooks/useSpendingLimit.ts @@ -74,6 +74,7 @@ export interface UseSpendingLimitReturn { limitType: LimitType; customLimit: string; isLoading: boolean; + isUiInteractionLocked: boolean; // Handlers setSelectedToken: (token: CardFundingToken | null) => void; @@ -217,6 +218,8 @@ const useSpendingLimit = ({ } = useCardDelegation(selectedToken); const isLoading = isDelegationLoading || isProcessing; + const isUiInteractionLocked = + isLoading && (!isMoneyAccountSource || isOnboardingFlow); // Wallet-only token balances for the currently selected MetaMask account. // Using this (instead of useAssetBalances) ensures sorting reflects the active @@ -620,7 +623,7 @@ const useSpendingLimit = ({ setTimeout(() => { if (isOnboardingFlow) { navigateToCardHome(); - } else { + } else if (navigation.isFocused()) { navigation.goBack(); } }, 0); @@ -742,6 +745,7 @@ const useSpendingLimit = ({ limitType, customLimit, isLoading, + isUiInteractionLocked, // Handlers setSelectedToken, diff --git a/app/components/UI/Carousel/constants.ts b/app/components/UI/Carousel/constants.ts index 37c6eb2743c..79dc5ac4cac 100644 --- a/app/components/UI/Carousel/constants.ts +++ b/app/components/UI/Carousel/constants.ts @@ -1,34 +1,8 @@ import { strings } from '../../../../locales/i18n'; import { CarouselSlide } from './types'; import { createBuyNavigationDetails } from '../Ramp/Aggregator/routes/utils'; -import Routes from '../../../constants/navigation/Routes'; -///: BEGIN:ONLY_INCLUDE_IF(solana) -import { WalletClientType } from '../../../core/SnapKeyring/MultichainWalletSnapClient'; -import { SolScope } from '@metamask/keyring-api'; -///: END:ONLY_INCLUDE_IF export const PREDEFINED_SLIDES: CarouselSlide[] = [ - ///: BEGIN:ONLY_INCLUDE_IF(solana) - { - id: 'solana', - title: strings('banner.solana.title'), - description: strings('banner.solana.subtitle'), - undismissable: false, - navigation: { - type: 'function', - navigate: () => [ - Routes.MODAL.ROOT_MODAL_FLOW, - { - screen: Routes.SHEET.ADD_ACCOUNT, - params: { - clientType: WalletClientType.Solana, - scope: SolScope.Mainnet, - }, - }, - ], - }, - }, - ///: END:ONLY_INCLUDE_IF { id: 'fund', title: strings('banner.fund.title'), diff --git a/app/components/UI/Carousel/index.test.tsx b/app/components/UI/Carousel/index.test.tsx index 5d697779d6b..ddd8e594089 100644 --- a/app/components/UI/Carousel/index.test.tsx +++ b/app/components/UI/Carousel/index.test.tsx @@ -12,16 +12,11 @@ import AppConstants from '../../../core/AppConstants'; import Carousel, { useFetchCarouselSlides } from './'; import { WalletViewSelectorsIDs } from '../../Views/Wallet/WalletView.testIds'; import { backgroundState } from '../../../util/test/initial-root-state'; -import Engine from '../../../core/Engine'; import { fetchCarouselSlidesFromContentful } from './fetchCarouselSlidesFromContentful'; import { CarouselSlide } from './types'; // eslint-disable-next-line import-x/no-namespace import * as FeatureFlagSelectorsModule from './selectors/featureFlags'; import { RootState } from '../../../reducers'; -import { selectLastSelectedSolanaAccount } from '../../../selectors/accountsController'; -import Routes from '../../../constants/navigation/Routes'; -import { WalletClientType } from '../../../core/SnapKeyring/MultichainWalletSnapClient'; -import { SolScope } from '@metamask/keyring-api'; import { setContentPreviewToken } from '../../../actions/notification/helpers'; import { createMockUseAnalyticsHook } from '../../../util/test/analyticsMock'; import { useAnalytics } from '../../../components/hooks/useAnalytics/useAnalytics'; @@ -37,14 +32,6 @@ const makeMockState = () => engine: { backgroundState: { ...backgroundState, - AccountsController: { - internalAccounts: { - selectedAccount: '1', - accounts: { - '1': { address: '0xSomeAddress' }, - }, - }, - }, }, }, settings: { showFiatOnTestnets: false }, @@ -62,11 +49,6 @@ jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({ navigate: mockNavigate }), })); -jest.mock('../../../core/Engine', () => ({ - setSelectedAddress: jest.fn(), - context: { PreferencesController: { state: {} } }, -})); - jest.mock('../../../components/hooks/useAnalytics/useAnalytics'); jest.mock('../../../core/DeeplinkManager/DeeplinkManager', () => { @@ -445,56 +427,6 @@ describe('Carousel Slide Dismissal', () => { }); }); -describe('Carousel Solana Integration', () => { - const setupSolanaTests = (hasSolanaAccount: boolean = false) => { - const mockState = makeMockState(); - jest.mocked(useSelector).mockImplementation((selector) => { - if (selector === selectLastSelectedSolanaAccount) { - return hasSolanaAccount ? { address: 'SolanaAddress123' } : null; - } - return selector(mockState); - }); - }; - - const arrangeActTestSolanaCarouselClick = async ( - props = { hasSolanaAccount: true }, - ) => { - setupSolanaTests(props.hasSolanaAccount); - const solanaSlide = createMockSlide({ - id: 'solana', - variableName: 'solana', - }); - mockFetchCarouselSlides.mockResolvedValue({ - prioritySlides: [], - regularSlides: [solanaSlide], - }); - - const { findByTestId } = render(); - const slide = await findByTestId('carousel-slide-solana'); - expect(slide).toBeVisible(); - fireEvent.press(slide); - }; - - it('switches to existing Solana account when clicked', async () => { - await arrangeActTestSolanaCarouselClick({ hasSolanaAccount: true }); - expect(Engine.setSelectedAddress).toHaveBeenCalledWith('SolanaAddress123'); - }); - - it('navigates to add account flow when no existing Solana account', async () => { - await arrangeActTestSolanaCarouselClick({ hasSolanaAccount: false }); // no solana account - - // Should navigate to add account flow instead of switching accounts - expect(mockNavigate).toHaveBeenCalledWith(Routes.MODAL.ROOT_MODAL_FLOW, { - screen: Routes.SHEET.ADD_ACCOUNT, - params: { - clientType: WalletClientType.Solana, - scope: SolScope.Mainnet, - }, - }); - expect(Engine.setSelectedAddress).not.toHaveBeenCalled(); - }); -}); - describe('Carousel UI Behavior', () => { it('renders stack-based carousel with current slide', async () => { const slides = [ diff --git a/app/components/UI/Carousel/index.tsx b/app/components/UI/Carousel/index.tsx index 617d3e316bc..87e3343fc92 100644 --- a/app/components/UI/Carousel/index.tsx +++ b/app/components/UI/Carousel/index.tsx @@ -24,15 +24,6 @@ import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { useAnalytics } from '../../../components/hooks/useAnalytics/useAnalytics'; import { WalletViewSelectorsIDs } from '../../Views/Wallet/WalletView.testIds'; import { selectDismissedBanners } from '../../../selectors/banner'; -///: BEGIN:ONLY_INCLUDE_IF(solana) -import { WalletClientType } from '../../../core/SnapKeyring/MultichainWalletSnapClient'; -import { - selectSelectedInternalAccount, - selectLastSelectedSolanaAccount, -} from '../../../selectors/accountsController'; -import { SolAccountType, SolScope } from '@metamask/keyring-api'; -import Engine from '../../../core/Engine'; -///: END:ONLY_INCLUDE_IF import { selectAddressHasTokenBalances } from '../../../selectors/tokenBalancesController'; import { fetchCarouselSlidesFromContentful, @@ -40,7 +31,6 @@ import { } from './fetchCarouselSlidesFromContentful'; import { selectContentfulCarouselEnabledFlag } from './selectors/featureFlags'; import { createBuyNavigationDetails } from '../Ramp/Aggregator/routes/utils'; -import Routes from '../../../constants/navigation/Routes'; import { subscribeToContentPreviewToken } from '../../../actions/notification/helpers'; import { BANNER_EVENT_DISPLAY } from '../../../constants/engagement'; import SharedDeeplinkManager from '../../../core/DeeplinkManager/DeeplinkManager'; @@ -187,13 +177,6 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { const { navigate } = useNavigation(); const tw = useTailwind(); const dismissedBanners = useSelector(selectDismissedBanners); - ///: BEGIN:ONLY_INCLUDE_IF(solana) - const selectedAccount = useSelector(selectSelectedInternalAccount); - const lastSelectedSolanaAccount = useSelector( - selectLastSelectedSolanaAccount, - ); - ///: END:ONLY_INCLUDE_IF - const isZeroBalance = !hasBalance; const applyLocalNavigation = useCallback( @@ -210,28 +193,6 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { }, }; } - ///: BEGIN:ONLY_INCLUDE_IF(solana) - // solana → open add-account flow (if we don't already redirect below) - if (variableName === 'solana') { - return { - ...s, - navigation: { - type: 'function', - navigate: () => - [ - Routes.MODAL.ROOT_MODAL_FLOW, - { - screen: Routes.SHEET.ADD_ACCOUNT, - params: { - clientType: WalletClientType.Solana, - scope: SolScope.Mainnet, - }, - }, - ] as const, - }, - }; - } - ///: END:ONLY_INCLUDE_IF return s; // keep Contentful linkUrl for everything else }, [], @@ -290,15 +251,6 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { const active = isActive(slide); if (!active) return false; - ///: BEGIN:ONLY_INCLUDE_IF(solana) - if ( - getSlideVariableName(slide) === 'solana' && - selectedAccount?.type === SolAccountType.DataAccount - ) { - return false; - } - ///: END:ONLY_INCLUDE_IF - return !dismissedBanners.includes(slide.id); }); @@ -313,13 +265,7 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { } return filtered.slice(0, MAX_CAROUSEL_SLIDES); - }, [ - slidesConfig, - dismissedBanners, - ///: BEGIN:ONLY_INCLUDE_IF(solana) - selectedAccount, - ///: END:ONLY_INCLUDE_IF - ]); + }, [slidesConfig, dismissedBanners]); // Ensure activeSlideIndex is within bounds after filtering const safeActiveSlideIndex = Math.min( @@ -407,16 +353,6 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { (slide: CarouselSlide) => { const slideName = getSlideAnalyticsName(slide); const { navigation } = slide; - const extraProperties: Record = {}; - - ///: BEGIN:ONLY_INCLUDE_IF(solana) - const isSolanaBanner = slideName === 'solana'; - if (isSolanaBanner && lastSelectedSolanaAccount) { - extraProperties.action = 'redirect-solana-account'; - } else if (isSolanaBanner && !lastSelectedSolanaAccount) { - extraProperties.action = 'create-solana-account'; - } - ///: END:ONLY_INCLUDE_IF trackEvent( createEventBuilder({ @@ -424,17 +360,10 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { }) .addProperties({ name: slideName, - ...extraProperties, }) .build(), ); - ///: BEGIN:ONLY_INCLUDE_IF(solana) - if (isSolanaBanner && lastSelectedSolanaAccount) { - return Engine.setSelectedAddress(lastSelectedSolanaAccount.address); - } - ///: END:ONLY_INCLUDE_IF - if (navigation.type === 'url') { return openUrl(navigation.href)(); } @@ -447,14 +376,7 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { return navigate(navigation.route); } }, - [ - trackEvent, - createEventBuilder, - navigate, - ///: BEGIN:ONLY_INCLUDE_IF(solana) - lastSelectedSolanaAccount, - ///: END:ONLY_INCLUDE_IF - ], + [trackEvent, createEventBuilder, navigate], ); const handleTransitionToNextCard = useCallback( diff --git a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx index 240fd847d49..026e06f014d 100644 --- a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx +++ b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { act, fireEvent, within } from '@testing-library/react-native'; import { Linking } from 'react-native'; +import type { ReactTestInstance } from 'react-test-renderer'; import BigNumber from 'bignumber.js'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; import MoneyHomeView from './MoneyHomeView'; @@ -172,6 +173,7 @@ jest.mock('../../../Card/hooks/useMoneyAccountCardLinkage', () => ({ __esModule: true, useMoneyAccountCardLinkage: jest.fn(() => ({ hasMoneyAccountRequirements: false, + hasMoneyAccountBaseRequirements: false, isCardAuthenticated: false, isCardVerified: false, isCardLinkedToMoneyAccount: false, @@ -319,6 +321,34 @@ jest.mock('../../../../../util/Logger', () => ({ default: { error: jest.fn() }, })); +const collectTestIdsInTreeOrder = ( + node: ReactTestInstance, + testIds: string[] = [], +): string[] => { + if (typeof node.props?.testID === 'string') { + testIds.push(node.props.testID); + } + + node.children.forEach((child) => { + if (typeof child === 'object' && child !== null && 'props' in child) { + collectTestIdsInTreeOrder(child as ReactTestInstance, testIds); + } + }); + + return testIds; +}; + +const expectTestIdBefore = ( + root: ReactTestInstance, + earlierTestId: string, + laterTestId: string, +) => { + const order = collectTestIdsInTreeOrder(root); + expect(order.indexOf(earlierTestId)).toBeGreaterThanOrEqual(0); + expect(order.indexOf(laterTestId)).toBeGreaterThanOrEqual(0); + expect(order.indexOf(earlierTestId)).toBeLessThan(order.indexOf(laterTestId)); +}; + describe('MoneyHomeView', () => { let defaultMoneyAccountBalance: ReturnType; @@ -343,6 +373,7 @@ describe('MoneyHomeView', () => { mockStartLinkFlow.mockReset(); mockUseMoneyAccountCardLinkage.mockReturnValue({ hasMoneyAccountRequirements: false, + hasMoneyAccountBaseRequirements: false, isCardAuthenticated: false, isCardVerified: false, isCardLinkedToMoneyAccount: false, @@ -995,6 +1026,7 @@ describe('MoneyHomeView', () => { expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, { screen: Routes.CARD.HOME, params: { postAuthRedirect: MONEY_HOME_CARD_ORIGIN }, + animation: 'slide_from_bottom', }); }); @@ -1076,6 +1108,7 @@ describe('MoneyHomeView', () => { expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, { screen: Routes.CARD.HOME, params: { postAuthRedirect: MONEY_HOME_CARD_ORIGIN }, + animation: 'slide_from_bottom', }); }); @@ -1372,6 +1405,7 @@ describe('MoneyHomeView', () => { // link CTA is offered. mockUseMoneyAccountCardLinkage.mockReturnValue({ hasMoneyAccountRequirements: true, + hasMoneyAccountBaseRequirements: true, isCardAuthenticated: false, isCardLinkedToMoneyAccount: false, primaryMoneyAccount: { address: '0xabc' }, @@ -1462,8 +1496,7 @@ describe('MoneyHomeView', () => { expect(mockStartLinkFlow).toHaveBeenCalledTimes(1); expect(mockStartLinkFlow).toHaveBeenCalledWith({ - screen: Routes.MONEY.ROOT, - params: { screen: Routes.MONEY.HOME }, + ...MONEY_HOME_CARD_ORIGIN, entrypoint: CardEntryPoint.MONEY_HOME_METAMASK_CARD, }); expect(mockNavigate).not.toHaveBeenCalledWith(Routes.CARD.ROOT, { @@ -1476,6 +1509,7 @@ describe('MoneyHomeView', () => { beforeEach(() => { mockUseMoneyAccountCardLinkage.mockReturnValue({ hasMoneyAccountRequirements: true, + hasMoneyAccountBaseRequirements: true, isCardAuthenticated: true, isCardVerified: true, isCardLinkedToMoneyAccount: false, @@ -1500,8 +1534,7 @@ describe('MoneyHomeView', () => { expect(mockStartLinkFlow).toHaveBeenCalledTimes(1); expect(mockStartLinkFlow).toHaveBeenCalledWith({ - screen: Routes.MONEY.ROOT, - params: { screen: Routes.MONEY.HOME }, + ...MONEY_HOME_CARD_ORIGIN, entrypoint: CardEntryPoint.MONEY_HOME_METAMASK_CARD, }); expect(mockNavigate).not.toHaveBeenCalledWith(Routes.CARD.ROOT, { @@ -1862,6 +1895,7 @@ describe('MoneyHomeView', () => { expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, { screen: Routes.CARD.HOME, params: { postAuthRedirect: MONEY_HOME_CARD_ORIGIN }, + animation: 'slide_from_bottom', }); }); }); @@ -1871,6 +1905,7 @@ describe('MoneyHomeView', () => { mockSelectIsCardholder.mockReturnValue(true); mockUseMoneyAccountCardLinkage.mockReturnValue({ hasMoneyAccountRequirements: false, + hasMoneyAccountBaseRequirements: false, isCardAuthenticated: false, isCardLinkedToMoneyAccount: true, primaryMoneyAccount: undefined, @@ -1895,6 +1930,7 @@ describe('MoneyHomeView', () => { mockSelectIsCardholder.mockReturnValue(true); mockUseMoneyAccountCardLinkage.mockReturnValue({ hasMoneyAccountRequirements: true, + hasMoneyAccountBaseRequirements: true, isCardAuthenticated: false, isCardLinkedToMoneyAccount: false, primaryMoneyAccount: { address: '0xabc' }, @@ -1915,10 +1951,40 @@ describe('MoneyHomeView', () => { ).toBeOnTheScreen(); }); + it('selects mode="link" for unauthenticated cardholder when base requirements are met but VEDA is unavailable', () => { + mockSelectIsCardholder.mockReturnValue(true); + mockUseMoneyAccountCardLinkage.mockReturnValue({ + hasMoneyAccountRequirements: false, + hasMoneyAccountBaseRequirements: true, + isCardAuthenticated: false, + isCardVerified: false, + isCardLinkedToMoneyAccount: false, + primaryMoneyAccount: { address: '0xabc' }, + moneyAccountCardToken: null, + canLink: false, + status: 'idle', + isLinking: false, + error: null, + startLinkFlow: mockStartLinkFlow, + openLinkCardSheet: mockOpenLinkCardSheet, + reset: jest.fn(), + } as unknown as ReturnType); + + const { getByTestId } = renderWithProvider(); + + expect( + getByTestId(MoneyMetaMaskCardTestIds.LINK_CONTAINER), + ).toBeOnTheScreen(); + expect( + getByTestId(MoneyMetaMaskCardTestIds.LINK_BUTTON), + ).toBeOnTheScreen(); + }); + it('hides the card section when cardholder but VEDA is not allowlisted (cannot link)', () => { mockSelectIsCardholder.mockReturnValue(true); mockUseMoneyAccountCardLinkage.mockReturnValue({ hasMoneyAccountRequirements: false, + hasMoneyAccountBaseRequirements: false, isCardAuthenticated: true, isCardLinkedToMoneyAccount: false, primaryMoneyAccount: { address: '0xabc' }, @@ -2001,6 +2067,7 @@ describe('MoneyHomeView', () => { mockSelectIsCardholder.mockReturnValue(true); mockUseMoneyAccountCardLinkage.mockReturnValue({ hasMoneyAccountRequirements: true, + hasMoneyAccountBaseRequirements: true, isCardAuthenticated: false, isCardLinkedToMoneyAccount: false, primaryMoneyAccount: { address: '0xabc' }, @@ -2039,10 +2106,62 @@ describe('MoneyHomeView', () => { ).toBeOnTheScreen(); }); + it('renders upsell MetaMask Card below Activity and Earn on your crypto', () => { + mockSelectIsCardholder.mockReturnValue(false); + + const { getByTestId } = renderWithProvider(); + const scrollRoot = getByTestId(MoneyHomeViewTestIds.SCROLL_VIEW); + + expectTestIdBefore( + scrollRoot, + MoneyActivityListTestIds.CONTAINER, + MoneyMetaMaskCardTestIds.CONTAINER, + ); + expectTestIdBefore( + scrollRoot, + MoneyPotentialEarningsTestIds.CONTAINER, + MoneyMetaMaskCardTestIds.CONTAINER, + ); + }); + + it('renders link MetaMask Card above Activity and Earn on your crypto', () => { + mockSelectIsCardholder.mockReturnValue(true); + mockUseMoneyAccountCardLinkage.mockReturnValue({ + hasMoneyAccountRequirements: true, + hasMoneyAccountBaseRequirements: true, + isCardAuthenticated: false, + isCardLinkedToMoneyAccount: false, + primaryMoneyAccount: { address: '0xabc' }, + moneyAccountCardToken: { symbol: 'veda' }, + canLink: false, + status: 'idle', + isLinking: false, + error: null, + startLinkFlow: mockStartLinkFlow, + openLinkCardSheet: mockOpenLinkCardSheet, + reset: jest.fn(), + } as unknown as ReturnType); + + const { getByTestId } = renderWithProvider(); + const scrollRoot = getByTestId(MoneyHomeViewTestIds.SCROLL_VIEW); + + expectTestIdBefore( + scrollRoot, + MoneyMetaMaskCardTestIds.CONTAINER, + MoneyActivityListTestIds.CONTAINER, + ); + expectTestIdBefore( + scrollRoot, + MoneyMetaMaskCardTestIds.CONTAINER, + MoneyPotentialEarningsTestIds.CONTAINER, + ); + }); + it('selects mode="link" when card-authenticated and VERIFIED even if selected wallet is not a cardholder account', () => { mockSelectIsCardholder.mockReturnValue(false); mockUseMoneyAccountCardLinkage.mockReturnValue({ hasMoneyAccountRequirements: true, + hasMoneyAccountBaseRequirements: true, isCardAuthenticated: true, isCardVerified: true, isCardLinkedToMoneyAccount: false, @@ -2064,10 +2183,11 @@ describe('MoneyHomeView', () => { ).toBeOnTheScreen(); }); - it('hides the MetaMask Card section when authenticated but not VERIFIED', () => { + it('shows the MetaMask Card section with verification banner when authenticated but not VERIFIED', () => { mockSelectIsCardholder.mockReturnValue(false); mockUseMoneyAccountCardLinkage.mockReturnValue({ hasMoneyAccountRequirements: true, + hasMoneyAccountBaseRequirements: true, isCardAuthenticated: true, isCardVerified: false, isCardLinkedToMoneyAccount: false, @@ -2082,17 +2202,22 @@ describe('MoneyHomeView', () => { reset: jest.fn(), } as unknown as ReturnType); - const { queryByTestId } = renderWithProvider(); + const { getByTestId, getByText } = renderWithProvider(); + expect(getByTestId(MoneyMetaMaskCardTestIds.CONTAINER)).toBeOnTheScreen(); expect( - queryByTestId(MoneyMetaMaskCardTestIds.CONTAINER), - ).not.toBeOnTheScreen(); + getByTestId(MoneyMetaMaskCardTestIds.VERIFYING_BANNER), + ).toBeOnTheScreen(); + expect( + getByText(strings('money.metamask_card.verification_pending')), + ).toBeOnTheScreen(); }); it('disables the link button when linkage is in progress', () => { mockSelectIsCardholder.mockReturnValue(true); mockUseMoneyAccountCardLinkage.mockReturnValue({ hasMoneyAccountRequirements: true, + hasMoneyAccountBaseRequirements: true, isCardAuthenticated: false, isCardLinkedToMoneyAccount: false, primaryMoneyAccount: { address: '0xabc' }, @@ -2118,6 +2243,7 @@ describe('MoneyHomeView', () => { mockSelectIsCardholder.mockReturnValue(true); mockUseMoneyAccountCardLinkage.mockReturnValue({ hasMoneyAccountRequirements: false, + hasMoneyAccountBaseRequirements: false, isCardAuthenticated: false, isCardLinkedToMoneyAccount: true, primaryMoneyAccount: undefined, @@ -2138,6 +2264,7 @@ describe('MoneyHomeView', () => { expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, { screen: Routes.CARD.HOME, params: { postAuthRedirect: MONEY_HOME_CARD_ORIGIN }, + animation: 'slide_from_bottom', }); }); }); @@ -2168,6 +2295,7 @@ describe('MoneyHomeView', () => { mockSelectIsCardholder.mockReturnValue(true); mockUseMoneyAccountCardLinkage.mockReturnValue({ hasMoneyAccountRequirements: false, + hasMoneyAccountBaseRequirements: false, isCardAuthenticated: false, isCardLinkedToMoneyAccount: true, primaryMoneyAccount: undefined, @@ -2200,6 +2328,7 @@ describe('MoneyHomeView', () => { mockSelectIsCardholder.mockReturnValue(true); mockUseMoneyAccountCardLinkage.mockReturnValue({ hasMoneyAccountRequirements: false, + hasMoneyAccountBaseRequirements: false, isCardAuthenticated: false, isCardLinkedToMoneyAccount: true, primaryMoneyAccount: undefined, @@ -2230,6 +2359,7 @@ describe('MoneyHomeView', () => { mockSelectHasMetalCard.mockReturnValue(true); mockUseMoneyAccountCardLinkage.mockReturnValue({ hasMoneyAccountRequirements: true, + hasMoneyAccountBaseRequirements: true, isCardAuthenticated: false, isCardLinkedToMoneyAccount: false, primaryMoneyAccount: { address: '0xabc' }, @@ -2258,6 +2388,7 @@ describe('MoneyHomeView', () => { expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, { screen: Routes.CARD.HOME, params: { postAuthRedirect: MONEY_HOME_CARD_ORIGIN }, + animation: 'slide_from_bottom', }); }); }); diff --git a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx index 366f37823bc..0378bea16d5 100644 --- a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx +++ b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx @@ -38,6 +38,7 @@ import { useMusdBalance } from '../../../Earn/hooks/useMusdBalance'; import { useMoneyAccountTransactions } from '../../hooks/useMoneyAccountTransactions'; import { useMoneyAccountCardTransactions } from '../../hooks/useMoneyAccountCardTransactions'; import { mergeMoneyActivity } from '../../hooks/useMoneyActivityItems'; +import { deriveMoneyMetaMaskCardMode } from '../../utils/moneyMetaMaskCardMode'; import MoneyActivityLoading from '../../components/MoneyActivityLoading/MoneyActivityLoading'; import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance'; import useMoneyAccountInfo from '../../hooks/useMoneyAccountInfo'; @@ -166,6 +167,7 @@ const MoneyHomeView = () => { isCardLinkedToMoneyAccount, isLinking, hasMoneyAccountRequirements, + hasMoneyAccountBaseRequirements, isResidencyBlocked, } = useMoneyAccountCardLinkage(); @@ -316,6 +318,7 @@ const MoneyHomeView = () => { navigation.navigate(Routes.CARD.ROOT, { screen: Routes.CARD.HOME, params: { postAuthRedirect: MONEY_HOME_CARD_ORIGIN }, + animation: 'slide_from_bottom', }); }, [navigation]); @@ -354,8 +357,7 @@ const MoneyHomeView = () => { const handleLinkCardPress = useCallback(() => { startLinkFlow({ - screen: Routes.MONEY.ROOT, - params: { screen: Routes.MONEY.HOME }, + ...MONEY_HOME_CARD_ORIGIN, entrypoint: CardEntryPoint.MONEY_HOME_METAMASK_CARD, }); }, [startLinkFlow]); @@ -579,18 +581,15 @@ const MoneyHomeView = () => { [navigation, trackActivitySurfaceClicked], ); - let metamaskCardMode: 'upsell' | 'link' | 'manage' | null; - if (isCardLinkedToMoneyAccount) { - metamaskCardMode = 'manage'; - } else if (isResidencyBlocked) { - metamaskCardMode = null; - } else if (isCardholder || (isCardAuthenticated && isCardVerified)) { - metamaskCardMode = hasMoneyAccountRequirements ? 'link' : null; - } else if (isCardAuthenticated) { - metamaskCardMode = null; - } else { - metamaskCardMode = 'upsell'; - } + const metamaskCardMode = deriveMoneyMetaMaskCardMode({ + isCardLinkedToMoneyAccount, + isCardholder, + isCardAuthenticated, + isCardVerified, + isResidencyBlocked, + hasMoneyAccountBaseRequirements, + hasMoneyAccountRequirements, + }); const { primaryToken: cardPrimaryToken } = useCardHomeData(); const cardBalance = cardPrimaryToken?.balanceFiat ?? formattedZero; @@ -602,6 +601,34 @@ const MoneyHomeView = () => { const isCardAnalyticsReady = cardHomeDataStatus === 'success' || cardHomeDataStatus === 'error'; + const metamaskCardSection = metamaskCardMode + ? { + key: 'metamask-card', + node: ( + + ), + } + : null; + + const shouldShowMetaMaskCardEarly = + metamaskCardMode !== null && metamaskCardMode !== 'upsell'; + const contentSections: { key: string; node: React.ReactNode }[] = []; if (hasBalanceValue && isFunded) { @@ -649,6 +676,10 @@ const MoneyHomeView = () => { }); } + if (shouldShowMetaMaskCardEarly && metamaskCardSection) { + contentSections.push(metamaskCardSection); + } + if (showCardActivityLoading || activityItems.length >= 1) { contentSections.push({ key: 'activity', @@ -684,29 +715,8 @@ const MoneyHomeView = () => { }); } - if (metamaskCardMode) { - contentSections.push({ - key: 'metamask-card', - node: ( - - ), - }); + if (!shouldShowMetaMaskCardEarly && metamaskCardSection) { + contentSections.push(metamaskCardSection); } if (isFunded) { diff --git a/app/components/UI/Money/Views/MoneyHowItWorksView/MoneyHowItWorksView.test.tsx b/app/components/UI/Money/Views/MoneyHowItWorksView/MoneyHowItWorksView.test.tsx index 4d5b3c91136..8813da2890d 100644 --- a/app/components/UI/Money/Views/MoneyHowItWorksView/MoneyHowItWorksView.test.tsx +++ b/app/components/UI/Money/Views/MoneyHowItWorksView/MoneyHowItWorksView.test.tsx @@ -6,6 +6,7 @@ import { MoneyHowItWorksViewTestIds } from './MoneyHowItWorksView.testIds'; import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance'; import { useMoneyAnalytics } from '../../hooks/useMoneyAnalytics'; import { SCREEN_NAMES } from '../../constants/moneyEvents'; +import { strings } from '../../../../../../locales/i18n'; const mockTrackScreenViewed = jest.fn(); @@ -35,51 +36,6 @@ jest.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ top: 48, bottom: 34, left: 0, right: 0 }), })); -jest.mock('../../../../../../locales/i18n', () => ({ - __esModule: true, - default: { locale: 'en-US' }, - strings: (key: string) => { - const map: Record = { - 'money.how_it_works_page.header_title': 'Money', - 'money.how_it_works_page.section_title': 'How it works', - 'money.how_it_works_page.description_1': - 'Add mUSD into your Money account and earn up to 4% APY (variable) automatically. Funds go into a DeFi vault, administered by Veda with risk monitoring by Steakhouse Financial. The rate is generated by third-party DeFi platforms that deploy funds in blockchain protocols. No staking, no claiming, no lock-ups.', - 'money.how_it_works_page.description_2': - 'Your Money balance is your spending balance. Link your MetaMask Card to spend at 150M+ merchants worldwide. Your money keeps earning until the moment you use it.', - 'money.how_it_works_page.description_3': - 'Money account is powered by Monad.', - 'money.how_it_works_page.faq_title': 'FAQs', - 'money.how_it_works_page.faq_q1': 'What is a MetaMask Money Account?', - 'money.how_it_works_page.faq_a1': 'Money Account answer 1', - 'money.how_it_works_page.faq_q2': 'Is MetaMask Money Account safe?', - 'money.how_it_works_page.faq_a2': 'Money Account answer 2', - 'money.how_it_works_page.faq_q3': - 'Who controls my MetaMask Money Account?', - 'money.how_it_works_page.faq_a3': 'Money Account answer 3', - 'money.how_it_works_page.faq_q4': 'What are MetaMask Money Account fees?', - 'money.how_it_works_page.faq_a4': 'Money Account answer 4', - 'money.how_it_works_page.faq_q5': - 'How much can I earn with a MetaMask Money Account?', - 'money.how_it_works_page.faq_a5': 'Money Account answer 5', - 'money.how_it_works_page.faq_q6': 'Where does the yield come from?', - 'money.how_it_works_page.faq_a6': 'Money Account answer 6', - 'money.how_it_works_page.faq_q7': - 'Which tokens can I deposit into the Money Account?', - 'money.how_it_works_page.faq_a7': 'Money Account answer 7', - 'money.how_it_works_page.faq_q8': - 'Can I withdraw from the MetaMask Money Account anytime?', - 'money.how_it_works_page.faq_a8': 'Money Account answer 8', - 'money.how_it_works_page.faq_q9': - 'Do I need to complete KYC to use MetaMask Money Account?', - 'money.how_it_works_page.faq_a9': 'Money Account answer 9', - 'money.how_it_works_page.faq_q10': - 'Which countries is MetaMask Money Account available in?', - 'money.how_it_works_page.faq_a10': 'Money Account answer 10', - }; - return map[key] ?? key; - }, -})); - describe('MoneyHowItWorksView', () => { beforeEach(() => { jest.clearAllMocks(); @@ -118,7 +74,9 @@ describe('MoneyHowItWorksView', () => { expect( getByTestId(MoneyHowItWorksViewTestIds.DESCRIPTION_3), ).toBeOnTheScreen(); - expect(getByText('Money account is powered by Monad.')).toBeOnTheScreen(); + expect( + getByText(strings('money.how_it_works_page.description_3')), + ).toBeOnTheScreen(); }); it('renders the FAQ title', () => { @@ -140,7 +98,9 @@ describe('MoneyHowItWorksView', () => { it('renders the "FAQs" FAQ header', () => { const { getByText } = renderWithProvider(); - expect(getByText('FAQs')).toBeOnTheScreen(); + expect( + getByText(strings('money.how_it_works_page.faq_title')), + ).toBeOnTheScreen(); }); it('reveals the matching answer when a FAQ item is expanded', () => { @@ -148,40 +108,23 @@ describe('MoneyHowItWorksView', () => { , ); - expect(queryByText('Money Account answer 1')).toBeNull(); + expect(queryByText(strings('money.how_it_works_page.faq_a2'))).toBeNull(); - fireEvent.press(getByTestId(MoneyHowItWorksViewTestIds.FAQ_ITEM(1))); + fireEvent.press(getByTestId(MoneyHowItWorksViewTestIds.FAQ_ITEM(2))); - expect(getByText('Money Account answer 1')).toBeOnTheScreen(); + expect( + getByText(strings('money.how_it_works_page.faq_a2')), + ).toBeOnTheScreen(); }); it('renders all 10 FAQ questions', () => { const { getByText } = renderWithProvider(); - expect(getByText('What is a MetaMask Money Account?')).toBeOnTheScreen(); - expect(getByText('Is MetaMask Money Account safe?')).toBeOnTheScreen(); - expect( - getByText('Who controls my MetaMask Money Account?'), - ).toBeOnTheScreen(); - expect( - getByText('What are MetaMask Money Account fees?'), - ).toBeOnTheScreen(); - expect( - getByText('How much can I earn with a MetaMask Money Account?'), - ).toBeOnTheScreen(); - expect(getByText('Where does the yield come from?')).toBeOnTheScreen(); - expect( - getByText('Which tokens can I deposit into the Money Account?'), - ).toBeOnTheScreen(); - expect( - getByText('Can I withdraw from the MetaMask Money Account anytime?'), - ).toBeOnTheScreen(); - expect( - getByText('Do I need to complete KYC to use MetaMask Money Account?'), - ).toBeOnTheScreen(); - expect( - getByText('Which countries is MetaMask Money Account available in?'), - ).toBeOnTheScreen(); + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].forEach((n) => { + expect( + getByText(strings(`money.how_it_works_page.faq_q${n}`)), + ).toBeOnTheScreen(); + }); }); it('pressing the back button calls navigation.goBack', () => { @@ -197,6 +140,22 @@ describe('MoneyHowItWorksView', () => { expect(queryByText('Sounds good')).toBeNull(); }); + it('renders the dash placeholder in description_1 when APY is unavailable', () => { + (useMoneyAccountBalance as jest.Mock).mockReturnValue({ + apyPercent: undefined, + }); + const { getByTestId, getByText } = renderWithProvider( + , + ); + + expect(getByTestId(MoneyHowItWorksViewTestIds.CONTAINER)).toBeOnTheScreen(); + expect( + getByText( + strings('money.how_it_works_page.description_1', { percentage: '-' }), + ), + ).toBeOnTheScreen(); + }); + it('toggles a FAQ item when the question row is pressed', () => { const { getByTestId } = renderWithProvider(); diff --git a/app/components/UI/Money/Views/MoneyHowItWorksView/MoneyHowItWorksView.tsx b/app/components/UI/Money/Views/MoneyHowItWorksView/MoneyHowItWorksView.tsx index a1447fd167c..5c20138b735 100644 --- a/app/components/UI/Money/Views/MoneyHowItWorksView/MoneyHowItWorksView.tsx +++ b/app/components/UI/Money/Views/MoneyHowItWorksView/MoneyHowItWorksView.tsx @@ -61,7 +61,6 @@ const FAQ_KEYS = [ ] as const; const ANIMATION_DURATION = 200; -const FALLBACK_APY = 4; const FaqItem = ({ question, @@ -131,7 +130,6 @@ const MoneyHowItWorksView = () => { const insets = useSafeAreaInsets(); const { colors: themeColors } = useTheme(); const { apyPercent } = useMoneyAccountBalance(); - const percentage = apyPercent ?? FALLBACK_APY; const noFeeTokens = resolveNoFeeTokens(useSelector(selectMoneyNoFeeTokens)); const tokenBullets = formatNoFeeTokenBullets(noFeeTokens); @@ -196,7 +194,9 @@ const MoneyHowItWorksView = () => { color={TextColor.TextAlternative} testID={MoneyHowItWorksViewTestIds.DESCRIPTION_1} > - {strings('money.how_it_works_page.description_1', { percentage })} + {strings('money.how_it_works_page.description_1', { + percentage: apyPercent ?? '-', + })} { {index > 0 && } { ); }; -function rejectPendingTransactions(transactions: TransactionMeta[]) { - const { ApprovalController } = Engine.context; - - for (const tx of transactions) { - if (tx.status !== TransactionStatus.unapproved) { - continue; - } - try { - ApprovalController.rejectRequest( - tx.id, - providerErrors.userRejectedRequest(), - ); - log('Rejected transaction', tx.type, tx.id); - } catch { - log('Failed to reject transaction', tx.type, tx.id); - } - } -} - export default MoneyAddMoneySheet; diff --git a/app/components/UI/Money/components/MoneyCardTransactionDetailsSheet/MoneyCardTransactionDetailsSheet.test.tsx b/app/components/UI/Money/components/MoneyCardTransactionDetailsSheet/MoneyCardTransactionDetailsSheet.test.tsx index c68afd81730..71b5bacc704 100644 --- a/app/components/UI/Money/components/MoneyCardTransactionDetailsSheet/MoneyCardTransactionDetailsSheet.test.tsx +++ b/app/components/UI/Money/components/MoneyCardTransactionDetailsSheet/MoneyCardTransactionDetailsSheet.test.tsx @@ -20,6 +20,7 @@ const card: CardTransaction = { const mockNavigate = jest.fn(); const mockGoBack = jest.fn(); +const mockOnCloseBottomSheet = jest.fn((cb?: () => void) => cb?.()); let mockRouteParams: { card?: CardTransaction } | undefined; jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({ navigate: mockNavigate, goBack: mockGoBack }), @@ -58,11 +59,23 @@ jest.mock('../../../../../util/networks', () => ({ // Heavy presentational deps reduced to passthroughs / stubs. jest.mock('@metamask/design-system-react-native', () => { const actual = jest.requireActual('@metamask/design-system-react-native'); + const ReactActual = jest.requireActual('react'); const { View, Text: RNText } = jest.requireActual('react-native'); return { ...actual, - BottomSheet: ({ children }: { children: React.ReactNode }) => ( - {children} + // Forward a ref exposing `onCloseBottomSheet`, mirroring the real sheet: + // it runs the post-close callback once the close animation finishes (here, + // synchronously) so navigation-on-close can be asserted. + BottomSheet: ReactActual.forwardRef( + ( + { children }: { children: React.ReactNode }, + ref: React.Ref<{ onCloseBottomSheet: (cb?: () => void) => void }>, + ) => { + ReactActual.useImperativeHandle(ref, () => ({ + onCloseBottomSheet: mockOnCloseBottomSheet, + })); + return {children}; + }, ), BottomSheetHeader: ({ children }: { children: React.ReactNode }) => ( {children} @@ -129,7 +142,7 @@ describe('MoneyCardTransactionDetailsSheet', () => { ).toHaveTextContent('-5.38 mUSD'); }); - it('opens the block explorer for the tx hash on press', () => { + it('closes the sheet before opening the block explorer for the tx hash on press', () => { const { getByTestId } = render(); fireEvent.press( @@ -141,6 +154,9 @@ describe('MoneyCardTransactionDetailsSheet', () => { card.hash, 'https://monadscan.com', ); + // The sheet must dismiss first — navigating while the transparent modal is + // still presented leaves the WebView behind it and strands the overlay. + expect(mockOnCloseBottomSheet).toHaveBeenCalledTimes(1); expect(mockNavigate).toHaveBeenCalledWith( 'Webview', expect.objectContaining({ diff --git a/app/components/UI/Money/components/MoneyCardTransactionDetailsSheet/MoneyCardTransactionDetailsSheet.tsx b/app/components/UI/Money/components/MoneyCardTransactionDetailsSheet/MoneyCardTransactionDetailsSheet.tsx index 366ebdd243b..5a2edfa0cc6 100644 --- a/app/components/UI/Money/components/MoneyCardTransactionDetailsSheet/MoneyCardTransactionDetailsSheet.tsx +++ b/app/components/UI/Money/components/MoneyCardTransactionDetailsSheet/MoneyCardTransactionDetailsSheet.tsx @@ -107,9 +107,17 @@ const CardTransactionDetails = ({ card }: { card: CardTransaction }) => { if (!url) { return; } - navigation.navigate(Routes.WEBVIEW.MAIN, { - screen: Routes.WEBVIEW.SIMPLE, - params: { url, title }, + // Dismiss the sheet *before* navigating. This sheet is a `transparentModal` + // presented over the main stack, and the WebView is a sibling screen on + // that same stack. Navigating while the modal is still up pushes the + // WebView behind it (so it never appears) and strands the sheet's overlay + // on screen. Closing first pops the modal, then the callback pushes the + // WebView onto the now-top stack. + sheetRef.current?.onCloseBottomSheet(() => { + navigation.navigate(Routes.WEBVIEW.MAIN, { + screen: Routes.WEBVIEW.SIMPLE, + params: { url, title }, + }); }); }, [card.chainId, card.hash, navigation, networkConfigurations]); diff --git a/app/components/UI/Money/components/MoneyEarnCryptoInfoSheet/MoneyEarnCryptoInfoSheet.test.tsx b/app/components/UI/Money/components/MoneyEarnCryptoInfoSheet/MoneyEarnCryptoInfoSheet.test.tsx index 0fc459cd526..641c2058566 100644 --- a/app/components/UI/Money/components/MoneyEarnCryptoInfoSheet/MoneyEarnCryptoInfoSheet.test.tsx +++ b/app/components/UI/Money/components/MoneyEarnCryptoInfoSheet/MoneyEarnCryptoInfoSheet.test.tsx @@ -201,13 +201,20 @@ describe('MoneyEarnCryptoInfoSheet', () => { }); }); - it('falls back to a baseline APY when the live value is unavailable', () => { + it('renders the body when APY is unavailable', () => { (useMoneyAccountBalance as jest.Mock).mockReturnValue({ apyPercent: undefined, }); - const { getByTestId } = renderWithProvider(); + const { getByTestId, getByText } = renderWithProvider( + , + ); expect(getByTestId(MoneyEarnCryptoInfoSheetTestIds.BODY)).toBeOnTheScreen(); + expect( + getByText( + strings('money.earn_crypto_info_sheet.body', { percentage: '-' }), + ), + ).toBeOnTheScreen(); }); describe('analytics', () => { diff --git a/app/components/UI/Money/components/MoneyEarnCryptoInfoSheet/MoneyEarnCryptoInfoSheet.tsx b/app/components/UI/Money/components/MoneyEarnCryptoInfoSheet/MoneyEarnCryptoInfoSheet.tsx index f158b67318d..a963d403fa9 100644 --- a/app/components/UI/Money/components/MoneyEarnCryptoInfoSheet/MoneyEarnCryptoInfoSheet.tsx +++ b/app/components/UI/Money/components/MoneyEarnCryptoInfoSheet/MoneyEarnCryptoInfoSheet.tsx @@ -21,8 +21,6 @@ import { useMoneyAnalytics } from '../../hooks/useMoneyAnalytics'; import useMountEffect from '../../hooks/useMountEffect'; import { BOTTOM_SHEET_NAMES } from '../../constants/moneyEvents'; -const FALLBACK_APY = 4; - type MoneyEarnCryptoInfoSheetVariant = 'default' | 'deposit'; interface MoneyEarnCryptoInfoSheetParams { @@ -79,7 +77,7 @@ const MoneyEarnCryptoInfoSheet = () => { testID={MoneyEarnCryptoInfoSheetTestIds.BODY} > {strings('money.earn_crypto_info_sheet.body', { - percentage: apyPercent ?? FALLBACK_APY, + percentage: apyPercent ?? '-', })} diff --git a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.test.tsx b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.test.tsx index 190530d69a1..c5943ce4afc 100644 --- a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.test.tsx +++ b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.test.tsx @@ -447,6 +447,49 @@ describe('MoneyMetaMaskCard', () => { }); }); + describe('mode="verifying"', () => { + it('renders the MetaMask Card title and verification pending banner', () => { + const { getByText, getByTestId } = render( + , + ); + + expect(getByText(strings('money.metamask_card.title'))).toBeOnTheScreen(); + expect( + getByTestId(MoneyMetaMaskCardTestIds.VERIFYING_BANNER), + ).toBeOnTheScreen(); + expect( + getByText(strings('money.metamask_card.verification_pending')), + ).toBeOnTheScreen(); + }); + + it('calls onHeaderPress when section header is tapped in verifying mode', () => { + const mockHeader = jest.fn(); + const { getByText } = render( + , + ); + + fireEvent.press(getByText(strings('money.metamask_card.title'))); + expect(mockHeader).toHaveBeenCalled(); + }); + + it('does not render upsell or link content', () => { + const { queryByTestId } = render( + , + ); + + expect( + queryByTestId(MoneyMetaMaskCardTestIds.VIRTUAL_CARD_ROW), + ).not.toBeOnTheScreen(); + expect( + queryByTestId(MoneyMetaMaskCardTestIds.LINK_CONTAINER), + ).not.toBeOnTheScreen(); + }); + }); + describe('upsell mode (default)', () => { it('renders only the virtual card row regardless of showMetalCard', () => { const { getByTestId, queryByTestId } = render( diff --git a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.testIds.ts b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.testIds.ts index 305b4225c1b..f3078d0855a 100644 --- a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.testIds.ts +++ b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.testIds.ts @@ -14,4 +14,5 @@ export const MoneyMetaMaskCardTestIds = { MANAGE_BUTTON: 'money-mm-card-manage-button', MANAGE_METAL_GET_NOW: 'money-mm-card-manage-metal-get-now', MANAGE_BALANCE: 'money-mm-card-manage-balance', + VERIFYING_BANNER: 'money-mm-card-verifying-banner', } as const; diff --git a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx index adf49e23717..34e1c3ab7c1 100644 --- a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx +++ b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx @@ -1,6 +1,8 @@ import React, { useCallback, useEffect, useRef } from 'react'; import { Image, ImageSourcePropType } from 'react-native'; import { + BannerAlert, + BannerAlertSeverity, Box, BoxAlignItems, BoxFlexDirection, @@ -33,6 +35,7 @@ import { import mmCardRegular from '../../../../../images/mm_card_regular.png'; import mmCardMetal from '../../../../../images/mm_card_metal.png'; +import { FLAT_BANNER_ALERT_STYLE } from '../../../shared/flatBannerAlertStyle'; interface MoneyMetaMaskCardProps { /** @@ -40,7 +43,7 @@ interface MoneyMetaMaskCardProps { * 'link': card-linking CTA layout. * 'manage': cardholder management layout with available balance and metal upsell. */ - mode?: 'upsell' | 'link' | 'manage'; + mode?: 'upsell' | 'link' | 'manage' | 'verifying'; onGetNowPress: () => void; onHeaderPress?: () => void; /** Called when the "Link card" button is pressed (link mode only). */ @@ -463,6 +466,18 @@ const MoneyMetaMaskCard = ({ showMetalCard={showMetalCard} /> ); + } else if (mode === 'verifying') { + content = ( + + + + ); } else { content = ( <> @@ -487,7 +502,7 @@ const MoneyMetaMaskCard = ({ let headerTitleKey: string; if (mode === 'link') { headerTitleKey = 'money.metamask_card.link_title'; - } else if (mode === 'manage') { + } else if (mode === 'manage' || mode === 'verifying') { headerTitleKey = 'money.metamask_card.title'; } else { headerTitleKey = 'money.metamask_card.upsell_title'; diff --git a/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneySentDetails.test.tsx b/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneySentDetails.test.tsx index 7d204bd1f3d..1e7bf4b1171 100644 --- a/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneySentDetails.test.tsx +++ b/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneySentDetails.test.tsx @@ -19,6 +19,8 @@ jest.mock('../../../../../util/analytics/externalLinkTracking', () => ({ })); import { trackBlockExplorerLinkClicked } from '../../../../../util/analytics/externalLinkTracking'; const mockNavigate = jest.fn(); +// Mirrors the host sheet: closes first, then runs the deferred navigation. +const mockCloseSheet = jest.fn((navigate: () => void) => navigate()); jest.mock( '../../../../Views/confirmations/hooks/activity/useTransactionDetails', @@ -162,9 +164,12 @@ function render(overrides: Partial = {}) { ...overrides, } as TransactionMeta, }); - return renderWithProvider(, { - state: merge({}, otherControllersMock), - }); + return renderWithProvider( + , + { + state: merge({}, otherControllersMock), + }, + ); } describe('MoneySentDetails', () => { @@ -220,9 +225,12 @@ describe('MoneySentDetails', () => { expect(getByText('$34.54')).toBeOnTheScreen(); }); - it('navigates to the block explorer when the button is pressed', () => { + it('closes the sheet before navigating to the block explorer when the button is pressed', () => { const { getByText } = render(); fireEvent.press(getByText('View on block explorer')); + // The sheet must dismiss first — navigating while its transparent modal is + // still presented leaves the WebView behind it and strands the overlay. + expect(mockCloseSheet).toHaveBeenCalledTimes(1); expect(mockNavigate).toHaveBeenCalledWith( 'Webview', expect.objectContaining({ screen: 'SimpleWebview' }), diff --git a/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneySentDetails.tsx b/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneySentDetails.tsx index f1aac328b24..ea4fad4b0e8 100644 --- a/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneySentDetails.tsx +++ b/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneySentDetails.tsx @@ -79,7 +79,17 @@ function useRecipient(): Hex | undefined { }, [transactionMeta]); } -export function MoneySentDetails() { +interface MoneySentDetailsProps { + /** + * Closes the host bottom sheet and runs `navigate` once it has finished + * dismissing. Navigating while the sheet's transparent modal is still + * presented pushes the WebView behind it and strands the overlay, so the + * explorer link must defer navigation until after the sheet is gone. + */ + onCloseSheet: (navigate: () => void) => void; +} + +export function MoneySentDetails({ onCloseSheet }: MoneySentDetailsProps) { const { styles } = useStyles(styleSheet, {}); const navigation = useNavigation(); const { trackEvent, createEventBuilder } = useAnalytics(); @@ -126,15 +136,18 @@ export function MoneySentDetails() { text: strings('transaction_details.view_on_block_explorer'), url, }); - navigation.navigate(Routes.WEBVIEW.MAIN, { - screen: Routes.WEBVIEW.SIMPLE, - params: { url, title }, + onCloseSheet(() => { + navigation.navigate(Routes.WEBVIEW.MAIN, { + screen: Routes.WEBVIEW.SIMPLE, + params: { url, title }, + }); }); }, [ chainId, createEventBuilder, navigation, networkConfigurations, + onCloseSheet, trackEvent, transactionMeta, ]); diff --git a/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneyTransactionDetailsSheet.tsx b/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneyTransactionDetailsSheet.tsx index 9b17abb05c6..5417997bda3 100644 --- a/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneyTransactionDetailsSheet.tsx +++ b/app/components/UI/Money/components/MoneyTransactionDetailsSheet/MoneyTransactionDetailsSheet.tsx @@ -86,6 +86,12 @@ const MoneyTransactionDetailsSheet = () => { sheetRef.current?.onCloseBottomSheet(); }, []); + // Close the sheet first, then navigate — see `MoneySentDetailsProps.onCloseSheet`. + const closeAndNavigate = useCallback( + (navigate: () => void) => sheetRef.current?.onCloseBottomSheet(navigate), + [], + ); + return ( { {isReceived ? ( ) : isSent ? ( - + ) : ( )} diff --git a/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.pendingTransaction.test.tsx b/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.pendingTransaction.test.tsx new file mode 100644 index 00000000000..920e701d643 --- /dev/null +++ b/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.pendingTransaction.test.tsx @@ -0,0 +1,283 @@ +import React, { useEffect, useState } from 'react'; +import { act, fireEvent } from '@testing-library/react-native'; +import { + TransactionStatus, + type TransactionMeta, +} from '@metamask/transaction-controller'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import MoneyTransferSheet from './MoneyTransferSheet'; +import { MoneyTransferSheetTestIds } from './MoneyTransferSheet.testIds'; +import Routes from '../../../../../constants/navigation/Routes'; +import Engine from '../../../../../core/Engine'; +import { updateBgState } from '../../../../../core/redux/slices/engine'; +import { addTransactionBatch } from '../../../../../util/transaction-controller'; +import { useMoneyPerpsDeposit } from '../../../../Views/confirmations/hooks/pay/useMoneyPerpsDeposit'; +import { useMoneyPredictDeposit } from '../../../../Views/confirmations/hooks/pay/useMoneyPredictDeposit'; +import { useMoneyAnalytics } from '../../hooks/useMoneyAnalytics'; + +const PENDING_TX_ID = 'pending-tx-from-elsewhere'; + +const mockNavigate = jest.fn(); +let unmountSheet: () => void = () => undefined; +const mockGoBack = jest.fn(() => unmountSheet()); + +const mockVaultConfig = { + chainId: '0x8f', + boringVault: '0xb4563bcD3B7764CCBf497f515585f70B6C3EA5Ae', + tellerAddress: '0x2D49EA58A4C70b62c8B56DE971310d9e999c8117', + accountantAddress: '0x7382c5b8B51B8C4f127B3123C1039581BAA5A06B', + lensAddress: '0xA816ECd922de94c6879AD23B9A884dB257F20947', +}; + +// Stable references so react-redux's useSelector doesn't warn on every render. +const mockPrimaryMoneyAccount = { + address: '0x1111111111111111111111111111111111111111', +}; +const mockRecipient = '0x2222222222222222222222222222222222222222'; + +// Mutable mock of Engine.state.TransactionController so the test can simulate +// the TransactionController removing a transaction after it is rejected. +const mockEngineState: { + TransactionController: { transactions: TransactionMeta[] }; +} = { + TransactionController: { transactions: [] }, +}; + +jest.mock('@react-navigation/native', () => { + const actual = jest.requireActual('@react-navigation/native'); + return { + ...actual, + useNavigation: () => ({ navigate: mockNavigate, goBack: mockGoBack }), + }; +}); + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + const { forwardRef, useImperativeHandle } = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + const MockBottomSheet = forwardRef( + ( + { + children, + testID, + goBack, + }: { + children: React.ReactNode; + testID?: string; + goBack?: () => void; + }, + ref: React.Ref<{ onCloseBottomSheet: (cb?: () => void) => void }>, + ) => { + useImperativeHandle( + ref, + () => ({ + onCloseBottomSheet: (cb?: () => void) => { + goBack?.(); + cb?.(); + }, + onOpenBottomSheet: jest.fn(), + }), + [goBack], + ); + return {children}; + }, + ); + const MockBottomSheetHeader = ({ + children, + }: { + children: React.ReactNode; + }) => {children}; + return { + ...actual, + BottomSheet: MockBottomSheet, + BottomSheetHeader: MockBottomSheetHeader, + }; +}); + +// Keep the real `useMoneyAccountWithdrawal` + `useConfirmNavigation`; stub only +// the heavy withdrawal-building internals so `initiateWithdrawal` reaches +// navigation. +jest.mock('../../utils/moneyAccountTransactions', () => ({ + buildMoneyAccountWithdrawBatch: jest.fn().mockResolvedValue({ + withdrawTx: { to: '0xwithdraw', data: '0x', value: '0x0' }, + transferTx: { to: '0xtransfer', data: '0x', value: '0x0' }, + }), + getMoneyAccountDepositAssetAddress: jest.fn(() => '0xasset'), +})); + +jest.mock('../../../../../util/notifications/methods/common', () => ({ + getProviderByChainId: jest.fn(() => ({})), +})); + +jest.mock('../../../../../util/transaction-controller', () => ({ + addTransactionBatch: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('../../../../../core/Engine', () => ({ + context: { + NetworkController: { + findNetworkClientIdByChainId: jest.fn(() => 'network-client-1'), + }, + ApprovalController: { rejectRequest: jest.fn() }, + }, + + get state() { + return mockEngineState; + }, +})); + +jest.mock( + '../../../../../selectors/featureFlagController/moneyAccount', + () => ({ + ...jest.requireActual( + '../../../../../selectors/featureFlagController/moneyAccount', + ), + selectMoneyAccountVaultConfig: jest.fn(() => mockVaultConfig), + }), +); + +jest.mock('../../../../../selectors/moneyAccountController', () => ({ + ...jest.requireActual('../../../../../selectors/moneyAccountController'), + selectPrimaryMoneyAccount: jest.fn(() => mockPrimaryMoneyAccount), +})); + +jest.mock('../../../../../selectors/accountsController', () => ({ + ...jest.requireActual('../../../../../selectors/accountsController'), + selectEvmAddress: jest.fn(() => mockRecipient), +})); + +// Other transfer options are out of scope here; disable them. +jest.mock( + '../../../../Views/confirmations/hooks/pay/useMoneyPerpsDeposit', + () => ({ useMoneyPerpsDeposit: jest.fn() }), +); +jest.mock( + '../../../../Views/confirmations/hooks/pay/useMoneyPredictDeposit', + () => ({ useMoneyPredictDeposit: jest.fn() }), +); +jest.mock('../../hooks/useMoneyAnalytics', () => ({ + useMoneyAnalytics: jest.fn(), +})); + +// Wrapper that unmounts the sheet when `goBack` runs — modelling the modal +// being popped on close, which is what tears the hook down in production. +const SheetHarness = () => { + const [open, setOpen] = useState(true); + useEffect(() => { + unmountSheet = () => setOpen(false); + return () => { + unmountSheet = () => undefined; + }; + }, []); + return open ? : null; +}; + +function renderHarness(pendingTransactions: TransactionMeta[]) { + return renderWithProvider(, { + state: { + engine: { + backgroundState: { + TransactionController: { transactions: pendingTransactions }, + }, + }, + }, + }); +} + +const flushAsync = async () => { + await act(async () => { + await Promise.resolve(); + }); +}; + +/** + * Regression test for MUSD-975: tapping "Another account" (Between accounts) + * while a stale unapproved transaction was pending created a second stuck + * pending transaction and never showed the Send flow, because closing the sheet + * unmounted the component before the deferred navigation could run. + */ +describe('MoneyTransferSheet — Between accounts with a pending transaction', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockEngineState.TransactionController = { transactions: [] }; + (useMoneyPerpsDeposit as jest.Mock).mockReturnValue({ + isEnabled: false, + initiatePerpsDeposit: jest.fn(), + }); + (useMoneyPredictDeposit as jest.Mock).mockReturnValue({ + isEnabled: false, + initiatePredictDeposit: jest.fn(), + }); + (useMoneyAnalytics as jest.Mock).mockReturnValue({ + trackBottomSheetViewed: jest.fn(), + trackSurfaceClicked: jest.fn(), + }); + }); + + it('navigates to the Send flow even when an unconfirmed transaction is already pending', async () => { + const pendingTx = { + id: PENDING_TX_ID, + status: TransactionStatus.unapproved, + } as TransactionMeta; + + const { getByTestId, store } = renderHarness([pendingTx]); + + fireEvent.press( + getByTestId(MoneyTransferSheetTestIds.BETWEEN_ACCOUNTS_OPTION), + ); + await flushAsync(); + + // The pre-existing pending transaction is rejected straight away. + expect( + jest.mocked(Engine.context.ApprovalController.rejectRequest), + ).toHaveBeenCalledWith(PENDING_TX_ID, expect.anything()); + + // Closing + navigating is deferred until the rejection clears from state, + // so the sheet stays mounted: it has not been closed and nothing has + // navigated yet. This is what keeps the navigation alive. + expect(mockGoBack).not.toHaveBeenCalled(); + expect(addTransactionBatch).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + + // Simulate the TransactionController removing the rejected transaction. + mockEngineState.TransactionController = { transactions: [] }; + await act(async () => { + store.dispatch(updateBgState({ key: 'TransactionController' })); + }); + await flushAsync(); + + // Now that nothing is pending, the sheet closes (modal popped) and the + // withdrawal flow runs in one step — the user reaches the Send + // confirmation instead of being stranded with a stuck pending transaction. + expect(mockGoBack).toHaveBeenCalledTimes(1); + expect(addTransactionBatch).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith( + Routes.MONEY.CONFIRMATIONS_ROOT, + expect.objectContaining({ + screen: Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS, + }), + ); + }); + + it('navigates to the Send flow when no transaction is pending (control)', async () => { + const { getByTestId } = renderHarness([]); + + fireEvent.press( + getByTestId(MoneyTransferSheetTestIds.BETWEEN_ACCOUNTS_OPTION), + ); + await flushAsync(); + + // Nothing to reject, so the sheet closes and navigates immediately. + expect( + jest.mocked(Engine.context.ApprovalController.rejectRequest), + ).not.toHaveBeenCalled(); + expect(mockGoBack).toHaveBeenCalledTimes(1); + expect(addTransactionBatch).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith( + Routes.MONEY.CONFIRMATIONS_ROOT, + expect.objectContaining({ + screen: Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS, + }), + ); + }); +}); diff --git a/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.test.tsx b/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.test.tsx index 9b3b306d6cc..b7a6ea5c49b 100644 --- a/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.test.tsx +++ b/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.test.tsx @@ -21,6 +21,11 @@ jest.mock('../../hooks/useMoneyAnalytics', () => ({ useMoneyAnalytics: jest.fn(), })); +jest.mock('../../../../../selectors/transactionController', () => ({ + ...jest.requireActual('../../../../../selectors/transactionController'), + selectTransactions: jest.fn(() => []), +})); + const mockInitiateWithdrawal = jest.fn().mockResolvedValue(undefined); const mockInitiatePerpsDeposit = jest.fn().mockResolvedValue(undefined); const mockInitiatePredictDeposit = jest.fn().mockResolvedValue(undefined); diff --git a/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.tsx b/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.tsx index 88648169fd8..b11a14454c2 100644 --- a/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.tsx +++ b/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.tsx @@ -1,6 +1,14 @@ -import React, { useCallback, useRef } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { View } from 'react-native'; +import { useSelector } from 'react-redux'; import { useNavigation } from '@react-navigation/native'; +import { TransactionStatus } from '@metamask/transaction-controller'; import { BottomSheet, BottomSheetHeader, @@ -11,6 +19,8 @@ import { } from '@metamask/design-system-react-native'; import { strings } from '../../../../../../locales/i18n'; import { useStyles } from '../../../../../component-library/hooks'; +import { selectTransactions } from '../../../../../selectors/transactionController'; +import { rejectPendingTransactions } from '../../utils/rejectPendingTransactions'; import { useMoneyAccountWithdrawal } from '../../hooks/useMoneyAccount'; import { useMoneyPerpsDeposit } from '../../../../Views/confirmations/hooks/pay/useMoneyPerpsDeposit'; import { useMoneyPredictDeposit } from '../../../../Views/confirmations/hooks/pay/useMoneyPredictDeposit'; @@ -29,6 +39,8 @@ import { import { useMoneyAnalytics } from '../../hooks/useMoneyAnalytics'; import useMountEffect from '../../hooks/useMountEffect'; +type TransferAction = 'withdraw' | 'perps' | 'predict'; + const MoneyTransferSheet = () => { const sheetRef = useRef(null); const navigation = useNavigation(); @@ -46,6 +58,71 @@ const MoneyTransferSheet = () => { useMountEffect(trackBottomSheetViewed); + const transactions = useSelector(selectTransactions); + + const hasPendingTransaction = useMemo( + () => + (transactions ?? []).some( + (tx) => tx.status === TransactionStatus.unapproved, + ), + [transactions], + ); + + const [deferredAction, setDeferredAction] = useState( + null, + ); + + // Close the sheet (which pops the modal) and kick off the transfer in one + // atomic step, so the confirmation slides straight over the sheet rather than + // flashing back to Money home in between. We resolve the initiator here rather + // than capturing it earlier so a deferred run uses the up-to-date navigation + // closure (which no longer sees the just-rejected transaction), not a stale + // snapshot. + const closeAndStart = useCallback( + (action: TransferAction) => { + let initiate = initiateWithdrawal; + if (action === 'perps') { + initiate = initiatePerpsDeposit; + } else if (action === 'predict') { + initiate = initiatePredictDeposit; + } + sheetRef.current?.onCloseBottomSheet(() => { + initiate().catch((error: Error) => { + Logger.error( + error, + '[MoneyTransferSheet] Transfer initiation failed', + ); + }); + }); + }, + [initiateWithdrawal, initiatePerpsDeposit, initiatePredictDeposit], + ); + + // A leftover unapproved transaction would be picked up by the confirmation + // screen, so we reject it before opening a fresh one. Rejection clears from + // state asynchronously and closing the sheet unmounts us, so when something is + // pending we stay mounted and defer the close+navigate (see effect) instead of + // letting it race the unmount. + const startAction = useCallback( + (action: TransferAction) => { + if (hasPendingTransaction) { + rejectPendingTransactions(transactions ?? []); + setDeferredAction(action); + return; + } + closeAndStart(action); + }, + [hasPendingTransaction, transactions, closeAndStart], + ); + + useEffect(() => { + if (!deferredAction || hasPendingTransaction) { + return; + } + closeAndStart(deferredAction); + setDeferredAction(null); + }, [deferredAction, hasPendingTransaction, closeAndStart]); + const handleGoBack = useCallback(() => { navigation.goBack(); }, [navigation]); @@ -57,15 +134,8 @@ const MoneyTransferSheet = () => { redirect_target: SCREEN_NAMES.MONEY_TRANSFER, }); - sheetRef.current?.onCloseBottomSheet(() => { - initiateWithdrawal().catch((error: Error) => { - Logger.error( - error, - '[MoneyTransferSheet] Withdrawal initiation failed', - ); - }); - }); - }, [initiateWithdrawal, trackSurfaceClicked]); + startAction('withdraw'); + }, [startAction, trackSurfaceClicked]); const handlePerpsAccount = useCallback(() => { trackSurfaceClicked({ @@ -73,10 +143,8 @@ const MoneyTransferSheet = () => { redirect_target: SCREEN_NAMES.MONEY_TRANSFER, }); - sheetRef.current?.onCloseBottomSheet(() => { - initiatePerpsDeposit(); - }); - }, [initiatePerpsDeposit, trackSurfaceClicked]); + startAction('perps'); + }, [startAction, trackSurfaceClicked]); const handlePredictionsAccount = useCallback(() => { trackSurfaceClicked({ @@ -85,10 +153,8 @@ const MoneyTransferSheet = () => { redirect_target: SCREEN_NAMES.MONEY_TRANSFER, }); - sheetRef.current?.onCloseBottomSheet(() => { - initiatePredictDeposit(); - }); - }, [initiatePredictDeposit, trackSurfaceClicked]); + startAction('predict'); + }, [startAction, trackSurfaceClicked]); const options: MoneySheetOption[] = [ { diff --git a/app/components/UI/Money/hooks/useMoneyToasts.test.tsx b/app/components/UI/Money/hooks/useMoneyToasts.test.tsx index 48b6baa9f63..0ec9d9588c2 100644 --- a/app/components/UI/Money/hooks/useMoneyToasts.test.tsx +++ b/app/components/UI/Money/hooks/useMoneyToasts.test.tsx @@ -104,6 +104,11 @@ describe('useMoneyToasts', () => { ).toBeDefined(); expect(result.current.MoneyToastOptions.withdraw.success).toBeDefined(); expect(result.current.MoneyToastOptions.withdraw.failed).toBeDefined(); + + expect(result.current.MoneyToastOptions.send).toBeDefined(); + expect(result.current.MoneyToastOptions.send.inProgress).toBeDefined(); + expect(result.current.MoneyToastOptions.send.success).toBeDefined(); + expect(result.current.MoneyToastOptions.send.failed).toBeDefined(); }); }); @@ -286,6 +291,75 @@ describe('useMoneyToasts', () => { }); }); + describe('send toasts', () => { + it('inProgress mirrors the in-progress configuration', () => { + const { result } = renderHook(() => useMoneyToasts(), { wrapper }); + + const toast = result.current.MoneyToastOptions.send.inProgress(); + + expect(toast.variant).toBe(ToastVariants.Icon); + expect(toast.iconName).toBe(IconName.Loading); + expect(toast.hapticsType).toBe(NotificationMoment.Warning); + expect(toast.hasNoTimeout).toBe(true); + }); + + it('inProgress title/body is "Sending funds" / "This may take a few minutes."', () => { + const { result } = renderHook(() => useMoneyToasts(), { wrapper }); + + const toast = result.current.MoneyToastOptions.send.inProgress(); + + expect(toast.labelOptions?.[0].label).toBe('Sending funds'); + const secondary = toast.labelOptions?.[2].label as React.ReactElement<{ + children?: React.ReactNode; + }>; + expect(secondary.props.children).toBe('This may take a few minutes.'); + }); + + it('success title/body interpolates the amount and destination', () => { + const { result } = renderHook(() => useMoneyToasts(), { wrapper }); + + const toast = result.current.MoneyToastOptions.send.success({ + amountFiat: '$50.00', + destination: 'Perps', + }); + + expect(toast.iconName).toBe(IconName.Confirmation); + expect(toast.hapticsType).toBe(NotificationMoment.Success); + expect(toast.labelOptions?.[0].label).toBe('Funds sent'); + const secondary = toast.labelOptions?.[2].label as React.ReactElement<{ + children?: React.ReactNode; + }>; + expect(secondary.props.children).toBe('$50.00 is available in Perps.'); + }); + + it('success body falls back to "Available in {{destination}}." when amount is missing', () => { + const { result } = renderHook(() => useMoneyToasts(), { wrapper }); + + const toast = result.current.MoneyToastOptions.send.success({ + destination: 'Predict', + }); + + const secondary = toast.labelOptions?.[2].label as React.ReactElement<{ + children?: React.ReactNode; + }>; + expect(secondary.props.children).toBe('Available in Predict.'); + }); + + it('failed title/body is "Send failed" / "Unable to send funds. Try again."', () => { + const { result } = renderHook(() => useMoneyToasts(), { wrapper }); + + const toast = result.current.MoneyToastOptions.send.failed(); + + expect(toast.iconName).toBe(IconName.CircleX); + expect(toast.hapticsType).toBe(NotificationMoment.Error); + expect(toast.labelOptions?.[0].label).toBe('Send failed'); + const secondary = toast.labelOptions?.[2].label as React.ReactElement<{ + children?: React.ReactNode; + }>; + expect(secondary.props.children).toBe('Unable to send funds. Try again.'); + }); + }); + describe('closeButtonOptions', () => { it.each([ ['deposit.inProgress', () => ({}), 'inProgress'], @@ -298,10 +372,17 @@ describe('useMoneyToasts', () => { 'success', ], ['withdraw.failed', () => ({}), 'failed'], + ['send.inProgress', () => ({}), 'inProgress'], + [ + 'send.success', + () => ({ amountFiat: '$1.00', destination: 'Perps' }), + 'success', + ], + ['send.failed', () => ({}), 'failed'], ])('exposes a Close button on %s', (key, paramsFactory, _builder) => { const { result } = renderHook(() => useMoneyToasts(), { wrapper }); const [namespace, builder] = key.split('.') as [ - 'deposit' | 'withdraw', + 'deposit' | 'withdraw' | 'send', 'inProgress' | 'success' | 'failed', ]; diff --git a/app/components/UI/Money/hooks/useMoneyToasts.tsx b/app/components/UI/Money/hooks/useMoneyToasts.tsx index 29e751a58ef..960a9f00a69 100644 --- a/app/components/UI/Money/hooks/useMoneyToasts.tsx +++ b/app/components/UI/Money/hooks/useMoneyToasts.tsx @@ -56,6 +56,11 @@ export interface WithdrawSuccessParams { destination: string; } +export interface SendSuccessParams { + amountFiat?: string; + destination: string; +} + export interface MoneyToastOptionsConfig { deposit: { inProgress: (params?: DepositInProgressParams) => MoneyToastOptions; @@ -67,6 +72,11 @@ export interface MoneyToastOptionsConfig { success: (params: WithdrawSuccessParams) => MoneyToastOptions; failed: () => MoneyToastOptions; }; + send: { + inProgress: () => MoneyToastOptions; + success: (params: SendSuccessParams) => MoneyToastOptions; + failed: () => MoneyToastOptions; + }; } interface MoneyToastLabelOptions { @@ -174,8 +184,27 @@ const useMoneyToasts = (): { [toastRef], ); - const MoneyToastOptions: MoneyToastOptionsConfig = useMemo( - () => ({ + const MoneyToastOptions: MoneyToastOptionsConfig = useMemo(() => { + const buildSendToast = ( + base: MoneyToastOptions, + primaryKey: string, + secondaryKey: string, + secondaryParams?: Record, + ): MoneyToastOptions => ({ + ...base, + labelOptions: getMoneyToastLabels({ + primary: strings(primaryKey), + primaryIsBold: true, + secondary: ( + + {strings(secondaryKey, secondaryParams)} + + ), + }), + closeButtonOptions, + }); + + return { deposit: { inProgress: (params?: DepositInProgressParams) => ({ ...moneyBaseToastOptions.inProgress, @@ -303,14 +332,41 @@ const useMoneyToasts = (): { closeButtonOptions, }), }, - }), - [ - closeButtonOptions, - moneyBaseToastOptions.error, - moneyBaseToastOptions.inProgress, - moneyBaseToastOptions.success, - ], - ); + send: { + inProgress: () => + buildSendToast( + moneyBaseToastOptions.inProgress, + 'money.toasts.send_in_progress_title', + 'money.toasts.in_progress_body', + ), + success: ({ amountFiat, destination }: SendSuccessParams) => + amountFiat + ? buildSendToast( + moneyBaseToastOptions.success, + 'money.toasts.send_success_title', + 'money.toasts.send_success_body', + { amount: amountFiat, destination }, + ) + : buildSendToast( + moneyBaseToastOptions.success, + 'money.toasts.send_success_title', + 'money.toasts.send_success_body_no_amount', + { destination }, + ), + failed: () => + buildSendToast( + moneyBaseToastOptions.error, + 'money.toasts.send_failed_title', + 'money.toasts.send_failed_body', + ), + }, + }; + }, [ + closeButtonOptions, + moneyBaseToastOptions.error, + moneyBaseToastOptions.inProgress, + moneyBaseToastOptions.success, + ]); return { showToast, MoneyToastOptions }; }; diff --git a/app/components/UI/Money/hooks/useMoneyTransactionStatus.test.ts b/app/components/UI/Money/hooks/useMoneyTransactionStatus.test.ts index 6b4b8ae5fff..6d6ccbb67c0 100644 --- a/app/components/UI/Money/hooks/useMoneyTransactionStatus.test.ts +++ b/app/components/UI/Money/hooks/useMoneyTransactionStatus.test.ts @@ -1,4 +1,5 @@ import { + CHAIN_IDS, TransactionMeta, TransactionStatus, TransactionType, @@ -203,6 +204,18 @@ describe('useMoneyTransactionStatus', () => { ReturnType, Parameters >(() => baseFailedToast); + const sendInProgressFn = jest.fn< + ReturnType, + Parameters + >(() => baseInProgressToast); + const sendSuccessFn = jest.fn< + ReturnType, + Parameters + >(() => baseSuccessToast); + const sendFailedFn = jest.fn< + ReturnType, + Parameters + >(() => baseFailedToast); const moneyToastOptions: MoneyToastOptionsConfig = { deposit: { @@ -215,6 +228,11 @@ describe('useMoneyTransactionStatus', () => { success: withdrawSuccessFn, failed: withdrawFailedFn, }, + send: { + inProgress: sendInProgressFn, + success: sendSuccessFn, + failed: sendFailedFn, + }, }; beforeEach(() => { @@ -744,6 +762,129 @@ describe('useMoneyTransactionStatus', () => { }); }); + describe('perps/predict send lifecycle', () => { + const buildSendTxMeta = ( + type: TransactionType, + overrides: Partial = {}, + ): TransactionMeta => + buildTxMeta({ + id: 'send-tx-1', + type, + metamaskPay: { + tokenAddress: MUSD_ADDRESS, + chainId: CHAIN_IDS.MONAD, + totalFiat: '100', + }, + ...overrides, + } as unknown as Partial); + + it('approved → send in-progress toast (after deferral), not deposit/withdraw', () => { + const { statusUpdatedHandler } = renderAndGetHandlers(); + + statusUpdatedHandler({ + transactionMeta: buildSendTxMeta(TransactionType.perpsDeposit, { + status: TransactionStatus.approved, + }), + }); + + expect(sendInProgressFn).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(IN_PROGRESS_DELAY_MS); + + expect(sendInProgressFn).toHaveBeenCalledTimes(1); + expect(depositInProgressFn).not.toHaveBeenCalled(); + expect(withdrawInProgressFn).not.toHaveBeenCalled(); + }); + + it('confirmed → send success toast with fiat amount and "Perps" destination', () => { + const { confirmedHandler } = renderAndGetHandlers(); + + confirmedHandler( + buildSendTxMeta(TransactionType.perpsDeposit, { + status: TransactionStatus.confirmed, + }), + ); + + expect(sendSuccessFn).toHaveBeenCalledTimes(1); + const params = sendSuccessFn.mock.calls[0][0]; + expect(params.amountFiat).toContain('100'); + expect(params.destination).toBe('Perps'); + expect(depositSuccessFn).not.toHaveBeenCalled(); + expect(withdrawSuccessFn).not.toHaveBeenCalled(); + }); + + it('confirmed predict deposit → "Predict" destination', () => { + const { confirmedHandler } = renderAndGetHandlers(); + + confirmedHandler( + buildSendTxMeta(TransactionType.predictDeposit, { + status: TransactionStatus.confirmed, + }), + ); + + expect(sendSuccessFn).toHaveBeenCalledTimes(1); + expect(sendSuccessFn.mock.calls[0][0].destination).toBe('Predict'); + }); + + it('confirmed with missing/zero fiat → success without amount', () => { + const { confirmedHandler } = renderAndGetHandlers(); + + confirmedHandler( + buildSendTxMeta(TransactionType.perpsDeposit, { + status: TransactionStatus.confirmed, + metamaskPay: { + tokenAddress: MUSD_ADDRESS, + chainId: CHAIN_IDS.MONAD, + totalFiat: '0', + }, + } as unknown as Partial), + ); + + expect(sendSuccessFn).toHaveBeenCalledTimes(1); + expect(sendSuccessFn.mock.calls[0][0].amountFiat).toBeUndefined(); + }); + + it.each([ + ['failed', TransactionStatus.failed], + ['dropped', TransactionStatus.dropped], + ['cancelled', TransactionStatus.cancelled], + ])('statusUpdated with %s → send failed toast', (_label, status) => { + const { statusUpdatedHandler } = renderAndGetHandlers(); + + statusUpdatedHandler({ + transactionMeta: buildSendTxMeta(TransactionType.perpsDeposit, { + status, + }), + }); + + expect(sendFailedFn).toHaveBeenCalledTimes(1); + expect(depositFailedFn).not.toHaveBeenCalled(); + expect(withdrawFailedFn).not.toHaveBeenCalled(); + expect(clearMoneyAccountDepositIntent).not.toHaveBeenCalled(); + }); + + it('ignores a perps deposit not funded with mUSD on the Money chain', () => { + const { statusUpdatedHandler } = renderAndGetHandlers(); + + statusUpdatedHandler({ + transactionMeta: buildTxMeta({ + id: 'send-tx-other', + type: TransactionType.perpsDeposit, + status: TransactionStatus.approved, + metamaskPay: { + tokenAddress: MUSD_ADDRESS, + chainId: '0x1', + totalFiat: '100', + }, + } as unknown as Partial), + }); + jest.advanceTimersByTime(IN_PROGRESS_DELAY_MS); + + expect(sendInProgressFn).not.toHaveBeenCalled(); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + }); + describe('dedup + cleanup', () => { it('does not fire the same status+id toast twice', () => { const { statusUpdatedHandler } = renderAndGetHandlers(); diff --git a/app/components/UI/Money/hooks/useMoneyTransactionStatus.ts b/app/components/UI/Money/hooks/useMoneyTransactionStatus.ts index fde71e2ee35..42fc98ebc1b 100644 --- a/app/components/UI/Money/hooks/useMoneyTransactionStatus.ts +++ b/app/components/UI/Money/hooks/useMoneyTransactionStatus.ts @@ -34,7 +34,9 @@ import { TELLER_ABI } from '../utils/moneyAccountTransactions'; import { isMoneyAccountTx, isMoneyDepositTx, + isPerpsPredictMoneyDeposit, nestedTxWithType, + perpsPredictServiceFamily, } from '../utils/moneyTransactionGuards'; import useMoneyToasts from './useMoneyToasts'; import { @@ -189,12 +191,15 @@ export const useMoneyTransactionStatus = () => { }; const showInProgressFor = (transactionMeta: TransactionMeta) => { - if (!isMoneyAccountTx(transactionMeta)) return; + const isSend = isPerpsPredictMoneyDeposit(transactionMeta); + if (!isMoneyAccountTx(transactionMeta) && !isSend) return; if (!reserveToastKey(transactionMeta.id, IN_PROGRESS_KEY)) return; if (pendingInProgress.has(transactionMeta.id)) return; const timeoutId = setTimeout(() => { pendingInProgress.delete(transactionMeta.id); - if (isMoneyDepositTx(transactionMeta)) { + if (isSend) { + showToast(MoneyToastOptions.send.inProgress()); + } else if (isMoneyDepositTx(transactionMeta)) { const intent = getMoneyAccountDepositIntent(transactionMeta.batchId); showToast(MoneyToastOptions.deposit.inProgress({ intent })); } else { @@ -205,10 +210,13 @@ export const useMoneyTransactionStatus = () => { }; const showFailedFor = (transactionMeta: TransactionMeta) => { - if (!isMoneyAccountTx(transactionMeta)) return; + const isSend = isPerpsPredictMoneyDeposit(transactionMeta); + if (!isMoneyAccountTx(transactionMeta) && !isSend) return; cancelPendingInProgress(transactionMeta.id); if (!reserveToastKey(transactionMeta.id, FAILED_KEY)) return; - if (isMoneyDepositTx(transactionMeta)) { + if (isSend) { + showToast(MoneyToastOptions.send.failed()); + } else if (isMoneyDepositTx(transactionMeta)) { const intent = getMoneyAccountDepositIntent(transactionMeta.batchId); showToast(MoneyToastOptions.deposit.failed({ intent })); clearMoneyAccountDepositIntent(transactionMeta.batchId); @@ -219,10 +227,31 @@ export const useMoneyTransactionStatus = () => { }; const showConfirmedFor = (transactionMeta: TransactionMeta) => { - if (!isMoneyAccountTx(transactionMeta)) return; + const isSend = isPerpsPredictMoneyDeposit(transactionMeta); + if (!isMoneyAccountTx(transactionMeta) && !isSend) return; cancelPendingInProgress(transactionMeta.id); if (!reserveToastKey(transactionMeta.id, CONFIRMED_KEY)) return; + if (isSend) { + const fiat = Number(transactionMeta.metamaskPay?.totalFiat); + const amountFiat = + !Number.isNaN(fiat) && fiat > 0 + ? moneyFormatFiat( + new BigNumber(fiat), + selectCurrentCurrency(store.getState()), + ) + : undefined; + const family = perpsPredictServiceFamily(transactionMeta); + const destination = strings( + family === 'predict' + ? 'money.toasts.send_destination_predict' + : 'money.toasts.send_destination_perps', + ); + showToast(MoneyToastOptions.send.success({ amountFiat, destination })); + scheduleCleanup(transactionMeta.id, CONFIRMED_KEY); + return; + } + const depositNested = nestedTxWithType( transactionMeta, TransactionType.moneyAccountDeposit, @@ -312,5 +341,10 @@ export const useMoneyTransactionStatus = () => { pendingCleanups.forEach((timeoutId) => clearTimeout(timeoutId)); pendingCleanups.clear(); }; - }, [MoneyToastOptions.deposit, MoneyToastOptions.withdraw, showToast]); + }, [ + MoneyToastOptions.deposit, + MoneyToastOptions.withdraw, + MoneyToastOptions.send, + showToast, + ]); }; diff --git a/app/components/UI/Money/utils/moneyMetaMaskCardMode.test.ts b/app/components/UI/Money/utils/moneyMetaMaskCardMode.test.ts new file mode 100644 index 00000000000..b37cfe772fc --- /dev/null +++ b/app/components/UI/Money/utils/moneyMetaMaskCardMode.test.ts @@ -0,0 +1,130 @@ +import { deriveMoneyMetaMaskCardMode } from './moneyMetaMaskCardMode'; + +const baseInput = { + isCardLinkedToMoneyAccount: false, + isCardholder: false, + isCardAuthenticated: false, + isCardVerified: false, + isResidencyBlocked: false, + hasMoneyAccountBaseRequirements: true, + hasMoneyAccountRequirements: true, +}; + +describe('deriveMoneyMetaMaskCardMode', () => { + it('returns manage when the card is linked to the Money account', () => { + expect( + deriveMoneyMetaMaskCardMode({ + ...baseInput, + isCardLinkedToMoneyAccount: true, + }), + ).toBe('manage'); + }); + + it('returns link for an unauthenticated cardholder when base requirements are met', () => { + expect( + deriveMoneyMetaMaskCardMode({ + ...baseInput, + isCardholder: true, + isCardAuthenticated: false, + hasMoneyAccountBaseRequirements: true, + }), + ).toBe('link'); + }); + + it('returns null for an unauthenticated cardholder when base requirements are not met', () => { + expect( + deriveMoneyMetaMaskCardMode({ + ...baseInput, + isCardholder: true, + isCardAuthenticated: false, + hasMoneyAccountBaseRequirements: false, + }), + ).toBeNull(); + }); + + it('returns null when residency is blocked', () => { + expect( + deriveMoneyMetaMaskCardMode({ + ...baseInput, + isCardholder: true, + isCardAuthenticated: true, + isCardVerified: true, + isResidencyBlocked: true, + }), + ).toBeNull(); + }); + + it('returns link for a cardholder when full requirements are met', () => { + expect( + deriveMoneyMetaMaskCardMode({ + ...baseInput, + isCardholder: true, + isCardAuthenticated: true, + isCardVerified: true, + hasMoneyAccountRequirements: true, + }), + ).toBe('link'); + }); + + it('returns null for a cardholder when full requirements are not met', () => { + expect( + deriveMoneyMetaMaskCardMode({ + ...baseInput, + isCardholder: true, + isCardAuthenticated: true, + isCardVerified: true, + hasMoneyAccountRequirements: false, + }), + ).toBeNull(); + }); + + it('returns link for a verified authenticated non-cardholder when full requirements are met', () => { + expect( + deriveMoneyMetaMaskCardMode({ + ...baseInput, + isCardAuthenticated: true, + isCardVerified: true, + hasMoneyAccountRequirements: true, + }), + ).toBe('link'); + }); + + it('returns verifying for an authenticated but unverified non-cardholder', () => { + expect( + deriveMoneyMetaMaskCardMode({ + ...baseInput, + isCardAuthenticated: true, + isCardVerified: false, + }), + ).toBe('verifying'); + }); + + it('returns upsell for a brand-new user', () => { + expect(deriveMoneyMetaMaskCardMode(baseInput)).toBe('upsell'); + }); + + it('prefers manage over all other states when the card is linked', () => { + expect( + deriveMoneyMetaMaskCardMode({ + ...baseInput, + isCardLinkedToMoneyAccount: true, + isCardholder: true, + isCardAuthenticated: true, + isCardVerified: false, + isResidencyBlocked: true, + }), + ).toBe('manage'); + }); + + it('prefers unauthenticated cardholder link over residency block', () => { + expect( + deriveMoneyMetaMaskCardMode({ + ...baseInput, + isCardholder: true, + isCardAuthenticated: false, + isResidencyBlocked: true, + hasMoneyAccountBaseRequirements: true, + }), + ).toBe('link'); + }); +}); diff --git a/app/components/UI/Money/utils/moneyMetaMaskCardMode.ts b/app/components/UI/Money/utils/moneyMetaMaskCardMode.ts new file mode 100644 index 00000000000..e38a103b108 --- /dev/null +++ b/app/components/UI/Money/utils/moneyMetaMaskCardMode.ts @@ -0,0 +1,43 @@ +export type MoneyMetaMaskCardMode = 'upsell' | 'link' | 'manage' | 'verifying'; + +export interface MoneyMetaMaskCardModeInput { + isCardLinkedToMoneyAccount: boolean; + isCardholder: boolean; + isCardAuthenticated: boolean; + isCardVerified: boolean; + isResidencyBlocked: boolean; + hasMoneyAccountBaseRequirements: boolean; + hasMoneyAccountRequirements: boolean; +} + +export const deriveMoneyMetaMaskCardMode = ({ + isCardLinkedToMoneyAccount, + isCardholder, + isCardAuthenticated, + isCardVerified, + isResidencyBlocked, + hasMoneyAccountBaseRequirements, + hasMoneyAccountRequirements, +}: MoneyMetaMaskCardModeInput): MoneyMetaMaskCardMode | null => { + // Card already linked to the Money account -> manage it. + if (isCardLinkedToMoneyAccount) return 'manage'; + + // Cardholder who hasn't authenticated yet -> offer to link (needs base reqs). + if (isCardholder && !isCardAuthenticated) { + return hasMoneyAccountBaseRequirements ? 'link' : null; + } + + // Residency-blocked users can't link. + if (isResidencyBlocked) return null; + + // Cardholder or a verified authenticated user -> offer to link (needs full reqs). + if (isCardholder || (isCardAuthenticated && isCardVerified)) { + return hasMoneyAccountRequirements ? 'link' : null; + } + + // Authenticated but identity not yet verified -> verification in progress. + if (isCardAuthenticated) return 'verifying'; + + // Brand-new user -> upsell. + return 'upsell'; +}; diff --git a/app/components/UI/Money/utils/rejectPendingTransactions.test.ts b/app/components/UI/Money/utils/rejectPendingTransactions.test.ts new file mode 100644 index 00000000000..3d4a600c510 --- /dev/null +++ b/app/components/UI/Money/utils/rejectPendingTransactions.test.ts @@ -0,0 +1,76 @@ +import { + TransactionStatus, + type TransactionMeta, +} from '@metamask/transaction-controller'; +import { providerErrors } from '@metamask/rpc-errors'; +import { rejectPendingTransactions } from './rejectPendingTransactions'; +import Engine from '../../../../core/Engine'; +import Logger from '../../../../util/Logger'; + +jest.mock('../../../../core/Engine', () => ({ + context: { + ApprovalController: { rejectRequest: jest.fn() }, + }, +})); + +jest.mock('../../../../util/Logger', () => ({ + error: jest.fn(), +})); + +const mockRejectRequest = jest.mocked( + Engine.context.ApprovalController.rejectRequest, +); + +const tx = (id: string, status: TransactionStatus): TransactionMeta => + ({ id, status }) as TransactionMeta; + +describe('rejectPendingTransactions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('rejects only unapproved transactions', () => { + rejectPendingTransactions([ + tx('a', TransactionStatus.unapproved), + tx('b', TransactionStatus.confirmed), + tx('c', TransactionStatus.submitted), + tx('d', TransactionStatus.unapproved), + ]); + + expect(mockRejectRequest).toHaveBeenCalledTimes(2); + expect(mockRejectRequest).toHaveBeenCalledWith( + 'a', + providerErrors.userRejectedRequest(), + ); + expect(mockRejectRequest).toHaveBeenCalledWith( + 'd', + providerErrors.userRejectedRequest(), + ); + }); + + it('does nothing when there are no unapproved transactions', () => { + rejectPendingTransactions([tx('a', TransactionStatus.confirmed)]); + + expect(mockRejectRequest).not.toHaveBeenCalled(); + expect(Logger.error).not.toHaveBeenCalled(); + }); + + it('logs and continues when a rejection throws', () => { + const error = new Error('boom'); + mockRejectRequest.mockImplementationOnce(() => { + throw error; + }); + + rejectPendingTransactions([ + tx('a', TransactionStatus.unapproved), + tx('b', TransactionStatus.unapproved), + ]); + + expect(Logger.error).toHaveBeenCalledWith( + error, + 'Failed to reject pending transaction', + ); + // The throw on the first tx does not stop the second from being rejected. + expect(mockRejectRequest).toHaveBeenCalledTimes(2); + }); +}); diff --git a/app/components/UI/Money/utils/rejectPendingTransactions.ts b/app/components/UI/Money/utils/rejectPendingTransactions.ts new file mode 100644 index 00000000000..2d43c98fe1a --- /dev/null +++ b/app/components/UI/Money/utils/rejectPendingTransactions.ts @@ -0,0 +1,31 @@ +import { providerErrors } from '@metamask/rpc-errors'; +import { + TransactionStatus, + type TransactionMeta, +} from '@metamask/transaction-controller'; +import Engine from '../../../../core/Engine'; +import Logger from '../../../../util/Logger'; + +/** + * Reject every unapproved transaction so a stale one is not picked up by the + * next confirmation screen. Best-effort: failures are logged, not thrown. + * + * @param transactions - Transactions to scan; only `unapproved` ones are rejected. + */ +export function rejectPendingTransactions(transactions: TransactionMeta[]) { + const { ApprovalController } = Engine.context; + + for (const tx of transactions) { + if (tx.status !== TransactionStatus.unapproved) { + continue; + } + try { + ApprovalController.rejectRequest( + tx.id, + providerErrors.userRejectedRequest(), + ); + } catch (error) { + Logger.error(error as Error, 'Failed to reject pending transaction'); + } + } +} diff --git a/app/components/UI/Perps/components/PerpsProgressBar/PerpsProgressBar.test.tsx b/app/components/UI/Perps/components/PerpsProgressBar/PerpsProgressBar.test.tsx index 3efafd3ae02..08a62e871cc 100644 --- a/app/components/UI/Perps/components/PerpsProgressBar/PerpsProgressBar.test.tsx +++ b/app/components/UI/Perps/components/PerpsProgressBar/PerpsProgressBar.test.tsx @@ -1212,4 +1212,44 @@ describe('PerpsProgressBar', () => { }).not.toThrow(); }); }); + + describe('withdrawal progress selector stability (TAT-3328)', () => { + it('returns a stable fallback reference when withdrawalProgress is null/undefined', () => { + renderWithProvider(); + + // Capture the selector function passed to usePerpsSelector + const selector = mockUsePerpsSelector.mock.calls[0][0] as ( + state: { withdrawalProgress?: unknown } | undefined, + ) => unknown; + + // Simulate Redux evaluating the selector across unrelated dispatches + const firstResult = selector({ withdrawalProgress: undefined }); + const secondResult = selector({ withdrawalProgress: undefined }); + const thirdResult = selector(undefined); + + // A new object literal on each call would break referential stability and + // cause spurious re-renders. The fallback must be the same reference. + expect(firstResult).toBe(secondResult); + expect(secondResult).toBe(thirdResult); + }); + + it('returns the actual withdrawalProgress when present, even with progress 0', () => { + renderWithProvider(); + + const selector = mockUsePerpsSelector.mock.calls[0][0] as ( + state: { withdrawalProgress?: unknown } | undefined, + ) => unknown; + + const validProgress = { + progress: 0, + lastUpdated: 123, + activeWithdrawalId: 'withdrawal1', + }; + + // A valid object with progress 0 must NOT be replaced by the fallback. + expect(selector({ withdrawalProgress: validProgress })).toBe( + validProgress, + ); + }); + }); }); diff --git a/app/components/UI/Perps/components/PerpsProgressBar/PerpsProgressBar.tsx b/app/components/UI/Perps/components/PerpsProgressBar/PerpsProgressBar.tsx index e4d2731f793..025be62da40 100644 --- a/app/components/UI/Perps/components/PerpsProgressBar/PerpsProgressBar.tsx +++ b/app/components/UI/Perps/components/PerpsProgressBar/PerpsProgressBar.tsx @@ -26,6 +26,12 @@ import { } from '@metamask/perps-controller'; import { HYPERLIQUID_WITHDRAWAL_PROGRESS_INTERVAL_MS } from '../../constants/perpsUIConfig'; +// Hoisted to module scope so the selector returns a stable reference when +// withdrawalProgress is null/undefined. An inline object literal would create a +// new reference on every Redux evaluation, breaking selector referential +// stability and causing spurious re-renders on unrelated dispatches. +const EMPTY_WITHDRAWAL_PROGRESS = { progress: 0, lastUpdated: 0 } as const; + interface PerpsProgressBarProps { /** * Progress amount as a number between 0 and 100 @@ -107,7 +113,7 @@ export const PerpsProgressBar: React.FC = ({ // Get persistent withdrawal progress from controller const persistentWithdrawalProgress = usePerpsSelector( - (state) => state?.withdrawalProgress || { progress: 0, lastUpdated: 0 }, + (state) => state?.withdrawalProgress ?? EMPTY_WITHDRAWAL_PROGRESS, ); // Track transaction progress diff --git a/app/components/UI/Perps/hooks/usePerpsDepositStatus.test.ts b/app/components/UI/Perps/hooks/usePerpsDepositStatus.test.ts index 5c8c879d3ef..6775c5e79ce 100644 --- a/app/components/UI/Perps/hooks/usePerpsDepositStatus.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsDepositStatus.test.ts @@ -22,12 +22,17 @@ import { ARBITRUM_MAINNET_CHAIN_ID_HEX, } from '@metamask/perps-controller'; import { selectTransactionBridgeQuotesById } from '../../../../core/redux/slices/confirmationMetrics'; +import { isPerpsPredictMoneyDeposit } from '../../Money/utils/moneyTransactionGuards'; // Mock dependencies jest.mock('react-redux', () => ({ useSelector: jest.fn(), })); +jest.mock('../../Money/utils/moneyTransactionGuards', () => ({ + isPerpsPredictMoneyDeposit: jest.fn(() => false), +})); + jest.mock('./stream/usePerpsLiveAccount'); jest.mock('./usePerpsTrading'); jest.mock('./usePerpsToasts'); @@ -66,6 +71,10 @@ const mockSelectTransactionBridgeQuotesById = selectTransactionBridgeQuotesById as jest.MockedFunction< typeof selectTransactionBridgeQuotesById >; +const mockIsPerpsPredictMoneyDeposit = + isPerpsPredictMoneyDeposit as jest.MockedFunction< + typeof isPerpsPredictMoneyDeposit + >; describe('usePerpsDepositStatus', () => { let mockSubscribe: jest.Mock; @@ -96,6 +105,8 @@ describe('usePerpsDepositStatus', () => { // Mock selectTransactionBridgeQuotesById mockSelectTransactionBridgeQuotesById.mockReturnValue([]); + mockIsPerpsPredictMoneyDeposit.mockReturnValue(false); + // Default mock for usePerpsLiveAccount mockUsePerpsLiveAccount.mockReturnValue({ account: { @@ -329,6 +340,60 @@ describe('usePerpsDepositStatus', () => { ).toHaveBeenCalledWith(60, 'test-tx-id'); }); + it('should not show any toast when perpsDeposit is funded from the Money account', () => { + mockShowToast.mockClear(); + mockIsPerpsPredictMoneyDeposit.mockReturnValue(true); + renderHook(() => usePerpsDepositStatus()); + const transactionMeta: TransactionMeta = { + id: 'test-tx-id', + type: TransactionType.perpsDeposit, + status: TransactionStatus.approved, + } as TransactionMeta; + + act(() => { + transactionHandler({ transactionMeta }); + }); + + expect(mockShowToast).not.toHaveBeenCalled(); + expect( + mockPerpsToastOptions.accountManagement.deposit.inProgress, + ).not.toHaveBeenCalled(); + }); + + it('should not set expecting state when perpsDeposit is funded from the Money account', () => { + mockShowToast.mockClear(); + mockIsPerpsPredictMoneyDeposit.mockReturnValue(true); + const { rerender } = renderHook(() => usePerpsDepositStatus()); + + act(() => { + transactionHandler({ + transactionMeta: { + id: 'test-tx-id', + type: TransactionType.perpsDeposit, + status: TransactionStatus.approved, + } as TransactionMeta, + }); + }); + + mockUsePerpsLiveAccount.mockReturnValue({ + account: { + spendableBalance: '1500.00', + withdrawableBalance: '1500.00', + marginUsed: '9000.00', + unrealizedPnl: '100.00', + returnOnEquity: '0.15', + totalBalance: '10600.00', + }, + isInitialLoading: false, + }); + + rerender({}); + + expect( + mockPerpsToastOptions.accountManagement.deposit.success, + ).not.toHaveBeenCalled(); + }); + it('should not show in-progress toast when perpsDeposit transaction is submitted', () => { renderHook(() => usePerpsDepositStatus()); const transactionMeta: TransactionMeta = { diff --git a/app/components/UI/Perps/hooks/usePerpsDepositStatus.ts b/app/components/UI/Perps/hooks/usePerpsDepositStatus.ts index 6aae71a93d6..e90c55d05bc 100644 --- a/app/components/UI/Perps/hooks/usePerpsDepositStatus.ts +++ b/app/components/UI/Perps/hooks/usePerpsDepositStatus.ts @@ -14,6 +14,7 @@ import { import { usePerpsLiveAccount } from './stream/usePerpsLiveAccount'; import usePerpsToasts from './usePerpsToasts'; import { usePerpsTrading } from './usePerpsTrading'; +import { isPerpsPredictMoneyDeposit } from '../../Money/utils/moneyTransactionGuards'; /** * Hook to monitor deposit status and show appropriate toasts @@ -71,6 +72,10 @@ export const usePerpsDepositStatus = () => { transactionMeta.type === TransactionType.perpsDeposit && transactionMeta.status === TransactionStatus.approved ) { + if (isPerpsPredictMoneyDeposit(transactionMeta)) { + return; + } + expectingDepositRef.current = true; prevSpendableBalanceRef.current = liveAccountRef.current?.spendableBalance || '0'; diff --git a/app/components/UI/Predict/components/PredictChipList/PredictChipList.test.tsx b/app/components/UI/Predict/components/PredictChipList/PredictChipList.test.tsx index 577c9d12a02..5db9c67f991 100644 --- a/app/components/UI/Predict/components/PredictChipList/PredictChipList.test.tsx +++ b/app/components/UI/Predict/components/PredictChipList/PredictChipList.test.tsx @@ -1,7 +1,15 @@ import React from 'react'; -import { render, fireEvent } from '@testing-library/react-native'; +import { + act, + render, + fireEvent, + renderHook, + waitFor, +} from '@testing-library/react-native'; +import type { LayoutChangeEvent, ScrollView } from 'react-native'; import PredictChipList from './PredictChipList'; import { calculateChipScrollX } from './calculateChipScrollX'; +import { useChipScrollList } from './useChipScrollList'; import { PREDICT_CHIP_LIST_TEST_IDS, getPredictChipTestId, @@ -9,6 +17,8 @@ import { } from './PredictChipList.testIds'; import type { PredictChipItem } from './PredictChipList.types'; +const mockScrollTo = jest.fn(); + jest.mock('@metamask/design-system-twrnc-preset', () => ({ useTailwind: () => ({ style: (...classes: (string | boolean | undefined)[]) => ({ @@ -210,6 +220,68 @@ describe('PredictChipList', () => { }); }); +describe('useChipScrollList', () => { + const layoutEvent = (x: number, width: number): LayoutChangeEvent => + ({ + nativeEvent: { layout: { x, y: 0, width, height: 40 } }, + }) as LayoutChangeEvent; + + beforeEach(() => { + mockScrollTo.mockClear(); + }); + + it('scrolls the active chip into view when activeChipIndex changes', async () => { + const { result, rerender } = renderHook( + ({ activeChipIndex }: { activeChipIndex: number }) => + useChipScrollList(3, { activeChipIndex }), + { initialProps: { activeChipIndex: 0 } }, + ); + + act(() => { + result.current.scrollViewRef.current = { + scrollTo: mockScrollTo, + } as unknown as ScrollView; + result.current.handleScrollViewLayout(layoutEvent(0, 200)); + result.current.handleChipLayout(0, layoutEvent(16, 100)); + result.current.handleChipLayout(1, layoutEvent(124, 100)); + result.current.handleChipLayout(2, layoutEvent(232, 100)); + }); + mockScrollTo.mockClear(); + + rerender({ activeChipIndex: 2 }); + + await waitFor(() => { + expect(mockScrollTo).toHaveBeenCalledWith({ x: 182, animated: true }); + }); + }); + + it('does not re-scroll on incidental relayouts once the active chip is positioned', () => { + const { result } = renderHook(() => + useChipScrollList(3, { activeChipIndex: 2 }), + ); + + act(() => { + result.current.scrollViewRef.current = { + scrollTo: mockScrollTo, + } as unknown as ScrollView; + result.current.handleScrollViewLayout(layoutEvent(0, 200)); + result.current.handleChipLayout(0, layoutEvent(16, 100)); + result.current.handleChipLayout(1, layoutEvent(124, 100)); + result.current.handleChipLayout(2, layoutEvent(232, 100)); + }); + + expect(mockScrollTo).toHaveBeenCalledTimes(1); + mockScrollTo.mockClear(); + + act(() => { + result.current.handleChipLayout(2, layoutEvent(240, 110)); + result.current.handleScrollViewLayout(layoutEvent(0, 200)); + }); + + expect(mockScrollTo).not.toHaveBeenCalled(); + }); +}); + describe('calculateChipScrollX', () => { const VIEWPORT = 375; diff --git a/app/components/UI/Predict/components/PredictChipList/PredictChipList.tsx b/app/components/UI/Predict/components/PredictChipList/PredictChipList.tsx index 0e2b4816321..30e30cd6d45 100644 --- a/app/components/UI/Predict/components/PredictChipList/PredictChipList.tsx +++ b/app/components/UI/Predict/components/PredictChipList/PredictChipList.tsx @@ -1,4 +1,4 @@ -import React, { memo, useCallback } from 'react'; +import React, { memo, useCallback, useMemo } from 'react'; import { Image, Pressable, ScrollView } from 'react-native'; import { ScrollView as GestureHandlerScrollView } from 'react-native-gesture-handler'; import { @@ -35,12 +35,16 @@ const PredictChipList: React.FC = ({ useGestureHandlerScrollView = false, }) => { const tw = useTailwind(); + const activeChipIndex = useMemo( + () => chips.findIndex((chip) => chip.key === activeChipKey), + [chips, activeChipKey], + ); const { scrollViewRef, handleScrollViewLayout, handleChipLayout, scrollToChipAtIndex, - } = useChipScrollList(chips.length); + } = useChipScrollList(chips.length, { activeChipIndex }); const handlePress = useCallback( (key: string, index: number) => { diff --git a/app/components/UI/Predict/components/PredictChipList/useChipScrollList.ts b/app/components/UI/Predict/components/PredictChipList/useChipScrollList.ts index cfb21b9b319..d6d3d137789 100644 --- a/app/components/UI/Predict/components/PredictChipList/useChipScrollList.ts +++ b/app/components/UI/Predict/components/PredictChipList/useChipScrollList.ts @@ -1,32 +1,38 @@ -import { useCallback, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import type { LayoutChangeEvent, ScrollView } from 'react-native'; import { calculateChipScrollX } from './calculateChipScrollX'; -export function useChipScrollList(chipCount: number) { +interface UseChipScrollListOptions { + activeChipIndex?: number; +} + +interface ScrollToChipOptions { + animated?: boolean; +} + +export function useChipScrollList( + chipCount: number, + options: UseChipScrollListOptions = {}, +) { + const { activeChipIndex } = options; const scrollViewRef = useRef(null); const chipLayoutsRef = useRef>( new Map(), ); const viewportWidthRef = useRef(0); - - const handleScrollViewLayout = useCallback((event: LayoutChangeEvent) => { - viewportWidthRef.current = event.nativeEvent.layout.width; - }, []); - - const handleChipLayout = useCallback( - (index: number, event: LayoutChangeEvent) => { - const { x, width } = event.nativeEvent.layout; - chipLayoutsRef.current.set(index, { x, width }); - }, - [], + // Active index still owed an auto-scroll. `null` once positioned, so + // incidental relayouts don't snap the rail back over a user's manual scroll. + const pendingAutoScrollIndexRef = useRef( + activeChipIndex ?? null, ); const scrollToChipAtIndex = useCallback( - (chipIndex: number) => { + (chipIndex: number, scrollOptions: ScrollToChipOptions = {}) => { + const { animated = true } = scrollOptions; const scrollView = scrollViewRef.current; const viewportWidth = viewportWidthRef.current; if (!scrollView || viewportWidth === 0) { - return; + return false; } const scrollX = calculateChipScrollX( @@ -36,14 +42,67 @@ export function useChipScrollList(chipCount: number) { viewportWidth, ); if (scrollX === null) { - return; + return false; } - scrollView.scrollTo({ x: scrollX, animated: true }); + scrollView.scrollTo({ x: scrollX, animated }); + return true; }, [chipCount], ); + // Scrolls to a still-pending active chip, then clears the marker so later + // layout passes leave the user's scroll position alone. + const flushPendingAutoScroll = useCallback( + (animated: boolean) => { + const pendingIndex = pendingAutoScrollIndexRef.current; + if ( + pendingIndex === null || + pendingIndex < 0 || + pendingIndex >= chipCount + ) { + return; + } + + if (scrollToChipAtIndex(pendingIndex, { animated })) { + pendingAutoScrollIndexRef.current = null; + } + }, + [chipCount, scrollToChipAtIndex], + ); + + const handleScrollViewLayout = useCallback( + (event: LayoutChangeEvent) => { + viewportWidthRef.current = event.nativeEvent.layout.width; + flushPendingAutoScroll(false); + }, + [flushPendingAutoScroll], + ); + + const handleChipLayout = useCallback( + (index: number, event: LayoutChangeEvent) => { + const { x, width } = event.nativeEvent.layout; + chipLayoutsRef.current.set(index, { x, width }); + flushPendingAutoScroll(false); + }, + [flushPendingAutoScroll], + ); + + // Mark the new active index pending; a layout pass flushes it if not yet ready. + useEffect(() => { + if ( + activeChipIndex === undefined || + activeChipIndex < 0 || + activeChipIndex >= chipCount + ) { + pendingAutoScrollIndexRef.current = null; + return; + } + + pendingAutoScrollIndexRef.current = activeChipIndex; + flushPendingAutoScroll(true); + }, [activeChipIndex, chipCount, flushPendingAutoScroll]); + return { scrollViewRef, handleScrollViewLayout, diff --git a/app/components/UI/Predict/components/PredictMarketRowItem/PredictMarketRowItem.tsx b/app/components/UI/Predict/components/PredictMarketRowItem/PredictMarketRowItem.tsx index 96c58852e8a..8e836e80692 100644 --- a/app/components/UI/Predict/components/PredictMarketRowItem/PredictMarketRowItem.tsx +++ b/app/components/UI/Predict/components/PredictMarketRowItem/PredictMarketRowItem.tsx @@ -28,6 +28,8 @@ interface PredictMarketRowItemProps { leadingAccessory?: React.ReactNode; /** Optional title for market-details navigation (row can still show `market.title`). */ detailsTitle?: string; + /** Optional content rendered between the row text and chevron. */ + endAccessory?: React.ReactNode; transactionActiveAbTests?: TransactionActiveAbTestEntry[]; onPress?: () => void; } @@ -39,6 +41,7 @@ const PredictMarketRowItem = ({ showChevron = false, leadingAccessory, detailsTitle, + endAccessory, transactionActiveAbTests, onPress, }: PredictMarketRowItemProps) => { @@ -130,8 +133,11 @@ const PredictMarketRowItem = ({ {topOutcome.probability} chance on {topOutcome.outcomeTitle} + {endAccessory} {showChevron && ( - + { ); }); + it('resolves the dynamic filter config for a feed from the registry', () => { + expect(resolvePredictFeedDynamicFilterConfig('popular-today')).toBe( + PREDICT_FEED_REGISTRY['popular-today'].tabs[0].filters.dynamic, + ); + expect(resolvePredictFeedDynamicFilterConfig('trending')).toBe( + PREDICT_FEED_REGISTRY.trending.tabs[0].filters.dynamic, + ); + }); + + it('returns undefined dynamic config for feeds without one, unknown feeds, or unknown tabs', () => { + expect(resolvePredictFeedDynamicFilterConfig('live')).toBeUndefined(); + expect(resolvePredictFeedDynamicFilterConfig('unknown')).toBeUndefined(); + expect( + resolvePredictFeedDynamicFilterConfig('sports', 'unknown-tab'), + ).toBeUndefined(); + }); + it('keeps static market params category-free', () => { const allowedParamKeys = new Set([ 'tags', diff --git a/app/components/UI/Predict/constants/feedConfig.ts b/app/components/UI/Predict/constants/feedConfig.ts index f060fe0704b..225d8c3cefa 100644 --- a/app/components/UI/Predict/constants/feedConfig.ts +++ b/app/components/UI/Predict/constants/feedConfig.ts @@ -307,3 +307,15 @@ export const resolvePredictFeedDefaultFilter = ( (filter) => filter.id === tab.defaultFilterId, ); }; + +export const resolvePredictFeedDynamicFilterConfig = ( + feedId?: string | null, + tabId?: string | null, +): PredictDynamicFilterConfig | undefined => { + const config = resolvePredictFeedConfig(feedId); + const tab = tabId + ? config?.tabs.find((candidateTab) => candidateTab.id === tabId) + : config?.tabs[0]; + + return tab?.filters.dynamic; +}; diff --git a/app/components/UI/Predict/hooks/usePredictToastRegistrations.test.tsx b/app/components/UI/Predict/hooks/usePredictToastRegistrations.test.tsx index 88b8679b72f..cf923f74787 100644 --- a/app/components/UI/Predict/hooks/usePredictToastRegistrations.test.tsx +++ b/app/components/UI/Predict/hooks/usePredictToastRegistrations.test.tsx @@ -5,6 +5,7 @@ import Routes from '../../../../constants/navigation/Routes'; import { usePredictToastRegistrations } from './usePredictToastRegistrations'; import { selectTransactionMetadataById } from '../../../../selectors/transactionController'; +import { isPerpsPredictMoneyDeposit } from '../../Money/utils/moneyTransactionGuards'; import { selectSingleTokenByAddressAndChainId } from '../../../../selectors/tokensController'; import { selectTickerByChainId } from '../../../../selectors/networkController'; @@ -92,6 +93,10 @@ jest.mock('../../../../selectors/transactionController', () => ({ selectTransactionMetadataById: jest.fn(() => undefined), })); +jest.mock('../../Money/utils/moneyTransactionGuards', () => ({ + isPerpsPredictMoneyDeposit: jest.fn(() => false), +})); + jest.mock('../../../../selectors/tokensController', () => ({ selectSingleTokenByAddressAndChainId: jest.fn(() => undefined), })); @@ -297,6 +302,60 @@ describe('usePredictToastRegistrations', () => { expect(showToast).not.toHaveBeenCalled(); expect(mockInvalidateQueries).not.toHaveBeenCalled(); }); + + it.each(['approved', 'confirmed', 'failed'])( + 'does not show toast on %s status when funded from the Money account', + (status) => { + (selectTransactionMetadataById as unknown as jest.Mock).mockReturnValue( + { id: 'tx-money' }, + ); + (isPerpsPredictMoneyDeposit as unknown as jest.Mock).mockReturnValue( + true, + ); + + const handler = getHandler(); + + handler( + { + type: 'deposit', + status, + transactionId: 'tx-money', + senderAddress: selectedAddress, + amount: 100, + }, + showToast, + ); + + expect(showToast).not.toHaveBeenCalled(); + }, + ); + + it('still shows pending toast for a non-money deposit', () => { + (selectTransactionMetadataById as unknown as jest.Mock).mockReturnValue({ + id: 'tx-normal', + }); + (isPerpsPredictMoneyDeposit as unknown as jest.Mock).mockReturnValue( + false, + ); + + const handler = getHandler(); + + handler( + { + type: 'deposit', + status: 'approved', + transactionId: 'tx-normal', + senderAddress: selectedAddress, + }, + showToast, + ); + + expect(showToast).toHaveBeenCalledWith( + expect.objectContaining({ + iconName: 'Loading', + }), + ); + }); }); describe('claim transactions', () => { diff --git a/app/components/UI/Predict/hooks/usePredictToastRegistrations.tsx b/app/components/UI/Predict/hooks/usePredictToastRegistrations.tsx index 5694ae8d5b5..e26b5becf17 100644 --- a/app/components/UI/Predict/hooks/usePredictToastRegistrations.tsx +++ b/app/components/UI/Predict/hooks/usePredictToastRegistrations.tsx @@ -25,6 +25,8 @@ import { usePredictClaim } from './usePredictClaim'; import { usePredictDeposit } from './usePredictDeposit'; import { usePredictWithdraw } from './usePredictWithdraw'; import { store } from '../../../../store'; +import { selectTransactionMetadataById } from '../../../../selectors/transactionController'; +import { isPerpsPredictMoneyDeposit } from '../../Money/utils/moneyTransactionGuards'; import { resolveWithdrawTokenInfo } from '../../../Views/confirmations/utils/withdraw-token-resolution'; import { selectPredictBottomSheetEnabledFlag } from '../selectors/featureFlags'; import { shouldSuppressLegacyOrderFailureToast } from '../contexts/PredictPreviewSheetContext'; @@ -173,6 +175,13 @@ export const usePredictToastRegistrations = (): ToastRegistration[] => { } if (type === 'deposit') { + const depositMeta = transactionId + ? selectTransactionMetadataById(store.getState(), transactionId) + : undefined; + if (depositMeta && isPerpsPredictMoneyDeposit(depositMeta)) { + return; + } + if (status === 'approved') { showPendingToast({ showToast, diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts index 3d964f649db..f2532ae263a 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts @@ -362,7 +362,6 @@ const defaultFeatureFlags: PredictFeatureFlags = { predictUpDownEnabled: false, predictPortfolioEnabled: false, predictHomeRedesignEnabled: false, - predictHomepageDiscoveryNbaChampionEnabled: true, predictWorldCup: DEFAULT_PREDICT_WORLD_CUP_FLAG, }; diff --git a/app/components/UI/Predict/selectors/featureFlags/index.test.ts b/app/components/UI/Predict/selectors/featureFlags/index.test.ts index 7d23cdb1e8a..2ede9695501 100644 --- a/app/components/UI/Predict/selectors/featureFlags/index.test.ts +++ b/app/components/UI/Predict/selectors/featureFlags/index.test.ts @@ -9,7 +9,6 @@ import { selectPredictGtmOnboardingModalEnabledFlag, selectPredictHomeFeaturedVariant, selectPredictHomeRedesignEnabledFlag, - selectPredictHomepageDiscoveryNbaChampionEnabledFlag, selectPredictHotTabFlag, selectPredictPortfolioEnabledFlag, selectPredictUpDownEnabledFlag, @@ -1476,11 +1475,6 @@ describe('Predict Feature Flag Selectors', () => { expect( selectPredictWorldCupScreenEnabledFlag(mockedEmptyFlagsState), ).toBe(false); - expect( - selectPredictHomepageDiscoveryNbaChampionEnabledFlag( - mockedEmptyFlagsState, - ), - ).toBe(true); }); it('returns normalized config and gated booleans when enabled', () => { @@ -1788,31 +1782,6 @@ describe('Predict Feature Flag Selectors', () => { }); }); - describe('selectPredictHomepageDiscoveryNbaChampionEnabledFlag', () => { - it('returns false when the remote flag is disabled', () => { - mockHasMinimumRequiredVersion.mockReturnValue(true); - const state = { - engine: { - backgroundState: { - RemoteFeatureFlagController: { - remoteFeatureFlags: { - predictHomepageDiscoveryNbaChampionEnabled: { - enabled: false, - minimumVersion: '1.0.0', - }, - }, - cacheTimestamp: 0, - }, - }, - }, - }; - - expect(selectPredictHomepageDiscoveryNbaChampionEnabledFlag(state)).toBe( - false, - ); - }); - }); - describe('selectPredictFeaturedCarouselEnabledFlag', () => { it('returns true when flag is enabled and version requirement is met', () => { mockHasMinimumRequiredVersion.mockReturnValue(true); diff --git a/app/components/UI/Predict/selectors/featureFlags/index.ts b/app/components/UI/Predict/selectors/featureFlags/index.ts index 084d315a76b..3bf603691a4 100644 --- a/app/components/UI/Predict/selectors/featureFlags/index.ts +++ b/app/components/UI/Predict/selectors/featureFlags/index.ts @@ -147,12 +147,6 @@ export const selectPredictUpDownEnabledFlag = createSelector( (flags) => flags.predictUpDownEnabled, ); -export const selectPredictHomepageDiscoveryNbaChampionEnabledFlag = - createSelector( - selectPredictFeatureFlags, - (flags) => flags.predictHomepageDiscoveryNbaChampionEnabled, - ); - export const selectPredictWorldCupConfig = createSelector( selectPredictFeatureFlags, (flags) => flags.predictWorldCup, diff --git a/app/components/UI/Predict/types/flags.ts b/app/components/UI/Predict/types/flags.ts index 5cc0023d1a9..9ec21374123 100644 --- a/app/components/UI/Predict/types/flags.ts +++ b/app/components/UI/Predict/types/flags.ts @@ -53,7 +53,6 @@ export interface PredictFeatureFlags { fakOrdersEnabled: boolean; predictWithAnyTokenEnabled: boolean; predictUpDownEnabled: boolean; - predictHomepageDiscoveryNbaChampionEnabled: boolean; predictWorldCup: PredictWorldCupConfig; predictPortfolioEnabled: boolean; predictHomeRedesignEnabled: boolean; diff --git a/app/components/UI/Predict/utils/resolvePredictFeatureFlags.test.ts b/app/components/UI/Predict/utils/resolvePredictFeatureFlags.test.ts index 109ca97af93..d42dbd867ea 100644 --- a/app/components/UI/Predict/utils/resolvePredictFeatureFlags.test.ts +++ b/app/components/UI/Predict/utils/resolvePredictFeatureFlags.test.ts @@ -34,7 +34,6 @@ describe('resolvePredictFeatureFlags', () => { predictUpDownEnabled: false, predictPortfolioEnabled: false, predictHomeRedesignEnabled: false, - predictHomepageDiscoveryNbaChampionEnabled: true, predictWorldCup: DEFAULT_PREDICT_WORLD_CUP_FLAG, }); }); @@ -217,39 +216,6 @@ describe('resolvePredictFeatureFlags', () => { expect(result.predictWithAnyTokenEnabled).toBe(false); }); - describe('predictHomepageDiscoveryNbaChampionEnabled', () => { - it('defaults to true to preserve the NBA champion discovery row', () => { - const result = resolvePredictFeatureFlags({}); - - expect(result.predictHomepageDiscoveryNbaChampionEnabled).toBe(true); - }); - - it('returns false when the remote flag is disabled and version gate passes', () => { - mockValidatedVersionGatedFeatureFlag.mockImplementation((flag) => { - if ( - flag && - typeof flag === 'object' && - 'enabled' in flag && - 'minimumVersion' in flag - ) { - return (flag as { enabled: boolean }).enabled; - } - return undefined; - }); - - const result = resolvePredictFeatureFlags({ - remoteFeatureFlags: { - predictHomepageDiscoveryNbaChampionEnabled: { - enabled: false, - minimumVersion: '1.0.0', - }, - }, - }); - - expect(result.predictHomepageDiscoveryNbaChampionEnabled).toBe(false); - }); - }); - describe('predictWorldCup', () => { it('returns default disabled config when flag is missing', () => { const result = resolvePredictFeatureFlags({}); diff --git a/app/components/UI/Predict/utils/resolvePredictFeatureFlags.ts b/app/components/UI/Predict/utils/resolvePredictFeatureFlags.ts index 1945ab7ab62..29d30fefc42 100644 --- a/app/components/UI/Predict/utils/resolvePredictFeatureFlags.ts +++ b/app/components/UI/Predict/utils/resolvePredictFeatureFlags.ts @@ -105,11 +105,6 @@ export function resolvePredictFeatureFlags( const predictHomeRedesignEnabled = resolveVersionGatedBooleanFlag( flags.predictHomeRedesign, ); - const predictHomepageDiscoveryNbaChampionEnabled = - resolveVersionGatedBooleanFlag( - flags.predictHomepageDiscoveryNbaChampionEnabled, - true, - ); const parsedPredictWorldCup = parse( unwrapRemoteFeatureFlag( flags.predictWorldCup, @@ -133,7 +128,6 @@ export function resolvePredictFeatureFlags( predictUpDownEnabled, predictPortfolioEnabled, predictHomeRedesignEnabled, - predictHomepageDiscoveryNbaChampionEnabled, predictWorldCup, }; } diff --git a/app/components/UI/Predict/views/PredictHome/PredictHome.view.test.tsx b/app/components/UI/Predict/views/PredictHome/PredictHome.view.test.tsx index 5e4f7c6e30b..45444d4d9ab 100644 --- a/app/components/UI/Predict/views/PredictHome/PredictHome.view.test.tsx +++ b/app/components/UI/Predict/views/PredictHome/PredictHome.view.test.tsx @@ -21,7 +21,6 @@ import { PredictSearchSelectorsIDs, } from '../../Predict.testIds'; import { PREDICT_HEADER_STACKED_TEST_IDS } from '../../components/PredictHeaderStacked'; -import { PREDICT_PORTFOLIO_TEST_IDS } from './components/PredictPortfolio'; import { MOCK_PREDICT_MARKET } from '../../../../../../tests/component-view/fixtures/predict'; const SEARCH_PLACEHOLDER = 'Search prediction markets'; @@ -53,6 +52,21 @@ describe('PredictHome', () => { ( Engine.context.PredictController.listMarkets as jest.Mock ).mockResolvedValue({ markets: [MOCK_PREDICT_MARKET], nextCursor: null }); + ( + Engine.context.PredictController.listFilterOptions as jest.Mock + ).mockResolvedValue([ + { + id: 'elections', + label: 'Elections', + source: 'related-tags', + params: { + tagSlugs: ['elections'], + status: 'open', + order: 'volume24hr', + limit: 12, + }, + }, + ]); ( Engine.context.PredictController.searchMarkets as jest.Mock ).mockResolvedValue({ markets: [], totalResults: 0 }); diff --git a/app/components/UI/Predict/views/PredictHome/components/PredictPopularTodaySection/PredictPopularTodaySection.test.tsx b/app/components/UI/Predict/views/PredictHome/components/PredictPopularTodaySection/PredictPopularTodaySection.test.tsx new file mode 100644 index 00000000000..960c708dbaf --- /dev/null +++ b/app/components/UI/Predict/views/PredictHome/components/PredictPopularTodaySection/PredictPopularTodaySection.test.tsx @@ -0,0 +1,222 @@ +import React from 'react'; +import { fireEvent, within } from '@testing-library/react-native'; +import { backgroundState } from '../../../../../../../util/test/initial-root-state'; +import renderWithProvider from '../../../../../../../util/test/renderWithProvider'; +import { strings } from '../../../../../../../../locales/i18n'; +import Routes from '../../../../../../../constants/navigation/Routes'; +import { PredictEventValues } from '../../../../constants/eventNames'; +import { usePredictFilterOptions } from '../../../../hooks/usePredictFilterOptions'; +import type { PredictFilterOption } from '../../../../types'; +import PredictPopularTodaySection, { + PREDICT_POPULAR_TODAY_SECTION_TEST_IDS, +} from './PredictPopularTodaySection'; + +const mockNavigate = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ navigate: mockNavigate }), +})); + +jest.mock('../../../../hooks/usePredictFilterOptions'); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: jest.fn(() => ({})) }), +})); + +const mockUsePredictFilterOptions = jest.mocked(usePredictFilterOptions); + +const createFilterOption = ( + id: string, + label: string, +): PredictFilterOption => ({ + id, + label, + source: 'related-tags', + params: { + tagSlugs: [id], + status: 'open', + order: 'volume24hr', + limit: 12, + }, +}); + +const setFilterOptions = ( + overrides: Partial> = {}, +) => { + mockUsePredictFilterOptions.mockReturnValue({ + filterOptions: [], + isLoading: false, + error: null, + refetch: jest.fn(), + ...overrides, + }); +}; + +type RenderSectionProps = React.ComponentProps< + typeof PredictPopularTodaySection +>; + +const renderSection = (props: RenderSectionProps = {}) => + renderWithProvider(, { + state: { engine: { backgroundState } }, + }); + +describe('PredictPopularTodaySection', () => { + beforeEach(() => { + setFilterOptions(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('does not render when there are no popular filter options', () => { + const { queryByTestId } = renderSection(); + + expect( + queryByTestId(PREDICT_POPULAR_TODAY_SECTION_TEST_IDS.SECTION), + ).toBeNull(); + }); + + it('renders the Popular today header', () => { + setFilterOptions({ + filterOptions: [createFilterOption('elections', 'Elections')], + }); + + const { getByTestId, getByText } = renderSection(); + + expect( + getByTestId(PREDICT_POPULAR_TODAY_SECTION_TEST_IDS.SECTION), + ).toBeOnTheScreen(); + expect( + getByTestId(PREDICT_POPULAR_TODAY_SECTION_TEST_IDS.HEADER), + ).toBeOnTheScreen(); + expect(getByText(strings('predict.feed.popular_today'))).toBeOnTheScreen(); + }); + + it('renders skeleton chips while loading', () => { + setFilterOptions({ isLoading: true }); + + const { getByTestId } = renderSection(); + + expect( + getByTestId( + `${PREDICT_POPULAR_TODAY_SECTION_TEST_IDS.SKELETON_PREFIX}-0`, + ), + ).toBeOnTheScreen(); + }); + + it('renders a chip for each popular filter option', () => { + setFilterOptions({ + filterOptions: [ + createFilterOption('elections', 'Elections'), + createFilterOption('crypto', 'Crypto'), + ], + }); + + const { getByTestId, getByText } = renderSection(); + + expect( + getByTestId( + `${PREDICT_POPULAR_TODAY_SECTION_TEST_IDS.CHIP_PREFIX}-elections`, + ), + ).toBeOnTheScreen(); + expect( + getByTestId( + `${PREDICT_POPULAR_TODAY_SECTION_TEST_IDS.CHIP_PREFIX}-crypto`, + ), + ).toBeOnTheScreen(); + expect(getByText('Elections')).toBeOnTheScreen(); + expect(getByText('Crypto')).toBeOnTheScreen(); + }); + + it('renders popular filter chips across two rows', () => { + setFilterOptions({ + filterOptions: [ + createFilterOption('iran', 'Iran'), + createFilterOption('esports', 'E-Sports'), + createFilterOption('march-madness', 'March Madness'), + createFilterOption('nba', 'NBA'), + createFilterOption('ufc', 'UFC'), + ], + }); + + const { getByTestId } = renderSection(); + const firstRow = getByTestId( + `${PREDICT_POPULAR_TODAY_SECTION_TEST_IDS.ROW_PREFIX}-0`, + ); + const secondRow = getByTestId( + `${PREDICT_POPULAR_TODAY_SECTION_TEST_IDS.ROW_PREFIX}-1`, + ); + + expect(within(firstRow).getByText('Iran')).toBeOnTheScreen(); + expect(within(firstRow).getByText('E-Sports')).toBeOnTheScreen(); + expect(within(firstRow).getByText('March Madness')).toBeOnTheScreen(); + expect(within(secondRow).getByText('NBA')).toBeOnTheScreen(); + expect(within(secondRow).getByText('UFC')).toBeOnTheScreen(); + }); + + it('can render popular filter chips in a single row when configured', () => { + setFilterOptions({ + filterOptions: [ + createFilterOption('iran', 'Iran'), + createFilterOption('esports', 'E-Sports'), + createFilterOption('march-madness', 'March Madness'), + ], + }); + + const { getByTestId, queryByTestId } = renderSection({ rowCount: 1 }); + const firstRow = getByTestId( + `${PREDICT_POPULAR_TODAY_SECTION_TEST_IDS.ROW_PREFIX}-0`, + ); + + expect(within(firstRow).getByText('Iran')).toBeOnTheScreen(); + expect(within(firstRow).getByText('E-Sports')).toBeOnTheScreen(); + expect(within(firstRow).getByText('March Madness')).toBeOnTheScreen(); + expect( + queryByTestId(`${PREDICT_POPULAR_TODAY_SECTION_TEST_IDS.ROW_PREFIX}-1`), + ).toBeNull(); + }); + + it('navigates to the Popular Today feed when the header is pressed', () => { + setFilterOptions({ + filterOptions: [createFilterOption('elections', 'Elections')], + }); + + const { getByTestId } = renderSection(); + + fireEvent.press(getByTestId(PREDICT_POPULAR_TODAY_SECTION_TEST_IDS.HEADER)); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.FEED, + params: { + feedId: 'popular-today', + entryPoint: PredictEventValues.ENTRY_POINT.HOME_SECTION, + }, + }); + }); + + it('navigates to the Popular Today feed with the selected filter when a chip is pressed', () => { + setFilterOptions({ + filterOptions: [createFilterOption('elections', 'Elections')], + }); + + const { getByTestId } = renderSection(); + + fireEvent.press( + getByTestId( + `${PREDICT_POPULAR_TODAY_SECTION_TEST_IDS.CHIP_PREFIX}-elections`, + ), + ); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.FEED, + params: { + feedId: 'popular-today', + initialFilterId: 'elections', + entryPoint: PredictEventValues.ENTRY_POINT.HOME_SECTION, + }, + }); + }); +}); diff --git a/app/components/UI/Predict/views/PredictHome/components/PredictPopularTodaySection/PredictPopularTodaySection.tsx b/app/components/UI/Predict/views/PredictHome/components/PredictPopularTodaySection/PredictPopularTodaySection.tsx index eba0d1d3baa..f59f7485ba2 100644 --- a/app/components/UI/Predict/views/PredictHome/components/PredictPopularTodaySection/PredictPopularTodaySection.tsx +++ b/app/components/UI/Predict/views/PredictHome/components/PredictPopularTodaySection/PredictPopularTodaySection.tsx @@ -1,32 +1,247 @@ -import React from 'react'; +import React, { useCallback, useMemo } from 'react'; +import { Pressable, ScrollView } from 'react-native'; +import { useNavigation, type NavigationProp } from '@react-navigation/native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { Box, Text, TextColor, TextVariant, + FontWeight, + SectionHeader, } from '@metamask/design-system-react-native'; import { strings } from '../../../../../../../../locales/i18n'; +import Routes from '../../../../../../../constants/navigation/Routes'; +import { Skeleton } from '../../../../../../../component-library/components-temp/Skeleton'; +import { PredictEventValues } from '../../../../constants/eventNames'; +import { resolvePredictFeedDynamicFilterConfig } from '../../../../constants/feedConfig'; +import { usePredictFilterOptions } from '../../../../hooks/usePredictFilterOptions'; +import type { + PredictFilterOption, + PredictFilterOptionsParams, +} from '../../../../types'; +import type { + PredictFeedRouteParams, + PredictNavigationParamList, +} from '../../../../types/navigation'; import { PredictHomeSelectorsIDs } from '../../../../Predict.testIds'; +/** + * Max number of filter chips rendered on the home section. The full + * `popular-today` feed shows the complete list; the home rail shows a compact + * prefix of the same ordered list. + */ +const POPULAR_TODAY_FILTER_LIMIT = 10; +const SKELETON_COUNT = 8; +const DEFAULT_CHIP_ROW_COUNT = 2; + +/** + * Derive the section's filter-options params from the canonical `popular-today` + * feed registry so there is a single source of truth. Crucially, we do NOT pass + * a top-level `limit` here: that keeps the React Query cache key identical to + * the full feed's `usePredictFilterOptions` call (which omits `limit`), so the + * home section and the feed share the same cached related-tags request instead + * of firing two. The display cap is applied client-side via + * `POPULAR_TODAY_FILTER_LIMIT` when slicing the returned options. + */ +const POPULAR_TODAY_FILTER_PARAMS: PredictFilterOptionsParams = (() => { + const dynamicConfig = resolvePredictFeedDynamicFilterConfig('popular-today'); + + return { + source: dynamicConfig?.source ?? 'related-tags', + baseTagSlug: dynamicConfig?.baseTagSlug, + baseParams: dynamicConfig?.baseParams, + limit: dynamicConfig?.limit, + }; +})(); + +export const PREDICT_POPULAR_TODAY_SECTION_TEST_IDS = { + SECTION: PredictHomeSelectorsIDs.POPULAR_TODAY_SECTION, + HEADER: 'predict-home-popular-today-header', + CHIP_PREFIX: 'predict-home-popular-today-chip', + ROW_PREFIX: 'predict-home-popular-today-row', + SKELETON_PREFIX: 'predict-home-popular-today-skeleton', +} as const; + +const normalizeRowCount = (rowCount: number) => + Math.max(1, Math.floor(rowCount)); + +const splitIntoRows = (items: Item[], rowCount: number): Item[][] => { + if (items.length === 0) { + return []; + } + + const normalizedRowCount = normalizeRowCount(rowCount); + const rows: Item[][] = []; + let start = 0; + + for (let rowIndex = 0; rowIndex < normalizedRowCount; rowIndex++) { + const remainingItems = items.length - start; + const remainingRows = normalizedRowCount - rowIndex; + const rowSize = Math.ceil(remainingItems / remainingRows); + const row = items.slice(start, start + rowSize); + + if (row.length > 0) { + rows.push(row); + } + + start += rowSize; + } + + return rows; +}; + interface PredictPopularTodaySectionProps { testID?: string; + rowCount?: number; } /** - * Placeholder for the Predict home "Popular today" tags section. - * Replaced by the real section in a later ticket; the shell composes it as-is. + * Predict home "Popular today" tag rail (PRED-917). + * + * Uses the same related-tag source as the full `popular-today` feed. The header + * opens the all-up Popular Today feed, while each chip opens that feed with the + * selected related-tag filter preselected. */ const PredictPopularTodaySection: React.FC = ({ - testID = PredictHomeSelectorsIDs.POPULAR_TODAY_SECTION, -}) => ( - - - {strings('predict.home.popular_today_placeholder')} - - -); + testID = PREDICT_POPULAR_TODAY_SECTION_TEST_IDS.SECTION, + rowCount = DEFAULT_CHIP_ROW_COUNT, +}) => { + const tw = useTailwind(); + const navigation = + useNavigation>(); + const { filterOptions, isLoading } = usePredictFilterOptions( + POPULAR_TODAY_FILTER_PARAMS, + ); + + const chips = useMemo( + () => + filterOptions.slice(0, POPULAR_TODAY_FILTER_LIMIT).map((option) => ({ + key: option.id, + label: option.label, + option, + })), + [filterOptions], + ); + const chipRows = useMemo( + () => splitIntoRows(chips, rowCount), + [chips, rowCount], + ); + const skeletonRows = useMemo( + () => + splitIntoRows( + Array.from({ length: SKELETON_COUNT }, (_, index) => index), + rowCount, + ), + [rowCount], + ); + + const navigateToPopularToday = useCallback( + (initialFilterId?: string) => { + const params: PredictFeedRouteParams = { + feedId: 'popular-today', + entryPoint: PredictEventValues.ENTRY_POINT.HOME_SECTION, + ...(initialFilterId ? { initialFilterId } : {}), + }; + + navigation.navigate(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.FEED, + params, + }); + }, + [navigation], + ); + + const handleSeeAll = useCallback(() => { + navigateToPopularToday(); + }, [navigateToPopularToday]); + + const handleChipPress = useCallback( + (option: PredictFilterOption) => { + navigateToPopularToday(option.id); + }, + [navigateToPopularToday], + ); + + if (!isLoading && chips.length === 0) { + return null; + } + + return ( + + + + {isLoading ? ( + + + {skeletonRows.map((row, rowIndex) => ( + + {row.map((index) => ( + + ))} + + ))} + + + ) : null} + + {!isLoading && chips.length > 0 ? ( + + + {chipRows.map((row, rowIndex) => ( + + {row.map(({ key, label, option }) => ( + handleChipPress(option)} + accessibilityRole="button" + accessibilityLabel={label} + style={tw.style('rounded-xl bg-muted px-4 py-2')} + > + + {label} + + + ))} + + ))} + + + ) : null} + + ); +}; export default PredictPopularTodaySection; diff --git a/app/components/UI/Ramp/Aggregator/types/analytics.ts b/app/components/UI/Ramp/Aggregator/types/analytics.ts index 3840fa28ea4..539ec5a02bf 100644 --- a/app/components/UI/Ramp/Aggregator/types/analytics.ts +++ b/app/components/UI/Ramp/Aggregator/types/analytics.ts @@ -387,7 +387,7 @@ export interface AnalyticsEvents { OFFRAMP_PURCHASE_COMPLETED: OffRampPurchaseCompleted; ONRAMP_PURCHASE_FAILED: OnRampPurchaseFailed; - OFFRAMP_PURCHASE_FAILED: OfframpCanceled; + OFFRAMP_PURCHASE_FAILED: OffRampPurchaseFailed; ONRAMP_PURCHASE_CANCELLED: OnRampPurchaseCanceled; OFFRAMP_PURCHASE_CANCELLED: OffRampPurchaseCanceled; diff --git a/app/components/UI/Ramp/Deposit/types/analytics.ts b/app/components/UI/Ramp/Deposit/types/analytics.ts index b9578cfab7e..9e6b799cf62 100644 --- a/app/components/UI/Ramp/Deposit/types/analytics.ts +++ b/app/components/UI/Ramp/Deposit/types/analytics.ts @@ -1,3 +1,16 @@ +/** + * Headless ramps surface the event was triggered from. Set on every event on + * the headless deposit path (TRAM-3623) so the funnel can be sliced by product + * feature. Optional and empty for non-headless (UB2 / native Deposit) events. + */ +export const RAMP_SURFACE = { + MONEY_ACCOUNT: 'money_account', + PERPS: 'perps', + PREDICTION: 'prediction', +} as const; + +export type RampSurface = (typeof RAMP_SURFACE)[keyof typeof RAMP_SURFACE]; + interface RampsButtonClicked { quote_session_id?: string; ramp_type: 'DEPOSIT' | 'SELL' | 'BUY' | 'UNIFIED_BUY' | 'UNIFIED_BUY_2'; @@ -21,7 +34,8 @@ interface RampsDepositCashButtonClicked { interface RampsPaymentMethodSelected { quote_session_id?: string; - ramp_type: 'DEPOSIT' | 'UNIFIED_BUY_2'; + ramp_type: 'DEPOSIT' | 'UNIFIED_BUY_2' | 'HEADLESS'; + ramp_surface?: RampSurface; user_id?: string; region: string; payment_method_id: string; @@ -53,7 +67,8 @@ interface RampsRegionSelected { interface RampsOrderProposed { quote_session_id?: string; - ramp_type: 'DEPOSIT'; + ramp_type: 'DEPOSIT' | 'HEADLESS'; + ramp_surface?: RampSurface; user_id?: string; amount_source: number; amount_destination: number; @@ -70,7 +85,8 @@ interface RampsOrderProposed { interface RampsOrderSelected { quote_session_id?: string; - ramp_type: 'DEPOSIT'; + ramp_type: 'DEPOSIT' | 'HEADLESS'; + ramp_surface?: RampSurface; user_id?: string; amount_source: number; amount_destination: number; @@ -89,7 +105,8 @@ interface RampsOrderSelected { interface RampsOrderFailed { quote_session_id?: string; - ramp_type: 'DEPOSIT'; + ramp_type: 'DEPOSIT' | 'HEADLESS'; + ramp_surface?: RampSurface; user_id?: string; amount_source: number; amount_destination: number; @@ -106,35 +123,41 @@ interface RampsOrderFailed { interface RampsEmailSubmitted { quote_session_id?: string; - ramp_type: 'DEPOSIT'; + ramp_type: 'DEPOSIT' | 'HEADLESS'; + ramp_surface?: RampSurface; user_id?: string; } interface RampsOtpConfirmed { quote_session_id?: string; region: string; - ramp_type: 'DEPOSIT'; + ramp_type: 'DEPOSIT' | 'HEADLESS'; + ramp_surface?: RampSurface; user_id?: string; } interface RampsOtpFailed { quote_session_id?: string; region: string; - ramp_type: 'DEPOSIT'; + ramp_type: 'DEPOSIT' | 'HEADLESS'; + ramp_surface?: RampSurface; user_id?: string; + error_message?: string; } interface RampsOtpResent { quote_session_id?: string; region: string; - ramp_type: 'DEPOSIT'; + ramp_type: 'DEPOSIT' | 'HEADLESS'; + ramp_surface?: RampSurface; user_id?: string; } interface RampsKycStarted { quote_session_id?: string; region: string; - ramp_type: 'DEPOSIT'; + ramp_type: 'DEPOSIT' | 'HEADLESS'; + ramp_surface?: RampSurface; user_id?: string; kyc_type: string; } @@ -142,7 +165,8 @@ interface RampsKycStarted { interface RampsBasicInfoEntered { quote_session_id?: string; region: string; - ramp_type: 'DEPOSIT'; + ramp_type: 'DEPOSIT' | 'HEADLESS'; + ramp_surface?: RampSurface; user_id?: string; kyc_type: string; } @@ -150,14 +174,16 @@ interface RampsBasicInfoEntered { interface RampsAddressEntered { quote_session_id?: string; region: string; - ramp_type: 'DEPOSIT'; + ramp_type: 'DEPOSIT' | 'HEADLESS'; + ramp_surface?: RampSurface; user_id?: string; kyc_type: string; } interface RampsTransactionConfirmed { quote_session_id?: string; - ramp_type: 'DEPOSIT'; + ramp_type: 'DEPOSIT' | 'HEADLESS'; + ramp_surface?: RampSurface; user_id?: string; amount_source: number; amount_destination: number; @@ -167,6 +193,7 @@ interface RampsTransactionConfirmed { total_fee: number; payment_method_id: string; country: string; + region?: string; chain_id: string; currency_destination: string; currency_destination_symbol?: string; @@ -176,7 +203,7 @@ interface RampsTransactionConfirmed { interface RampsTransactionCompleted { quote_session_id?: string; - ramp_type: 'DEPOSIT'; + ramp_type: 'DEPOSIT' | 'UNIFIED_BUY_2'; user_id?: string; amount_source: number; amount_destination: number; @@ -196,7 +223,8 @@ interface RampsTransactionCompleted { interface RampsTransactionFailed { quote_session_id?: string; - ramp_type: 'DEPOSIT'; + ramp_type: 'DEPOSIT' | 'UNIFIED_BUY_2' | 'HEADLESS'; + ramp_surface?: RampSurface; user_id?: string; amount_source: number; amount_destination: number; @@ -206,6 +234,7 @@ interface RampsTransactionFailed { total_fee: number; payment_method_id: string; country: string; + region?: string; chain_id: string; currency_destination: string; currency_destination_symbol?: string; @@ -217,14 +246,18 @@ interface RampsTransactionFailed { interface RampsKycApplicationFailed { quote_session_id?: string; - ramp_type: 'DEPOSIT'; + ramp_type: 'DEPOSIT' | 'HEADLESS'; + ramp_surface?: RampSurface; user_id?: string; kyc_type: string; + region?: string; + error_message?: string; } interface RampsKycApplicationApproved { quote_session_id?: string; - ramp_type: 'DEPOSIT'; + ramp_type: 'DEPOSIT' | 'HEADLESS'; + ramp_surface?: RampSurface; user_id?: string; kyc_type: string; } @@ -260,12 +293,15 @@ interface RampsUserDetailsFetched { interface RampsScreenViewed { location: string; - ramp_type: 'UNIFIED_BUY_2'; + ramp_type: 'UNIFIED_BUY_2' | 'HEADLESS'; + ramp_surface?: RampSurface; + region?: string; } interface RampsBackButtonClicked { location: string; - ramp_type: 'UNIFIED_BUY_2'; + ramp_type: 'UNIFIED_BUY_2' | 'HEADLESS'; + ramp_surface?: RampSurface; } interface RampsNetworkFilterClicked { @@ -295,7 +331,8 @@ interface RampsSettingOptionClicked { interface RampsPaymentMethodSelectorClicked { current_payment_method?: string; location: string; - ramp_type: 'UNIFIED_BUY_2'; + ramp_type: 'UNIFIED_BUY_2' | 'HEADLESS'; + ramp_surface?: RampSurface; } interface RampsQuickAmountClicked { @@ -319,7 +356,8 @@ interface RampsProviderSelected { } interface RampsContinueButtonClicked { - ramp_type: 'UNIFIED_BUY_2'; + ramp_type: 'UNIFIED_BUY_2' | 'HEADLESS'; + ramp_surface?: RampSurface; amount_source: number; amount_destination?: number; payment_method_id: string; @@ -340,14 +378,16 @@ interface RampsContinueButtonClicked { interface RampsTermsConsentClicked { location: string; - ramp_type: 'UNIFIED_BUY_2'; + ramp_type: 'UNIFIED_BUY_2' | 'HEADLESS'; + ramp_surface?: RampSurface; } interface RampsExternalLinkClicked { location: string; external_link_description: string; url_domain?: string; - ramp_type: 'UNIFIED_BUY_2'; + ramp_type: 'UNIFIED_BUY_2' | 'HEADLESS'; + ramp_surface?: RampSurface; } interface RampsCloseButtonClicked { @@ -362,7 +402,8 @@ interface RampsQuoteError { currency_destination?: string; payment_method_id?: string; chain_id?: string; - ramp_type: 'UNIFIED_BUY_2'; + ramp_type: 'UNIFIED_BUY_2' | 'HEADLESS'; + ramp_surface?: RampSurface; } interface RampsQuoteErrorTooltipClicked { @@ -393,7 +434,9 @@ interface RampsToastButtonClicked { interface RampsCheckoutFunnelBase { checkout_session_id: string; location: 'Checkout'; - ramp_type: 'UNIFIED_BUY_2'; + ramp_type: 'UNIFIED_BUY_2' | 'HEADLESS'; + ramp_surface?: RampSurface; + region?: string; provider_name?: string; } @@ -421,6 +464,7 @@ interface RampsCheckoutHttpErrorReceived extends RampsCheckoutFunnelBase { url_path: string; status_code: number; is_initial_url: boolean; + error_message?: string; } interface RampsCheckoutCallbackDetected extends RampsCheckoutFunnelBase { diff --git a/app/components/UI/Ramp/Deposit/utils/getDepositAnalyticsPayload.ts b/app/components/UI/Ramp/Deposit/utils/getDepositAnalyticsPayload.ts index b340b9bddcd..bea536b720d 100644 --- a/app/components/UI/Ramp/Deposit/utils/getDepositAnalyticsPayload.ts +++ b/app/components/UI/Ramp/Deposit/utils/getDepositAnalyticsPayload.ts @@ -8,6 +8,21 @@ import { hasDepositOrderField } from './index'; import { AnalyticsEvents } from '../types'; import { selectNetworkConfigurationsByCaipChainId } from '../../../../../selectors/networkController'; +/** + * Builds the terminal RAMPS_TRANSACTION_COMPLETED / RAMPS_TRANSACTION_FAILED + * payload for an order processed by the global FiatOrders observer + * (`app/components/UI/Ramp/index.tsx`). + * + * TRAM-3623 scope note: this is the DEPOSIT path only. The observer polls the + * redux `fiatOrders` store (`getPendingOrders`), which is fed exclusively by + * `addFiatOrder` (native Deposit via `useHandleNewOrder`, plus the aggregator + * paths). Headless buy orders are added through + * `RampsController.addOrder` (`useTransakRouting` -> `useRampsOrders`) and never + * enter the redux store, so they do not flow through this observer. Headless + * terminal success/failure are emitted directly by `useTransakRouting` + * (`RAMPS_TRANSACTION_CONFIRMED` / `RAMPS_ORDER_FAILED`). Hence `ramp_type` + * stays hardcoded `'DEPOSIT'` here and no `ramp_surface` is emitted. + */ function getDepositAnalyticsPayload( fiatOrder: FiatOrder, state: RootState, diff --git a/app/components/UI/Ramp/Views/Checkout/Checkout.test.tsx b/app/components/UI/Ramp/Views/Checkout/Checkout.test.tsx index 097556bd7dd..84a281e1cba 100644 --- a/app/components/UI/Ramp/Views/Checkout/Checkout.test.tsx +++ b/app/components/UI/Ramp/Views/Checkout/Checkout.test.tsx @@ -742,6 +742,35 @@ describe('Checkout', () => { expect(mockNavigation.reset).not.toHaveBeenCalled(); }); + it('emits HEADLESS RAMPS_ORDER_FAILED with quote context when a live session is failed', async () => { + mockUseParams.mockReturnValue({ + ...callbackFlowParams, + network: 'eip155:1', + currency: 'USD', + cryptocurrency: 'ETH', + }); + mockGetSession.mockReturnValue({ + id: 'hs-1', + params: { + rampSurface: 'money_account', + amount: 100, + quote: { + quote: { amountIn: 120, amountOut: 0.04, paymentMethod: 'card' }, + }, + }, + }); + mockFailSession.mockReturnValue({ code: 'UNKNOWN', message: 'boom' }); + const { getByTestId } = renderWithProvider(, {}, true, false); + + await act(async () => { + fireEvent.press(getByTestId('trigger-http-error-main-uri')); + }); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ ramp_type: 'HEADLESS' }), + ); + }); + it('swallows consumer onOrderCreated errors and still closes + pops', async () => { const Logger = jest.requireMock('../../../../../util/Logger') as { error: jest.Mock; @@ -875,6 +904,104 @@ describe('Checkout', () => { expect(mockParentPop).toHaveBeenCalledTimes(1); }); + it('emits HEADLESS RAMPS_ORDER_FAILED with the session quote context when a live headless session fails', async () => { + mockUseParams.mockReturnValue({ + ...callbackFlowParams, + currency: 'USD', + cryptocurrency: 'ETH', + }); + mockGetSession.mockReturnValue({ + id: 'hs-1', + status: 'continued', + params: { + rampSurface: 'money_account', + amount: 100, + quote: { + quote: { + amountIn: 100, + amountOut: 0.05, + paymentMethod: 'debit-credit-card', + }, + }, + }, + callbacks: { + onOrderCreated: jest.fn(), + onError: jest.fn(), + onClose: jest.fn(), + }, + }); + mockFailSession.mockReturnValue({ + code: 'UNKNOWN', + message: 'fiat_on_ramp_aggregator.webview_received_error', + }); + + const { getByTestId } = renderWithProvider(, {}, true, false); + + await act(async () => { + fireEvent.press(getByTestId('trigger-http-error-main-uri')); + }); + + expect(mockFailSession).toHaveBeenCalledWith('hs-1', expect.anything()); + const idx = mockCreateEventBuilder.mock.calls.findIndex( + (c) => c[0] === MetaMetricsEvents.RAMPS_ORDER_FAILED, + ); + expect(idx).toBeGreaterThanOrEqual(0); + expect(mockAddProperties.mock.calls[idx][0]).toEqual( + expect.objectContaining({ + ramp_type: 'HEADLESS', + ramp_surface: 'money_account', + amount_source: 100, + amount_destination: 0.05, + payment_method_id: 'debit-credit-card', + currency_destination: 'ETH', + currency_source: 'USD', + is_authenticated: true, + error_message: expect.any(String), + }), + ); + }); + + it('falls back to empty/zero context when the failing headless session has no quote', async () => { + mockUseParams.mockReturnValue(callbackFlowParams); + mockGetSession.mockReturnValue({ + id: 'hs-1', + status: 'continued', + params: {}, + callbacks: { + onOrderCreated: jest.fn(), + onError: jest.fn(), + onClose: jest.fn(), + }, + }); + mockFailSession.mockReturnValue({ + code: 'UNKNOWN', + message: 'fiat_on_ramp_aggregator.webview_received_error', + }); + + const { getByTestId } = renderWithProvider(, {}, true, false); + + await act(async () => { + fireEvent.press(getByTestId('trigger-http-error-main-uri')); + }); + + const idx = mockCreateEventBuilder.mock.calls.findIndex( + (c) => c[0] === MetaMetricsEvents.RAMPS_ORDER_FAILED, + ); + expect(idx).toBeGreaterThanOrEqual(0); + expect(mockAddProperties.mock.calls[idx][0]).toEqual( + expect.objectContaining({ + ramp_type: 'HEADLESS', + ramp_surface: undefined, + amount_source: 0, + amount_destination: 0, + payment_method_id: '', + region: '', + currency_destination: '', + currency_source: '', + }), + ); + }); + it('treats an empty provider callback as user dismissal when headless', async () => { mockUseParams.mockReturnValue(callbackFlowParams); diff --git a/app/components/UI/Ramp/Views/Checkout/Checkout.tsx b/app/components/UI/Ramp/Views/Checkout/Checkout.tsx index 195c7c2d097..56620d5478f 100644 --- a/app/components/UI/Ramp/Views/Checkout/Checkout.tsx +++ b/app/components/UI/Ramp/Views/Checkout/Checkout.tsx @@ -31,7 +31,7 @@ import { HeaderStandard, type BottomSheetRef, } from '@metamask/design-system-react-native'; - +import { useRampsUserRegion } from '../../hooks/useRampsUserRegion'; import { closeSession, failSession, @@ -102,6 +102,7 @@ const Checkout = () => { const { addOrder, addPrecreatedOrder, getOrderFromCallback } = useRampsOrders(); const { trackEvent, createEventBuilder } = useAnalytics(); + const { userRegion } = useRampsUserRegion(); const { url: uri, providerName, @@ -117,6 +118,34 @@ const Checkout = () => { } = params ?? {}; const effectiveOrderId = (orderIdParam ?? customOrderId)?.trim() || null; + // Headless deposit (TRAM-3623): when a headless session drives this Checkout, + // every `buildBaseProps` funnel event is tagged `ramp_type: 'HEADLESS'` plus + // the seeded `ramp_surface`/`region`; non-headless UB2 keeps its defaults. + const headlessRampSurface = + getSession(headlessSessionId)?.params?.rampSurface; + const regionCode = userRegion?.regionCode || undefined; + const headlessBaseOverrides = useMemo( + () => + headlessSessionId + ? { + rampType: 'HEADLESS' as const, + rampSurface: headlessRampSurface, + region: regionCode, + } + : {}, + [headlessSessionId, headlessRampSurface, regionCode], + ); + // The non-`buildBaseProps` Checkout emits (RAMPS_SCREEN_VIEWED, + // RAMPS_CLOSE_BUTTON_CLICKED) default to 'UNIFIED_BUY_2'; flip them the same + // way (TRAM-3623). + const headlessRampProps = useMemo( + () => + headlessSessionId + ? { ramp_type: 'HEADLESS' as const, ramp_surface: headlessRampSurface } + : { ramp_type: 'UNIFIED_BUY_2' as const }, + [headlessSessionId, headlessRampSurface], + ); + const initialUriRef = useRef(uri); const registeredOrderIdsRef = useRef>(new Set()); const hasCallbackFlow = Boolean(providerCode && walletAddress); @@ -162,7 +191,8 @@ const Checkout = () => { createEventBuilder(MetaMetricsEvents.RAMPS_SCREEN_VIEWED) .addProperties({ location: 'Checkout', - ramp_type: 'UNIFIED_BUY_2', + ...headlessRampProps, + ...(headlessSessionId ? { region: regionCode } : {}), }) .build(), ); @@ -172,6 +202,7 @@ const Checkout = () => { ...buildBaseProps({ checkoutSessionId, providerName, + ...headlessBaseOverrides, }), initial_url_path: redactUrlForAnalytics(uri), has_callback_flow: hasCallbackFlow, @@ -188,6 +219,10 @@ const Checkout = () => { providerName, hasCallbackFlow, effectiveOrderId, + headlessRampProps, + headlessBaseOverrides, + headlessSessionId, + regionCode, ]); const dismissActiveHeadlessFlow = useCallback(() => { @@ -196,17 +231,55 @@ const Checkout = () => { const failHeadlessCheckout = useCallback( (checkoutError: unknown) => { - if ( - hasTerminatedHeadlessSessionRef.current || - !failSession(headlessSessionId, checkoutError) - ) { + if (hasTerminatedHeadlessSessionRef.current) { return false; } + // Snapshot the session BEFORE failSession tears it down so the HEADLESS + // RAMPS_ORDER_FAILED event (TRAM-3623 §7) can carry the seeded + // ramp_surface and quote/amount context; failSession can't emit itself. + const session = getSession(headlessSessionId); + if (!failSession(headlessSessionId, checkoutError)) { + return false; + } + if (session) { + const quoteRecord = session.params?.quote?.quote; + trackEvent( + createEventBuilder(MetaMetricsEvents.RAMPS_ORDER_FAILED) + .addProperties({ + ramp_type: 'HEADLESS', + ramp_surface: session.params?.rampSurface, + amount_source: Number( + quoteRecord?.amountIn ?? session.params?.amount ?? 0, + ), + amount_destination: Number(quoteRecord?.amountOut ?? 0), + payment_method_id: quoteRecord?.paymentMethod ?? '', + region: regionCode ?? '', + chain_id: network ?? '', + currency_destination: params?.cryptocurrency ?? '', + currency_source: params?.currency ?? '', + error_message: + checkoutError instanceof Error + ? checkoutError.message + : String(checkoutError), + is_authenticated: true, + }) + .build(), + ); + } hasTerminatedHeadlessSessionRef.current = true; dismissActiveHeadlessFlow(); return true; }, - [headlessSessionId, dismissActiveHeadlessFlow], + [ + headlessSessionId, + dismissActiveHeadlessFlow, + trackEvent, + createEventBuilder, + regionCode, + network, + params?.cryptocurrency, + params?.currency, + ], ); useEffect(() => { @@ -255,6 +328,7 @@ const Checkout = () => { ...buildBaseProps({ checkoutSessionId, providerName, + ...headlessBaseOverrides, }), url_path: redacted, previous_url_path: urlHistoryRef.current.previous ?? undefined, @@ -272,6 +346,7 @@ const Checkout = () => { checkoutSessionId, providerName, effectiveOrderId, + headlessBaseOverrides, ], ); @@ -294,6 +369,7 @@ const Checkout = () => { ...buildBaseProps({ checkoutSessionId, providerName, + ...headlessBaseOverrides, }), url_path: redactUrlForAnalytics(navState.url), order_id: effectiveOrderId ?? undefined, @@ -405,6 +481,7 @@ const Checkout = () => { checkoutSessionId, providerName, effectiveOrderId, + headlessBaseOverrides, ], ); @@ -414,11 +491,11 @@ const Checkout = () => { createEventBuilder(MetaMetricsEvents.RAMPS_CLOSE_BUTTON_CLICKED) .addProperties({ location: 'Checkout', - ramp_type: 'UNIFIED_BUY_2', + ...headlessRampProps, }) .build(), ); - }, [createEventBuilder, trackEvent]); + }, [createEventBuilder, trackEvent, headlessRampProps]); const handleClosePress = useCallback(() => { handleCancelPress(); if (headlessSessionId) { @@ -472,6 +549,7 @@ const Checkout = () => { ...buildBaseProps({ checkoutSessionId, providerName, + ...headlessBaseOverrides, }), url_path: redactedLoadedUrl, load_duration_ms: durationMs, @@ -487,6 +565,7 @@ const Checkout = () => { providerName, headlessSessionId, navigation, + headlessBaseOverrides, ], ); @@ -522,6 +601,7 @@ const Checkout = () => { ...buildBaseProps({ checkoutSessionId, providerName, + ...headlessBaseOverrides, }), close_source: closeSourceRef.current ?? 'background', order_id: effectiveOrderId ?? undefined, @@ -622,10 +702,15 @@ const Checkout = () => { ...buildBaseProps({ checkoutSessionId, providerName, + ...headlessBaseOverrides, }), url_path: redactUrlForAnalytics(errorUrl), status_code: nativeEvent.statusCode, is_initial_url: isInitialUrl, + error_message: strings( + 'fiat_on_ramp_aggregator.webview_received_error', + { code: nativeEvent.statusCode }, + ), }) .build(), ); diff --git a/app/components/UI/Ramp/Views/HeadlessPlayground/HeadlessPlayground.test.tsx b/app/components/UI/Ramp/Views/HeadlessPlayground/HeadlessPlayground.test.tsx index fa011a07683..0f4bf8d902e 100644 --- a/app/components/UI/Ramp/Views/HeadlessPlayground/HeadlessPlayground.test.tsx +++ b/app/components/UI/Ramp/Views/HeadlessPlayground/HeadlessPlayground.test.tsx @@ -1100,6 +1100,8 @@ describe('HeadlessPlayground', () => { assetId: HEADLESS_SIM_ASSET_ID, amount: 25, currency: mockUserRegion.country?.currency, + // Playground sandbox passes a default money_account surface (TRAM-3623). + rampSurface: 'money_account', }); expect(typeof callbacks.onOrderCreated).toBe('function'); expect(typeof callbacks.onError).toBe('function'); diff --git a/app/components/UI/Ramp/Views/HeadlessPlayground/HeadlessPlayground.tsx b/app/components/UI/Ramp/Views/HeadlessPlayground/HeadlessPlayground.tsx index 6389c31eab7..43ef81a484c 100644 --- a/app/components/UI/Ramp/Views/HeadlessPlayground/HeadlessPlayground.tsx +++ b/app/components/UI/Ramp/Views/HeadlessPlayground/HeadlessPlayground.tsx @@ -39,6 +39,7 @@ import { type QuotesResponse, } from '../../headless'; import type { Quote } from '../../types'; +import { RAMP_SURFACE } from '../../Deposit/types/analytics'; import styleSheet from './HeadlessPlayground.styles'; @@ -499,6 +500,9 @@ function HeadlessPlayground() { assetId: effectiveAssetId, amount: headlessAmountAsNumber, currency: fiatCurrency, + // Sandbox default: the playground simulates the Money "Add funds" + // consumer, so tag the headless analytics surface accordingly. + rampSurface: RAMP_SURFACE.MONEY_ACCOUNT, }; const session = startHeadlessBuy(params, { onOrderCreated: (orderId) => { diff --git a/app/components/UI/Ramp/Views/NativeFlow/BasicInfo.tsx b/app/components/UI/Ramp/Views/NativeFlow/BasicInfo.tsx index f7899a104eb..f0d3cfc0766 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/BasicInfo.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/BasicInfo.tsx @@ -47,6 +47,7 @@ import { useRampsUserRegion } from '../../hooks/useRampsUserRegion'; import type { TransakBuyQuote } from '@metamask/ramps-controller'; import type { AddressFormData } from '../../Deposit/Views/EnterAddress/EnterAddress'; import { parseUserFacingError } from '../../utils/parseUserFacingError'; +import { useHeadlessRampProps } from '../../headless/useHeadlessRampProps'; import { BASIC_INFO_TEST_IDS } from './BasicInfo.testIds'; import { createV2EnterEmailNavDetails } from './EnterEmail'; @@ -83,6 +84,11 @@ const V2BasicInfo = (): JSX.Element => { const [error, setError] = useState(null); const [isPhoneRegisteredError, setIsPhoneRegisteredError] = useState(false); + // Headless deposit (TRAM-3623): tag RAMPS_BASIC_INFO_ENTERED with + // `ramp_type: 'HEADLESS'` + the seeded `ramp_surface` when this screen is + // part of a headless buy flow; keep 'DEPOSIT' otherwise. + const { headlessDepositRampProps } = useHeadlessRampProps(headlessSessionId); + const firstNameInputRef = useRef(null); const lastNameInputRef = useRef(null); const phoneInputRef = useRef(null); @@ -176,7 +182,7 @@ const V2BasicInfo = (): JSX.Element => { trackEvent('RAMPS_BASIC_INFO_ENTERED', { region: regionIsoCode, - ramp_type: 'DEPOSIT', + ...headlessDepositRampProps, kyc_type: 'SIMPLE', }); @@ -249,6 +255,7 @@ const V2BasicInfo = (): JSX.Element => { headlessSessionId, regionIsoCode, trackEvent, + headlessDepositRampProps, ]); const enterEmailParamsForLogout = useMemo( diff --git a/app/components/UI/Ramp/Views/NativeFlow/EnterAddress.tsx b/app/components/UI/Ramp/Views/NativeFlow/EnterAddress.tsx index 9eee8a77b0c..098a632afdc 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/EnterAddress.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/EnterAddress.tsx @@ -32,6 +32,7 @@ import type { TransakBuyQuote } from '@metamask/ramps-controller'; import Routes from '../../../../../constants/navigation/Routes'; import type { BasicInfoFormData } from './BasicInfo'; import { parseUserFacingError } from '../../utils/parseUserFacingError'; +import { useHeadlessRampProps } from '../../headless/useHeadlessRampProps'; import { ENTER_ADDRESS_TEST_IDS } from './EnterAddress.testIds'; import StateSelector from './StateSelector'; @@ -64,6 +65,11 @@ const V2EnterAddress = (): JSX.Element => { const regionIsoCode = userRegion?.country?.isoCode || ''; + // Headless deposit (TRAM-3623): tag RAMPS_ADDRESS_ENTERED with + // `ramp_type: 'HEADLESS'` + the seeded `ramp_surface` when this screen is + // part of a headless buy flow; keep 'DEPOSIT' otherwise. + const { headlessDepositRampProps } = useHeadlessRampProps(headlessSessionId); + const transakRoutingConfig = useMemo( () => headlessSessionId @@ -204,7 +210,7 @@ const V2EnterAddress = (): JSX.Element => { trackEvent('RAMPS_ADDRESS_ENTERED', { region: regionIsoCode, - ramp_type: 'DEPOSIT', + ...headlessDepositRampProps, kyc_type: 'SIMPLE', }); @@ -238,6 +244,7 @@ const V2EnterAddress = (): JSX.Element => { routeAfterAuthentication, regionIsoCode, trackEvent, + headlessDepositRampProps, ]); return ( diff --git a/app/components/UI/Ramp/Views/NativeFlow/EnterEmail.tsx b/app/components/UI/Ramp/Views/NativeFlow/EnterEmail.tsx index bcd11ac1c5d..93c9c956860 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/EnterEmail.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/EnterEmail.tsx @@ -28,6 +28,7 @@ import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { useTransakController } from '../../hooks/useTransakController'; import { parseUserFacingError } from '../../utils/parseUserFacingError'; +import { useHeadlessRampProps } from '../../headless/useHeadlessRampProps'; import { EnterEmailSelectorsIDs } from './EnterEmail.testIds'; export interface V2EnterEmailParams { @@ -57,17 +58,25 @@ const V2EnterEmail = () => { const { trackEvent, createEventBuilder } = useAnalytics(); const { sendUserOtp } = useTransakController(); + // Headless deposit (TRAM-3623): when this screen is part of a headless buy + // flow, every analytics emit is tagged `ramp_type: 'HEADLESS'` (overriding + // the UB2/DEPOSIT literals) plus the seeded `ramp_surface`, sourced from the + // per-screen `headlessSessionId` so non-headless UB2 traffic is unaffected. + const headlessSessionId = params?.headlessSessionId; + const { headlessRampProps, headlessDepositRampProps } = + useHeadlessRampProps(headlessSessionId); + const handleHeaderBack = useCallback(() => { navigation.goBack(); trackEvent( createEventBuilder(MetaMetricsEvents.RAMPS_BACK_BUTTON_CLICKED) .addProperties({ location: 'Enter Email', - ramp_type: 'UNIFIED_BUY_2', + ...headlessRampProps, }) .build(), ); - }, [navigation, trackEvent, createEventBuilder]); + }, [navigation, trackEvent, createEventBuilder, headlessRampProps]); const hasTrackedScreenViewRef = useRef(false); useEffect(() => { @@ -77,11 +86,11 @@ const V2EnterEmail = () => { createEventBuilder(MetaMetricsEvents.RAMPS_SCREEN_VIEWED) .addProperties({ location: 'Enter Email', - ramp_type: 'UNIFIED_BUY_2', + ...headlessRampProps, }) .build(), ); - }, [trackEvent, createEventBuilder]); + }, [trackEvent, createEventBuilder, headlessRampProps]); const emailInputRef = useRef(null); @@ -109,7 +118,7 @@ const V2EnterEmail = () => { trackEvent( createEventBuilder(MetaMetricsEvents.RAMPS_EMAIL_SUBMITTED) .addProperties({ - ramp_type: 'DEPOSIT', + ...headlessDepositRampProps, }) .build(), ); @@ -132,7 +141,15 @@ const V2EnterEmail = () => { } finally { setIsLoading(false); } - }, [email, navigation, sendUserOtp, trackEvent, createEventBuilder, params]); + }, [ + email, + navigation, + sendUserOtp, + trackEvent, + createEventBuilder, + params, + headlessDepositRampProps, + ]); return ( diff --git a/app/components/UI/Ramp/Views/NativeFlow/KycProcessing.test.tsx b/app/components/UI/Ramp/Views/NativeFlow/KycProcessing.test.tsx index 4d5adba0e17..86243cd7576 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/KycProcessing.test.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/KycProcessing.test.tsx @@ -6,12 +6,15 @@ import { ThemeContext, mockTheme } from '../../../../../util/theme'; const mockNavigate = jest.fn(); const mockGoBack = jest.fn(); +let mockRouteParams: Record = {}; + jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), useNavigation: () => ({ navigate: mockNavigate, goBack: mockGoBack, }), + useRoute: () => ({ params: mockRouteParams }), useFocusEffect: (cb: () => void) => { const { useEffect } = jest.requireActual('react'); useEffect(cb, [cb]); @@ -45,6 +48,16 @@ jest.mock('../../hooks/useTransakRouting', () => ({ }), })); +let mockUserRegion: { regionCode?: string } | null = { regionCode: 'us-ca' }; +jest.mock('../../hooks/useRampsUserRegion', () => ({ + __esModule: true, + useRampsUserRegion: () => ({ userRegion: mockUserRegion }), +})); + +jest.mock('../../headless/sessionRegistry', () => ({ + getSession: jest.fn(() => undefined), +})); + const mockTrackEvent = jest.fn(); jest.mock('../../hooks/useAnalytics', () => ({ __esModule: true, @@ -75,6 +88,8 @@ describe('V2KycProcessing', () => { beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); + mockRouteParams = {}; + mockUserRegion = { regionCode: 'us-ca' }; mockBuyQuote = { quoteId: 'test-quote-id', fiatAmount: 100 }; mockGetAdditionalRequirements.mockResolvedValue({ formsRequired: [], @@ -250,6 +265,41 @@ describe('V2KycProcessing', () => { }); }); + it('flips KYC events to HEADLESS with surface/region/error_message in a headless flow (TRAM-3623 §2/§7)', async () => { + mockRouteParams = { headlessSessionId: 'hs-1' }; + const { getSession } = jest.requireMock('../../headless/sessionRegistry'); + (getSession as jest.Mock).mockReturnValue({ + id: 'hs-1', + status: 'continued', + params: { rampSurface: 'money_account' }, + }); + mockGetAdditionalRequirements.mockResolvedValue({ formsRequired: [] }); + mockGetUserDetails.mockResolvedValue({ + kyc: { status: 'REJECTED', type: 'SIMPLE' }, + }); + + renderWithTheme(); + + await act(async () => { + await Promise.resolve(); + }); + await act(async () => { + jest.advanceTimersByTime(100); + }); + + await waitFor(() => { + expect(mockTrackEvent).toHaveBeenCalledWith( + 'RAMPS_KYC_APPLICATION_FAILED', + expect.objectContaining({ + ramp_type: 'HEADLESS', + ramp_surface: 'money_account', + region: 'us-ca', + error_message: expect.any(String), + }), + ); + }); + }); + it('renders error state when getUserDetails fails', async () => { mockGetAdditionalRequirements.mockResolvedValue({ formsRequired: [], diff --git a/app/components/UI/Ramp/Views/NativeFlow/KycProcessing.tsx b/app/components/UI/Ramp/Views/NativeFlow/KycProcessing.tsx index 4a56738ab18..b3aa33ce652 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/KycProcessing.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/KycProcessing.tsx @@ -27,18 +27,38 @@ import { useTransakController } from '../../hooks/useTransakController'; import { useTransakRouting } from '../../hooks/useTransakRouting'; import type { TransakUserDetails } from '@metamask/ramps-controller'; import { parseUserFacingError } from '../../utils/parseUserFacingError'; +import { useHeadlessRampProps } from '../../headless/useHeadlessRampProps'; +import { useRampsUserRegion } from '../../hooks/useRampsUserRegion'; +import { useParams } from '../../../../../util/navigation/navUtils'; import { KYC_PROCESSING_TEST_IDS } from './KycProcessing.testIds'; +export interface V2KycProcessingParams { + /** + * Threaded from `useTransakRouting` resets when the KYC step is part of a + * headless buy flow (TRAM-3623). Used to flip the KYC analytics to + * `ramp_type: 'HEADLESS'` + the seeded `ramp_surface`. + */ + headlessSessionId?: string; +} + const V2KycProcessing = () => { const navigation = useNavigation(); const { styles, theme } = useStyles(styleSheet, {}); const trackEvent = useAnalytics(); + const { headlessSessionId } = useParams(); + + // Headless deposit (TRAM-3623): flip the KYC outcome events to + // `ramp_type: 'HEADLESS'` + the seeded `ramp_surface` when in a headless + // flow; keep 'DEPOSIT' otherwise. + const { headlessDepositRampProps } = useHeadlessRampProps(headlessSessionId); const { getAdditionalRequirements, getUserDetails, buyQuote: quote, } = useTransakController(); + const { userRegion } = useRampsUserRegion(); + const regionIsoCode = userRegion?.regionCode || ''; const { routeAfterAuthentication } = useTransakRouting({ screenLocation: 'V2 KycProcessing Screen', }); @@ -156,16 +176,26 @@ const V2KycProcessing = () => { useEffect(() => { if (kycStatus === KycStatus.REJECTED) { trackEvent('RAMPS_KYC_APPLICATION_FAILED', { - ramp_type: 'DEPOSIT', + ...headlessDepositRampProps, kyc_type: userDetails?.kyc?.type || '', + region: regionIsoCode, + error_message: + error || strings('deposit.kyc_processing.error_description'), }); } else if (kycStatus === KycStatus.APPROVED) { trackEvent('RAMPS_KYC_APPLICATION_APPROVED', { - ramp_type: 'DEPOSIT', + ...headlessDepositRampProps, kyc_type: userDetails?.kyc?.type || '', }); } - }, [kycStatus, trackEvent, userDetails?.kyc?.type]); + }, [ + kycStatus, + trackEvent, + userDetails?.kyc?.type, + headlessDepositRampProps, + regionIsoCode, + error, + ]); if (error || kycStatus === KycStatus.REJECTED || hasPendingForms) { return ( diff --git a/app/components/UI/Ramp/Views/NativeFlow/OtpCode.tsx b/app/components/UI/Ramp/Views/NativeFlow/OtpCode.tsx index f991a12d571..6bfca8d48b9 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/OtpCode.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/OtpCode.tsx @@ -47,6 +47,7 @@ import { useTransakController } from '../../hooks/useTransakController'; import { useTransakRouting } from '../../hooks/useTransakRouting'; import { useRampsController } from '../../hooks/useRampsController'; import { parseUserFacingError } from '../../utils/parseUserFacingError'; +import { useHeadlessRampProps } from '../../headless/useHeadlessRampProps'; import { OtpCodeSelectorsIDs } from './OtpCode.testIds'; import { hasTestOverrides } from '../../../../../util/test/utils'; @@ -98,6 +99,14 @@ const V2OtpCode = () => { useParams(); const { trackEvent, createEventBuilder } = useAnalytics(); + // Headless deposit (TRAM-3623): tag every emit on this screen with + // `ramp_type: 'HEADLESS'` + the seeded `ramp_surface` when a headless session + // drives the flow, sourced from the per-screen `headlessSessionId`. Two + // variants because some events default to 'UNIFIED_BUY_2' and others (OTP_*) + // to 'DEPOSIT' when not headless. + const { headlessRampProps, headlessDepositRampProps } = + useHeadlessRampProps(headlessSessionId); + const { setAuthToken, verifyUserOtp, @@ -141,11 +150,11 @@ const V2OtpCode = () => { createEventBuilder(MetaMetricsEvents.RAMPS_BACK_BUTTON_CLICKED) .addProperties({ location: 'OTP Code', - ramp_type: 'UNIFIED_BUY_2', + ...headlessRampProps, }) .build(), ); - }, [navigation, trackEvent, createEventBuilder]); + }, [navigation, trackEvent, createEventBuilder, headlessRampProps]); const hasTrackedScreenViewRef = useRef(false); useEffect(() => { @@ -155,11 +164,11 @@ const V2OtpCode = () => { createEventBuilder(MetaMetricsEvents.RAMPS_SCREEN_VIEWED) .addProperties({ location: 'OTP Code', - ramp_type: 'UNIFIED_BUY_2', + ...headlessRampProps, }) .build(), ); - }, [trackEvent, createEventBuilder]); + }, [trackEvent, createEventBuilder, headlessRampProps]); const [value, setValue] = useState(''); @@ -211,7 +220,7 @@ const V2OtpCode = () => { trackEvent( createEventBuilder(MetaMetricsEvents.RAMPS_OTP_RESENT) .addProperties({ - ramp_type: 'DEPOSIT', + ...headlessDepositRampProps, region: userRegion?.regionCode || '', }) .build(), @@ -230,6 +239,7 @@ const V2OtpCode = () => { userRegion?.regionCode, trackEvent, createEventBuilder, + headlessDepositRampProps, ]); const handleContactSupport = useCallback(() => { @@ -261,7 +271,7 @@ const V2OtpCode = () => { trackEvent( createEventBuilder(MetaMetricsEvents.RAMPS_OTP_CONFIRMED) .addProperties({ - ramp_type: 'DEPOSIT', + ...headlessDepositRampProps, region: userRegion?.regionCode || '', }) .build(), @@ -310,8 +320,12 @@ const V2OtpCode = () => { trackEvent( createEventBuilder(MetaMetricsEvents.RAMPS_OTP_FAILED) .addProperties({ - ramp_type: 'DEPOSIT', + ...headlessDepositRampProps, region: userRegion?.regionCode || '', + error_message: parseUserFacingError( + e, + strings('deposit.otp_code.error'), + ), }) .build(), ); @@ -340,6 +354,7 @@ const V2OtpCode = () => { selectedPaymentMethod?.id, routeAfterAuthentication, headlessSessionId, + headlessDepositRampProps, ]); const handleValueChange = useCallback( diff --git a/app/components/UI/Ramp/Views/NativeFlow/VerifyIdentity.tsx b/app/components/UI/Ramp/Views/NativeFlow/VerifyIdentity.tsx index 60ad9591f00..281a76a7dc1 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/VerifyIdentity.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/VerifyIdentity.tsx @@ -32,6 +32,7 @@ import { } from '../../../../../util/navigation/navUtils'; import { useDispatch } from 'react-redux'; import { createV2EnterEmailNavDetails } from './EnterEmail'; +import { useHeadlessRampProps } from '../../headless/useHeadlessRampProps'; import { VerifyIdentitySelectorsIDs } from './VerifyIdentity.testIds'; import { setHasAgreedTransakNativePolicy } from '../../../../../reducers/fiatOrders'; @@ -61,6 +62,11 @@ const V2VerifyIdentity = () => { const regionIsoCode = userRegion?.country?.isoCode || ''; + // Headless deposit (TRAM-3623): flip every emit on this screen to + // `ramp_type: 'HEADLESS'` + the seeded `ramp_surface` when a headless + // session drives the flow. All emits here default to 'UNIFIED_BUY_2'. + const { headlessRampProps } = useHeadlessRampProps(headlessSessionId); + const navigateToEnterEmail = useCallback(() => { navigation.navigate( ...createV2EnterEmailNavDetails({ @@ -78,11 +84,11 @@ const V2VerifyIdentity = () => { createEventBuilder(MetaMetricsEvents.RAMPS_BACK_BUTTON_CLICKED) .addProperties({ location: 'Verify Identity', - ramp_type: 'UNIFIED_BUY_2', + ...headlessRampProps, }) .build(), ); - }, [navigation, trackEvent, createEventBuilder]); + }, [navigation, trackEvent, createEventBuilder, headlessRampProps]); const hasTrackedScreenViewRef = useRef(false); useEffect(() => { @@ -92,24 +98,30 @@ const V2VerifyIdentity = () => { createEventBuilder(MetaMetricsEvents.RAMPS_SCREEN_VIEWED) .addProperties({ location: 'Verify Identity', - ramp_type: 'UNIFIED_BUY_2', + ...headlessRampProps, }) .build(), ); - }, [trackEvent, createEventBuilder]); + }, [trackEvent, createEventBuilder, headlessRampProps]); const handleSubmit = useCallback(() => { trackEvent( createEventBuilder(MetaMetricsEvents.RAMPS_TERMS_CONSENT_CLICKED) .addProperties({ location: 'Verify Identity', - ramp_type: 'UNIFIED_BUY_2', + ...headlessRampProps, }) .build(), ); dispatch(setHasAgreedTransakNativePolicy(true)); navigateToEnterEmail(); - }, [dispatch, navigateToEnterEmail, trackEvent, createEventBuilder]); + }, [ + dispatch, + navigateToEnterEmail, + trackEvent, + createEventBuilder, + headlessRampProps, + ]); const handleTransakLink = useCallback(() => { let urlDomain: string = TRANSAK_URL; @@ -124,12 +136,12 @@ const V2VerifyIdentity = () => { location: 'Verify Identity', external_link_description: 'Transak', url_domain: urlDomain, - ramp_type: 'UNIFIED_BUY_2', + ...headlessRampProps, }) .build(), ); Linking.openURL(TRANSAK_URL); - }, [trackEvent, createEventBuilder]); + }, [trackEvent, createEventBuilder, headlessRampProps]); const handlePrivacyPolicyLink = useCallback(() => { let urlDomain: string = CONSENSYS_PRIVACY_POLICY_URL; @@ -144,12 +156,12 @@ const V2VerifyIdentity = () => { location: 'Verify Identity', external_link_description: 'Privacy Policy', url_domain: urlDomain, - ramp_type: 'UNIFIED_BUY_2', + ...headlessRampProps, }) .build(), ); Linking.openURL(CONSENSYS_PRIVACY_POLICY_URL); - }, [trackEvent, createEventBuilder]); + }, [trackEvent, createEventBuilder, headlessRampProps]); const handleTransakTermsLink = useCallback(() => { const termsUrl = @@ -166,12 +178,12 @@ const V2VerifyIdentity = () => { location: 'Verify Identity', external_link_description: 'Transak Terms', url_domain: urlDomain, - ramp_type: 'UNIFIED_BUY_2', + ...headlessRampProps, }) .build(), ); Linking.openURL(termsUrl); - }, [regionIsoCode, trackEvent, createEventBuilder]); + }, [regionIsoCode, trackEvent, createEventBuilder, headlessRampProps]); return ( diff --git a/app/components/UI/Ramp/headless/types.ts b/app/components/UI/Ramp/headless/types.ts index c9d1dad57f7..431855da7a8 100644 --- a/app/components/UI/Ramp/headless/types.ts +++ b/app/components/UI/Ramp/headless/types.ts @@ -8,6 +8,7 @@ import type { UserRegion, } from '@metamask/ramps-controller'; import type { Quote } from '../types'; +import type { RampSurface } from '../Deposit/types/analytics'; /** * Public input for {@link useHeadlessBuy}'s `getQuotes`. @@ -134,6 +135,14 @@ export interface HeadlessBuyParams { * `useRampAccountAddress(chainId)` — same EOA the BuildQuote screen uses. */ walletAddress?: string; + /** + * Which product surface initiated this headless buy (TRAM-3623 analytics). + * Stored on the session so every downstream emit (auth loop, KYC, checkout, + * terminal events) can tag the `ramp_surface` analytics property consistently + * without re-deriving it. Set by the confirmations Money-deposit caller via a + * tx-type map; the playground passes a sensible default. + */ + rampSurface?: RampSurface; } /** diff --git a/app/components/UI/Ramp/headless/useHeadlessRampProps.test.ts b/app/components/UI/Ramp/headless/useHeadlessRampProps.test.ts new file mode 100644 index 00000000000..84921ff35f5 --- /dev/null +++ b/app/components/UI/Ramp/headless/useHeadlessRampProps.test.ts @@ -0,0 +1,70 @@ +import { renderHook } from '@testing-library/react-native'; +import { useHeadlessRampProps } from './useHeadlessRampProps'; +import { getSession } from './sessionRegistry'; + +jest.mock('./sessionRegistry', () => ({ + getSession: jest.fn(() => undefined), +})); + +const getSessionMock = jest.mocked(getSession); + +describe('useHeadlessRampProps', () => { + beforeEach(() => { + jest.clearAllMocks(); + getSessionMock.mockReturnValue(undefined); + }); + + it('returns the UB2 / DEPOSIT defaults with no surface when there is no session id', () => { + const { result } = renderHook(() => useHeadlessRampProps(undefined)); + + expect(result.current.headlessRampProps).toEqual({ + ramp_type: 'UNIFIED_BUY_2', + }); + expect(result.current.headlessDepositRampProps).toEqual({ + ramp_type: 'DEPOSIT', + }); + // No session lookup happens for non-headless traffic. + expect(getSessionMock).toHaveBeenCalledWith(undefined); + }); + + it('flips both variants to HEADLESS with the seeded rampSurface when a headless session is present', () => { + getSessionMock.mockReturnValue({ + id: 'hs-1', + status: 'continued', + params: { rampSurface: 'money_account' }, + } as never); + + const { result } = renderHook(() => useHeadlessRampProps('hs-1')); + + expect(result.current.headlessRampProps).toEqual({ + ramp_type: 'HEADLESS', + ramp_surface: 'money_account', + }); + expect(result.current.headlessDepositRampProps).toEqual({ + ramp_type: 'HEADLESS', + ramp_surface: 'money_account', + }); + expect(getSessionMock).toHaveBeenCalledWith('hs-1'); + }); + + it('keeps prop object identity stable across re-renders with the same session id', () => { + getSessionMock.mockReturnValue({ + id: 'hs-3', + status: 'continued', + params: { rampSurface: 'perps' }, + } as never); + + const { result, rerender } = renderHook( + (sessionId: string) => useHeadlessRampProps(sessionId), + { initialProps: 'hs-3' }, + ); + + const first = result.current; + rerender('hs-3'); + + expect(result.current.headlessRampProps).toBe(first.headlessRampProps); + expect(result.current.headlessDepositRampProps).toBe( + first.headlessDepositRampProps, + ); + }); +}); diff --git a/app/components/UI/Ramp/headless/useHeadlessRampProps.ts b/app/components/UI/Ramp/headless/useHeadlessRampProps.ts new file mode 100644 index 00000000000..301ede4afd0 --- /dev/null +++ b/app/components/UI/Ramp/headless/useHeadlessRampProps.ts @@ -0,0 +1,66 @@ +import { useMemo } from 'react'; +import { getSession } from './sessionRegistry'; +import type { RampSurface } from '../Deposit/types/analytics'; + +/** + * Analytics ramp-type/surface props for a NativeFlow screen, in the two shapes + * the funnel needs (TRAM-3623). + * + * The emitted analytics property is always `ramp_surface`; only the internal + * session param that seeds it is named `rampSurface`. + */ +export interface HeadlessRampProps { + /** + * For events whose non-headless `ramp_type` is `'UNIFIED_BUY_2'` (most + * NativeFlow emits). Flips to `'HEADLESS'` plus the seeded surface when a + * headless session drives the flow. + */ + headlessRampProps: + | { ramp_type: 'HEADLESS'; ramp_surface?: RampSurface } + | { ramp_type: 'UNIFIED_BUY_2' }; + /** + * For events whose non-headless `ramp_type` is `'DEPOSIT'` (EMAIL_SUBMITTED, + * OTP_*, BASIC_INFO_ENTERED, ADDRESS_ENTERED, KYC outcomes). Flips to + * `'HEADLESS'` plus the seeded surface on the headless path. + */ + headlessDepositRampProps: + | { ramp_type: 'HEADLESS'; ramp_surface?: RampSurface } + | { ramp_type: 'DEPOSIT' }; +} + +/** + * Shared headless analytics tagging for the NativeFlow screens (TRAM-3623). + * Centralizes deriving the two prop objects from + * `getSession(headlessSessionId)?.params`: with a `headlessSessionId` the + * emits are tagged `ramp_type: 'HEADLESS'` plus the seeded `ramp_surface`, + * otherwise they keep their original UB2 / DEPOSIT literal and no surface so + * non-headless traffic is unaffected. + * + * @param headlessSessionId - Session id from navigation params, or `undefined` + * for regular (non-headless) UB2 / Deposit traffic. + */ +export function useHeadlessRampProps( + headlessSessionId: string | undefined, +): HeadlessRampProps { + const headlessSurface = getSession(headlessSessionId)?.params?.rampSurface; + + const headlessRampProps = useMemo( + () => + headlessSessionId + ? { ramp_type: 'HEADLESS', ramp_surface: headlessSurface } + : { ramp_type: 'UNIFIED_BUY_2' }, + [headlessSessionId, headlessSurface], + ); + + const headlessDepositRampProps = useMemo< + HeadlessRampProps['headlessDepositRampProps'] + >( + () => + headlessSessionId + ? { ramp_type: 'HEADLESS', ramp_surface: headlessSurface } + : { ramp_type: 'DEPOSIT' }, + [headlessSessionId, headlessSurface], + ); + + return { headlessRampProps, headlessDepositRampProps }; +} diff --git a/app/components/UI/Ramp/hooks/useTransakRouting.test.ts b/app/components/UI/Ramp/hooks/useTransakRouting.test.ts index 4d0a6b5547f..a78c47f18f3 100644 --- a/app/components/UI/Ramp/hooks/useTransakRouting.test.ts +++ b/app/components/UI/Ramp/hooks/useTransakRouting.test.ts @@ -1,6 +1,7 @@ import { renderHook, act } from '@testing-library/react-native'; import { type TransakBuyQuote } from '@metamask/ramps-controller'; import { useTransakRouting } from './useTransakRouting'; +import { extractOrderCode } from '../utils/extractOrderCode'; const mockNavigate = jest.fn(); const mockReset = jest.fn(); @@ -29,6 +30,16 @@ jest.mock('../headless', () => ({ }, })); +const mockSetHeadlessOrderContext = jest.fn(); +jest.mock( + '../../../../core/Engine/controllers/ramps-controller/headlessOrderContextRegistry', + () => ({ + setHeadlessOrderContext: ( + ...args: Parameters + ) => mockSetHeadlessOrderContext(...args), + }), +); + const MOCK_WALLET_ADDRESS = '0xabcdef1234567890'; jest.mock('react-redux', () => ({ @@ -258,6 +269,12 @@ describe('useTransakRouting', () => { beforeEach(() => { jest.clearAllMocks(); capturedHandleNavigationStateChange = null; + // clearAllMocks resets call records but not return values set via + // mockReturnValue, so explicitly default getSession back to "no session" + // (matching the bare jest.fn() default) to stop per-test sessions leaking. + ( + jest.requireMock('../headless/sessionRegistry').getSession as jest.Mock + ).mockReturnValue(undefined); mockUserRegion = { country: { currency: 'USD', isoCode: 'US' }, regionCode: 'us-ca', @@ -318,6 +335,64 @@ describe('useTransakRouting', () => { 'RAMPS_KYC_STARTED', expect.objectContaining({ ramp_type: 'DEPOSIT' }), ); + // Non-headless stays DEPOSIT and carries no surface (TRAM-3623). + expect(mockTrackEvent).not.toHaveBeenCalledWith( + 'RAMPS_KYC_STARTED', + expect.objectContaining({ ramp_type: 'HEADLESS' }), + ); + expect(mockTrackEvent).toHaveBeenCalledWith( + 'RAMPS_KYC_STARTED', + expect.objectContaining({ ramp_surface: undefined }), + ); + }); + + it('emits HEADLESS RAMPS_KYC_STARTED with ramp_surface when the session is headless (NOT_SUBMITTED)', async () => { + const mockGetSession = jest.requireMock('../headless/sessionRegistry') + .getSession as jest.Mock; + mockGetSession.mockReturnValue({ + id: 'hs-kyc', + params: { rampSurface: 'perps' }, + callbacks: { + onOrderCreated: jest.fn(), + onClose: jest.fn(), + onError: jest.fn(), + }, + }); + mockGetUserDetails.mockResolvedValue({ + firstName: 'John', + lastName: 'Doe', + mobileNumber: '+1234567890', + dob: '1990-01-01', + address: {}, + }); + mockGetKycRequirement.mockResolvedValue({ + status: 'NOT_SUBMITTED', + kycType: 'SIMPLE', + }); + + const { result } = renderHook(() => + useTransakRouting({ + baseRoute: 'RampHeadlessHost', + baseRouteParams: { headlessSessionId: 'hs-kyc' }, + }), + ); + + await act(async () => { + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); + }); + + expect(mockTrackEvent).toHaveBeenCalledWith( + 'RAMPS_KYC_STARTED', + expect.objectContaining({ + ramp_type: 'HEADLESS', + ramp_surface: 'perps', + kyc_type: 'SIMPLE', + region: 'us-ca', + }), + ); }); it('merges baseRouteParams onto BasicInfo when KYC is NOT_SUBMITTED (headless)', async () => { @@ -719,6 +794,69 @@ describe('useTransakRouting', () => { ], }), ); + // Non-headless IDPROOF KYC_STARTED stays DEPOSIT (TRAM-3623). + expect(mockTrackEvent).toHaveBeenCalledWith( + 'RAMPS_KYC_STARTED', + expect.objectContaining({ ramp_type: 'DEPOSIT', kyc_type: 'STANDARD' }), + ); + expect(mockTrackEvent).not.toHaveBeenCalledWith( + 'RAMPS_KYC_STARTED', + expect.objectContaining({ ramp_type: 'HEADLESS' }), + ); + }); + + it('emits HEADLESS RAMPS_KYC_STARTED with ramp_surface for the IDPROOF path when headless', async () => { + const mockGetSession = jest.requireMock('../headless/sessionRegistry') + .getSession as jest.Mock; + mockGetSession.mockReturnValue({ + id: 'hs-idproof', + params: { rampSurface: 'prediction' }, + callbacks: { + onOrderCreated: jest.fn(), + onClose: jest.fn(), + onError: jest.fn(), + }, + }); + mockGetUserDetails.mockResolvedValue({ + firstName: 'John', + address: {}, + }); + mockGetKycRequirement.mockResolvedValue({ + status: 'ADDITIONAL_FORMS_REQUIRED', + kycType: 'STANDARD', + }); + mockGetAdditionalRequirements.mockResolvedValue({ + formsRequired: [ + { + type: 'IDPROOF', + metadata: { + kycUrl: 'https://kyc.example.com', + workFlowRunId: 'wf-123', + }, + }, + ], + }); + + const { result } = renderHook(() => + useTransakRouting({ + baseRoute: 'RampHeadlessHost', + baseRouteParams: { headlessSessionId: 'hs-idproof' }, + }), + ); + + await act(async () => { + await result.current.routeAfterAuthentication(mockQuote as never, 25); + }); + + expect(mockTrackEvent).toHaveBeenCalledWith( + 'RAMPS_KYC_STARTED', + expect.objectContaining({ + ramp_type: 'HEADLESS', + ramp_surface: 'prediction', + kyc_type: 'STANDARD', + region: 'us-ca', + }), + ); }); it('handles ADDITIONAL_FORMS_REQUIRED with IDPROOF when user amount is omitted', async () => { @@ -1557,9 +1695,12 @@ describe('useTransakRouting', () => { ]), }), ); + // Headless webview-redirect terminal event is flipped to HEADLESS and + // carries region (TRAM-3623 §4/§5); the session mock has no params so + // ramp_surface is undefined here. expect(mockTrackEvent).toHaveBeenCalledWith( 'RAMPS_TRANSACTION_CONFIRMED', - expect.objectContaining({ ramp_type: 'DEPOSIT' }), + expect.objectContaining({ ramp_type: 'HEADLESS', region: 'us-ca' }), ); expect(mockReset).not.toHaveBeenCalledWith( expect.objectContaining({ @@ -1569,6 +1710,45 @@ describe('useTransakRouting', () => { expect(mockShowV2OrderToast).not.toHaveBeenCalled(); }); + it('carries ramp_surface from the live session on the HEADLESS terminal event', async () => { + const onOrderCreated = jest.fn(); + mockGetSession.mockReturnValue({ + id: 'hs-1', + status: 'continued', + params: { rampSurface: 'money_account' }, + callbacks: { + onOrderCreated, + onError: jest.fn(), + onClose: jest.fn(), + }, + }); + + const handler = await runApprovedFlowHeadless(); + expect(handler).not.toBeNull(); + if (!handler) return; + + await act(async () => { + await handler({ + url: 'https://redirect.example.com?orderId=order-hs', + }); + }); + + expect(mockTrackEvent).toHaveBeenCalledWith( + 'RAMPS_TRANSACTION_CONFIRMED', + expect.objectContaining({ + ramp_type: 'HEADLESS', + ramp_surface: 'money_account', + region: 'us-ca', + }), + ); + // Writes the headless context for the terminal-failed subscriber + // (TRAM-3623 §2), keyed by the same providerOrderId. + expect(mockSetHeadlessOrderContext).toHaveBeenCalledWith('order-hs', { + rampSurface: 'money_account', + region: 'us-ca', + }); + }); + it('swallows consumer onOrderCreated errors and still closes + pops', async () => { const Logger = jest.requireMock('../../../../util/Logger'); const mockLoggerError = Logger.error as jest.Mock; @@ -1668,6 +1848,42 @@ describe('useTransakRouting', () => { expect(mockParentPop).toHaveBeenCalled(); }); + it('emits a HEADLESS RAMPS_ORDER_FAILED when a headless checkout processing failure occurs (TRAM-3623 §7)', async () => { + mockGetSession.mockReturnValue({ + id: 'hs-1', + status: 'continued', + params: { rampSurface: 'money_account', amount: 100 }, + callbacks: { + onOrderCreated: jest.fn(), + onError: jest.fn(), + onClose: jest.fn(), + }, + }); + mockFailSession.mockReturnValue({ code: 'UNKNOWN', message: 'boom' }); + + const handler = await runApprovedFlowHeadless(); + expect(handler).not.toBeNull(); + if (!handler) return; + + mockGetOrder.mockRejectedValue(new Error('Network error')); + + await act(async () => { + await handler({ + url: 'https://redirect.example.com?orderId=order-hs', + }); + }); + + expect(mockTrackEvent).toHaveBeenCalledWith( + 'RAMPS_ORDER_FAILED', + expect.objectContaining({ + ramp_type: 'HEADLESS', + ramp_surface: 'money_account', + region: 'us-ca', + error_message: expect.any(String), + }), + ); + }); + it('treats a Transak redirect without orderId as user dismissal when headless', async () => { const handler = await runApprovedFlowHeadless(); expect(handler).not.toBeNull(); @@ -1692,6 +1908,7 @@ describe('useTransakRouting', () => { mockGetSession.mockReturnValue({ id: 'hs-1', status: 'continued', + params: { rampSurface: 'money_account' }, callbacks: { onOrderCreated, onError: jest.fn(), @@ -1739,6 +1956,23 @@ describe('useTransakRouting', () => { reason: 'completed', }); expect(mockParentPop).toHaveBeenCalled(); + // Manual-bank headless branch now fires a HEADLESS terminal confirmed + // event (TRAM-3623 §4) that previously did not exist. + expect(mockTrackEvent).toHaveBeenCalledWith( + 'RAMPS_TRANSACTION_CONFIRMED', + expect.objectContaining({ + ramp_type: 'HEADLESS', + ramp_surface: 'money_account', + region: 'us-ca', + }), + ); + // Manual-bank headless branch also writes the terminal-failed context + // (TRAM-3623 §2), keyed by the same providerOrderId. + expect(mockSetHeadlessOrderContext).toHaveBeenCalledWith('order-bank-1', { + rampSurface: 'money_account', + region: 'us-ca', + }); + // ...and the order toast stays suppressed on the headless path. expect(mockShowV2OrderToast).not.toHaveBeenCalled(); }); @@ -1778,6 +2012,120 @@ describe('useTransakRouting', () => { expect(mockShowV2OrderToast).not.toHaveBeenCalled(); expect(mockCloseSession).not.toHaveBeenCalled(); expect(mockParentPop).not.toHaveBeenCalled(); + // CRITICAL guard (TRAM-3623 §2): the write is headless-gated, so a + // session-less (non-headless UB2-native) order must NOT seed the + // registry - otherwise it would wrongly emit a HEADLESS terminal-failed. + expect(mockSetHeadlessOrderContext).not.toHaveBeenCalled(); + }); + + it('does not write the headless context for a non-headless webview success (no session)', async () => { + // No headless session: useTransakRouting() with no config, getSession + // returns undefined (the suite default). + mockGetSession.mockReturnValue(undefined); + mockGetUserDetails.mockResolvedValue({ + firstName: 'John', + lastName: 'Doe', + mobileNumber: '+1', + dob: '1990-01-01', + address: {}, + }); + mockGetKycRequirement.mockResolvedValue({ + status: 'APPROVED', + kycType: 'SIMPLE', + }); + mockGetUserLimits.mockResolvedValue({ + remaining: { '1': 10000, '30': 50000, '365': 200000 }, + }); + mockRequestOtt.mockResolvedValue({ ott: 'test-ott' }); + mockGeneratePaymentWidgetUrl.mockReturnValue( + 'https://payment.example.com', + ); + + const { result } = renderHook(() => useTransakRouting()); + await act(async () => { + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); + }); + const handler = capturedHandleNavigationStateChange; + expect(handler).not.toBeNull(); + if (!handler) return; + + mockGetOrder.mockResolvedValue({ + id: 'order-123', + providerOrderId: 'order-123', + provider: 'transak-native', + walletAddress: MOCK_WALLET_ADDRESS, + paymentDetails: {}, + }); + mockRefreshOrder.mockResolvedValue({ + providerOrderId: 'order-123', + cryptoCurrency: { symbol: 'ETH' }, + cryptoAmount: '0.05', + status: 'Pending', + fiatAmount: 100, + exchangeRate: 2000, + networkFees: 0, + partnerFees: 0, + totalFeesFiat: 0, + paymentMethod: { id: 'card' }, + network: { chainId: '1', name: 'Ethereum' }, + fiatCurrency: { symbol: 'USD' }, + }); + + await act(async () => { + await handler({ + url: 'https://redirect.example.com?orderId=order-123', + }); + }); + + // Non-headless (UB2-native) webview success: main routes it to + // RAMPS_ORDER_DETAILS, so the inline confirmed event and the headless + // context write (both headless-only) do not fire here. + expect(mockTrackEvent).not.toHaveBeenCalledWith( + 'RAMPS_TRANSACTION_CONFIRMED', + expect.anything(), + ); + expect(mockSetHeadlessOrderContext).not.toHaveBeenCalled(); + }); + + it('key-parity: the providerOrderId written by the headless CONFIRMED block matches what the subscriber receives for the same order', async () => { + const onOrderCreated = jest.fn(); + mockGetSession.mockReturnValue({ + id: 'hs-1', + status: 'continued', + params: { rampSurface: 'money_account' }, + callbacks: { + onOrderCreated, + onError: jest.fn(), + onClose: jest.fn(), + }, + }); + + const handler = await runApprovedFlowHeadless(); + expect(handler).not.toBeNull(); + if (!handler) return; + + await act(async () => { + await handler({ + url: 'https://redirect.example.com?orderId=order-hs', + }); + }); + + // The hook writes the context keyed by the order it just created/refreshed. + expect(mockSetHeadlessOrderContext).toHaveBeenCalledTimes(1); + const writtenOrderId = mockSetHeadlessOrderContext.mock.calls[0][0]; + + // The controller-side subscriber receives this same order under the same + // providerOrderId (the refreshed order's id). extractOrderCode is applied + // on both the registry write and read, so the keys resolve identically - + // proving the lookup actually matches in production, not just in synthetic + // tests. refreshedOrder.providerOrderId is 'order-hs'. + expect(writtenOrderId).toBe('order-hs'); + expect(extractOrderCode(writtenOrderId)).toBe( + extractOrderCode(refreshedOrder.providerOrderId), + ); }); }); }); diff --git a/app/components/UI/Ramp/hooks/useTransakRouting.ts b/app/components/UI/Ramp/hooks/useTransakRouting.ts index 540d660f7f1..e1f81134081 100644 --- a/app/components/UI/Ramp/hooks/useTransakRouting.ts +++ b/app/components/UI/Ramp/hooks/useTransakRouting.ts @@ -36,6 +36,7 @@ import { } from '../headless/sessionRegistry'; import { dismissHeadlessFlow } from '../headless/headlessEntryNavigation'; import { getChainIdFromAssetId } from '../headless'; +import { setHeadlessOrderContext } from '../../../../core/Engine/controllers/ramps-controller/headlessOrderContextRegistry'; interface RampStackParamList { /** `baseRouteParams` (e.g. `headlessSessionId`) are merged onto this route in resets — see `navigateToVerifyIdentityCallback`. */ @@ -64,7 +65,7 @@ interface RampStackParamList { /** User-entered fiat from BuildQuote; used when resetting stack so amount screen keeps the typed value. */ amount?: number; }; - RampKycProcessing: undefined; + RampKycProcessing: { headlessSessionId?: string } | undefined; RampEnterEmail: undefined; Checkout: { url: string; @@ -182,6 +183,47 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => { const fiatCurrency = userRegion?.country?.currency || ''; const regionIsoCode = userRegion?.regionCode || ''; + /** + * Emits HEADLESS `RAMPS_ORDER_FAILED` for headless buy failures (TRAM-3623 + * §7): `failSession` (non-React) can't emit, so this host does, around that + * call. No-op without a live session; `quote` (when in scope) seeds amount. + */ + const emitHeadlessOrderFailed = useCallback( + (error: unknown, quote?: TransakBuyQuote) => { + const session = getSession(headlessSessionId); + if (!session) { + return; + } + trackEvent('RAMPS_ORDER_FAILED', { + ramp_type: 'HEADLESS', + ramp_surface: session.params?.rampSurface, + amount_source: Number(quote?.fiatAmount ?? session.params?.amount ?? 0), + amount_destination: 0, + payment_method_id: selectedPaymentMethod?.id || '', + region: regionIsoCode, + chain_id: (selectedToken?.chainId as string) || '', + currency_destination: selectedToken?.assetId || '', + currency_destination_symbol: selectedToken?.symbol || undefined, + currency_source: quote?.fiatCurrency || fiatCurrency || '', + error_message: parseUserFacingError( + error, + strings('deposit.buildQuote.unexpectedError'), + ), + is_authenticated: true, + }); + }, + [ + headlessSessionId, + trackEvent, + selectedPaymentMethod?.id, + regionIsoCode, + selectedToken?.chainId, + selectedToken?.assetId, + selectedToken?.symbol, + fiatCurrency, + ], + ); + const checkUserLimits = useCallback( async (quote: TransakBuyQuote, kycType: string) => { try { @@ -253,16 +295,27 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => { } } catch (error) { if (error instanceof LimitExceededError) { + // Surfaced to the Headless Host as recoverable data, not a terminal + // failure - intentionally no RAMPS_ORDER_FAILED here. throw error; } if (headlessSessionId) { + // Infra failure during the limits check: emit the HEADLESS failure + // event (TRAM-3623 §7) before failSession closes the session. + emitHeadlessOrderFailed(error, quote); failSession(headlessSessionId, error); throw error; } Logger.error(error as Error, 'Failed to check user limits'); } }, - [getUserLimits, fiatCurrency, selectedPaymentMethod?.id, headlessSessionId], + [ + getUserLimits, + fiatCurrency, + selectedPaymentMethod?.id, + headlessSessionId, + emitHeadlessOrderFailed, + ], ); const navigateToVerifyIdentityCallback = useCallback( @@ -448,6 +501,26 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => { paymentDetails: depositOrder.paymentDetails, }); + // Snapshot the headless context BEFORE closeSession tears it down + // (TRAM-3623 §4) so the terminal RAMPS_TRANSACTION_CONFIRMED carries + // ramp_type HEADLESS + the seeded ramp_surface. + const confirmSession = getSession(headlessSessionId); + const wasHeadless = Boolean(confirmSession); + const rampSurface = confirmSession?.params?.rampSurface; + // Carry the headless context (surface + region) to the controller-side + // terminal-failed subscriber (TRAM-3623 §1/§5). GUARD: this write MUST + // stay inside the headless gate (`if (wasHeadless)`); this hook also + // serves non-headless UB2-native, and an unconditional write at the + // `addOrder(...)` above would make those orders wrongly emit a HEADLESS + // RAMPS_TRANSACTION_FAILED. Keyed by providerOrderId (the same id the + // subscriber receives), normalized via extractOrderCode on both sides. + if (wasHeadless) { + setHeadlessOrderContext(rampsOrder.providerOrderId, { + rampSurface, + region: regionIsoCode, + }); + } + try { session.callbacks.onOrderCreated(rampsOrder.providerOrderId); } catch (callbackError) { @@ -463,7 +536,8 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => { navigation.getParent()?.pop(); trackEvent('RAMPS_TRANSACTION_CONFIRMED', { - ramp_type: 'DEPOSIT', + ramp_type: wasHeadless ? 'HEADLESS' : 'DEPOSIT', + ramp_surface: rampSurface, amount_source: Number(rampsOrder.fiatAmount), amount_destination: Number(rampsOrder.cryptoAmount), exchange_rate: Number(rampsOrder.exchangeRate), @@ -476,6 +550,7 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => { total_fee: Number(rampsOrder.totalFeesFiat), payment_method_id: rampsOrder.paymentMethod?.id || '', country: regionIsoCode, + region: regionIsoCode, chain_id: rampsOrder.network?.chainId || '', currency_destination: rampsOrder.cryptoCurrency?.assetId || '', currency_destination_symbol: @@ -489,6 +564,9 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => { message: 'useTransakRouting: Failed to process order after checkout', }); + // Emit the HEADLESS failure event BEFORE failSession tears the + // session down (TRAM-3623 §7) so the surface snapshot is available. + emitHeadlessOrderFailed(error); if (failSession(headlessSessionId, error)) { // @ts-expect-error `pop` exists on the parent stack navigator at // runtime but is not surfaced on the generic `NavigationProp` @@ -539,6 +617,7 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => { selectedToken, selectedProvider, dismissActiveHeadlessFlow, + emitHeadlessOrderFailed, ], ); @@ -569,10 +648,18 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => { const baseEntry = buildBaseRouteEntry({ amount }); navigation.reset({ index: 1, - routes: [baseEntry, { name: Routes.RAMP.KYC_PROCESSING }], + routes: [ + baseEntry, + { + name: Routes.RAMP.KYC_PROCESSING, + // Thread the headless session id so KycProcessing (no params of its + // own) can flip its KYC analytics to `ramp_type: 'HEADLESS'` (TRAM-3623). + params: headlessSessionId ? { headlessSessionId } : undefined, + }, + ], }); }, - [navigation, buildBaseRouteEntry], + [navigation, buildBaseRouteEntry, headlessSessionId], ); const navigateToKycWebviewCallback = useCallback( @@ -600,12 +687,14 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => { buildBaseRouteEntry({ amount }), { name: Routes.RAMP.KYC_PROCESSING, + // Thread the headless session id (TRAM-3623) - see the reset above. + params: headlessSessionId ? { headlessSessionId } : undefined, }, { name: routeName, params: routeParams }, ], }); }, - [navigation, buildBaseRouteEntry], + [navigation, buildBaseRouteEntry, headlessSessionId], ); const routeAfterAuthentication = useCallback( @@ -663,10 +752,49 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => { paymentDetails: depositOrder.paymentDetails, }); - if (getSession(headlessSessionId)) { + // Manual-bank-transfer headless branch (TRAM-3623 §4): snapshot + // the surface before navigateToOrderProcessingCallback closes + // the session, then emit the confirmed event. + const manualBankSession = getSession(headlessSessionId); + if (manualBankSession) { + const rampSurface = manualBankSession.params?.rampSurface; + // Carry the headless context to the terminal-failed + // subscriber (TRAM-3623 §1/§5). GUARD: inside the + // `if (manualBankSession)` headless gate only - never at the + // unconditional `addOrder(...)` above - so non-headless + // UB2-native manual-bank orders never get an entry. + setHeadlessOrderContext(rampsOrder.providerOrderId, { + rampSurface, + region: regionIsoCode, + }); navigateToOrderProcessingCallback({ orderId: rampsOrder.providerOrderId, }); + trackEvent('RAMPS_TRANSACTION_CONFIRMED', { + ramp_type: 'HEADLESS', + ramp_surface: rampSurface, + amount_source: Number(rampsOrder.fiatAmount), + amount_destination: Number(rampsOrder.cryptoAmount), + exchange_rate: Number(rampsOrder.exchangeRate), + gas_fee: rampsOrder.networkFees + ? Number(rampsOrder.networkFees) + : 0, + processing_fee: rampsOrder.partnerFees + ? Number(rampsOrder.partnerFees) + : 0, + total_fee: Number(rampsOrder.totalFeesFiat), + payment_method_id: rampsOrder.paymentMethod?.id || '', + country: regionIsoCode, + region: regionIsoCode, + chain_id: rampsOrder.network?.chainId || '', + currency_destination: + rampsOrder.cryptoCurrency?.assetId || '', + currency_destination_symbol: + rampsOrder.cryptoCurrency?.symbol || '', + currency_destination_network: + rampsOrder.network?.name || '', + currency_source: rampsOrder.fiatCurrency?.symbol || '', + }); return true; } @@ -718,15 +846,21 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => { } } - case 'NOT_SUBMITTED': + case 'NOT_SUBMITTED': { + // Snapshot the headless session BEFORE navigating so KYC_STARTED + // carries `ramp_type: 'HEADLESS'` + the seeded `ramp_surface` + // (TRAM-3623); the regular flow keeps `'DEPOSIT'`. + const kycStartedSession = getSession(headlessSessionId); trackEvent('RAMPS_KYC_STARTED', { - ramp_type: 'DEPOSIT', + ramp_type: kycStartedSession ? 'HEADLESS' : 'DEPOSIT', + ramp_surface: kycStartedSession?.params?.rampSurface, kyc_type: requirements.kycType || '', region: regionIsoCode, }); navigateToBasicInfoCallback({ quote, previousFormData, amount }); return; + } case 'ADDITIONAL_FORMS_REQUIRED': { const additionalRequirements = await getAdditionalRequirements( @@ -760,8 +894,12 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => { throw new Error('Missing ID proof metadata'); } + // Same headless snapshot as the NOT_SUBMITTED branch above + // (TRAM-3623): HEADLESS + ramp_surface on the headless path. + const idProofKycSession = getSession(headlessSessionId); trackEvent('RAMPS_KYC_STARTED', { - ramp_type: 'DEPOSIT', + ramp_type: idProofKycSession ? 'HEADLESS' : 'DEPOSIT', + ramp_surface: idProofKycSession?.params?.rampSurface, kyc_type: 'STANDARD', region: regionIsoCode, }); diff --git a/app/components/UI/Ramp/utils/getRampsV2AnalyticsPayload.test.ts b/app/components/UI/Ramp/utils/getRampsV2AnalyticsPayload.test.ts index c9316a2a0e5..0d3129cea00 100644 --- a/app/components/UI/Ramp/utils/getRampsV2AnalyticsPayload.test.ts +++ b/app/components/UI/Ramp/utils/getRampsV2AnalyticsPayload.test.ts @@ -15,6 +15,8 @@ describe('getRampsV2AnalyticsPayload', () => { currency: 'USD', cryptocurrency: 'ETH', network: '1', + fee: '1', + cryptoAmount: '0.01', data: { paymentMethod: { id: '/payments/debit-credit-card', @@ -23,6 +25,13 @@ describe('getRampsV2AnalyticsPayload', () => { id: '/providers/transak', name: 'Transak', }, + region: 'US', + network: { name: 'Ethereum', chainId: 'eip155:1' }, + cryptoCurrency: { symbol: 'ETH', assetId: 'eip155:1/slip44:60' }, + fiatCurrency: { symbol: 'USD' }, + networkFees: 0.5, + partnerFees: 0.5, + statusDescription: 'card_declined', }, }; @@ -36,15 +45,24 @@ describe('getRampsV2AnalyticsPayload', () => { mockBuyOrder as FiatOrder, ); - expect(eventName).toBe('ONRAMP_PURCHASE_FAILED'); + expect(eventName).toBe('RAMPS_TRANSACTION_FAILED'); expect(params).toEqual({ - amount: 100, - currency_source: 'USD', - currency_destination: 'ETH', - order_type: 'BUY', + ramp_type: 'UNIFIED_BUY_2', + amount_source: 100, + amount_destination: 0.01, + exchange_rate: 9900, payment_method_id: '/payments/debit-credit-card', - chain_id_destination: '1', + country: 'US', + chain_id: 'eip155:1', + currency_destination: 'eip155:1/slip44:60', + currency_destination_symbol: 'ETH', + currency_destination_network: 'Ethereum', + currency_source: 'USD', provider_onramp: 'Transak', + gas_fee: 0.5, + processing_fee: 0.5, + total_fee: 1, + error_message: 'card_declined', }); }); @@ -70,26 +88,24 @@ describe('getRampsV2AnalyticsPayload', () => { const [eventName, params] = getRampsV2AnalyticsPayload({ ...mockBuyOrder, state: FIAT_ORDER_STATES.COMPLETED, - fee: '1', - cryptoAmount: '0.01', - data: { - ...mockBuyOrder.data, - fiatAmountInUsd: 99, - }, } as FiatOrder); - expect(eventName).toBe('ONRAMP_PURCHASE_COMPLETED'); + expect(eventName).toBe('RAMPS_TRANSACTION_COMPLETED'); expect(params).toEqual({ - amount: 100, - amount_in_usd: 99, - crypto_out: '0.01', - currency_source: 'USD', - currency_destination: 'ETH', - order_type: 'BUY', - payment_method_id: '/payments/debit-credit-card', - chain_id_destination: '1', + ramp_type: 'UNIFIED_BUY_2', + amount_source: 100, + amount_destination: 0.01, exchange_rate: 9900, + payment_method_id: '/payments/debit-credit-card', + country: 'US', + chain_id: 'eip155:1', + currency_destination: 'eip155:1/slip44:60', + currency_destination_symbol: 'ETH', + currency_destination_network: 'Ethereum', + currency_source: 'USD', provider_onramp: 'Transak', + gas_fee: 0.5, + processing_fee: 0.5, total_fee: 1, }); }); @@ -177,6 +193,86 @@ describe('getRampsV2AnalyticsPayload', () => { expect(params).toBeNull(); }); + // The V2 unified API returns orderType: 'DEPOSIT' for native flows + // (e.g. Transak + Apple Pay). DEPOSIT must map to the on-ramp events, + // not the off-ramp events. See TRAM-3534. + describe('DEPOSIT orders (Transak native on-ramp)', () => { + const mockDepositOrder = { + ...mockBuyOrder, + orderType: 'DEPOSIT', + }; + + it('returns RAMPS_TRANSACTION_FAILED for DEPOSIT orders', () => { + const [eventName, params] = getRampsV2AnalyticsPayload( + mockDepositOrder as FiatOrder, + ); + + expect(eventName).toBe('RAMPS_TRANSACTION_FAILED'); + expect(params).toEqual({ + ramp_type: 'UNIFIED_BUY_2', + amount_source: 100, + amount_destination: 0.01, + exchange_rate: 9900, + payment_method_id: '/payments/debit-credit-card', + country: 'US', + chain_id: 'eip155:1', + currency_destination: 'eip155:1/slip44:60', + currency_destination_symbol: 'ETH', + currency_destination_network: 'Ethereum', + currency_source: 'USD', + provider_onramp: 'Transak', + gas_fee: 0.5, + processing_fee: 0.5, + total_fee: 1, + error_message: 'card_declined', + }); + }); + + it('returns ONRAMP_PURCHASE_CANCELLED for DEPOSIT orders', () => { + const [eventName, params] = getRampsV2AnalyticsPayload({ + ...mockDepositOrder, + state: FIAT_ORDER_STATES.CANCELLED, + } as FiatOrder); + + expect(eventName).toBe('ONRAMP_PURCHASE_CANCELLED'); + expect(params).toEqual({ + amount: 100, + currency_source: 'USD', + currency_destination: 'ETH', + order_type: 'DEPOSIT', + payment_method_id: '/payments/debit-credit-card', + chain_id_destination: '1', + provider_onramp: 'Transak', + }); + }); + + it('returns RAMPS_TRANSACTION_COMPLETED for DEPOSIT orders', () => { + const [eventName, params] = getRampsV2AnalyticsPayload({ + ...mockDepositOrder, + state: FIAT_ORDER_STATES.COMPLETED, + } as FiatOrder); + + expect(eventName).toBe('RAMPS_TRANSACTION_COMPLETED'); + expect(params).toEqual({ + ramp_type: 'UNIFIED_BUY_2', + amount_source: 100, + amount_destination: 0.01, + exchange_rate: 9900, + payment_method_id: '/payments/debit-credit-card', + country: 'US', + chain_id: 'eip155:1', + currency_destination: 'eip155:1/slip44:60', + currency_destination_symbol: 'ETH', + currency_destination_network: 'Ethereum', + currency_source: 'USD', + provider_onramp: 'Transak', + gas_fee: 0.5, + processing_fee: 0.5, + total_fee: 1, + }); + }); + }); + it('handles missing optional data fields gracefully', () => { const orderWithNoData = { ...mockBuyOrder, @@ -187,15 +283,107 @@ describe('getRampsV2AnalyticsPayload', () => { orderWithNoData as FiatOrder, ); - expect(eventName).toBe('ONRAMP_PURCHASE_FAILED'); + expect(eventName).toBe('RAMPS_TRANSACTION_FAILED'); expect(params).toEqual({ - amount: 100, - currency_source: 'USD', - currency_destination: 'ETH', - order_type: 'BUY', + ramp_type: 'UNIFIED_BUY_2', + amount_source: 100, + amount_destination: 0.01, + exchange_rate: 9900, payment_method_id: '', - chain_id_destination: '1', + country: '', + chain_id: '1', + currency_destination: '', + currency_source: 'USD', provider_onramp: '', + gas_fee: 0, + processing_fee: 0, + total_fee: 1, + error_message: 'transaction_failed', }); }); + + it('handles missing optional data fields gracefully for SELL orders (legacy off-ramp params)', () => { + const sellOrderWithNoData = { + ...mockSellOrder, + data: {}, + }; + + const [eventName, params] = getRampsV2AnalyticsPayload( + sellOrderWithNoData as FiatOrder, + ); + + expect(eventName).toBe('OFFRAMP_PURCHASE_FAILED'); + expect(params).toEqual({ + amount: 100, + currency_source: 'ETH', + currency_destination: 'USD', + order_type: 'SELL', + payment_method_id: '', + chain_id_source: '1', + provider_offramp: '', + }); + }); + + it('falls back to empty string for chain_id and currency_source when both data and fiatOrder fields are missing', () => { + const orderMissingAll = { + ...mockBuyOrder, + network: undefined, + currency: undefined, + data: {}, + }; + + const [, params] = getRampsV2AnalyticsPayload( + orderMissingAll as unknown as FiatOrder, + ); + + expect(params).toEqual( + expect.objectContaining({ + chain_id: '', + currency_source: '', + }), + ); + }); + + it('uses data.exchangeRate verbatim when present instead of the computed fallback', () => { + const orderWithExchangeRate = { + ...mockBuyOrder, + state: FIAT_ORDER_STATES.COMPLETED, + data: { + ...mockBuyOrder.data, + exchangeRate: 5000, + }, + }; + + const [, params] = getRampsV2AnalyticsPayload( + orderWithExchangeRate as FiatOrder, + ); + + expect(params).toEqual( + expect.objectContaining({ + exchange_rate: 5000, + }), + ); + }); + + it('falls back to 0 for computed exchange_rate when cryptoAmount is 0', () => { + const orderZeroCrypto = { + ...mockBuyOrder, + state: FIAT_ORDER_STATES.COMPLETED, + cryptoAmount: '0', + data: { + ...mockBuyOrder.data, + exchangeRate: undefined, + }, + }; + + const [, params] = getRampsV2AnalyticsPayload( + orderZeroCrypto as unknown as FiatOrder, + ); + + expect(params).toEqual( + expect.objectContaining({ + exchange_rate: 0, + }), + ); + }); }); diff --git a/app/components/UI/Ramp/utils/getRampsV2AnalyticsPayload.ts b/app/components/UI/Ramp/utils/getRampsV2AnalyticsPayload.ts index 203813d4a89..55e459a2aaf 100644 --- a/app/components/UI/Ramp/utils/getRampsV2AnalyticsPayload.ts +++ b/app/components/UI/Ramp/utils/getRampsV2AnalyticsPayload.ts @@ -1,12 +1,13 @@ import type { RampsOrder } from '@metamask/ramps-controller'; import { FiatOrder } from '../../../../reducers/fiatOrders'; import { AnalyticsEvents } from '../Aggregator/types'; +import type { AnalyticsEvents as UnifiedAnalyticsEvents } from '../Deposit/types/analytics'; import { FIAT_ORDER_STATES } from '../../../../constants/on-ramp'; type EventName = - | 'ONRAMP_PURCHASE_FAILED' + | 'RAMPS_TRANSACTION_COMPLETED' + | 'RAMPS_TRANSACTION_FAILED' | 'ONRAMP_PURCHASE_CANCELLED' - | 'ONRAMP_PURCHASE_COMPLETED' | 'OFFRAMP_PURCHASE_FAILED' | 'OFFRAMP_PURCHASE_CANCELLED' | 'OFFRAMP_PURCHASE_COMPLETED' @@ -14,18 +15,29 @@ type EventName = type EventParams = | AnalyticsEvents[NonNullable & keyof AnalyticsEvents] + | UnifiedAnalyticsEvents['RAMPS_TRANSACTION_COMPLETED'] + | UnifiedAnalyticsEvents['RAMPS_TRANSACTION_FAILED'] | null; /** * Builds analytics payload for RAMPS_V2 orders, reading from the * RampsOrder stored in fiatOrder.data instead of the legacy aggregator * Order shape. + * + * V2 buy completion/failure emit the unified `RAMPS_TRANSACTION_*` event + * names and therefore use the deposit-flow payload schema (ramp_type, + * amount_source, amount_destination, country, chain_id, gas_fee, + * processing_fee, ...) so a single Mixpanel event name maps to a single + * schema. Sell side and buy cancellation keep the legacy aggregator + * shape because their event names (OFFRAMP_PURCHASE_*, ONRAMP_PURCHASE_CANCELLED) + * are still typed against that shape and are scheduled for deprecation. */ const getRampsV2AnalyticsPayload = ( fiatOrder: FiatOrder, ): [EventName, EventParams] => { const data = fiatOrder.data as RampsOrder | undefined; - const isBuy = fiatOrder.orderType === 'BUY'; + const isBuy = + fiatOrder.orderType === 'BUY' || fiatOrder.orderType === 'DEPOSIT'; let failedOrCancelledParams: | AnalyticsEvents['ONRAMP_PURCHASE_FAILED'] @@ -56,15 +68,16 @@ const getRampsV2AnalyticsPayload = ( } const cryptoAmount = Number(fiatOrder.cryptoAmount); + const computedExchangeRate = + cryptoAmount > 0 + ? (Number(fiatOrder.amount) - Number(fiatOrder.fee)) / cryptoAmount + : 0; + const sharedCompletedPayload: Partial< - | AnalyticsEvents['OFFRAMP_PURCHASE_COMPLETED'] - | AnalyticsEvents['ONRAMP_PURCHASE_COMPLETED'] + AnalyticsEvents['OFFRAMP_PURCHASE_COMPLETED'] > = { total_fee: Number(fiatOrder.fee), - exchange_rate: - cryptoAmount > 0 - ? (Number(fiatOrder.amount) - Number(fiatOrder.fee)) / cryptoAmount - : 0, + exchange_rate: computedExchangeRate, amount_in_usd: data?.fiatAmountInUsd, }; @@ -74,18 +87,45 @@ const getRampsV2AnalyticsPayload = ( fiat_out: fiatOrder.amount, } as AnalyticsEvents['OFFRAMP_PURCHASE_COMPLETED']; - const buyCompletePayload: AnalyticsEvents['ONRAMP_PURCHASE_COMPLETED'] = { - ...failedOrCancelledParams, - ...sharedCompletedPayload, - crypto_out: fiatOrder.cryptoAmount, - } as AnalyticsEvents['ONRAMP_PURCHASE_COMPLETED']; + const unifiedBuyBase = { + ramp_type: 'UNIFIED_BUY_2' as const, + amount_source: Number(fiatOrder.amount), + amount_destination: cryptoAmount, + exchange_rate: data?.exchangeRate ?? computedExchangeRate, + payment_method_id: data?.paymentMethod?.id ?? '', + country: data?.region ?? '', + chain_id: data?.network?.chainId ?? fiatOrder.network ?? '', + currency_destination: data?.cryptoCurrency?.assetId ?? '', + currency_destination_symbol: data?.cryptoCurrency?.symbol, + currency_destination_network: data?.network?.name, + currency_source: data?.fiatCurrency?.symbol ?? fiatOrder.currency ?? '', + provider_onramp: data?.provider?.name ?? '', + }; + + const unifiedBuyFees = { + gas_fee: Number(data?.networkFees ?? 0), + processing_fee: Number(data?.partnerFees ?? 0), + total_fee: Number(fiatOrder.fee), + }; + + const unifiedBuyCompletePayload: UnifiedAnalyticsEvents['RAMPS_TRANSACTION_COMPLETED'] = + { + ...unifiedBuyBase, + ...unifiedBuyFees, + }; + + const unifiedBuyFailedPayload: UnifiedAnalyticsEvents['RAMPS_TRANSACTION_FAILED'] = + { + ...unifiedBuyBase, + ...unifiedBuyFees, + error_message: data?.statusDescription ?? 'transaction_failed', + }; switch (fiatOrder.state) { case FIAT_ORDER_STATES.FAILED: { - return [ - isBuy ? 'ONRAMP_PURCHASE_FAILED' : 'OFFRAMP_PURCHASE_FAILED', - failedOrCancelledParams, - ]; + return isBuy + ? ['RAMPS_TRANSACTION_FAILED', unifiedBuyFailedPayload] + : ['OFFRAMP_PURCHASE_FAILED', failedOrCancelledParams]; } case FIAT_ORDER_STATES.CANCELLED: { return [ @@ -95,7 +135,7 @@ const getRampsV2AnalyticsPayload = ( } case FIAT_ORDER_STATES.COMPLETED: { return isBuy - ? ['ONRAMP_PURCHASE_COMPLETED', buyCompletePayload] + ? ['RAMPS_TRANSACTION_COMPLETED', unifiedBuyCompletePayload] : ['OFFRAMP_PURCHASE_COMPLETED', sellCompletePayload]; } case FIAT_ORDER_STATES.PENDING: diff --git a/app/components/UI/Ramp/utils/webviewFunnelAnalytics.test.ts b/app/components/UI/Ramp/utils/webviewFunnelAnalytics.test.ts index e68683855a0..c0f67a8b79e 100644 --- a/app/components/UI/Ramp/utils/webviewFunnelAnalytics.test.ts +++ b/app/components/UI/Ramp/utils/webviewFunnelAnalytics.test.ts @@ -23,6 +23,25 @@ describe('webviewFunnelAnalytics', () => { expect(result.ramp_type).toBe('UNIFIED_BUY_2'); expect(result.provider_name).toBeUndefined(); }); + + it('applies HEADLESS overrides (ramp_type, ramp_surface, region)', () => { + expect( + buildBaseProps({ + checkoutSessionId: 'session-4', + providerName: 'Transak', + rampType: 'HEADLESS', + rampSurface: 'money_account', + region: 'us-ca', + }), + ).toEqual({ + checkout_session_id: 'session-4', + location: 'Checkout', + ramp_type: 'HEADLESS', + ramp_surface: 'money_account', + region: 'us-ca', + provider_name: 'Transak', + }); + }); }); describe('extractHostname', () => { diff --git a/app/components/UI/Ramp/utils/webviewFunnelAnalytics.ts b/app/components/UI/Ramp/utils/webviewFunnelAnalytics.ts index e528ed2f148..7f5e923a4b9 100644 --- a/app/components/UI/Ramp/utils/webviewFunnelAnalytics.ts +++ b/app/components/UI/Ramp/utils/webviewFunnelAnalytics.ts @@ -1,3 +1,4 @@ +import type { RampSurface } from '../Deposit/types/analytics'; import { redactUrlForAnalytics } from './redactUrlForAnalytics'; export type CloseSource = @@ -10,22 +11,39 @@ export type CloseSource = export interface FunnelBaseProps { checkout_session_id: string; location: 'Checkout'; - ramp_type: 'UNIFIED_BUY_2'; + ramp_type: 'UNIFIED_BUY_2' | 'HEADLESS'; + ramp_surface?: RampSurface; + region?: string; provider_name?: string; } export interface BuildBaseArgs { checkoutSessionId: string; providerName?: string; + /** + * Headless deposit overrides (TRAM-3623). When a `headlessSessionId` drives + * the Checkout, the host passes `rampType: 'HEADLESS'` plus the seeded + * `rampSurface`/`region` so every webview funnel event is tagged + * consistently. Defaults keep the UB2 behavior (`UNIFIED_BUY_2`, no surface) + * so non-headless callers are unchanged. + */ + rampType?: 'UNIFIED_BUY_2' | 'HEADLESS'; + rampSurface?: RampSurface; + region?: string; } export const buildBaseProps = ({ checkoutSessionId, providerName, + rampType = 'UNIFIED_BUY_2', + rampSurface, + region, }: BuildBaseArgs): FunnelBaseProps => ({ checkout_session_id: checkoutSessionId, location: 'Checkout', - ramp_type: 'UNIFIED_BUY_2', + ramp_type: rampType, + ramp_surface: rampSurface, + region, provider_name: providerName ?? undefined, }); diff --git a/app/components/UI/Rewards/Views/RewardsDashboard.tsx b/app/components/UI/Rewards/Views/RewardsDashboard.tsx index 62c1a368c21..a40481ed531 100644 --- a/app/components/UI/Rewards/Views/RewardsDashboard.tsx +++ b/app/components/UI/Rewards/Views/RewardsDashboard.tsx @@ -47,12 +47,11 @@ import { navigateToRewardsRoute } from '../utils'; import CampaignsPreview from '../components/Campaigns/CampaignsPreview'; import EarnRewardsPreview from '../components/EarnRewards/EarnRewardsPreview'; import BenefitsPreview from '../components/Benefits/BenefitsPreview.tsx'; -import { Image, Pressable, ScrollView } from 'react-native'; +import { Pressable, ScrollView } from 'react-native'; import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast'; import { usePerpsTradingCampaignEndedOutcomeToast } from '../hooks/usePerpsTradingCampaignEndedOutcomeToast'; import { useGetPredictThePitchOutcomeToast } from '../hooks/useGetPredictThePitchOutcomeToast'; import VipIcon from '../../../../images/rewards/vip.svg'; -import VipRefereeFoxIcon from '../../../../images/rewards/vip_splash.png'; import Engine from '../../../../core/Engine'; const VIP_UNLOCK_TAP_COUNT = 5; @@ -379,12 +378,7 @@ const RewardsDashboard: React.FC = () => { style={tw.style('h-8 w-8 items-center justify-center')} testID={REWARDS_VIEW_SELECTORS.VIP_REFEREE_BUTTON} > - + )} {isVipProgramEnabled && isVipEnabled && ( diff --git a/app/components/UI/Rewards/Views/RewardsVipRefereeView.test.tsx b/app/components/UI/Rewards/Views/RewardsVipRefereeView.test.tsx index 1175ff6d17c..c98c2de0a43 100644 --- a/app/components/UI/Rewards/Views/RewardsVipRefereeView.test.tsx +++ b/app/components/UI/Rewards/Views/RewardsVipRefereeView.test.tsx @@ -1,11 +1,18 @@ import React from 'react'; -import { render } from '@testing-library/react-native'; +import { fireEvent, render } from '@testing-library/react-native'; import { StackActions } from '@react-navigation/native'; import { useDispatch, useSelector } from 'react-redux'; import Routes from '../../../../constants/navigation/Routes'; import { acceptVipRefereeInvite } from '../../../../reducers/rewards'; import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import { selectSelectedInternalAccountFormattedAddress } from '../../../../selectors/accountsController'; import { selectVipProgramEnabled } from '../../../../selectors/featureFlagController/vipProgram'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import { + createMockEventBuilder, + createMockUseAnalyticsHook, +} from '../../../../util/test/analyticsMock'; +import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView'; import { useVipRefereeDashboard } from '../hooks/useVipRefereeDashboard'; import type { VipRefereeMeState } from '../../../../core/Engine/controllers/rewards-controller/types'; @@ -16,8 +23,12 @@ import RewardsVipRefereeView, { const mockNavDispatch = jest.fn(); const mockReduxDispatch = jest.fn(); const mockGoBack = jest.fn(); +const mockNavigate = jest.fn(); +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn(() => createMockEventBuilder()); // Obviously-synthetic fixtures — never real VIP codes/figures. const mockSubscriptionId = 'test-subscription-id'; +const mockAccountAddress = '0xAbC0000000000000000000000000000000000123'; let mockIsVipReferee = true; let mockIsVipProgramEnabled = true; let mockVipRefereeSplashAccepted: Record = {}; @@ -34,6 +45,7 @@ jest.mock('@react-navigation/native', () => { useNavigation: () => ({ dispatch: mockNavDispatch, goBack: mockGoBack, + navigate: mockNavigate, }), }; }); @@ -60,10 +72,31 @@ jest.mock('@metamask/design-system-react-native', () => { }) => ReactActual.createElement(View, { testID: props.testID }, props.children); + const Button = ({ + children, + onPress, + testID, + disabled, + }: { + children?: React.ReactNode; + onPress?: () => void; + testID?: string; + disabled?: boolean; + }) => + ReactActual.createElement( + Pressable, + { onPress: disabled ? undefined : onPress, testID, disabled }, + ReactActual.createElement(Text, null, children), + ); + return { HeaderStandard, Box: passthrough, BoxFlexDirection: { Row: 'row', Column: 'column' }, + Button, + ButtonVariant: { Primary: 'primary', Secondary: 'secondary' }, + ButtonSize: { Lg: 'lg' }, + IconName: { MessageQuestion: 'MessageQuestion', Export: 'Export' }, Text: ({ children, ...rest }: { children?: React.ReactNode }) => ReactActual.createElement(Text, rest, children), TextColor: { TextDefault: 'default', TextAlternative: 'alt' }, @@ -93,7 +126,7 @@ jest.mock('@metamask/design-system-twrnc-preset', () => { }; }); -jest.mock('../../../../images/rewards/vip_splash.png', () => 1); +jest.mock('../../../../images/rewards/vip.svg', () => 'VipIcon'); jest.mock('../../../../../locales/i18n', () => ({ __esModule: true, @@ -111,6 +144,8 @@ jest.mock('../../../../../locales/i18n', () => ({ 'rewards.vip.referee_perps_volume_label': 'Perps volume', 'rewards.vip.referee_error_title': 'Error title', 'rewards.vip.referee_error_description': 'Error description', + 'rewards.vip.referee_contact_support': 'Contact support', + 'app_settings.contact_support': 'Contact support', 'rewards.vip.retry_button': 'Retry', }; return translations[key] ?? key; @@ -121,6 +156,10 @@ jest.mock('../../../../selectors/rewards', () => ({ selectRewardsSubscriptionId: jest.fn(), })); +jest.mock('../../../../selectors/accountsController', () => ({ + selectSelectedInternalAccountFormattedAddress: jest.fn(), +})); + jest.mock('../../../../selectors/featureFlagController/vipProgram', () => ({ selectVipProgramEnabled: jest.fn(), })); @@ -146,6 +185,10 @@ jest.mock('../components/RewardsErrorBanner', () => { jest.mock('../hooks/useTrackRewardsPageView', () => jest.fn()); +jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: jest.fn(), +})); + jest.mock('../hooks/useVipRefereeDashboard', () => ({ useVipRefereeDashboard: jest.fn(), })); @@ -185,8 +228,16 @@ describe('RewardsVipRefereeView', () => { mockIsVipProgramEnabled = true; mockVipRefereeSplashAccepted = {}; mockUseDispatch.mockReturnValue(mockReduxDispatch); + jest.mocked(useAnalytics).mockReturnValue( + createMockUseAnalyticsHook({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), + ); mockUseSelector.mockImplementation((selector) => { if (selector === selectRewardsSubscriptionId) return mockSubscriptionId; + if (selector === selectSelectedInternalAccountFormattedAddress) + return mockAccountAddress; if (selector === selectVipProgramEnabled) return mockIsVipProgramEnabled; return ( selector as ( @@ -268,6 +319,126 @@ describe('RewardsVipRefereeView', () => { ).toBeNull(); }); + it('renders the referred-by card when dashboard data is present', () => { + const { getByTestId } = render(); + + expect( + getByTestId(REWARDS_VIP_REFEREE_VIEW_TEST_IDS.REFERRED_BY_CARD), + ).toBeOnTheScreen(); + }); + + it('renders the loading skeleton without the contact support button', () => { + mockUseVipRefereeDashboard.mockReturnValue({ + dashboard: null, + isLoading: true, + hasError: false, + hasAttemptedFetch: false, + fetchVipRefereeDashboard: mockFetch, + }); + + const { getByTestId, queryByTestId } = render(); + + expect( + getByTestId(REWARDS_VIP_REFEREE_VIEW_TEST_IDS.SKELETON), + ).toBeOnTheScreen(); + expect( + queryByTestId(REWARDS_VIP_REFEREE_VIEW_TEST_IDS.CONTACT_SUPPORT_BUTTON), + ).toBeNull(); + }); + + it('does not render the contact support button when the fetch errors with no data', () => { + mockUseVipRefereeDashboard.mockReturnValue({ + dashboard: null, + isLoading: false, + hasError: true, + hasAttemptedFetch: true, + fetchVipRefereeDashboard: mockFetch, + }); + + const { getByTestId, queryByTestId } = render(); + + expect( + getByTestId(REWARDS_VIP_REFEREE_VIEW_TEST_IDS.ERROR), + ).toBeOnTheScreen(); + expect( + queryByTestId(REWARDS_VIP_REFEREE_VIEW_TEST_IDS.CONTACT_SUPPORT_BUTTON), + ).toBeNull(); + }); + + it('renders the contact support button', () => { + const { getByTestId, getByText } = render(); + + expect( + getByTestId(REWARDS_VIP_REFEREE_VIEW_TEST_IDS.CONTACT_SUPPORT_BUTTON), + ).toBeOnTheScreen(); + expect(getByText('Contact support')).toBeOnTheScreen(); + }); + + it('disables the contact support button when the selected account address is missing', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectRewardsSubscriptionId) return mockSubscriptionId; + if (selector === selectSelectedInternalAccountFormattedAddress) + return undefined; + if (selector === selectVipProgramEnabled) return mockIsVipProgramEnabled; + return ( + selector as ( + state: ReturnType, + ) => unknown + )(getRewardsSelectorState()); + }); + + const { getByTestId } = render(); + + expect( + getByTestId(REWARDS_VIP_REFEREE_VIEW_TEST_IDS.CONTACT_SUPPORT_BUTTON), + ).toBeDisabled(); + }); + + it('opens the priority support webview tagged as VIP with the account address on press', () => { + const { getByTestId } = render(); + + fireEvent.press( + getByTestId(REWARDS_VIP_REFEREE_VIEW_TEST_IDS.CONTACT_SUPPORT_BUTTON), + ); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.WEBVIEW.MAIN, { + screen: Routes.WEBVIEW.SIMPLE, + params: { + url: expect.stringContaining( + `priority=vip&account=${encodeURIComponent(mockAccountAddress)}`, + ), + title: 'Contact support', + }, + }); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.NAVIGATION_TAPS_GET_HELP, + ); + expect(mockTrackEvent).toHaveBeenCalled(); + }); + + it('does not open support when the selected account address is missing', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectRewardsSubscriptionId) return mockSubscriptionId; + if (selector === selectSelectedInternalAccountFormattedAddress) + return undefined; + if (selector === selectVipProgramEnabled) return mockIsVipProgramEnabled; + return ( + selector as ( + state: ReturnType, + ) => unknown + )(getRewardsSelectorState()); + }); + + const { getByTestId } = render(); + + fireEvent.press( + getByTestId(REWARDS_VIP_REFEREE_VIEW_TEST_IDS.CONTACT_SUPPORT_BUTTON), + ); + + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + it('renders the error banner when the fetch errors with no data', () => { mockUseVipRefereeDashboard.mockReturnValue({ dashboard: null, diff --git a/app/components/UI/Rewards/Views/RewardsVipRefereeView.tsx b/app/components/UI/Rewards/Views/RewardsVipRefereeView.tsx index 2142d758b1b..f2e91e42ab7 100644 --- a/app/components/UI/Rewards/Views/RewardsVipRefereeView.tsx +++ b/app/components/UI/Rewards/Views/RewardsVipRefereeView.tsx @@ -1,9 +1,12 @@ -import React, { useEffect } from 'react'; -import { Image, ScrollView } from 'react-native'; +import React, { useCallback, useEffect } from 'react'; +import { ScrollView } from 'react-native'; import { StackActions, useNavigation } from '@react-navigation/native'; import { Box, BoxFlexDirection, + Button, + ButtonSize, + ButtonVariant, FontWeight, HeaderStandard, Skeleton, @@ -16,15 +19,22 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { useDispatch, useSelector } from 'react-redux'; import { strings } from '../../../../../locales/i18n'; import Routes from '../../../../constants/navigation/Routes'; -import VipSplashFox from '../../../../images/rewards/vip_splash.png'; +import { + buildVipPrioritySupportUrl, + METAMASK_SUPPORT_URL, +} from '../../../../constants/urls'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import VipIcon from '../../../../images/rewards/vip.svg'; import { acceptVipRefereeInvite } from '../../../../reducers/rewards'; import { selectHasAcceptedVipRefereeInvite, selectIsVipReferee, } from '../../../../reducers/rewards/selectors'; import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import { selectSelectedInternalAccountFormattedAddress } from '../../../../selectors/accountsController'; import { selectVipProgramEnabled } from '../../../../selectors/featureFlagController/vipProgram'; import ErrorBoundary from '../../../Views/ErrorBoundary'; +import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView'; import { useVipRefereeDashboard } from '../hooks/useVipRefereeDashboard'; import RewardsErrorBanner from '../components/RewardsErrorBanner'; @@ -50,6 +60,7 @@ export const REWARDS_VIP_REFEREE_VIEW_TEST_IDS = { SWAPS_VOLUME: 'rewards-vip-referee-view-swaps-volume', PERPS_VOLUME: 'rewards-vip-referee-view-perps-volume', LAST_UPDATED: 'rewards-vip-referee-view-last-updated', + CONTACT_SUPPORT_BUTTON: 'rewards-vip-referee-view-contact-support-button', } as const; const referredByCardStyle = { @@ -62,7 +73,11 @@ const RewardsVipRefereeViewContent: React.FC = () => { const tw = useTailwind(); const dispatch = useDispatch(); const navigation = useNavigation(); + const { trackEvent, createEventBuilder } = useAnalytics(); const subscriptionId = useSelector(selectRewardsSubscriptionId); + const accountAddress = useSelector( + selectSelectedInternalAccountFormattedAddress, + ); const isVipProgramEnabled = useSelector(selectVipProgramEnabled); const isVipReferee = useSelector(selectIsVipReferee); const canViewReferee = Boolean( @@ -101,6 +116,34 @@ const RewardsVipRefereeViewContent: React.FC = () => { dispatch(acceptVipRefereeInvite({ subscriptionId })); }, [canViewReferee, dispatch, hasAcceptedVipRefereeInvite, subscriptionId]); + // Opens the help center in the in-app WebView, tagging the request as VIP priority + // and passing the user's account address so the support-side automation can verify + // enrollment (via the existing GET /support/subscriptions/check?account= endpoint). + const handleContactPrioritySupport = useCallback(() => { + if (!accountAddress) { + return; + } + + let supportBaseUrl; + + ///: BEGIN:ONLY_INCLUDE_IF(beta) + supportBaseUrl = 'https://intercom.help/internal-beta-testing/en/'; + ///: END:ONLY_INCLUDE_IF + + supportBaseUrl = supportBaseUrl || METAMASK_SUPPORT_URL; + + navigation.navigate(Routes.WEBVIEW.MAIN, { + screen: Routes.WEBVIEW.SIMPLE, + params: { + url: buildVipPrioritySupportUrl(accountAddress, supportBaseUrl), + title: strings('app_settings.contact_support'), + }, + }); + trackEvent( + createEventBuilder(MetaMetricsEvents.NAVIGATION_TAPS_GET_HELP).build(), + ); + }, [accountAddress, createEventBuilder, navigation, trackEvent]); + if (!canViewReferee) { return null; } @@ -123,145 +166,199 @@ const RewardsVipRefereeViewContent: React.FC = () => { onBack={() => navigation.goBack()} backButtonProps={{ testID: 'header-back-button' }} /> + + + {showSkeleton ? ( + + - - - {strings('rewards.vip.referee_page_title')} - + + + + - {showSkeleton ? ( - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + - - ) : showError ? ( - - ) : dashboard ? ( - <> - - + - - {strings('rewards.vip.referee_referred_by', { - code: dashboard.referredByCode ?? '', - })} - - - + ) : dashboard ? ( + - {strings('rewards.vip.referee_stats_title')} - - - {strings('rewards.vip.referee_period_last_30d')} + {strings('rewards.vip.referee_page_title')} - - - + - {strings('rewards.vip.referee_points_label')} + {strings('rewards.vip.referee_referred_by', { + code: dashboard.referredByCode ?? '', + })} + + {/* Divider */} + + + - {formatNumber(dashboard.points)} + {strings('rewards.vip.referee_stats_title')} + + + {strings('rewards.vip.referee_period_last_30d')} + + + + + {strings('rewards.vip.referee_points_label')} + + + {formatNumber(dashboard.points)} + + + + + {strings('rewards.vip.referee_swaps_volume_label')} + + + {formatUsd(dashboard.swapsVolume)} + + + + - {strings('rewards.vip.referee_swaps_volume_label')} + {strings('rewards.vip.referee_perps_volume_label')} - {formatUsd(dashboard.swapsVolume)} + {formatUsd(dashboard.perpsVolume)} - - - - {strings('rewards.vip.referee_perps_volume_label')} - - - {formatUsd(dashboard.perpsVolume)} - + {dashboard.computedAt ? ( + + {strings('rewards.vip.last_updated', { + time: formatRewardsTimeOnly( + new Date(dashboard.computedAt), + ), + })} + + ) : null} + ) : null} + - {dashboard.computedAt ? ( - - {strings('rewards.vip.last_updated', { - time: formatRewardsTimeOnly(new Date(dashboard.computedAt)), - })} - - ) : null} - + {showSkeleton ? ( + + + + ) : dashboard ? ( + + + ) : null} - + ); diff --git a/app/components/UI/Rewards/Views/RewardsVipView.tsx b/app/components/UI/Rewards/Views/RewardsVipView.tsx index e573e839574..7feeb476f45 100644 --- a/app/components/UI/Rewards/Views/RewardsVipView.tsx +++ b/app/components/UI/Rewards/Views/RewardsVipView.tsx @@ -38,6 +38,10 @@ import VipFeeTile, { import VipPointsSection from '../components/Vip/VipPointsSection'; import VipTierProgressCard from '../components/Vip/VipTierProgressCard'; import VipVolumeSection from '../components/Vip/VipVolumeSection'; +import { + VIP_GOLD_BACKGROUND_MUTED, + VIP_GOLD_BORDER_DEFAULT, +} from '../components/Vip/Vip.constants'; import { REWARDS_VIEW_SELECTORS } from './RewardsView.constants'; import { selectHasAcceptedVipInvite, @@ -67,6 +71,11 @@ export const REWARDS_VIP_VIEW_TEST_IDS = { const BENEFIT_TILE_GAP = 12; const BENEFIT_TILE_SNAP_INTERVAL = VIP_FEE_TILE_WIDTH + BENEFIT_TILE_GAP; +const vipTierCardSkeletonStyle = { + borderColor: VIP_GOLD_BORDER_DEFAULT, + backgroundColor: VIP_GOLD_BACKGROUND_MUTED, +}; + const RewardsVipViewContent: React.FC = () => { const tw = useTailwind(); const dispatch = useDispatch(); @@ -170,13 +179,49 @@ const RewardsVipViewContent: React.FC = () => { > {showSkeleton ? ( - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + {[0, 1, 2, 3].map((index) => ( { /> ))} - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) : showError ? ( diff --git a/app/components/UI/Rewards/components/Vip/VipRefereeSplashScreen.tsx b/app/components/UI/Rewards/components/Vip/VipRefereeSplashScreen.tsx index edb72de425e..8b16ffc6ab4 100644 --- a/app/components/UI/Rewards/components/Vip/VipRefereeSplashScreen.tsx +++ b/app/components/UI/Rewards/components/Vip/VipRefereeSplashScreen.tsx @@ -133,7 +133,7 @@ const VipRefereeSplashScreen: React.FC = ({ {referredByCode ? ( { - const { theme } = params; - const { colors } = theme; - - return StyleSheet.create({ - base: { - display: 'flex', - alignItems: 'flex-start', - justifyContent: 'center', - paddingVertical: 16, - paddingHorizontal: 16, - }, - accountInputContainer: { - width: '100%', - gap: 10, - marginBottom: 16, - paddingHorizontal: 16, - paddingVertical: 12, - backgroundColor: colors.background.muted, - borderRadius: 8, - }, - accountInput: { - display: 'flex', - width: '100%', - color: colors.text.default, - backgroundColor: colors.background.muted, - }, - srpSelectorContainer: { - display: 'flex', - width: '100%', - flexShrink: 1, - }, - srpSelector: { - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - borderRadius: 8, - maxHeight: 200, - }, - srp: { - flex: 1, - display: 'flex', - flexDirection: 'column', - borderColor: colors.border.muted, - backgroundColor: colors.background.muted, - }, - srpArrow: { - display: 'flex', - flexDirection: 'column', - justifyContent: 'center', - alignItems: 'center', - }, - footerContainer: { - width: '100%', - display: 'flex', - flexDirection: 'row', - justifyContent: 'space-between', - paddingVertical: 16, - gap: 16, - }, - button: { - flex: 1, - }, - }); -}; - -export default styleSheet; diff --git a/app/components/Views/AddNewAccount/AddNewAccount.test.tsx b/app/components/Views/AddNewAccount/AddNewAccount.test.tsx deleted file mode 100644 index 3c3e2b6bc41..00000000000 --- a/app/components/Views/AddNewAccount/AddNewAccount.test.tsx +++ /dev/null @@ -1,400 +0,0 @@ -import React from 'react'; -import { fireEvent, waitFor } from '@testing-library/react-native'; -import renderWithProvider from '../../../util/test/renderWithProvider'; -import { strings } from '../../../../locales/i18n'; -import AddNewAccount from './AddNewAccount'; -import { backgroundState } from '../../../util/test/initial-root-state'; -import { - createMockSnapInternalAccount, - internalAccount1, - internalAccount2, -} from '../../../util/test/accountsControllerTestUtils'; -import ExtendedKeyringTypes from '../../../constants/keyringTypes'; -import Engine from '../../../core/Engine'; -import { AddNewAccountProps } from './AddNewAccount.types'; -import { WalletClientType } from '../../../core/SnapKeyring/MultichainWalletSnapClient'; -import { MultichainNetwork } from '@metamask/multichain-transactions-controller'; -import { RootState } from '../../../reducers'; -import { KeyringTypes } from '@metamask/keyring-controller'; -import { AddNewAccountIds } from './AddHdAccount.testIds'; -import Logger from '../../../util/Logger'; -import { SolAccountType, TrxScope } from '@metamask/keyring-api'; -import { AccountGroupType, AccountWalletType } from '@metamask/account-api'; - -const mockNavigate = jest.fn(); - -jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual('@react-navigation/native'), - useNavigation: () => ({ - navigate: mockNavigate, - }), -})); - -const mockCreateMultichainAccount = jest.fn().mockResolvedValue(null); -const mockMultichainWalletSnapClient = { - createAccount: mockCreateMultichainAccount, - getSnapId: jest.fn().mockReturnValue('mock-snap-id'), - getSnapName: jest.fn().mockReturnValue('mock-snap-name'), - getScopes: jest.fn().mockReturnValue([]), - getSnapSender: jest.fn().mockReturnValue({}), - withSnapKeyring: jest.fn().mockImplementation(async (callback) => { - await callback({ createAccount: mockCreateMultichainAccount }); - }), -}; - -jest.mock('../../../core/SnapKeyring/MultichainWalletSnapClient', () => ({ - ...jest.requireActual('../../../core/SnapKeyring/MultichainWalletSnapClient'), - WalletClientType: { - Bitcoin: 'bitcoin', - Solana: 'solana', - Tron: 'tron', - }, - MultichainWalletSnapFactory: { - createClient: jest - .fn() - .mockImplementation(() => mockMultichainWalletSnapClient), - }, -})); - -jest.mock('../../../util/Logger', () => ({ - error: jest.fn(), -})); - -const mockAccount1 = { - ...internalAccount1, - options: { entropySource: '01JKZ55Y6KPCYH08M6B9VSZWKW' }, -}; -const mockAccount2 = { - ...internalAccount2, - options: { entropySource: '01JKZ56KRVYEEHC601HSNW28T2' }, -}; - -const mockKeyring1 = { - type: ExtendedKeyringTypes.hd, - accounts: [mockAccount1.address], - metadata: { - id: '01JKZ55Y6KPCYH08M6B9VSZWKW', - name: '', - }, -}; - -const mockKeyring2 = { - type: ExtendedKeyringTypes.hd, - accounts: [mockAccount2.address], - metadata: { - id: '01JKZ56KRVYEEHC601HSNW28T2', - name: '', - }, -}; - -const mockNextAccountName = 'Account 3'; - -const initialState = { - engine: { - backgroundState: { - ...backgroundState, - AccountsController: { - internalAccounts: { - accounts: { - [mockAccount1.id]: mockAccount1, - [mockAccount2.id]: mockAccount2, - }, - selectedAccount: mockAccount2.id, - }, - }, - KeyringController: { - keyrings: [mockKeyring1, mockKeyring2], - }, - }, - }, -} as unknown as RootState; - -const mockGetNextAvailableAccountName = jest - .fn() - .mockImplementation((keyringType: KeyringTypes) => { - switch (keyringType) { - case KeyringTypes.snap: - return 'Snap Account 1'; - case KeyringTypes.hd: - return mockNextAccountName; - default: - return mockNextAccountName; - } - }); - -jest.mock('../../../core/Engine', () => { - const { MOCK_ACCOUNTS_CONTROLLER_STATE: mockAccountsControllerState } = - jest.requireActual('../../../util/test/accountsControllerTestUtils'); - return { - context: { - AccountsController: { - state: mockAccountsControllerState, - getNextAvailableAccountName: (keyringType: KeyringTypes) => - mockGetNextAvailableAccountName(keyringType), - }, - }, - }; -}); - -jest.mocked(Engine); - -const render = ( - state: RootState, - params: AddNewAccountProps, -): ReturnType => - renderWithProvider(, { state }); - -const defaultMultichainParams: AddNewAccountProps = { - scope: MultichainNetwork.Solana, - clientType: WalletClientType.Solana, -}; - -describe('AddNewAccount', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('shows SRP list when selector is clicked', () => { - const { getByText } = render(initialState, defaultMultichainParams); - - const srpSelector = getByText( - strings('accounts.select_secret_recovery_phrase'), - ); - fireEvent.press(srpSelector); - - expect( - getByText(strings('accounts.select_secret_recovery_phrase')), - ).toBeDefined(); - }); - - it('handles SRP selection', () => { - const { getByText, queryByText } = render( - initialState, - defaultMultichainParams, - ); - - const srpSelector = getByText( - strings('accounts.select_secret_recovery_phrase'), - ); - fireEvent.press(srpSelector); - - // clicking the second srp will display the srp list - // it is also the current selected account - const secondSRP = getByText('Secret Recovery Phrase 2'); - fireEvent.press(secondSRP); - - // We get by text again because we go to the picker and the secondSrp node is unmounted. - fireEvent.press(getByText('Secret Recovery Phrase 2')); - - // The picker will now only show the second srp. - expect(queryByText('Secret Recovery Phrase 1')).toBeNull(); - }); - - it('handles cancellation', () => { - const { getByText } = render(initialState, defaultMultichainParams); - - const cancelButton = getByText(strings('accounts.cancel')); - fireEvent.press(cancelButton); - - expect(mockNavigate).toHaveBeenCalled(); - }); - - describe('multichain', () => { - it('calls create account with the MultichainWalletSnapClient', async () => { - const { getByTestId } = render(initialState, { - scope: MultichainNetwork.Solana, - clientType: WalletClientType.Solana, - }); - - const addButton = getByTestId(AddNewAccountIds.CONFIRM); - fireEvent.press(addButton); - - await waitFor(() => { - expect( - mockMultichainWalletSnapClient.createAccount, - ).toHaveBeenCalledWith({ - scope: MultichainNetwork.Solana, - accountNameSuggestion: 'Solana Account ', - entropySource: mockKeyring2.metadata.id, - }); - }); - }); - - it.each([ - { - scope: MultichainNetwork.Solana, - clientType: WalletClientType.Solana, - }, - { - scope: MultichainNetwork.Bitcoin, - clientType: WalletClientType.Bitcoin, - }, - { - scope: TrxScope.Mainnet, - clientType: WalletClientType.Tron, - }, - ])( - 'handles error when creating $clientType account fails', - async ({ scope, clientType }) => { - mockCreateMultichainAccount.mockRejectedValueOnce( - new Error(`Failed to create ${clientType} account`), - ); - - const { getByTestId } = render(initialState, { - scope, - clientType, - }); - - const addButton = getByTestId(AddNewAccountIds.CONFIRM); - fireEvent.press(addButton); - - await waitFor(() => { - expect(mockNavigate).not.toHaveBeenCalled(); - expect(Logger.error).toHaveBeenCalledWith( - expect.any(Error), - `Failed to create ${clientType} account`, - ); - }); - }, - ); - - it('disables buttons while loading', () => { - const { getByTestId } = render(initialState, { - scope: MultichainNetwork.Solana, - clientType: WalletClientType.Solana, - }); - - const addButton = getByTestId(AddNewAccountIds.CONFIRM); - fireEvent.press(addButton); - - expect(addButton).toBeDisabled(); - }); - - it.each([ - { - scope: MultichainNetwork.Solana, - clientType: WalletClientType.Solana, - expectedHeader: 'account_actions.headers.solana', - }, - { - scope: MultichainNetwork.Bitcoin, - clientType: WalletClientType.Bitcoin, - expectedHeader: 'account_actions.headers.bitcoin', - }, - { - scope: TrxScope.Mainnet, - clientType: WalletClientType.Tron, - expectedHeader: 'account_actions.headers.tron', - }, - ])( - 'shows the correct header for $clientType', - async ({ scope, clientType, expectedHeader }) => { - mockCreateMultichainAccount.mockRejectedValueOnce( - new Error(`Failed to create ${clientType} account`), - ); - - const { getByText } = render(initialState, { - scope, - clientType, - }); - - expect( - getByText( - strings('account_actions.add_multichain_account', { - networkName: strings(expectedHeader), - }), - ), - ).toBeDefined(); - }, - ); - - it('shows the correct number of accounts in srp item', () => { - const solanaAccount = createMockSnapInternalAccount( - '0x1234567890123456789012345678901234567890', - 'Solana Account 1', - SolAccountType.DataAccount, - mockKeyring1.metadata.id, - ); - const snapKeyring = { - accounts: [solanaAccount.address], - type: KeyringTypes.snap, - }; - - // Create account groups for the accounts - const mockAccountGroup1 = { - id: `entropy:${mockKeyring1.metadata.id}/0`, - type: AccountGroupType.MultichainAccount, - accounts: [mockAccount1.id], - metadata: { - name: 'Account 1', - pinned: false, - hidden: false, - entropy: { groupIndex: 0 }, - }, - }; - - const mockAccountGroup2 = { - id: `entropy:${mockKeyring1.metadata.id}/1`, - type: AccountGroupType.MultichainAccount, - accounts: [solanaAccount.id], - metadata: { - name: 'Solana Account 1', - pinned: false, - hidden: false, - entropy: { groupIndex: 1 }, - }, - }; - - const mockAccountTreeControllerState = { - accountTree: { - wallets: { - [`entropy:${mockKeyring1.metadata.id}`]: { - id: `entropy:${mockKeyring1.metadata.id}`, - type: AccountWalletType.Entropy, - metadata: { - name: 'Wallet 1', - entropy: { id: mockKeyring1.metadata.id }, - }, - groups: { - [mockAccountGroup1.id]: mockAccountGroup1, - [mockAccountGroup2.id]: mockAccountGroup2, - }, - }, - }, - selectedAccountGroup: mockAccountGroup1.id, - }, - hasAccountTreeSyncingSyncedAtLeastOnce: false, - isAccountTreeSyncingInProgress: false, - }; - - const stateWithSnapAccount = { - engine: { - backgroundState: { - ...backgroundState, - AccountsController: { - internalAccounts: { - accounts: { - [mockAccount1.id]: mockAccount1, - [mockAccount2.id]: mockAccount2, - [solanaAccount.id]: solanaAccount, - }, - selectedAccount: mockAccount1.id, - }, - }, - KeyringController: { - keyrings: [mockKeyring1, mockKeyring2, snapKeyring], - }, - AccountTreeController: mockAccountTreeControllerState, - }, - }, - } as unknown as RootState; - const { getByText } = render( - stateWithSnapAccount, - defaultMultichainParams, - ); - - // 2 accounts are associated with the primary srp. 1 hd and 1 solana - expect(getByText('Show 2 accounts')).toBeDefined(); - }); - }); -}); diff --git a/app/components/Views/AddNewAccount/AddNewAccount.tsx b/app/components/Views/AddNewAccount/AddNewAccount.tsx deleted file mode 100644 index bb56430d865..00000000000 --- a/app/components/Views/AddNewAccount/AddNewAccount.tsx +++ /dev/null @@ -1,261 +0,0 @@ -// Third party dependencies. -import React, { - Fragment, - useCallback, - useEffect, - useMemo, - useState, -} from 'react'; -import { View } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; - -// External dependencies. -import SheetHeader from '../../../component-library/components/Sheet/SheetHeader'; -import { strings } from '../../../../locales/i18n'; - -// Internal dependencies -import { AddNewAccountProps } from './AddNewAccount.types'; -import { AddNewAccountIds } from './AddHdAccount.testIds'; -import Text, { - TextColor, - TextVariant, -} from '../../../component-library/components/Texts/Text'; -import Input from '../../../component-library/components/Form/TextField/foundation/Input'; -import { useStyles } from '../../hooks/useStyles'; -import styleSheet from './AddNewAccount.styles'; -import { useSelector } from 'react-redux'; -import { Button, ButtonVariant } from '@metamask/design-system-react-native'; -import SRPList from '../../UI/SRPList'; -import Logger from '../../../util/Logger'; -import { - MultichainWalletSnapFactory, - WalletClientType, -} from '../../../core/SnapKeyring/MultichainWalletSnapClient'; -import { useNavigation } from '@react-navigation/native'; -import Routes from '../../../constants/navigation/Routes'; -import { - selectInternalAccounts, - selectSelectedInternalAccount, -} from '../../../selectors/accountsController'; -import SRPListItem from '../../UI/SRPListItem'; -import { getMultichainAccountName } from '../../../core/SnapKeyring/utils/getMultichainAccountName'; -import { InternalAccount } from '@metamask/keyring-internal-api'; -import { MetaMetricsEvents } from '../../../core/Analytics'; -import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics'; -import { useHdKeyringsWithSnapAccounts } from '../../hooks/useHdKeyringsWithSnapAccounts'; - -const AddNewAccount = ({ - scope, - clientType, - onActionComplete, - onBack, -}: AddNewAccountProps) => { - const { navigate } = useNavigation(); - const { styles, theme } = useStyles(styleSheet, {}); - const { colors } = theme; - const [isLoading, setIsLoading] = useState(false); - const [accountName, setAccountName] = useState(undefined); - const selectedInternalAccount = useSelector(selectSelectedInternalAccount); - const internalAccounts = useSelector(selectInternalAccounts); - const hdKeyringsWithSnapAccounts = useHdKeyringsWithSnapAccounts(); - const [primaryKeyringId] = hdKeyringsWithSnapAccounts; - const initialKeyringIdToUse = useMemo( - () => - // For HD accounts (since v29.0.1), use the entropySource if available. - // Cast to string since it's typed as Json in the keyring API. - // Fall back to primary keyring ID for non-HD accounts. - (selectedInternalAccount?.options.entropySource as string) ?? - primaryKeyringId.metadata.id, - [selectedInternalAccount, primaryKeyringId], - ); - const [keyringId, setKeyringId] = useState(initialKeyringIdToUse); - const hasMultipleSRPs = hdKeyringsWithSnapAccounts.length > 1; - const [showSRPList, setShowSRPList] = useState(false); - const [error, setError] = useState(''); - const { trackEvent, createEventBuilder } = useAnalytics(); - - const { keyringToDisplay, keyringIndex } = useMemo(() => { - const keyring = - hdKeyringsWithSnapAccounts.find((kr) => kr.metadata.id === keyringId) ?? - hdKeyringsWithSnapAccounts[0]; - return { - keyringToDisplay: keyring, - keyringIndex: hdKeyringsWithSnapAccounts.indexOf(keyring) + 1, - }; - }, [hdKeyringsWithSnapAccounts, keyringId]); - - const handleOnBack = () => { - if (showSRPList) { - setShowSRPList(false); - return; - } - - if (onBack) { - onBack(); - } else { - navigate(Routes.SHEET.ACCOUNT_SELECTOR); - } - }; - - const isDuplicateName = useMemo( - () => - Object.values(internalAccounts).some( - (account) => - account.metadata.name.toLowerCase() === accountName?.toLowerCase(), - ), - [accountName, internalAccounts], - ); - - const onSubmit = useCallback(async () => { - setIsLoading(true); - try { - const multichainWalletSnapClient = - MultichainWalletSnapFactory.createClient(clientType); - const account = (await multichainWalletSnapClient.createAccount({ - scope, - accountNameSuggestion: accountName, - entropySource: keyringId, - })) as InternalAccount; - if (onActionComplete) { - onActionComplete(account); - } else { - navigate(Routes.WALLET.HOME); - } - } catch (e: unknown) { - const errorMessage = strings( - 'accounts.error_messages.failed_to_create_account', - { - clientType, - }, - ); - setError(errorMessage); - Logger.error(e as Error, errorMessage); - } finally { - setIsLoading(false); - } - }, [clientType, scope, onActionComplete, accountName, keyringId, navigate]); - - useEffect(() => { - setAccountName(getMultichainAccountName(scope, clientType)); - }, [clientType, scope]); - - const addAccountTitle = useMemo(() => { - switch (clientType) { - case WalletClientType.Bitcoin: - return strings('account_actions.add_multichain_account', { - networkName: strings('account_actions.headers.bitcoin'), - }); - case WalletClientType.Solana: - return strings('account_actions.add_multichain_account', { - networkName: strings('account_actions.headers.solana'), - }); - case WalletClientType.Tron: - return strings('account_actions.add_multichain_account', { - networkName: strings('account_actions.headers.tron'), - }); - default: - return strings('account_actions.add_account'); - } - }, [clientType]); - - const onKeyringSelection = (id: string) => { - setShowSRPList(false); - setKeyringId(id); - }; - - return ( - - - - {showSRPList ? ( - onKeyringSelection(id)} /> - ) : ( - - - - { - setAccountName(newName); - }} - placeholder={accountName} - placeholderTextColor={colors.text.default} - onSubmitEditing={onSubmit} - /> - - {hasMultipleSRPs && ( - - - {strings('accounts.select_secret_recovery_phrase')} - - - { - trackEvent( - createEventBuilder( - MetaMetricsEvents.SECRET_RECOVERY_PHRASE_PICKER_CLICKED, - ) - .addProperties({ - button_type: 'picker', - }) - .build(), - ); - setShowSRPList(true); - }} - testID={AddNewAccountIds.SRP_SELECTOR} - /> - - - {strings('accounts.add_new_hd_account_helper_text')} - - - )} - - - - - {error && ( - - {error} - - )} - - - )} - - - ); -}; - -export default AddNewAccount; diff --git a/app/components/Views/AddNewAccount/AddNewAccount.types.ts b/app/components/Views/AddNewAccount/AddNewAccount.types.ts deleted file mode 100644 index 8e4f7588220..00000000000 --- a/app/components/Views/AddNewAccount/AddNewAccount.types.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { CaipChainId } from '@metamask/utils'; -import { WalletClientType } from '../../../core/SnapKeyring/MultichainWalletSnapClient'; -import { InternalAccount } from '@metamask/keyring-internal-api'; - -/** - * AddNewAccountProps props. - */ -export interface AddNewAccountProps { - scope: CaipChainId; - clientType: WalletClientType; - onActionComplete?: (account: InternalAccount) => void; - onBack?: () => void; -} diff --git a/app/components/Views/AddNewAccount/AddNewAccountBottomSheet.test.tsx b/app/components/Views/AddNewAccount/AddNewAccountBottomSheet.test.tsx deleted file mode 100644 index 2d2dc8ef405..00000000000 --- a/app/components/Views/AddNewAccount/AddNewAccountBottomSheet.test.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import React from 'react'; -import AddNewAccountBottomSheet from './AddNewAccountBottomSheet'; -import { WalletClientType } from '../../../core/SnapKeyring/MultichainWalletSnapClient'; -import { SolScope } from '@metamask/keyring-api'; -import { renderScreen } from '../../../util/test/renderWithProvider'; -import { RootState } from '../../../reducers'; -import { backgroundState } from '../../../util/test/initial-root-state'; -import { KeyringTypes } from '@metamask/keyring-controller'; -import { - internalAccount1, - internalAccount2, - MOCK_ACCOUNTS_CONTROLLER_STATE, -} from '../../../util/test/accountsControllerTestUtils'; -import Engine from '../../../core/Engine/Engine'; - -const mockKeyring1 = { - type: KeyringTypes.hd, - accounts: [internalAccount1.address], - metadata: { - id: '01JKZ55Y6KPCYH08M6B9VSZWKW', - name: '', - }, -}; - -const mockKeyring2 = { - type: KeyringTypes.hd, - accounts: [internalAccount2.address], - metadata: { - id: '01JKZ56KRVYEEHC601HSNW28T2', - name: '', - }, -}; - -const mockNextAccountName = 'Account 3'; - -const initialState = { - engine: { - backgroundState: { - ...backgroundState, - AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE, - KeyringController: { - keyrings: [mockKeyring1, mockKeyring2], - }, - }, - }, -} as unknown as RootState; - -const mockGetNextAvailableAccountName = jest - .fn() - .mockImplementation((keyringType: KeyringTypes) => { - switch (keyringType) { - case KeyringTypes.snap: - return 'Snap Account 1'; - case KeyringTypes.hd: - return mockNextAccountName; - default: - return mockNextAccountName; - } - }); - -jest.mock('../../../core/Engine', () => { - const { MOCK_ACCOUNTS_CONTROLLER_STATE: mockAccountsControllerState } = - jest.requireActual('../../../util/test/accountsControllerTestUtils'); - return { - context: { - AccountsController: { - state: mockAccountsControllerState, - getNextAvailableAccountName: (keyringType: KeyringTypes) => - mockGetNextAvailableAccountName(keyringType), - }, - }, - }; -}); - -jest.mocked(Engine); - -let mockRouteParams: Record | undefined; - -jest.mock('@react-navigation/native', () => { - const actualNav = jest.requireActual('@react-navigation/native'); - return { - ...actualNav, - useRoute: () => ({ - key: '1', - name: 'params', - params: mockRouteParams, - }), - }; -}); - -const renderComponent = () => - renderScreen( - () => , - { - name: 'AddNewAccountBottomSheet', - }, - { state: initialState }, - ); - -describe('AddNewAccountBottomSheet', () => { - it('renders correctly with provided scope and clientType', () => { - mockRouteParams = { - scope: SolScope.Mainnet, - clientType: WalletClientType.Solana, - }; - const { toJSON } = renderComponent(); - expect(toJSON()).toBeTruthy(); - }); -}); diff --git a/app/components/Views/AddNewAccount/AddNewAccountBottomSheet.tsx b/app/components/Views/AddNewAccount/AddNewAccountBottomSheet.tsx deleted file mode 100644 index 4986026261d..00000000000 --- a/app/components/Views/AddNewAccount/AddNewAccountBottomSheet.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React, { useRef } from 'react'; -import { RouteProp, useRoute } from '@react-navigation/native'; -import AddNewAccount from './AddNewAccount'; -import BottomSheet, { - BottomSheetRef, -} from '../../../component-library/components/BottomSheets/BottomSheet'; -import { AddNewAccountBottomSheetRouteParams } from './AddNewAccountBottomSheet.types'; - -const AddNewAccountBottomSheet = () => { - const route = - useRoute< - RouteProp<{ params: AddNewAccountBottomSheetRouteParams }, 'params'> - >(); - const sheetRef = useRef(null); - const { scope, clientType } = route.params; - - return ( - - - - ); -}; - -export default AddNewAccountBottomSheet; diff --git a/app/components/Views/AddNewAccount/AddNewAccountBottomSheet.types.ts b/app/components/Views/AddNewAccount/AddNewAccountBottomSheet.types.ts deleted file mode 100644 index 26c4e11b6c2..00000000000 --- a/app/components/Views/AddNewAccount/AddNewAccountBottomSheet.types.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { CaipChainId } from '@metamask/utils'; -import { WalletClientType } from '../../../core/SnapKeyring/MultichainWalletSnapClient'; - -export interface AddNewAccountBottomSheetRouteParams { - scope: CaipChainId; - clientType: WalletClientType; -} diff --git a/app/components/Views/AddNewAccount/index.tsx b/app/components/Views/AddNewAccount/index.tsx deleted file mode 100644 index 1b9655bdddf..00000000000 --- a/app/components/Views/AddNewAccount/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from './AddNewAccount'; diff --git a/app/components/Views/Homepage/Homepage.test.tsx b/app/components/Views/Homepage/Homepage.test.tsx index c40b60f1938..8a0deaeacf0 100644 --- a/app/components/Views/Homepage/Homepage.test.tsx +++ b/app/components/Views/Homepage/Homepage.test.tsx @@ -211,7 +211,6 @@ jest.mock('../../UI/Predict/selectors/featureFlags', () => ({ stages: [], })), selectPredictWorldCupScreenEnabledFlag: jest.fn(() => false), - selectPredictHomepageDiscoveryNbaChampionEnabledFlag: jest.fn(() => false), })); jest.mock('../../UI/Predict/hooks/usePredictWorldCup', () => ({ diff --git a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx index 7b5d3ae2def..7f460a5841a 100644 --- a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx +++ b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx @@ -64,6 +64,7 @@ const worldCupEventCountMock = ( const HOMEPAGE_DISCOVERY_WINNER_MARKET = { id: 'market-1', title: '2026 FIFA World Cup Winner', + slug: '2026-fifa-world-cup-winner', endDate: '2026-06-01', outcomes: [ { @@ -75,26 +76,44 @@ const HOMEPAGE_DISCOVERY_WINNER_MARKET = { ], }; -const HOMEPAGE_DISCOVERY_NBA_CHAMPION_PARENT = { - id: '27830', - title: '2026 NBA Champion', - endDate: '2026-07-01', +const HOMEPAGE_DISCOVERY_LIVE_GAME_MARKET = { + id: 'live-market-1', + title: 'France vs. Senegal', + slug: 'france-vs-senegal', + endDate: '2026-06-01', + status: 'open' as const, outcomes: [ { - id: 'outcome-nba-lakers', - title: 'Will the Los Angeles Lakers win the 2026 NBA Finals?', - status: 'open' as const, - tokens: [{ title: 'Yes', price: 0.08 }], - groupItemTitle: 'Lakers', - }, - { - id: 'outcome-nba-thunder', - title: 'Will the Oklahoma City Thunder win the 2026 NBA Finals?', + id: 'outcome-france', + title: 'France', status: 'open' as const, - tokens: [{ title: 'Yes', price: 0.22 }], - groupItemTitle: 'Thunder', + groupItemTitle: 'France', + tokens: [{ title: 'France', price: 0.67 }], }, ], + game: { + id: 'game-france-senegal', + startTime: '2026-06-01T20:00:00.000Z', + status: 'ongoing' as const, + league: 'fifwc' as const, + elapsed: '3:58', + period: '1H' as const, + score: null, + homeTeam: { + id: 'france', + name: 'France', + logo: '', + abbreviation: 'FRA', + color: '', + }, + awayTeam: { + id: 'senegal', + name: 'Senegal', + logo: '', + abbreviation: 'SEN', + color: '', + }, + }, }; const worldCupMarketsWithDiscoveryChampionship = () => @@ -143,7 +162,6 @@ jest.mock('@react-navigation/native', () => { jest.mock('../../../../UI/Predict/selectors/featureFlags', () => ({ selectPredictEnabledFlag: jest.fn(() => true), - selectPredictHomepageDiscoveryNbaChampionEnabledFlag: jest.fn(() => true), selectPredictWorldCupScreenEnabledFlag: jest.fn(() => true), selectPredictUpDownEnabledFlag: jest.fn(() => true), })); @@ -207,16 +225,11 @@ jest.mock('@tanstack/react-query', () => { // Mock the hooks jest.mock('./hooks', () => { const actual = jest.requireActual('./hooks') as Record; - const tagQueries = actual.HOMEPAGE_PREDICT_TAG_QUERIES as { - nbaChampion: string; - }; const worldCupMock = jest.fn(() => worldCupMarketsWithDiscoveryChampionship(), ); + const liveWorldCupMock = jest.fn(() => worldCupHomepageMarketsMock([])); const worldCupEventCount = jest.fn(() => worldCupEventCountMock()); - const nbaMock = jest.fn(() => - worldCupHomepageMarketsMock([HOMEPAGE_DISCOVERY_NBA_CHAMPION_PARENT]), - ); return { ...actual, usePredictMarketsForHomepage: jest.fn(() => ({ @@ -232,16 +245,11 @@ jest.mock('./hooks', () => { refetch: jest.fn(), })), useHomepagePredictWorldCupMarkets: worldCupMock, + useHomepagePredictLiveWorldCupMarkets: liveWorldCupMock, useHomepagePredictWorldCupEventCount: worldCupEventCount, - useHomepagePredictTaggedMarkets: jest.fn( - ({ customQueryParams }: { customQueryParams: string }) => - customQueryParams === tagQueries.nbaChampion - ? nbaMock() - : worldCupHomepageMarketsMock([]), - ), __mockUsePredictWorldCupHomepageMarkets: worldCupMock, + __mockUsePredictLiveWorldCupHomepageMarkets: liveWorldCupMock, __mockUsePredictWorldCupEventCount: worldCupEventCount, - __mockUsePredictNbaChampionHomepageMarkets: nbaMock, }; }); @@ -266,16 +274,13 @@ const mockUsePredictPositionsForHomepage = jest.requireMock('./hooks').usePredictPositionsForHomepage; const mockUsePredictWorldCupHomepageMarkets = jest.requireMock('./hooks') .__mockUsePredictWorldCupHomepageMarkets as jest.Mock; +const mockUsePredictLiveWorldCupHomepageMarkets = jest.requireMock('./hooks') + .__mockUsePredictLiveWorldCupHomepageMarkets as jest.Mock; const mockUsePredictWorldCupEventCount = jest.requireMock('./hooks') .__mockUsePredictWorldCupEventCount as jest.Mock; -const mockUsePredictNbaChampionHomepageMarkets = jest.requireMock('./hooks') - .__mockUsePredictNbaChampionHomepageMarkets as jest.Mock; const mockSelectPrivacyMode = jest.requireMock( '../../../../../selectors/preferencesController', ).selectPrivacyMode as jest.Mock; -const mockSelectPredictHomepageDiscoveryNbaChampionEnabledFlag = - jest.requireMock('../../../../UI/Predict/selectors/featureFlags') - .selectPredictHomepageDiscoveryNbaChampionEnabledFlag as jest.Mock; const mockUseHomeViewedEvent = jest.requireMock( '../../hooks/useHomeViewedEvent', ).default as jest.Mock; @@ -372,10 +377,6 @@ describe('PredictionsSection', () => { jest .requireMock('../../../../UI/Predict/selectors/featureFlags') .selectPredictEnabledFlag.mockReturnValue(true); - mockSelectPredictHomepageDiscoveryNbaChampionEnabledFlag.mockReturnValue( - true, - ); - // Reset hooks to default state - include a market so the section renders mockUsePredictMarketsForHomepage.mockReturnValue({ markets: [ @@ -400,10 +401,10 @@ describe('PredictionsSection', () => { mockUsePredictWorldCupHomepageMarkets.mockReturnValue( worldCupMarketsWithDiscoveryChampionship(), ); - mockUsePredictWorldCupEventCount.mockReturnValue(worldCupEventCountMock()); - mockUsePredictNbaChampionHomepageMarkets.mockReturnValue( - worldCupHomepageMarketsMock([HOMEPAGE_DISCOVERY_NBA_CHAMPION_PARENT]), + mockUsePredictLiveWorldCupHomepageMarkets.mockReturnValue( + worldCupHomepageMarketsMock([]), ); + mockUsePredictWorldCupEventCount.mockReturnValue(worldCupEventCountMock()); mockUsePredictPositionsForHomepage.mockImplementation( (_options: { maxPositions?: number; claimable?: boolean } = {}) => ({ @@ -602,7 +603,7 @@ describe('PredictionsSection', () => { mockUsePredictWorldCupHomepageMarkets.mockReturnValue( worldCupHomepageMarketsMock([], { isFetching: true }), ); - mockUsePredictNbaChampionHomepageMarkets.mockReturnValue( + mockUsePredictLiveWorldCupHomepageMarkets.mockReturnValue( worldCupHomepageMarketsMock([], { isFetching: true }), ); @@ -648,7 +649,7 @@ describe('PredictionsSection', () => { }); }); - it('renders trending markets when user has no positions', async () => { + it('renders FIFA World Cup winner when user has no positions', async () => { mockUsePredictMarketsForHomepage.mockReturnValue({ markets: noPositionsTrendingMarkets, isLoading: false, @@ -664,14 +665,13 @@ describe('PredictionsSection', () => { ); await waitFor(() => { - expect(screen.getByText('NBA 2026 Champion')).toBeOnTheScreen(); + expect( + screen.getByText('2026 FIFA World Cup Winner'), + ).toBeOnTheScreen(); }); }); - it('renders FIFA World Cup winner when NBA champion discovery flag is disabled', async () => { - mockSelectPredictHomepageDiscoveryNbaChampionEnabledFlag.mockReturnValue( - false, - ); + it('does not render the retired NBA Champion row', async () => { mockUsePredictMarketsForHomepage.mockReturnValue({ markets: noPositionsTrendingMarkets, isLoading: false, @@ -694,7 +694,34 @@ describe('PredictionsSection', () => { expect(screen.queryByText('NBA 2026 Champion')).not.toBeOnTheScreen(); }); - it('navigates to market details from sports list (treatment)', async () => { + it('renders a live World Cup game instead of FIFA World Cup winner when a game is ongoing', async () => { + mockUsePredictMarketsForHomepage.mockReturnValue({ + markets: noPositionsTrendingMarkets, + isLoading: false, + error: null, + refetch: jest.fn(), + }); + mockUsePredictWorldCupHomepageMarkets.mockReturnValue( + worldCupHomepageMarketsMock([HOMEPAGE_DISCOVERY_WINNER_MARKET]), + ); + mockUsePredictLiveWorldCupHomepageMarkets.mockReturnValue( + worldCupHomepageMarketsMock([HOMEPAGE_DISCOVERY_LIVE_GAME_MARKET]), + ); + + renderWithProvider( + , + ); + + await waitFor(() => { + expect(screen.getByText('France vs. Senegal')).toBeOnTheScreen(); + }); + expect( + screen.queryByText('2026 FIFA World Cup Winner'), + ).not.toBeOnTheScreen(); + expect(screen.getByText('03:58')).toBeOnTheScreen(); + }); + + it('navigates to World Cup winner market details from sports list treatment', async () => { mockUsePredictMarketsForHomepage.mockReturnValue({ markets: noPositionsTrendingMarkets, isLoading: false, @@ -710,24 +737,26 @@ describe('PredictionsSection', () => { ); await waitFor(() => { - expect(screen.getByText('NBA 2026 Champion')).toBeOnTheScreen(); + expect( + screen.getByText('2026 FIFA World Cup Winner'), + ).toBeOnTheScreen(); }); - fireEvent.press(screen.getByText('NBA 2026 Champion')); + fireEvent.press(screen.getByText('2026 FIFA World Cup Winner')); expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { screen: Routes.PREDICT.MARKET_DETAILS, params: { - marketId: '27830', + marketId: 'market-1', entryPoint: PredictEventValues.ENTRY_POINT.HOME_SECTION, - title: 'Will the Oklahoma City Thunder win the 2026 NBA Finals?', + title: '2026 FIFA World Cup Winner', image: undefined, transactionActiveAbTests: predictEmptyStateTreatmentActiveAbTests, }, }); }); - it('tracks treatment CTA clicks with CTA and category names', async () => { + it('tracks treatment CTA clicks for World Cup winner with CTA and category names', async () => { mockUsePredictMarketsForHomepage.mockReturnValue({ markets: noPositionsTrendingMarkets, isLoading: false, @@ -743,25 +772,24 @@ describe('PredictionsSection', () => { ); await waitFor(() => { - expect(screen.getByText('NBA 2026 Champion')).toBeOnTheScreen(); + expect( + screen.getByText('2026 FIFA World Cup Winner'), + ).toBeOnTheScreen(); }); - fireEvent.press(screen.getByText('NBA 2026 Champion')); + fireEvent.press(screen.getByText('2026 FIFA World Cup Winner')); expect(mockTrackEvent).toHaveBeenCalledWith({ event: MetaMetricsEvents.PREDICT_EMPTY_STATE_CTA_CLICKED, properties: { cta_name: 'browse_category', - category_name: 'nba', + category_name: 'world_cup', active_ab_tests: predictEmptyStateTreatmentActiveAbTests, }, }); }); it('tracks World Cup winner CTA clicks with the canonical category name', async () => { - mockSelectPredictHomepageDiscoveryNbaChampionEnabledFlag.mockReturnValue( - false, - ); mockUsePredictMarketsForHomepage.mockReturnValue({ markets: noPositionsTrendingMarkets, isLoading: false, @@ -912,7 +940,9 @@ describe('PredictionsSection', () => { ); await waitFor(() => { - expect(screen.getByText('NBA 2026 Champion')).toBeOnTheScreen(); + expect( + screen.getByText('2026 FIFA World Cup Winner'), + ).toBeOnTheScreen(); }); }); @@ -1162,7 +1192,9 @@ describe('PredictionsSection', () => { await waitFor(() => { expect(screen.getByText('Claim $200.00')).toBeOnTheScreen(); - expect(screen.getByText('NBA 2026 Champion')).toBeOnTheScreen(); + expect( + screen.getByText('2026 FIFA World Cup Winner'), + ).toBeOnTheScreen(); }); }); @@ -1177,9 +1209,6 @@ describe('PredictionsSection', () => { mockUsePredictWorldCupHomepageMarkets.mockReturnValue( worldCupHomepageMarketsMock([]), ); - mockUsePredictNbaChampionHomepageMarkets.mockReturnValue( - worldCupHomepageMarketsMock([]), - ); renderWithProvider( , @@ -1589,7 +1618,7 @@ describe('PredictionsSection', () => { }); }); - it('navigates to market details from sports list when treatment', () => { + it('navigates to World Cup winner market details from sports list when treatment', () => { const abTests = [ { key: 'homeTMCU470AbtestTrendingSections', @@ -1621,14 +1650,14 @@ describe('PredictionsSection', () => { />, ); - fireEvent.press(screen.getByText('NBA 2026 Champion')); + fireEvent.press(screen.getByText('2026 FIFA World Cup Winner')); expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { screen: Routes.PREDICT.MARKET_DETAILS, params: { - marketId: '27830', + marketId: 'market-1', entryPoint: PredictEventValues.ENTRY_POINT.HOME_SECTION, - title: 'Will the Oklahoma City Thunder win the 2026 NBA Finals?', + title: '2026 FIFA World Cup Winner', image: undefined, transactionActiveAbTests: abTests, }, diff --git a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.tsx b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.tsx index ad045370658..33c1d547384 100644 --- a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.tsx +++ b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.tsx @@ -24,10 +24,9 @@ import HomepagePredictPositions from './components/HomepagePredictPositions'; import { usePredictMarketsForHomepage, usePredictPositionsForHomepage, - useHomepagePredictTaggedMarkets, + useHomepagePredictLiveWorldCupMarkets, useHomepagePredictWorldCupMarkets, useHomepagePredictWorldCupEventCount, - HOMEPAGE_PREDICT_TAG_QUERIES, usePredictHomepageDiscoveryExperiment, } from './hooks'; import { MAX_MARKETS_DISPLAYED } from './predictionsSectionConstants'; @@ -52,14 +51,11 @@ import { } from '../../abTestConfig'; import type { TransactionActiveAbTestEntry } from '../../../../../util/transactions/transaction-active-ab-test-attribution-registry'; -/** Loads both feeds the World Cup discovery rail needs (World Cup tag + NBA Champion event). */ +/** Loads the feeds the World Cup discovery rail needs. */ const useWorldCupDiscoveryFeeds = (enabled: boolean) => ({ worldCup: useHomepagePredictWorldCupMarkets({ enabled }), + liveWorldCup: useHomepagePredictLiveWorldCupMarkets({ enabled }), worldCupEventCount: useHomepagePredictWorldCupEventCount({ enabled }), - nbaChampion: useHomepagePredictTaggedMarkets({ - enabled, - customQueryParams: HOMEPAGE_PREDICT_TAG_QUERIES.nbaChampion, - }), }); const mergeActiveAbTests = ( @@ -338,18 +334,19 @@ const PredictionsSectionDefault = forwardRef< const { worldCup: worldCupHomepageMarkets, + liveWorldCup: liveWorldCupHomepageMarkets, worldCupEventCount, - nbaChampion: nbaChampionHomepageMarkets, } = useWorldCupDiscoveryFeeds(isPredictEnabled && isTreatmentDiscovery); const { refetch: refetchWorldCupHomepageMarkets } = worldCupHomepageMarkets; + const { refetch: refetchLiveWorldCupHomepageMarkets } = + liveWorldCupHomepageMarkets; const { refetch: refetchWorldCupEventCount } = worldCupEventCount; - const { refetch: refetchNbaChampionHomepageMarkets } = - nbaChampionHomepageMarkets; const isLoadingWorldCupHomepage = useTreatmentDiscoveryFeedsLoading({ isTreatmentDiscovery, isWorldCupFetching: - worldCupHomepageMarkets.isFetching || worldCupEventCount.isFetching, - isNbaChampionFetching: nbaChampionHomepageMarkets.isFetching, + worldCupHomepageMarkets.isFetching || + liveWorldCupHomepageMarkets.isFetching || + worldCupEventCount.isFetching, }); const { @@ -415,8 +412,8 @@ const PredictionsSectionDefault = forwardRef< const tasks: Promise[] = [refreshPositions(), refetchMarkets()]; if (isTreatmentDiscovery) { tasks.push(refetchWorldCupHomepageMarkets()); + tasks.push(refetchLiveWorldCupHomepageMarkets()); tasks.push(refetchWorldCupEventCount()); - tasks.push(refetchNbaChampionHomepageMarkets()); } await Promise.all(tasks); }, [ @@ -424,8 +421,8 @@ const PredictionsSectionDefault = forwardRef< refetchMarkets, isTreatmentDiscovery, refetchWorldCupHomepageMarkets, + refetchLiveWorldCupHomepageMarkets, refetchWorldCupEventCount, - refetchNbaChampionHomepageMarkets, ]); const positionsLayout = @@ -465,8 +462,8 @@ const PredictionsSectionDefault = forwardRef< markets={markets} transactionActiveAbTests={discoveryTransactionActiveAbTests} worldCupHomepage={worldCupHomepageMarkets} + liveWorldCupHomepage={liveWorldCupHomepageMarkets} worldCupEventCount={worldCupEventCount.eventCount} - nbaChampionHomepage={nbaChampionHomepageMarkets} emptyStateTransactionActiveAbTests={ discoveryTransactionActiveAbTests } @@ -503,8 +500,8 @@ const PredictionsSectionDefault = forwardRef< markets={markets} transactionActiveAbTests={discoveryTransactionActiveAbTests} worldCupHomepage={worldCupHomepageMarkets} + liveWorldCupHomepage={liveWorldCupHomepageMarkets} worldCupEventCount={worldCupEventCount.eventCount} - nbaChampionHomepage={nbaChampionHomepageMarkets} emptyStateTransactionActiveAbTests={ discoveryTransactionActiveAbTests } @@ -635,13 +632,13 @@ const PredictionsSectionTrendingOnly = forwardRef< const isListLayout = discoveryLayout === 'list'; const { worldCup: worldCupHomepageMarkets, + liveWorldCup: liveWorldCupHomepageMarkets, worldCupEventCount, - nbaChampion: nbaChampionHomepageMarkets, } = useWorldCupDiscoveryFeeds(isPredictEnabled && isListLayout); const { refetch: refetchWorldCupHomepageMarkets } = worldCupHomepageMarkets; + const { refetch: refetchLiveWorldCupHomepageMarkets } = + liveWorldCupHomepageMarkets; const { refetch: refetchWorldCupEventCount } = worldCupEventCount; - const { refetch: refetchNbaChampionHomepageMarkets } = - nbaChampionHomepageMarkets; const itemCount = isListLayout ? 1 : markets.length; const willRender = @@ -652,16 +649,16 @@ const PredictionsSectionTrendingOnly = forwardRef< const tasks: Promise[] = [refetchMarkets()]; if (isListLayout) { tasks.push(refetchWorldCupHomepageMarkets()); + tasks.push(refetchLiveWorldCupHomepageMarkets()); tasks.push(refetchWorldCupEventCount()); - tasks.push(refetchNbaChampionHomepageMarkets()); } await Promise.all(tasks); }, [ refetchMarkets, isListLayout, refetchWorldCupHomepageMarkets, + refetchLiveWorldCupHomepageMarkets, refetchWorldCupEventCount, - refetchNbaChampionHomepageMarkets, ]); return ( @@ -676,8 +673,8 @@ const PredictionsSectionTrendingOnly = forwardRef< isLoading={ isListLayout ? worldCupHomepageMarkets.isFetching || - worldCupEventCount.isFetching || - nbaChampionHomepageMarkets.isFetching + liveWorldCupHomepageMarkets.isFetching || + worldCupEventCount.isFetching : isLoadingMarkets } isEmpty={isListLayout ? false : !isLoadingMarkets && itemCount === 0} @@ -699,8 +696,8 @@ const PredictionsSectionTrendingOnly = forwardRef< trendingTransactionActiveAbTests } worldCupHomepage={worldCupHomepageMarkets} + liveWorldCupHomepage={liveWorldCupHomepageMarkets} worldCupEventCount={worldCupEventCount.eventCount} - nbaChampionHomepage={nbaChampionHomepageMarkets} /> @@ -732,24 +729,24 @@ const PredictionsSectionSportsOnly = forwardRef< const { worldCup: worldCupHomepageMarkets, + liveWorldCup: liveWorldCupHomepageMarkets, worldCupEventCount, - nbaChampion: nbaChampionHomepageMarkets, } = useWorldCupDiscoveryFeeds(isPredictEnabled); const { refetch: refetchWorldCupHomepageMarkets } = worldCupHomepageMarkets; + const { refetch: refetchLiveWorldCupHomepageMarkets } = + liveWorldCupHomepageMarkets; const { refetch: refetchWorldCupEventCount } = worldCupEventCount; - const { refetch: refetchNbaChampionHomepageMarkets } = - nbaChampionHomepageMarkets; const refresh = useCallback(async () => { await Promise.all([ refetchWorldCupHomepageMarkets(), + refetchLiveWorldCupHomepageMarkets(), refetchWorldCupEventCount(), - refetchNbaChampionHomepageMarkets(), ]); }, [ refetchWorldCupHomepageMarkets, + refetchLiveWorldCupHomepageMarkets, refetchWorldCupEventCount, - refetchNbaChampionHomepageMarkets, ]); return ( @@ -760,8 +757,8 @@ const PredictionsSectionSportsOnly = forwardRef< refresh={refresh} isLoading={ worldCupHomepageMarkets.isFetching || - worldCupEventCount.isFetching || - nbaChampionHomepageMarkets.isFetching + liveWorldCupHomepageMarkets.isFetching || + worldCupEventCount.isFetching } isEmpty={false} itemCount={1} @@ -775,8 +772,8 @@ const PredictionsSectionSportsOnly = forwardRef< onViewAll={handleViewAllPredictions} headerTestIdKey="trending-predictions" worldCup={worldCupHomepageMarkets} + liveWorldCup={liveWorldCupHomepageMarkets} worldCupEventCount={worldCupEventCount.eventCount} - nbaChampion={nbaChampionHomepageMarkets} /> diff --git a/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictTrendingMarkets.tsx b/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictTrendingMarkets.tsx index fad010c4132..20db9aa53e1 100644 --- a/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictTrendingMarkets.tsx +++ b/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictTrendingMarkets.tsx @@ -2,7 +2,6 @@ import React from 'react'; import type { PredictMarket } from '../../../../../UI/Predict/types'; import type { TransactionActiveAbTestEntry } from '../../../../../../util/transactions/transaction-active-ab-test-attribution-registry'; import type { UseHomepagePredictWorldCupMarketsResult } from '../hooks/useHomepagePredictWorldCupMarkets'; -import type { UseHomepagePredictTaggedMarketsResult } from '../hooks/useHomepagePredictTaggedMarkets'; import type { PredictionsTrendingHeaderTestId } from '../predictionsSectionTypes'; import type { PredictEmptyStateCtaName } from '../../../abTestConfig'; import HomepagePredictWorldCupDiscovery from './HomepagePredictWorldCupDiscovery'; @@ -20,9 +19,9 @@ export interface HomepagePredictTrendingMarketsProps { transactionActiveAbTests?: TransactionActiveAbTestEntry[]; /** Required when `discoveryLayout` is `list` (World Cup discovery rail). */ worldCupHomepage?: UseHomepagePredictWorldCupMarketsResult; + /** Required when `discoveryLayout` is `list` (World Cup live games rail item). */ + liveWorldCupHomepage?: UseHomepagePredictWorldCupMarketsResult; worldCupEventCount?: number; - /** Required when `discoveryLayout` is `list` (NBA champion event, separate from World Cup tag). */ - nbaChampionHomepage?: UseHomepagePredictTaggedMarketsResult; emptyStateTransactionActiveAbTests?: TransactionActiveAbTestEntry[]; onEmptyStateTreatmentCtaClick?: ( ctaName: PredictEmptyStateCtaName, @@ -39,8 +38,8 @@ const HomepagePredictTrendingMarkets = ({ markets, transactionActiveAbTests, worldCupHomepage, + liveWorldCupHomepage, worldCupEventCount, - nbaChampionHomepage, emptyStateTransactionActiveAbTests, onEmptyStateTreatmentCtaClick, }: HomepagePredictTrendingMarketsProps) => { @@ -57,7 +56,7 @@ const HomepagePredictTrendingMarkets = ({ ); } - if (!worldCupHomepage || !nbaChampionHomepage) { + if (!worldCupHomepage || !liveWorldCupHomepage) { return null; } @@ -67,8 +66,8 @@ const HomepagePredictTrendingMarkets = ({ onViewAll={onViewAll} headerTestIdKey={headerTestIdKey} worldCup={worldCupHomepage} + liveWorldCup={liveWorldCupHomepage} worldCupEventCount={worldCupEventCount} - nbaChampion={nbaChampionHomepage} transactionActiveAbTests={emptyStateTransactionActiveAbTests} onTreatmentCtaClick={onEmptyStateTreatmentCtaClick} /> diff --git a/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictWorldCupDiscovery/BtcLiveRow.tsx b/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictWorldCupDiscovery/BtcLiveRow.tsx index ddbde33731f..b0ebf59cd81 100644 --- a/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictWorldCupDiscovery/BtcLiveRow.tsx +++ b/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictWorldCupDiscovery/BtcLiveRow.tsx @@ -3,10 +3,7 @@ import { Pressable } from 'react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { Box, - BoxAlignItems, - BoxBackgroundColor, BoxFlexDirection, - BoxJustifyContent, Icon, IconColor, IconName, @@ -18,6 +15,7 @@ import { import { strings } from '../../../../../../../../locales/i18n'; import { formatPrice } from '../../../../../../UI/Predict/utils/format'; import HomepagePredictDiscoveryMaterialGlyph from './HomepagePredictDiscoveryMaterialGlyph'; +import HomepagePredictDiscoveryLivePill from './HomepagePredictDiscoveryLivePill'; const formatBtc = (value: number | undefined) => value === undefined || Number.isNaN(value) @@ -78,36 +76,7 @@ const BtcLiveRow = ({ })} - - - - - - {strings('predict.homepage_discovery.btc_live')} - - - {countdown} - - + + value.replace(/^([0-9]):([0-9]{2})$/, '0$1:$2'); + +const HomepagePredictDiscoveryLivePill = ({ + value, +}: HomepagePredictDiscoveryLivePillProps) => { + const tw = useTailwind(); + const displayValue = value ? formatLivePillValue(value) : undefined; + + return ( + + + + + + {strings('predict.homepage_discovery.btc_live')} + + {displayValue ? ( + + {displayValue} + + ) : null} + + ); +}; + +export default HomepagePredictDiscoveryLivePill; diff --git a/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictWorldCupDiscovery/LiveGameRow.tsx b/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictWorldCupDiscovery/LiveGameRow.tsx new file mode 100644 index 00000000000..9b9466ff6fa --- /dev/null +++ b/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictWorldCupDiscovery/LiveGameRow.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import PredictMarketRowItem from '../../../../../../UI/Predict/components/PredictMarketRowItem'; +import { PredictEventValues } from '../../../../../../UI/Predict/constants/eventNames'; +import type { PredictMarket } from '../../../../../../UI/Predict/types'; +import type { TransactionActiveAbTestEntry } from '../../../../../../../util/transactions/transaction-active-ab-test-attribution-registry'; +import HomepagePredictDiscoveryMaterialGlyph from './HomepagePredictDiscoveryMaterialGlyph'; +import HomepagePredictDiscoveryLivePill from './HomepagePredictDiscoveryLivePill'; + +interface LiveGameRowProps { + market: PredictMarket; + onPress?: () => void; + transactionActiveAbTests?: TransactionActiveAbTestEntry[]; +} + +const LiveGameRow = ({ + market, + onPress, + transactionActiveAbTests, +}: LiveGameRowProps) => ( + + } + endAccessory={ + + } + detailsTitle={market.title} + onPress={onPress} + transactionActiveAbTests={transactionActiveAbTests} + testID="homepage-predict-discovery-live-game-row" + /> +); + +export default LiveGameRow; diff --git a/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictWorldCupDiscovery/index.tsx b/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictWorldCupDiscovery/index.tsx index fd41184ccc4..ace694f8912 100644 --- a/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictWorldCupDiscovery/index.tsx +++ b/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictWorldCupDiscovery/index.tsx @@ -22,20 +22,19 @@ import { useCurrentCryptoUpDownMarketData } from '../../../../../../UI/Predict/h import { usePredictNavigation } from '../../../../../../UI/Predict/hooks/usePredictNavigation'; import { selectPredictEnabledFlag, - selectPredictHomepageDiscoveryNbaChampionEnabledFlag, selectPredictWorldCupScreenEnabledFlag, } from '../../../../../../UI/Predict/selectors/featureFlags'; import { + pickLiveWorldCupGameMarket, pickWorldCupWinnerMarket, - resolveNbaChampionHomepageMarket, } from '../../utils/marketResolvers'; import type { UseHomepagePredictWorldCupMarketsResult } from '../../hooks/useHomepagePredictWorldCupMarkets'; -import type { UseHomepagePredictTaggedMarketsResult } from '../../hooks/useHomepagePredictTaggedMarkets'; import type { PredictionsTrendingHeaderTestId } from '../../predictionsSectionTypes'; import type { TransactionActiveAbTestEntry } from '../../../../../../../util/transactions/transaction-active-ab-test-attribution-registry'; import BtcLiveRow from './BtcLiveRow'; import ChampionshipRow, { type ChampionshipRowState } from './ChampionshipRow'; import MensWorldCupRow from './MensWorldCupRow'; +import LiveGameRow from './LiveGameRow'; import BracketPills from './BracketPills'; const WORLD_CUP_CTA_CATEGORY_NAME = 'world_cup'; @@ -47,8 +46,8 @@ export interface HomepagePredictWorldCupDiscoveryProps { ) => void; headerTestIdKey: PredictionsTrendingHeaderTestId; worldCup: UseHomepagePredictWorldCupMarketsResult; + liveWorldCup: UseHomepagePredictWorldCupMarketsResult; worldCupEventCount?: number; - nbaChampion: UseHomepagePredictTaggedMarketsResult; transactionActiveAbTests?: TransactionActiveAbTestEntry[]; onTreatmentCtaClick?: ( ctaName: PredictEmptyStateCtaName, @@ -63,8 +62,8 @@ const HomepagePredictWorldCupDiscovery: React.FC< onViewAll, headerTestIdKey, worldCup, + liveWorldCup, worldCupEventCount, - nbaChampion, transactionActiveAbTests, onTreatmentCtaClick, }) => { @@ -74,9 +73,6 @@ const HomepagePredictWorldCupDiscovery: React.FC< selectPredictWorldCupScreenEnabledFlag, ); const isPredictEnabled = useSelector(selectPredictEnabledFlag); - const showNbaChampionDiscoveryRow = useSelector( - selectPredictHomepageDiscoveryNbaChampionEnabledFlag, - ); const { marketId: btcMarketId, market: btcWindowMarket, @@ -87,22 +83,13 @@ const HomepagePredictWorldCupDiscovery: React.FC< series: BTC_UP_OR_DOWN_5M_SERIES, enabled: isPredictEnabled, }); - const championshipRowKind = showNbaChampionDiscoveryRow - ? 'nba' - : 'world_cup_winner'; - const championshipCtaCategoryName = - championshipRowKind === 'world_cup_winner' - ? WORLD_CUP_CTA_CATEGORY_NAME - : 'nba'; - const { marketData, isFetching } = worldCup; - const { marketData: nbaMarketData, isFetching: isNbaFetching } = nbaChampion; const isInitialLoad = isFetching && marketData.length === 0; - const nbaLeadLoading = - championshipRowKind === 'nba' && - isNbaFetching && - nbaMarketData.length === 0; + const liveWorldCupGame = useMemo( + () => pickLiveWorldCupGameMarket(liveWorldCup.marketData), + [liveWorldCup.marketData], + ); const eventCountLabel = useMemo( () => @@ -115,22 +102,8 @@ const HomepagePredictWorldCupDiscovery: React.FC< ); const championshipRow: ChampionshipRowState = useMemo(() => { - if (championshipRowKind === 'nba') { - if (nbaLeadLoading) { - return { kind: 'loading' }; - } - const lead = resolveNbaChampionHomepageMarket(nbaMarketData, marketData); - if (!lead) { - return { kind: 'empty' }; - } - return { - kind: 'market', - market: { - ...lead, - title: strings('predict.homepage_discovery.nba_2026_champion_title'), - }, - detailsTitle: lead.outcomes[0]?.title ?? lead.title, - }; + if (liveWorldCupGame) { + return { kind: 'empty' }; } if (isInitialLoad) { return { kind: 'loading' }; @@ -138,14 +111,8 @@ const HomepagePredictWorldCupDiscovery: React.FC< const winner = pickWorldCupWinnerMarket(marketData); return winner ? { kind: 'market', market: winner, detailsTitle: undefined } - : { kind: 'empty' }; - }, [ - championshipRowKind, - isInitialLoad, - marketData, - nbaLeadLoading, - nbaMarketData, - ]); + : { kind: 'loading' }; + }, [isInitialLoad, liveWorldCupGame, marketData]); const handleBtcRow = useCallback(() => { onTreatmentCtaClick?.( @@ -237,9 +204,15 @@ const HomepagePredictWorldCupDiscovery: React.FC< const handleChampionshipRowPress = useCallback(() => { onTreatmentCtaClick?.( PREDICT_EMPTY_STATE_CTA_NAMES.BROWSE_CATEGORY, - championshipCtaCategoryName, + WORLD_CUP_CTA_CATEGORY_NAME, + ); + }, [onTreatmentCtaClick]); + const handleLiveGameRowPress = useCallback(() => { + onTreatmentCtaClick?.( + PREDICT_EMPTY_STATE_CTA_NAMES.BROWSE_CATEGORY, + WORLD_CUP_CTA_CATEGORY_NAME, ); - }, [championshipCtaCategoryName, onTreatmentCtaClick]); + }, [onTreatmentCtaClick]); return ( <> @@ -260,11 +233,19 @@ const HomepagePredictWorldCupDiscovery: React.FC< priceToBeat={priceToBeat} countdown={btcCountdown} /> - + {liveWorldCupGame ? ( + + ) : ( + + )} { expect(result.current).not.toHaveProperty('eventCount'); expect(result.current).not.toHaveProperty('totalResults'); }); + + it('loads homepage World Cup markets from the all tab', () => { + renderHook(() => useHomepagePredictWorldCupMarkets({ enabled: true }), { + wrapper: createWrapper(), + }); + + expect(mockUsePredictWorldCupMarkets).toHaveBeenCalledWith({ + tabKey: PREDICT_WORLD_CUP_TAB_KEYS.ALL, + config: DEFAULT_PREDICT_WORLD_CUP_FLAG, + enabled: true, + }); + }); + + it('loads homepage live World Cup markets from the live tab', () => { + renderHook(() => useHomepagePredictLiveWorldCupMarkets({ enabled: true }), { + wrapper: createWrapper(), + }); + + expect(mockUsePredictWorldCupMarkets).toHaveBeenCalledWith({ + tabKey: PREDICT_WORLD_CUP_TAB_KEYS.LIVE, + config: DEFAULT_PREDICT_WORLD_CUP_FLAG, + enabled: true, + }); + }); }); diff --git a/app/components/Views/Homepage/Sections/Predictions/hooks/useHomepagePredictWorldCupMarkets.ts b/app/components/Views/Homepage/Sections/Predictions/hooks/useHomepagePredictWorldCupMarkets.ts index b856da5c145..4c14ad847ca 100644 --- a/app/components/Views/Homepage/Sections/Predictions/hooks/useHomepagePredictWorldCupMarkets.ts +++ b/app/components/Views/Homepage/Sections/Predictions/hooks/useHomepagePredictWorldCupMarkets.ts @@ -112,5 +112,21 @@ export function useHomepagePredictWorldCupMarkets({ }); } +/** + * Homepage discovery: loads live World Cup games using the same LIVE-tab query + * path as the dedicated World Cup screen. + */ +export function useHomepagePredictLiveWorldCupMarkets({ + enabled, +}: UseHomepagePredictWorldCupMarketsArgs): UseHomepagePredictWorldCupMarketsResult { + const config = useSelector(selectPredictWorldCupConfig); + + return usePredictWorldCupMarkets({ + tabKey: PREDICT_WORLD_CUP_TAB_KEYS.LIVE, + config, + enabled, + }); +} + export type UseHomepagePredictWorldCupMarketsResult = UsePredictMarketDataResult; diff --git a/app/components/Views/Homepage/Sections/Predictions/hooks/useTreatmentDiscoveryFeedsLoading.test.ts b/app/components/Views/Homepage/Sections/Predictions/hooks/useTreatmentDiscoveryFeedsLoading.test.ts index a24905ffa1e..650e312176a 100644 --- a/app/components/Views/Homepage/Sections/Predictions/hooks/useTreatmentDiscoveryFeedsLoading.test.ts +++ b/app/components/Views/Homepage/Sections/Predictions/hooks/useTreatmentDiscoveryFeedsLoading.test.ts @@ -7,14 +7,13 @@ describe('useTreatmentDiscoveryFeedsLoading', () => { useTreatmentDiscoveryFeedsLoading({ isTreatmentDiscovery: false, isWorldCupFetching: true, - isNbaChampionFetching: true, }), ); expect(result.current).toBe(false); }); - it('returns true until both feeds have settled at least once', () => { + it('returns true until World Cup feeds have settled at least once', () => { const { result, rerender } = renderHook( (props: Parameters[0]) => useTreatmentDiscoveryFeedsLoading(props), @@ -22,7 +21,6 @@ describe('useTreatmentDiscoveryFeedsLoading', () => { initialProps: { isTreatmentDiscovery: true, isWorldCupFetching: true, - isNbaChampionFetching: false, }, }, ); @@ -32,7 +30,6 @@ describe('useTreatmentDiscoveryFeedsLoading', () => { rerender({ isTreatmentDiscovery: true, isWorldCupFetching: false, - isNbaChampionFetching: false, }); expect(result.current).toBe(false); @@ -46,7 +43,6 @@ describe('useTreatmentDiscoveryFeedsLoading', () => { initialProps: { isTreatmentDiscovery: true, isWorldCupFetching: false, - isNbaChampionFetching: false, }, }, ); @@ -56,13 +52,11 @@ describe('useTreatmentDiscoveryFeedsLoading', () => { rerender({ isTreatmentDiscovery: false, isWorldCupFetching: false, - isNbaChampionFetching: false, }); rerender({ isTreatmentDiscovery: true, isWorldCupFetching: true, - isNbaChampionFetching: false, }); expect(result.current).toBe(true); @@ -70,7 +64,6 @@ describe('useTreatmentDiscoveryFeedsLoading', () => { rerender({ isTreatmentDiscovery: true, isWorldCupFetching: false, - isNbaChampionFetching: false, }); expect(result.current).toBe(false); diff --git a/app/components/Views/Homepage/Sections/Predictions/hooks/useTreatmentDiscoveryFeedsLoading.ts b/app/components/Views/Homepage/Sections/Predictions/hooks/useTreatmentDiscoveryFeedsLoading.ts index c472647e8cf..dd15b787b69 100644 --- a/app/components/Views/Homepage/Sections/Predictions/hooks/useTreatmentDiscoveryFeedsLoading.ts +++ b/app/components/Views/Homepage/Sections/Predictions/hooks/useTreatmentDiscoveryFeedsLoading.ts @@ -8,11 +8,9 @@ import { useLayoutEffect, useRef, useState } from 'react'; export function useTreatmentDiscoveryFeedsLoading({ isTreatmentDiscovery, isWorldCupFetching, - isNbaChampionFetching, }: { isTreatmentDiscovery: boolean; isWorldCupFetching: boolean; - isNbaChampionFetching: boolean; }): boolean { const [hasDiscoveryFeedsSettled, setHasDiscoveryFeedsSettled] = useState(false); @@ -30,10 +28,10 @@ export function useTreatmentDiscoveryFeedsLoading({ wasTreatmentDiscoveryRef.current = true; } - if (!isWorldCupFetching && !isNbaChampionFetching) { + if (!isWorldCupFetching) { setHasDiscoveryFeedsSettled(true); } - }, [isTreatmentDiscovery, isWorldCupFetching, isNbaChampionFetching]); + }, [isTreatmentDiscovery, isWorldCupFetching]); return isTreatmentDiscovery && !hasDiscoveryFeedsSettled; } diff --git a/app/components/Views/Homepage/Sections/Predictions/utils/marketResolvers.test.ts b/app/components/Views/Homepage/Sections/Predictions/utils/marketResolvers.test.ts index fd3d4d82cea..b4175144624 100644 --- a/app/components/Views/Homepage/Sections/Predictions/utils/marketResolvers.test.ts +++ b/app/components/Views/Homepage/Sections/Predictions/utils/marketResolvers.test.ts @@ -5,6 +5,7 @@ import { } from '../../../../../UI/Predict/types'; import { FIFA_WORLD_CUP_2026_WINNER_POLYMARKET_SLUG, + pickLiveWorldCupGameMarket, pickWorldCupWinnerMarket, } from './marketResolvers'; @@ -71,4 +72,142 @@ describe('marketResolvers', () => { ); }); }); + + describe('pickLiveWorldCupGameMarket', () => { + it('returns the first ongoing game with an open priced outcome', () => { + const scheduled = createMarket({ + id: 'scheduled-game', + outcomes: [ + { + id: 'scheduled-outcome', + providerId: 'scheduled-provider-outcome', + marketId: 'scheduled-game', + title: 'France', + description: 'France wins', + image: '', + status: 'open', + tokens: [{ id: 'scheduled-token', title: 'France', price: 0.67 }], + volume: 0, + groupItemTitle: 'France', + }, + ], + game: { + id: 'game-1', + startTime: '2026-06-01T20:00:00.000Z', + status: 'scheduled', + league: 'fifwc', + elapsed: null, + period: null, + score: null, + homeTeam: { + id: 'france', + name: 'France', + logo: '', + abbreviation: 'FRA', + color: '', + }, + awayTeam: { + id: 'senegal', + name: 'Senegal', + logo: '', + abbreviation: 'SEN', + color: '', + }, + }, + }); + const live = createMarket({ + id: 'live-game', + title: 'France vs. Senegal', + outcomes: [ + { + id: 'live-outcome', + providerId: 'live-provider-outcome', + marketId: 'live-game', + title: 'France', + description: 'France wins', + image: '', + status: 'open', + tokens: [{ id: 'live-token', title: 'France', price: 0.67 }], + volume: 0, + groupItemTitle: 'France', + }, + ], + game: { + id: 'game-2', + startTime: '2026-06-01T20:00:00.000Z', + status: 'ongoing', + league: 'fifwc', + elapsed: '03:58', + period: '1H', + score: null, + homeTeam: { + id: 'france', + name: 'France', + logo: '', + abbreviation: 'FRA', + color: '', + }, + awayTeam: { + id: 'senegal', + name: 'Senegal', + logo: '', + abbreviation: 'SEN', + color: '', + }, + }, + }); + + const result = pickLiveWorldCupGameMarket([scheduled, live]); + + expect(result).toBe(live); + }); + + it('ignores games that have ended even when status remains ongoing', () => { + const ended = createMarket({ + id: 'ended-game', + outcomes: [ + { + id: 'ended-outcome', + providerId: 'ended-provider-outcome', + marketId: 'ended-game', + title: 'France', + description: 'France wins', + image: '', + status: 'open', + tokens: [{ id: 'ended-token', title: 'France', price: 0.67 }], + volume: 0, + groupItemTitle: 'France', + }, + ], + game: { + id: 'game-3', + startTime: '2026-06-01T20:00:00.000Z', + endTime: '2026-06-01T22:00:00.000Z', + status: 'ongoing', + league: 'fifwc', + elapsed: '90', + period: 'FT', + score: null, + homeTeam: { + id: 'france', + name: 'France', + logo: '', + abbreviation: 'FRA', + color: '', + }, + awayTeam: { + id: 'senegal', + name: 'Senegal', + logo: '', + abbreviation: 'SEN', + color: '', + }, + }, + }); + + const result = pickLiveWorldCupGameMarket([ended]); + + expect(result).toBeUndefined(); + }); + }); }); diff --git a/app/components/Views/Homepage/Sections/Predictions/utils/marketResolvers.ts b/app/components/Views/Homepage/Sections/Predictions/utils/marketResolvers.ts index 870be472a80..77477651fa3 100644 --- a/app/components/Views/Homepage/Sections/Predictions/utils/marketResolvers.ts +++ b/app/components/Views/Homepage/Sections/Predictions/utils/marketResolvers.ts @@ -1,30 +1,11 @@ -import type { - PredictMarket, - PredictOutcome, -} from '../../../../../UI/Predict/types'; -import { NBA_2026_CHAMPION_POLYMARKET_EVENT_ID } from '../constants/homepageNbaChampionDiscovery'; +import type { PredictMarket } from '../../../../../UI/Predict/types'; +import { isGameEnded } from '../../../../../UI/Predict/utils/scoreboard'; import Logger from '../../../../../../util/Logger'; -/** - * Pick the open outcome with the highest implied "Yes" price (`tokens[0].price`). - * Used to narrow event-aggregate markets like "NBA Champion" — one outcome per - * team — without relying on the API's incoming sort order. - */ -function pickHighestProbabilityOpenOutcome( - market: Pick, -): PredictOutcome | undefined { - const open = market.outcomes.filter( - (o) => o.status === 'open' && o.tokens?.[0], +const hasOpenOutcomeWithPrice = (market: Pick) => + market.outcomes.some( + (outcome) => outcome.status === 'open' && outcome.tokens?.[0], ); - if (open.length === 0) { - return undefined; - } - return open.reduce((best, candidate) => { - const bestPrice = best.tokens[0]?.price ?? 0; - const candidatePrice = candidate.tokens[0]?.price ?? 0; - return candidatePrice > bestPrice ? candidate : best; - }); -} /** * Polymarket slug for the 2026 FIFA World Cup Winner aggregate event. Slugs @@ -88,39 +69,20 @@ export function pickWorldCupWinnerMarket( return fallback; } -/** - * Resolve the Polymarket "2026 NBA Champion" aggregate event (one - * `PredictMarket` per Gamma event; each team is an `outcome`, not a separate - * top-level market). - */ -function pickNbaChampionEventMarket( +export function pickLiveWorldCupGameMarket( markets: PredictMarket[], ): PredictMarket | undefined { - return ( - markets.find((m) => m.id === NBA_2026_CHAMPION_POLYMARKET_EVENT_ID) ?? - markets.find((m) => includesAll(m.title, ['nba', '2026', 'champion'])) ?? - markets.find((m) => includesAll(m.title, ['nba', 'champion'])) - ); -} + return markets.find((market) => { + const { game } = market; -/** - * For the homepage row: keep event-level `id` for navigation, but narrow - * `outcomes` to the open team with the highest implied "Yes" so the subtitle - * matches the favorite (same shape `PredictMarketRowItem` expects). - */ -function narrowNbaChampionMarketToFavoriteOutcome( - parent: PredictMarket, -): PredictMarket | undefined { - const best = pickHighestProbabilityOpenOutcome(parent); - return best ? { ...parent, outcomes: [best] } : undefined; -} - -export function resolveNbaChampionHomepageMarket( - nbaMarkets: PredictMarket[], - worldCupMarkets: PredictMarket[], -): PredictMarket | undefined { - const parent = - pickNbaChampionEventMarket(nbaMarkets) ?? - pickNbaChampionEventMarket(worldCupMarkets); - return parent ? narrowNbaChampionMarketToFavoriteOutcome(parent) : undefined; + return ( + game?.status === 'ongoing' && + !isGameEnded({ + status: game.status, + period: game.period, + endTime: game.endTime, + }) && + hasOpenOutcomeWithPrice(market) + ); + }); } diff --git a/app/components/Views/Homepage/Sections/TopTraders/hooks/useTopTraders.test.ts b/app/components/Views/Homepage/Sections/TopTraders/hooks/useTopTraders.test.ts index 91dc0dcdfd2..baa276fdbf3 100644 --- a/app/components/Views/Homepage/Sections/TopTraders/hooks/useTopTraders.test.ts +++ b/app/components/Views/Homepage/Sections/TopTraders/hooks/useTopTraders.test.ts @@ -39,7 +39,7 @@ const mockTraders = [ name: 'alpha.eth', imageUrl: 'https://example.com/avatar1.png', pnl7d: 963146.8, - roiPercent7d: 0.43, + roiPercent7d: 43, pnlPerChain: { base: 963146.8 }, }, { @@ -49,7 +49,7 @@ const mockTraders = [ name: 'beta.eth', imageUrl: 'https://example.com/avatar2.png', pnl7d: 474751.45, - roiPercent7d: 3.59, + roiPercent7d: 359, pnlPerChain: { ethereum: 474751.45 }, }, { @@ -59,7 +59,7 @@ const mockTraders = [ name: 'gamma.eth', imageUrl: 'https://example.com/avatar3.png', pnl7d: 374735.16, - roiPercent7d: 6.17, + roiPercent7d: 617, pnlPerChain: { solana: 374735.16 }, }, ]; @@ -135,7 +135,7 @@ describe('useTopTraders', () => { overallRank: first.rank, username: first.name, avatarUri: first.imageUrl, - percentageChange: first.roiPercent7d * 100, + percentageChange: first.roiPercent7d, pnlValue: first.pnl7d, pnlPerChain: first.pnlPerChain ?? {}, isFollowing: false, diff --git a/app/components/Views/Homepage/Sections/TopTraders/hooks/useTopTraders.ts b/app/components/Views/Homepage/Sections/TopTraders/hooks/useTopTraders.ts index ff985dbfd23..34eb1201051 100644 --- a/app/components/Views/Homepage/Sections/TopTraders/hooks/useTopTraders.ts +++ b/app/components/Views/Homepage/Sections/TopTraders/hooks/useTopTraders.ts @@ -92,7 +92,9 @@ export const useTopTraders = ( overallRank: entry.rank, username: entry.name, avatarUri: entry.imageUrl ?? undefined, - percentageChange: (entry.roiPercent7d ?? 0) * 100, + // `roiPercent7d` is already a whole-percent value from the API + // (e.g. 20.98 → "20.98%"); do not multiply by 100. + percentageChange: entry.roiPercent7d ?? 0, pnlValue: entry.pnl7d ?? 0, pnlPerChain: entry.pnlPerChain ?? {}, isFollowing: isFollowing(entry.profileId), diff --git a/app/components/Views/MultichainAccounts/shared/AccountConnect.types.ts b/app/components/Views/MultichainAccounts/shared/AccountConnect.types.ts index e7a42311d80..2eba1fed960 100644 --- a/app/components/Views/MultichainAccounts/shared/AccountConnect.types.ts +++ b/app/components/Views/MultichainAccounts/shared/AccountConnect.types.ts @@ -18,7 +18,6 @@ export enum AccountConnectScreens { SingleConnectSelector = 'SingleConnectSelector', MultiConnectSelector = 'MultiConnectSelector', MultiConnectNetworkSelector = 'MultiConnectNetworkSelector', - AddNewAccount = 'AddNewAccount', MaliciousWarning = 'MaliciousWarning', } diff --git a/app/components/Views/Settings/NotificationsSettings/SocialAINotificationPreferencesContent.test.tsx b/app/components/Views/Settings/NotificationsSettings/SocialAINotificationPreferencesContent.test.tsx index ef4c666715e..4d312a8dc84 100644 --- a/app/components/Views/Settings/NotificationsSettings/SocialAINotificationPreferencesContent.test.tsx +++ b/app/components/Views/Settings/NotificationsSettings/SocialAINotificationPreferencesContent.test.tsx @@ -106,7 +106,7 @@ const makeNotificationPreferencesResult = ( const followedTraders: FollowedTrader[] = [ { id: 'trader-1', - username: 'dutchiono', + username: 'trader1', avatarUri: 'https://example.com/avatar.png', }, { @@ -325,7 +325,7 @@ describe('SocialAINotificationPreferencesContent', () => { Routes.SOCIAL_LEADERBOARD.PROFILE, { traderId: 'trader-1', - traderName: 'dutchiono', + traderName: 'trader1', }, ); }); diff --git a/app/components/Views/SocialLeaderboard/NotificationPreferences/hooks/useFollowedTraders.test.ts b/app/components/Views/SocialLeaderboard/NotificationPreferences/hooks/useFollowedTraders.test.ts index 7ab42bf565b..4138836d064 100644 --- a/app/components/Views/SocialLeaderboard/NotificationPreferences/hooks/useFollowedTraders.test.ts +++ b/app/components/Views/SocialLeaderboard/NotificationPreferences/hooks/useFollowedTraders.test.ts @@ -36,13 +36,13 @@ const fixtureFollowing = { { profileId: 'trader-1', address: '0x1', - name: 'dutchiono', + name: 'trader1', imageUrl: 'https://example.com/a1.png', }, { profileId: 'trader-2', address: '0x2', - name: 'Kien', + name: 'trader2', imageUrl: null, }, ], @@ -99,10 +99,10 @@ describe('useFollowedTraders', () => { expect(result.current.traders).toEqual([ { id: 'trader-1', - username: 'dutchiono', + username: 'trader1', avatarUri: 'https://example.com/a1.png', }, - { id: 'trader-2', username: 'Kien', avatarUri: undefined }, + { id: 'trader-2', username: 'trader2', avatarUri: undefined }, ]); }); }); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx index a5b33fe175a..5fc65f3d073 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx @@ -68,7 +68,7 @@ const makeDefaultPosition = (): Position => ({ let mockRouteParams: MockRouteParams = { traderId: 'trader-1', - traderName: 'dutchiono', + traderName: 'trader1', traderAddress: '0xabc', tokenSymbol: 'PEPE', position: makeDefaultPosition(), @@ -224,7 +224,7 @@ describe('TraderPositionView', () => { mockGetAssetImageUrl.mockReturnValue('https://example.com/token.png'); mockRouteParams = { traderId: 'trader-1', - traderName: 'dutchiono', + traderName: 'trader1', traderAddress: '0xabc', tokenSymbol: 'PEPE', position: makeDefaultPosition(), @@ -237,7 +237,7 @@ describe('TraderPositionView', () => { expect( screen.getByTestId(TraderPositionViewSelectorsIDs.CONTAINER), ).toBeOnTheScreen(); - expect(screen.getByText('dutchiono')).toBeOnTheScreen(); + expect(screen.getByText('trader1')).toBeOnTheScreen(); expect(screen.getAllByText('PEPE').length).toBeGreaterThanOrEqual(1); }); @@ -271,7 +271,7 @@ describe('TraderPositionView', () => { Routes.SOCIAL_LEADERBOARD.PROFILE, { traderId: 'trader-1', - traderName: 'dutchiono', + traderName: 'trader1', }, ); expect(mockGoBack).not.toHaveBeenCalled(); @@ -619,7 +619,7 @@ describe('TraderPositionView', () => { it('refreshes profile on pull even when name and image came via nav params', async () => { mockRouteParams = { ...mockRouteParams, - traderName: 'dutchiono', + traderName: 'trader1', traderImageUrl: 'https://example.com/avatar.png', }; @@ -642,7 +642,7 @@ describe('TraderPositionView', () => { it('does not render the refresh control in the fallback state', () => { mockRouteParams = { traderId: 'trader-1', - traderName: 'dutchiono', + traderName: 'trader1', tokenSymbol: 'PEPE', position: undefined, }; diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyActionFooter.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyActionFooter.tsx index ae31d2e796b..bf72b82f16b 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyActionFooter.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyActionFooter.tsx @@ -67,7 +67,7 @@ const QuickBuyActionFooter: React.FC = () => { justifyContent={BoxJustifyContent.Between} twClassName="pb-5" > - + {tradeMode === 'sell' ? strings('social_leaderboard.quick_buy.receive') : strings('social_leaderboard.quick_buy.pay_with')} diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.tsx index 5166d380d89..03e593a0488 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyAmountSection.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { StyleSheet, TextInput } from 'react-native'; +import { TextInput } from 'react-native'; import { Box, Text, @@ -15,10 +15,6 @@ import { import type { QuickBuyAmountDisplayMode } from '../types'; import { formatTokenAmount } from '../../../../utils/formatters'; -const styles = StyleSheet.create({ - amountText: { fontSize: 48, lineHeight: 52 }, -}); - interface QuickBuyAmountSectionProps { amountDisplayMode: QuickBuyAmountDisplayMode; /** Entered amount preformatted in the user's display currency (e.g. "$20", "20 €"). */ @@ -86,7 +82,7 @@ const QuickBuyAmountSection: React.FC = ({ testID="quick-buy-amount-area" > @@ -101,9 +97,9 @@ const QuickBuyAmountSection: React.FC = ({ /> ) : ( {secondaryLabel} diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyRateTag.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyRateTag.tsx index 92affc832ff..20c5ea26988 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyRateTag.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyRateTag.tsx @@ -45,7 +45,7 @@ const QuickBuyRateTag: React.FC = ({ alignItems={BoxAlignItems.Center} gap={1} > - + {displayLabel} diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyTradeModeToggle.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyTradeModeToggle.tsx index 55c2e665506..b2f5f055a44 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyTradeModeToggle.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyTradeModeToggle.tsx @@ -18,6 +18,7 @@ import { useQuickBuyContext } from '../useQuickBuyContext'; import type { QuickBuyTradeMode } from '../types'; import { useTheme } from '../../../../../../../util/theme'; import { playSelection } from '../../../../../../../util/haptics'; +import { brandColor } from '@metamask/design-tokens'; const styles = StyleSheet.create({ // Inner row owns the relative positioning context. It carries no border or @@ -72,13 +73,13 @@ const QuickBuyTradeModeToggle: React.FC = ({ const sliderWidth = tradeMode === 'buy' ? (buyLayout?.width ?? 0) : sellWidth; - // Transition the slider background from green (buy, translateX 0) to red + // Transition the slider background from green (buy, translateX 0) to orange // (sell, translateX = buy button width) as it slides across. const sliderBackgroundColor = buyLayout && buyLayout.width > 0 ? slideAnim.interpolate({ inputRange: [0, buyLayout.width], - outputRange: [colors.success.default, colors.error.default], + outputRange: [colors.success.default, brandColor.orange400], }) : colors.success.default; diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.test.tsx b/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.test.tsx index 5ef5173d1df..0f817ddf218 100644 --- a/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.test.tsx @@ -162,7 +162,7 @@ let mockRouteParams: { traderRank?: number; } = { traderId: 'trader-1', - traderName: 'dutchiono', + traderName: 'trader1', traderAddress: '0xabc', source: 'leaderboard', traderRank: 1, @@ -182,7 +182,7 @@ const fixtureProfile: TraderProfileResponse = { profileId: 'trader-1', address: '0xabc', allAddresses: ['0xabc'], - name: 'dutchiono', + name: 'trader1', imageUrl: 'https://example.com/avatar.png', }, stats: { @@ -337,7 +337,7 @@ describe('TraderProfileView', () => { mockIsTraderNotificationEnabled.mockReturnValue(true); mockRouteParams = { traderId: 'trader-1', - traderName: 'dutchiono', + traderName: 'trader1', traderAddress: '0xabc', source: 'leaderboard', traderRank: 1, @@ -353,7 +353,7 @@ describe('TraderProfileView', () => { it('displays the trader name', () => { renderWithProvider(); - const visibleTraderNames = screen.getAllByText('dutchiono'); + const visibleTraderNames = screen.getAllByText('trader1'); expect(visibleTraderNames).toHaveLength(1); expect(visibleTraderNames[0]).toBeOnTheScreen(); }); @@ -408,7 +408,7 @@ describe('TraderProfileView', () => { Routes.SOCIAL_LEADERBOARD.POSITION, { traderId: 'trader-1', - traderName: 'dutchiono', + traderName: 'trader1', traderImageUrl: 'https://example.com/avatar.png', traderAddress: '0xabc', tokenSymbol: fixtureOpenPositions[0].tokenSymbol, @@ -616,7 +616,7 @@ describe('TraderProfileView', () => { it('defaults source to deep_link when source param is absent', () => { mockRouteParams = { traderId: 'trader-1', - traderName: 'dutchiono', + traderName: 'trader1', traderAddress: '0xabc', traderRank: 1, }; @@ -632,7 +632,7 @@ describe('TraderProfileView', () => { it('falls back to profile address when traderAddress route param is absent', () => { mockRouteParams = { traderId: 'trader-1', - traderName: 'dutchiono', + traderName: 'trader1', source: 'leaderboard', traderRank: 1, }; @@ -644,7 +644,7 @@ describe('TraderProfileView', () => { }); it('does not track tab change when traderAddress is absent and profile has no address', () => { - mockRouteParams = { traderId: 'trader-1', traderName: 'dutchiono' }; + mockRouteParams = { traderId: 'trader-1', traderName: 'trader1' }; mockProfileResult = { ...mockProfileResult, profile: { @@ -672,7 +672,7 @@ describe('TraderProfileView', () => { MetaMetricsEvents.SOCIAL_TRADER_PROFILE_SCREEN_VIEWED, expect.objectContaining({ trader_address: '0xabc', - trader_username: 'dutchiono', + trader_username: 'trader1', source: 'leaderboard', is_following: false, trader_rank: 1, @@ -687,7 +687,7 @@ describe('TraderProfileView', () => { expect.objectContaining({ source: 'trader_profile', traderAddress: '0xabc', - traderUsername: 'dutchiono', + traderUsername: 'trader1', }), ); }); diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/components/ProfileHeader.test.tsx b/app/components/Views/SocialLeaderboard/TraderProfileView/components/ProfileHeader.test.tsx index 8b15d6c7c3d..b104af56872 100644 --- a/app/components/Views/SocialLeaderboard/TraderProfileView/components/ProfileHeader.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderProfileView/components/ProfileHeader.test.tsx @@ -18,7 +18,7 @@ const baseProfile: TraderProfile = { profileId: 'trader-1', address: '0xabc', allAddresses: ['0xabc'], - name: 'dutchiono', + name: 'trader1', imageUrl: 'https://example.com/avatar.png', }; @@ -36,7 +36,7 @@ describe('ProfileHeader', () => { renderWithProvider( , ); - expect(screen.getByText('dutchiono')).toBeOnTheScreen(); + expect(screen.getByText('trader1')).toBeOnTheScreen(); }); it('renders avatar image when imageUrl is present', () => { @@ -53,7 +53,7 @@ describe('ProfileHeader', () => { renderWithProvider( , ); - expect(screen.getByText('dutchiono')).toBeOnTheScreen(); + expect(screen.getByText('trader1')).toBeOnTheScreen(); }); it('renders singular follower string when count is 1', () => { @@ -88,7 +88,7 @@ describe('ProfileHeader', () => { ); expect(screen.queryByText('D')).toBeNull(); - expect(screen.getByText('dutchiono')).toBeOnTheScreen(); + expect(screen.getByText('trader1')).toBeOnTheScreen(); }); it('renders without a fallback letter when imageUrl is empty string', () => { @@ -102,7 +102,7 @@ describe('ProfileHeader', () => { ); expect(screen.queryByText('D')).toBeNull(); - expect(screen.getByText('dutchiono')).toBeOnTheScreen(); + expect(screen.getByText('trader1')).toBeOnTheScreen(); }); describe('X (Twitter) icon', () => { @@ -115,7 +115,7 @@ describe('ProfileHeader', () => { , ); expect( @@ -150,7 +150,7 @@ describe('ProfileHeader', () => { , ); @@ -159,7 +159,7 @@ describe('ProfileHeader', () => { ); expect(Linking.openURL).toHaveBeenCalledTimes(1); - expect(Linking.openURL).toHaveBeenCalledWith('https://x.com/dutchiono'); + expect(Linking.openURL).toHaveBeenCalledWith('https://x.com/trader1'); }); }); }); diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/components/TraderNotificationsBottomSheet/TraderNotificationsBottomSheet.test.tsx b/app/components/Views/SocialLeaderboard/TraderProfileView/components/TraderNotificationsBottomSheet/TraderNotificationsBottomSheet.test.tsx index 3b1a1b5736a..d9c3de11f69 100644 --- a/app/components/Views/SocialLeaderboard/TraderProfileView/components/TraderNotificationsBottomSheet/TraderNotificationsBottomSheet.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderProfileView/components/TraderNotificationsBottomSheet/TraderNotificationsBottomSheet.test.tsx @@ -123,7 +123,7 @@ interface OpenedSheetProps { const OpenedSheet: React.FC = ({ traderId = 'trader-1', - traderName = 'dutchiono', + traderName = 'trader1', onDismiss, }) => { const ref = useRef(null); @@ -175,7 +175,7 @@ describe('TraderNotificationsBottomSheet', () => { , ); @@ -197,25 +197,25 @@ describe('TraderNotificationsBottomSheet', () => { }); it('renders the title interpolated with trader name', () => { - renderOpenedSheet({ traderName: 'dutchiono' }); + renderOpenedSheet({ traderName: 'trader1' }); expect( screen.getByText( strings('social_leaderboard.trader_notifications.title', { - traderName: 'dutchiono', + traderName: 'trader1', }), ), ).toBeOnTheScreen(); }); it('renders the notification description with trader name', () => { - renderOpenedSheet({ traderName: 'dutchiono' }); + renderOpenedSheet({ traderName: 'trader1' }); expect( screen.getByText( strings( 'social_leaderboard.trader_notifications.allow_push_notifications_desc', - { traderName: 'dutchiono' }, + { traderName: 'trader1' }, ), ), ).toBeOnTheScreen(); @@ -288,7 +288,7 @@ describe('TraderNotificationsBottomSheet', () => { ({ })), })); jest.mock('../../../hooks/transactions/useTransactionAccountOverride'); +jest.mock('../../../hooks/pay/useMoneyNoFeeTokens'); jest.mock('../../../../../../util/transaction-controller', () => ({})); jest.mock('../../../../../../util/Logger'); jest.mock('../../../../../../core/Engine', () => ({ @@ -226,6 +228,7 @@ describe('CustomAmountInfo', () => { const useClearConfirmationOnBackSwipeMock = jest.mocked( useClearConfirmationOnBackSwipe, ); + const useMoneyNoFeeTokensMock = jest.mocked(useMoneyNoFeeTokens); const setIsConfirmationSubmittingMock = jest.fn(); const useRouteMock = jest.mocked(useRoute); @@ -318,6 +321,8 @@ describe('CustomAmountInfo', () => { type: TransactionType.contractInteraction, txParams: { from: '0x123' }, } as never); + + useMoneyNoFeeTokensMock.mockReturnValue({ isMoneyNoFeeToken: false }); }); it('renders amount', () => { @@ -739,6 +744,47 @@ describe('CustomAmountInfo', () => { expect(getByText('90%')).toBeOnTheScreen(); expect(queryByText('Max')).not.toBeOnTheScreen(); }); + + it('renders Max for money deposit when useMoneyNoFeeTokens returns true', () => { + useMoneyNoFeeTokensMock.mockReturnValue({ isMoneyNoFeeToken: true }); + + const { getByText, queryByText } = render(); + + expect(getByText('Max')).toBeOnTheScreen(); + expect(queryByText('90%')).not.toBeOnTheScreen(); + }); + + it('renders 90% for money deposit when useMoneyNoFeeTokens returns false', () => { + useMoneyNoFeeTokensMock.mockReturnValue({ isMoneyNoFeeToken: false }); + + const { getByText, queryByText } = render(); + + expect(getByText('90%')).toBeOnTheScreen(); + expect(queryByText('Max')).not.toBeOnTheScreen(); + }); + + it('falls back to 90% when useMoneyNoFeeTokens is true but pay token is native', () => { + useMoneyNoFeeTokensMock.mockReturnValue({ isMoneyNoFeeToken: true }); + useTransactionPayTokenMock.mockReturnValue({ + payToken: { + address: '0x123', + balanceHuman: '0', + balanceFiat: '0', + balanceRaw: '0', + balanceUsd: '0', + chainId: '0x1', + decimals: 18, + symbol: 'ETH', + }, + setPayToken: noop as never, + isNative: true, + }); + + const { getByText, queryByText } = render(); + + expect(getByText('90%')).toBeOnTheScreen(); + expect(queryByText('Max')).not.toBeOnTheScreen(); + }); }); describe('showPaymentDetails', () => { 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 d3cc5595e54..9ae7ad73b6b 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 @@ -67,6 +67,7 @@ import Engine from '../../../../../../core/Engine'; import Logger from '../../../../../../util/Logger'; import { ConfirmationFooterSelectorIDs } from '../../../ConfirmationView.testIds'; import { useTransactionPayToken } from '../../../hooks/pay/useTransactionPayToken'; +import { useMoneyNoFeeTokens } from '../../../hooks/pay/useMoneyNoFeeTokens'; import { getNativeTokenAddress } from '@metamask/assets-controllers'; import PayAccountSelector from '../../PayAccountSelector'; import { PerpsAccountPickerRow } from '../../rows/perps-account-picker-row'; @@ -135,6 +136,7 @@ export const CustomAmountInfo: React.FC = memo( useTransactionPayPostQuote(); // Set isPostQuote=true for post-quote transactions const { isNative: isNativePayToken } = useTransactionPayToken(); + const { isMoneyNoFeeToken: isMoneyDepositNoFee } = useMoneyNoFeeTokens(); const { styles } = useStyles(styleSheet, {}); const [isKeyboardVisible, setIsKeyboardVisible] = useState(true); const { hasTokens: hasAvailableTokens } = @@ -320,7 +322,10 @@ export const CustomAmountInfo: React.FC = memo( onDonePress={handleDone} onPercentagePress={updatePendingAmountPercentage} hasInput={hasInput} - hasMax={hasMax && (isWithdraw || !isNativePayToken)} + hasMax={ + (hasMax || isMoneyDepositNoFee) && + (isWithdraw || !isNativePayToken) + } /> )} {!hasPaymentOption && !hasAccountNoFunds && } diff --git a/app/components/Views/confirmations/components/rows/pay-with-row/pay-with-row.test.tsx b/app/components/Views/confirmations/components/rows/pay-with-row/pay-with-row.test.tsx index 0e5e5b6739e..ba7812ad19c 100644 --- a/app/components/Views/confirmations/components/rows/pay-with-row/pay-with-row.test.tsx +++ b/app/components/Views/confirmations/components/rows/pay-with-row/pay-with-row.test.tsx @@ -22,9 +22,11 @@ import { useConfirmationMetricEvents } from '../../../hooks/metrics/useConfirmat import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest'; import { useParams } from '../../../../../../util/navigation/navUtils'; import useMoneyAccountBalance from '../../../../../UI/Money/hooks/useMoneyAccountBalance'; +import { useIsMoneyAccountFlagDefault } from '../../../hooks/pay/useIsMoneyAccountFlagDefault'; jest.mock('../../../hooks/transactions/useTransactionMetadataRequest'); jest.mock('../../../../../../util/navigation/navUtils'); +jest.mock('../../../hooks/pay/useIsMoneyAccountFlagDefault'); jest.mock('../../../../../UI/Money/hooks/useMoneyAccountBalance'); jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), @@ -147,6 +149,7 @@ describe('PayWithRow', () => { isHardwareAccountMock.mockReturnValue(false); jest.mocked(useAccountNoFundsAlert).mockReturnValue([]); + jest.mocked(useIsMoneyAccountFlagDefault).mockReturnValue(false); }); it('renders selected pay token', async () => { @@ -366,6 +369,26 @@ describe('PayWithRow', () => { expect(getByTestId('pay-with-symbol')).toHaveTextContent('Money account'); }); + it('renders interactive row on review page when money account was flag-defaulted', () => { + jest.mocked(useIsMoneyAccountFlagDefault).mockReturnValue(true); + jest.mocked(useTransactionMetadataRequest).mockReturnValue({ + id: TRANSACTION_ID_MOCK, + txParams: { from: '0x1' }, + } as never); + + const { getByTestId } = renderMoneyAccount({ isResultReady: true }); + + expect(getByTestId('pay-with-symbol')).toHaveTextContent(/^test/); + }); + + it('renders money account row on initial page when money account is flag-defaulted', () => { + jest.mocked(useIsMoneyAccountFlagDefault).mockReturnValue(true); + + const { getByTestId } = renderMoneyAccount(); + + expect(getByTestId('pay-with-symbol')).toHaveTextContent('Money account'); + }); + it('renders money account row for perps deposit', () => { jest.mocked(useTransactionMetadataRequest).mockReturnValue({ id: TRANSACTION_ID_MOCK, diff --git a/app/components/Views/confirmations/components/rows/pay-with-row/pay-with-row.tsx b/app/components/Views/confirmations/components/rows/pay-with-row/pay-with-row.tsx index f92b65d9402..2056d611d81 100644 --- a/app/components/Views/confirmations/components/rows/pay-with-row/pay-with-row.tsx +++ b/app/components/Views/confirmations/components/rows/pay-with-row/pay-with-row.tsx @@ -88,7 +88,9 @@ export function PayWithRow({ paymentOverride === PaymentOverride.MoneyAccount || (isDefaultMoneyAccount && !overrideApplied.current) ) { - return ; + if (!(isResultReady && isDefaultMoneyAccount)) { + return ; + } } return ; diff --git a/app/components/Views/confirmations/hooks/pay/sections/usePayWithMoneyAccountSection.test.tsx b/app/components/Views/confirmations/hooks/pay/sections/usePayWithMoneyAccountSection.test.tsx index 6e1434fab73..32884f58c53 100644 --- a/app/components/Views/confirmations/hooks/pay/sections/usePayWithMoneyAccountSection.test.tsx +++ b/app/components/Views/confirmations/hooks/pay/sections/usePayWithMoneyAccountSection.test.tsx @@ -74,8 +74,12 @@ describe('usePayWithMoneyAccountSection', () => { } if (selector === selectMetaMaskPayFlags) { return { - enablePerpsMoneyAccountTransactions: true, - enablePredictMoneyAccountTransactions: true, + enableMoneyAccountTransactions: { + perpsDeposit: true, + perpsWithdraw: true, + predictDeposit: true, + predictWithdraw: true, + }, }; } return undefined; @@ -86,15 +90,14 @@ describe('usePayWithMoneyAccountSection', () => { } as never); }); - it('returns null when both money account flags are false', () => { + it('returns null when all money account flags are false', () => { useSelectorMock.mockImplementation((selector) => { if (selector === selectPrimaryMoneyAccount) { return moneyAccountMock; } if (selector === selectMetaMaskPayFlags) { return { - enablePerpsMoneyAccountTransactions: false, - enablePredictMoneyAccountTransactions: false, + enableMoneyAccountTransactions: {}, }; } return undefined; @@ -105,15 +108,17 @@ describe('usePayWithMoneyAccountSection', () => { expect(result.current).toBeNull(); }); - it('returns null for predict transaction when only perps flag is true', () => { + it('returns null for predict transaction when only perps types are enabled', () => { useSelectorMock.mockImplementation((selector) => { if (selector === selectPrimaryMoneyAccount) { return moneyAccountMock; } if (selector === selectMetaMaskPayFlags) { return { - enablePerpsMoneyAccountTransactions: true, - enablePredictMoneyAccountTransactions: false, + enableMoneyAccountTransactions: { + perpsDeposit: true, + perpsWithdraw: true, + }, }; } return undefined; @@ -130,15 +135,17 @@ describe('usePayWithMoneyAccountSection', () => { expect(result.current).toBeNull(); }); - it('returns null for perps transaction when only predict flag is true', () => { + it('returns null for perps transaction when only predict types are enabled', () => { useSelectorMock.mockImplementation((selector) => { if (selector === selectPrimaryMoneyAccount) { return moneyAccountMock; } if (selector === selectMetaMaskPayFlags) { return { - enablePerpsMoneyAccountTransactions: false, - enablePredictMoneyAccountTransactions: true, + enableMoneyAccountTransactions: { + predictDeposit: true, + predictWithdraw: true, + }, }; } return undefined; @@ -174,8 +181,12 @@ describe('usePayWithMoneyAccountSection', () => { } if (selector === selectMetaMaskPayFlags) { return { - enablePerpsMoneyAccountTransactions: true, - enablePredictMoneyAccountTransactions: true, + enableMoneyAccountTransactions: { + perpsDeposit: true, + perpsWithdraw: true, + predictDeposit: true, + predictWithdraw: true, + }, }; } return undefined; @@ -199,7 +210,6 @@ describe('usePayWithMoneyAccountSection', () => { TransactionType.predictDeposit, TransactionType.perpsWithdraw, TransactionType.predictWithdraw, - TransactionType.predictDepositAndOrder, ])( 'returns section config with "available" subtitle for transaction type %s', (txType) => { diff --git a/app/components/Views/confirmations/hooks/pay/sections/usePayWithMoneyAccountSection.tsx b/app/components/Views/confirmations/hooks/pay/sections/usePayWithMoneyAccountSection.tsx index f53920599ca..4a64d34c771 100644 --- a/app/components/Views/confirmations/hooks/pay/sections/usePayWithMoneyAccountSection.tsx +++ b/app/components/Views/confirmations/hooks/pay/sections/usePayWithMoneyAccountSection.tsx @@ -2,7 +2,6 @@ import React, { useCallback, useMemo } from 'react'; import { Image, StyleSheet } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { useSelector } from 'react-redux'; -import { TransactionType } from '@metamask/transaction-controller'; import { PaymentOverride } from '@metamask/transaction-pay-controller'; import type { Hex } from '@metamask/utils'; import { strings } from '../../../../../../../locales/i18n'; @@ -15,7 +14,7 @@ import { selectPaymentOverrideByTransactionId } from '../../../../../../selector import useMoneyAccountBalance from '../../../../../UI/Money/hooks/useMoneyAccountBalance'; import { useTransactionMetadataRequest } from '../../transactions/useTransactionMetadataRequest'; import { - hasTransactionType, + getTransactionType, isTransactionPayWithdraw, } from '../../../utils/transaction'; import { @@ -31,26 +30,14 @@ const styles = StyleSheet.create({ moneyIcon: { width: 24, height: 24 }, }); -const PERPS_TRANSACTION_TYPES = [ - TransactionType.perpsDeposit, - TransactionType.perpsWithdraw, -] as const; - -const PREDICT_TRANSACTION_TYPES = [ - TransactionType.predictDeposit, - TransactionType.predictDepositAndOrder, - TransactionType.predictWithdraw, -] as const; - export function usePayWithMoneyAccountSection(): PayWithSectionConfig | null { const navigation = useNavigation(); const transactionMeta = useTransactionMetadataRequest(); const transactionId = transactionMeta?.id ?? ''; const moneyAccount = useSelector(selectPrimaryMoneyAccount); - const { - enablePerpsMoneyAccountTransactions, - enablePredictMoneyAccountTransactions, - } = useSelector(selectMetaMaskPayFlags); + const { enableMoneyAccountTransactions } = useSelector( + selectMetaMaskPayFlags, + ); const { totalFiatFormatted } = useMoneyAccountBalance(); const paymentOverride = useSelector((state: RootState) => @@ -59,17 +46,10 @@ export function usePayWithMoneyAccountSection(): PayWithSectionConfig | null { const isMoneyAccountSelected = paymentOverride === PaymentOverride.MoneyAccount; - const isPerps = hasTransactionType( - transactionMeta, - PERPS_TRANSACTION_TYPES as unknown as TransactionType[], - ); - const isPredict = hasTransactionType( - transactionMeta, - PREDICT_TRANSACTION_TYPES as unknown as TransactionType[], + const transactionType = getTransactionType(transactionMeta); + const isEnabled = Boolean( + transactionType && enableMoneyAccountTransactions[transactionType], ); - const isEnabled = - (isPerps && enablePerpsMoneyAccountTransactions) || - (isPredict && enablePredictMoneyAccountTransactions); const isWithdraw = isTransactionPayWithdraw(transactionMeta); diff --git a/app/components/Views/confirmations/hooks/pay/useFiatConfirm.ts b/app/components/Views/confirmations/hooks/pay/useFiatConfirm.ts index c2e5ee256dd..c7ec68ec304 100644 --- a/app/components/Views/confirmations/hooks/pay/useFiatConfirm.ts +++ b/app/components/Views/confirmations/hooks/pay/useFiatConfirm.ts @@ -1,5 +1,6 @@ import { useCallback } from 'react'; import { createProjectLogger } from '@metamask/utils'; +import { TransactionType } from '@metamask/transaction-controller'; import BigNumber from 'bignumber.js'; import { strings } from '../../../../../../locales/i18n'; import { @@ -7,6 +8,10 @@ import { type HeadlessBuyError, } from '../../../../UI/Ramp/headless'; import type { Quote } from '../../../../UI/Ramp/types'; +import { + RAMP_SURFACE, + type RampSurface, +} from '../../../../UI/Ramp/Deposit/types/analytics'; import Engine from '../../../../../core/Engine'; import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; import { @@ -18,6 +23,20 @@ import { useConfirmationContext } from '../../context/confirmation-context'; const log = createProjectLogger('fiat-confirm'); +/** + * Maps a confirmation transaction type to the headless ramps `ramp_surface` + * (TRAM-3623). Only deposit flows routed through the headless buy belong here. + * `musdConversion` and withdraw types are intentionally omitted: not + * money/perps/prediction deposits, so they get an `undefined` surface. + */ +const TRANSACTION_TYPE_TO_RAMP_SURFACE: Partial< + Record +> = { + [TransactionType.moneyAccountDeposit]: RAMP_SURFACE.MONEY_ACCOUNT, + [TransactionType.perpsDeposit]: RAMP_SURFACE.PERPS, + [TransactionType.predictDeposit]: RAMP_SURFACE.PREDICTION, +}; + export function useFiatConfirm() { const transactionMetadata = useTransactionMetadataRequest(); const fiatPayment = useTransactionPayFiatPayment(); @@ -53,6 +72,10 @@ export function useFiatConfirm() { .minus(new BigNumber(totals?.fees.providerFiat?.usd ?? 0)) .toNumber(); + const rampSurface = transactionMetadata?.type + ? TRANSACTION_TYPE_TO_RAMP_SURFACE[transactionMetadata.type] + : undefined; + startHeadlessBuy( { quote: rampsQuote, @@ -61,6 +84,7 @@ export function useFiatConfirm() { paymentMethodId: fiatPayment?.selectedPaymentMethodId, currency: 'USD', walletAddress: transactionMetadata?.txParams?.from, + rampSurface, }, { onOrderCreated: (orderIdFromCallback) => { @@ -94,6 +118,7 @@ export function useFiatConfirm() { startHeadlessBuy, transactionMetadata?.id, transactionMetadata?.txParams?.from, + transactionMetadata?.type, ]); return { onFiatConfirm, isFiatPaymentSelected, orderId }; diff --git a/app/components/Views/confirmations/hooks/pay/useMoneyNoFeeTokens.test.ts b/app/components/Views/confirmations/hooks/pay/useMoneyNoFeeTokens.test.ts new file mode 100644 index 00000000000..383d270aec8 --- /dev/null +++ b/app/components/Views/confirmations/hooks/pay/useMoneyNoFeeTokens.test.ts @@ -0,0 +1,156 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useSelector } from 'react-redux'; +import { TransactionType } from '@metamask/transaction-controller'; +import { Hex } from '@metamask/utils'; +import { useMoneyNoFeeTokens } from './useMoneyNoFeeTokens'; +import { useTransactionPayToken } from './useTransactionPayToken'; +import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; +import { selectRelayFixedSpread } from '../../../../../selectors/featureFlagController/confirmations'; +import { RelayFixedSpreadConfig } from '../../utils/relayFixedSpread'; + +jest.mock('react-redux'); +jest.mock('./useTransactionPayToken'); +jest.mock('../transactions/useTransactionMetadataRequest'); + +const mockUseSelector = useSelector as jest.MockedFunction; +const mockUseTransactionPayToken = + useTransactionPayToken as jest.MockedFunction; +const mockUseTransactionMetadataRequest = + useTransactionMetadataRequest as jest.MockedFunction< + typeof useTransactionMetadataRequest + >; + +const PAY_TOKEN = { + address: '0xabc' as Hex, + balanceHuman: '10', + balanceFiat: '10', + balanceRaw: '10000', + balanceUsd: '10', + chainId: '0x1' as Hex, + decimals: 6, + symbol: 'USDC', +}; + +const EMPTY_CONFIG: RelayFixedSpreadConfig = { routes: [] }; + +const configWithRoute = ( + sourceChain: string, + sourceToken: string, + targetChain = '0x279b', + targetToken = '0xmusd', +): RelayFixedSpreadConfig => ({ + routes: [ + { + sourceChain: sourceChain.toLowerCase() as Hex, + sourceToken: sourceToken.toLowerCase() as Hex, + targetChain: targetChain.toLowerCase() as Hex, + targetToken: targetToken.toLowerCase() as Hex, + }, + ], +}); + +describe('useMoneyNoFeeTokens', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockUseTransactionMetadataRequest.mockReturnValue({ + type: TransactionType.moneyAccountDeposit, + txParams: { from: '0x123' }, + } as never); + + mockUseTransactionPayToken.mockReturnValue({ + payToken: PAY_TOKEN, + setPayToken: jest.fn(), + }); + + mockUseSelector.mockImplementation((selector) => { + if (selector === selectRelayFixedSpread) { + return EMPTY_CONFIG; + } + return undefined; + }); + }); + + it('returns false when transaction is not a money account deposit or withdraw', () => { + mockUseTransactionMetadataRequest.mockReturnValue({ + type: TransactionType.contractInteraction, + txParams: { from: '0x123' }, + } as never); + + const { result } = renderHook(() => useMoneyNoFeeTokens()); + expect(result.current).toEqual({ isMoneyNoFeeToken: false }); + }); + + it('returns false when payToken is undefined', () => { + mockUseTransactionPayToken.mockReturnValue({ + payToken: undefined, + setPayToken: jest.fn(), + }); + + const { result } = renderHook(() => useMoneyNoFeeTokens()); + expect(result.current).toEqual({ isMoneyNoFeeToken: false }); + }); + + it('returns false when no routes match the pay token', () => { + const { result } = renderHook(() => useMoneyNoFeeTokens()); + expect(result.current).toEqual({ isMoneyNoFeeToken: false }); + }); + + it('returns true when pay token matches a subsidized source route', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectRelayFixedSpread) { + return configWithRoute('0x1', '0xabc'); + } + return undefined; + }); + + const { result } = renderHook(() => useMoneyNoFeeTokens()); + expect(result.current).toEqual({ isMoneyNoFeeToken: true }); + }); + + it('returns true with case-insensitive address matching', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectRelayFixedSpread) { + return configWithRoute('0x1', '0xABC'); + } + return undefined; + }); + + const { result } = renderHook(() => useMoneyNoFeeTokens()); + expect(result.current).toEqual({ isMoneyNoFeeToken: true }); + }); + + it('returns false when pay token address does not match any route', () => { + mockUseTransactionPayToken.mockReturnValue({ + payToken: { ...PAY_TOKEN, address: '0xdef' as Hex }, + setPayToken: jest.fn(), + }); + + mockUseSelector.mockImplementation((selector) => { + if (selector === selectRelayFixedSpread) { + return configWithRoute('0x1', '0xabc'); + } + return undefined; + }); + + const { result } = renderHook(() => useMoneyNoFeeTokens()); + expect(result.current).toEqual({ isMoneyNoFeeToken: false }); + }); + + it('returns false when pay token chain does not match any route', () => { + mockUseTransactionPayToken.mockReturnValue({ + payToken: { ...PAY_TOKEN, chainId: '0xa' as Hex }, + setPayToken: jest.fn(), + }); + + mockUseSelector.mockImplementation((selector) => { + if (selector === selectRelayFixedSpread) { + return configWithRoute('0x1', '0xabc'); + } + return undefined; + }); + + const { result } = renderHook(() => useMoneyNoFeeTokens()); + expect(result.current).toEqual({ isMoneyNoFeeToken: false }); + }); +}); diff --git a/app/components/Views/confirmations/hooks/pay/useMoneyNoFeeTokens.ts b/app/components/Views/confirmations/hooks/pay/useMoneyNoFeeTokens.ts new file mode 100644 index 00000000000..b8219c9102c --- /dev/null +++ b/app/components/Views/confirmations/hooks/pay/useMoneyNoFeeTokens.ts @@ -0,0 +1,34 @@ +import { useSelector } from 'react-redux'; +import { TransactionType } from '@metamask/transaction-controller'; +import { useTransactionPayToken } from './useTransactionPayToken'; +import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; +import { hasTransactionType } from '../../utils/transaction'; +import { selectRelayFixedSpread } from '../../../../../selectors/featureFlagController/confirmations'; +import { isSubsidizedSource } from '../../utils/relayFixedSpread'; + +const MONEY_ACCOUNT_TRANSACTION_TYPES: TransactionType[] = [ + TransactionType.moneyAccountDeposit, + TransactionType.moneyAccountWithdraw, +]; + +export function useMoneyNoFeeTokens(): { isMoneyNoFeeToken: boolean } { + const transactionMeta = useTransactionMetadataRequest(); + const { payToken } = useTransactionPayToken(); + const relayFixedSpread = useSelector(selectRelayFixedSpread); + + const isMoneyAccountTransaction = hasTransactionType( + transactionMeta, + MONEY_ACCOUNT_TRANSACTION_TYPES, + ); + + if (!isMoneyAccountTransaction || !payToken) { + return { isMoneyNoFeeToken: false }; + } + + return { + isMoneyNoFeeToken: isSubsidizedSource(relayFixedSpread, { + chainId: payToken.chainId, + address: payToken.address, + }), + }; +} diff --git a/app/components/Views/confirmations/hooks/pay/useMoneyPerpsDeposit.test.ts b/app/components/Views/confirmations/hooks/pay/useMoneyPerpsDeposit.test.ts index b1a3c9dcacc..688328fe6e9 100644 --- a/app/components/Views/confirmations/hooks/pay/useMoneyPerpsDeposit.test.ts +++ b/app/components/Views/confirmations/hooks/pay/useMoneyPerpsDeposit.test.ts @@ -45,7 +45,7 @@ describe('useMoneyPerpsDeposit', () => { navigateToConfirmation: mockNavigateToConfirmation, }); (selectMetaMaskPayFlags as unknown as jest.Mock).mockReturnValue({ - enableMoneyHomePagePerpsTransaction: false, + enableMoneyAccountTransactions: {}, }); }); @@ -58,7 +58,7 @@ describe('useMoneyPerpsDeposit', () => { it('returns isEnabled true when flag is enabled', () => { (selectMetaMaskPayFlags as unknown as jest.Mock).mockReturnValue({ - enableMoneyHomePagePerpsTransaction: true, + enableMoneyAccountTransactions: { perpsDeposit: true }, }); const { result } = render(); diff --git a/app/components/Views/confirmations/hooks/pay/useMoneyPerpsDeposit.ts b/app/components/Views/confirmations/hooks/pay/useMoneyPerpsDeposit.ts index cb63bd03573..188f554abb7 100644 --- a/app/components/Views/confirmations/hooks/pay/useMoneyPerpsDeposit.ts +++ b/app/components/Views/confirmations/hooks/pay/useMoneyPerpsDeposit.ts @@ -1,5 +1,6 @@ import { useCallback } from 'react'; import { useSelector } from 'react-redux'; +import { TransactionType } from '@metamask/transaction-controller'; import { selectMetaMaskPayFlags } from '../../../../../selectors/featureFlagController/confirmations'; import { usePerpsTrading } from '../../../../UI/Perps/hooks/usePerpsTrading'; import { useConfirmNavigation } from '../useConfirmNavigation'; @@ -10,7 +11,7 @@ import Logger from '../../../../../util/Logger'; const LOG_TAG = '[MoneyPerpsDeposit]'; export function useMoneyPerpsDeposit() { - const { enableMoneyHomePagePerpsTransaction } = useSelector( + const { enableMoneyAccountTransactions } = useSelector( selectMetaMaskPayFlags, ); const { depositWithConfirmation } = usePerpsTrading(); @@ -33,7 +34,9 @@ export function useMoneyPerpsDeposit() { }, [depositWithConfirmation, navigateToConfirmation]); return { - isEnabled: enableMoneyHomePagePerpsTransaction, + isEnabled: Boolean( + enableMoneyAccountTransactions[TransactionType.perpsDeposit], + ), initiatePerpsDeposit, }; } diff --git a/app/components/Views/confirmations/hooks/pay/useMoneyPredictDeposit.test.ts b/app/components/Views/confirmations/hooks/pay/useMoneyPredictDeposit.test.ts index 574d068af78..52f5028b0d9 100644 --- a/app/components/Views/confirmations/hooks/pay/useMoneyPredictDeposit.test.ts +++ b/app/components/Views/confirmations/hooks/pay/useMoneyPredictDeposit.test.ts @@ -45,7 +45,7 @@ describe('useMoneyPredictDeposit', () => { navigateToConfirmation: mockNavigateToConfirmation, }); (selectMetaMaskPayFlags as unknown as jest.Mock).mockReturnValue({ - enableMoneyHomePagePredictTransaction: false, + enableMoneyAccountTransactions: {}, }); }); @@ -58,7 +58,7 @@ describe('useMoneyPredictDeposit', () => { it('returns isEnabled true when flag is enabled', () => { (selectMetaMaskPayFlags as unknown as jest.Mock).mockReturnValue({ - enableMoneyHomePagePredictTransaction: true, + enableMoneyAccountTransactions: { predictDeposit: true }, }); const { result } = render(); diff --git a/app/components/Views/confirmations/hooks/pay/useMoneyPredictDeposit.ts b/app/components/Views/confirmations/hooks/pay/useMoneyPredictDeposit.ts index 2b6862095e6..959f1304cd9 100644 --- a/app/components/Views/confirmations/hooks/pay/useMoneyPredictDeposit.ts +++ b/app/components/Views/confirmations/hooks/pay/useMoneyPredictDeposit.ts @@ -1,5 +1,6 @@ import { useCallback } from 'react'; import { useSelector } from 'react-redux'; +import { TransactionType } from '@metamask/transaction-controller'; import { selectMetaMaskPayFlags } from '../../../../../selectors/featureFlagController/confirmations'; import { usePredictTrading } from '../../../../UI/Predict/hooks/usePredictTrading'; import { useConfirmNavigation } from '../useConfirmNavigation'; @@ -10,7 +11,7 @@ import Logger from '../../../../../util/Logger'; const LOG_TAG = '[MoneyPredictDeposit]'; export function useMoneyPredictDeposit() { - const { enableMoneyHomePagePredictTransaction } = useSelector( + const { enableMoneyAccountTransactions } = useSelector( selectMetaMaskPayFlags, ); const { deposit: depositWithConfirmation } = usePredictTrading(); @@ -33,7 +34,9 @@ export function useMoneyPredictDeposit() { }, [depositWithConfirmation, navigateToConfirmation]); return { - isEnabled: enableMoneyHomePagePredictTransaction, + isEnabled: Boolean( + enableMoneyAccountTransactions[TransactionType.predictDeposit], + ), initiatePredictDeposit, }; } diff --git a/app/components/Views/confirmations/hooks/pay/usePaySectionRecipientMetrics.test.ts b/app/components/Views/confirmations/hooks/pay/usePaySectionRecipientMetrics.test.ts new file mode 100644 index 00000000000..3cf5476d002 --- /dev/null +++ b/app/components/Views/confirmations/hooks/pay/usePaySectionRecipientMetrics.test.ts @@ -0,0 +1,173 @@ +import { merge } from 'lodash'; +import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; +import { simpleSendTransactionControllerMock } from '../../__mocks__/controllers/transaction-controller-mock'; +import { transactionApprovalControllerMock } from '../../__mocks__/controllers/approval-controller-mock'; +import { otherControllersMock } from '../../__mocks__/controllers/other-controllers-mock'; +import { TransactionType } from '@metamask/transaction-controller'; +import { usePaySectionRecipientMetrics } from './usePaySectionRecipientMetrics'; +import { PayWithSectionId } from '../../components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet.types'; + +function runHook({ + type, + sourceSection = 'crypto' as PayWithSectionId, + hasPayToken = true, +}: { + type?: TransactionType; + sourceSection?: PayWithSectionId; + hasPayToken?: boolean; +} = {}) { + const state = merge( + {}, + simpleSendTransactionControllerMock, + transactionApprovalControllerMock, + otherControllersMock, + ); + + state.engine.backgroundState.TransactionController.transactions[0].type = + type ?? TransactionType.perpsDeposit; + + return renderHookWithProvider( + () => usePaySectionRecipientMetrics(sourceSection, hasPayToken), + { state }, + ); +} + +describe('usePaySectionRecipientMetrics', () => { + describe('deposit flows (fixed by transaction type)', () => { + it('returns perps for perpsDeposit', () => { + const { result } = runHook({ type: TransactionType.perpsDeposit }); + + expect(result.current).toEqual({ + presented: 'perps', + selected: 'perps', + switchCount: 0, + }); + }); + + it('returns perps for perpsDepositAndOrder', () => { + const { result } = runHook({ + type: TransactionType.perpsDepositAndOrder, + }); + + expect(result.current.selected).toBe('perps'); + }); + + it('returns predict for predictDeposit', () => { + const { result } = runHook({ type: TransactionType.predictDeposit }); + + expect(result.current.selected).toBe('predict'); + }); + + it('returns predict for predictDepositAndOrder', () => { + const { result } = runHook({ + type: TransactionType.predictDepositAndOrder, + }); + + expect(result.current.selected).toBe('predict'); + }); + + it('returns money-account for moneyAccountDeposit', () => { + const { result } = runHook({ + type: TransactionType.moneyAccountDeposit, + }); + + expect(result.current.selected).toBe('money-account'); + }); + + it('ignores sourceSection for deposit flows', () => { + const { result } = runHook({ + type: TransactionType.perpsDeposit, + sourceSection: 'bank-card', + }); + + expect(result.current.selected).toBe('perps'); + }); + }); + + describe('withdraw flows (uses sourceSection)', () => { + it('returns sourceSection for perpsWithdraw', () => { + const { result } = runHook({ + type: TransactionType.perpsWithdraw, + sourceSection: 'crypto', + }); + + expect(result.current.selected).toBe('crypto'); + }); + + it('returns sourceSection for predictWithdraw', () => { + const { result } = runHook({ + type: TransactionType.predictWithdraw, + sourceSection: 'money-account', + }); + + expect(result.current.selected).toBe('money-account'); + }); + + it('returns sourceSection for moneyAccountWithdraw', () => { + const { result } = runHook({ + type: TransactionType.moneyAccountWithdraw, + sourceSection: 'bank-card', + }); + + expect(result.current.selected).toBe('bank-card'); + }); + }); + + describe('presented / switchCount tracking', () => { + it('returns null presented when hasPayToken is false', () => { + const { result } = runHook({ hasPayToken: false }); + + expect(result.current.presented).toBeNull(); + expect(result.current.switchCount).toBe(0); + }); + + it('captures presented on first render and preserves it after sourceSection changes', () => { + let currentSourceSection: PayWithSectionId = 'crypto'; + + const state = merge( + {}, + simpleSendTransactionControllerMock, + transactionApprovalControllerMock, + otherControllersMock, + ); + state.engine.backgroundState.TransactionController.transactions[0].type = + TransactionType.perpsWithdraw; + + const { result, rerender } = renderHookWithProvider( + () => usePaySectionRecipientMetrics(currentSourceSection, true), + { state }, + ); + + expect(result.current.presented).toBe('crypto'); + + currentSourceSection = 'money-account'; + rerender({}); + + expect(result.current.presented).toBe('crypto'); + expect(result.current.selected).toBe('money-account'); + expect(result.current.switchCount).toBe(1); + }); + + it('does not increment switchCount for deposit flows (section is fixed)', () => { + const { result, rerender } = runHook({ + type: TransactionType.perpsDeposit, + }); + + rerender({}); + rerender({}); + + expect(result.current.switchCount).toBe(0); + }); + }); + + describe('fallback', () => { + it('falls back to sourceSection for unrecognized transaction types', () => { + const { result } = runHook({ + type: TransactionType.simpleSend, + sourceSection: 'bank-card', + }); + + expect(result.current.selected).toBe('bank-card'); + }); + }); +}); diff --git a/app/components/Views/confirmations/hooks/pay/usePaySectionRecipientMetrics.ts b/app/components/Views/confirmations/hooks/pay/usePaySectionRecipientMetrics.ts new file mode 100644 index 00000000000..0d6d1de9d64 --- /dev/null +++ b/app/components/Views/confirmations/hooks/pay/usePaySectionRecipientMetrics.ts @@ -0,0 +1,55 @@ +import { TransactionType } from '@metamask/transaction-controller'; +import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; +import { + hasTransactionType, + isTransactionPayWithdraw, +} from '../../utils/transaction'; +import { PayWithSectionId } from '../../components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet.types'; +import { useSectionTracking } from './useSectionTracking'; + +export type { SectionTrackingResult as PaySectionRecipientMetrics } from './useSectionTracking'; + +export function usePaySectionRecipientMetrics( + sourceSection: PayWithSectionId, + hasPayToken: boolean, +) { + const transactionMeta = useTransactionMetadataRequest(); + const currentSection = getRecipientSectionId(transactionMeta, sourceSection); + + return useSectionTracking(currentSection, hasPayToken); +} + +function getRecipientSectionId( + transactionMeta: Parameters[0], + payWithSection: PayWithSectionId, +): PayWithSectionId { + if (isTransactionPayWithdraw(transactionMeta)) { + return payWithSection; + } + + if ( + hasTransactionType(transactionMeta, [ + TransactionType.perpsDeposit, + TransactionType.perpsDepositAndOrder, + ]) + ) { + return 'perps'; + } + + if ( + hasTransactionType(transactionMeta, [ + TransactionType.predictDeposit, + TransactionType.predictDepositAndOrder, + ]) + ) { + return 'predict'; + } + + if ( + hasTransactionType(transactionMeta, [TransactionType.moneyAccountDeposit]) + ) { + return 'money-account'; + } + + return payWithSection; +} diff --git a/app/components/Views/confirmations/hooks/pay/usePaySectionSourceMetrics.test.ts b/app/components/Views/confirmations/hooks/pay/usePaySectionSourceMetrics.test.ts new file mode 100644 index 00000000000..64787c462ff --- /dev/null +++ b/app/components/Views/confirmations/hooks/pay/usePaySectionSourceMetrics.test.ts @@ -0,0 +1,187 @@ +import { merge } from 'lodash'; +import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; +import { simpleSendTransactionControllerMock } from '../../__mocks__/controllers/transaction-controller-mock'; +import { transactionApprovalControllerMock } from '../../__mocks__/controllers/approval-controller-mock'; +import { otherControllersMock } from '../../__mocks__/controllers/other-controllers-mock'; +import { TransactionType } from '@metamask/transaction-controller'; +import { PaymentOverride } from '@metamask/transaction-pay-controller'; +import { selectPaymentOverrideByTransactionId } from '../../../../../selectors/transactionPayController'; +import { useIsPerpsBalanceSelected } from '../../../../UI/Perps/hooks/useIsPerpsBalanceSelected'; +import { selectPredictSelectedPaymentToken } from '../../../../UI/Predict/selectors/predictController'; +import { useIsMoneyAccountFlagDefault } from './useIsMoneyAccountFlagDefault'; +import { useTransactionPayFiatPayment } from './useTransactionPayData'; +import { usePaySectionSourceMetrics } from './usePaySectionSourceMetrics'; + +jest.mock('../../../../../selectors/transactionPayController'); +jest.mock('../../../../UI/Perps/hooks/useIsPerpsBalanceSelected'); +jest.mock('../../../../UI/Predict/selectors/predictController'); +jest.mock('./useIsMoneyAccountFlagDefault'); +jest.mock('../pay/useTransactionPayData'); + +const selectPaymentOverrideMock = jest.mocked( + selectPaymentOverrideByTransactionId, +); +const useIsPerpsBalanceSelectedMock = jest.mocked(useIsPerpsBalanceSelected); +const selectPredictSelectedPaymentTokenMock = jest.mocked( + selectPredictSelectedPaymentToken, +); +const useIsMoneyAccountFlagDefaultMock = jest.mocked( + useIsMoneyAccountFlagDefault, +); +const useTransactionPayFiatPaymentMock = jest.mocked( + useTransactionPayFiatPayment, +); + +function runHook({ + type, + hasPayToken = true, +}: { type?: TransactionType; hasPayToken?: boolean } = {}) { + const state = merge( + {}, + simpleSendTransactionControllerMock, + transactionApprovalControllerMock, + otherControllersMock, + ); + + state.engine.backgroundState.TransactionController.transactions[0].type = + type ?? TransactionType.perpsDeposit; + + return renderHookWithProvider(() => usePaySectionSourceMetrics(hasPayToken), { + state, + }); +} + +describe('usePaySectionSourceMetrics', () => { + beforeEach(() => { + jest.resetAllMocks(); + selectPaymentOverrideMock.mockReturnValue(undefined); + useIsPerpsBalanceSelectedMock.mockReturnValue(false); + selectPredictSelectedPaymentTokenMock.mockReturnValue({ + address: '0x123', + chainId: '0x1', + }); + useIsMoneyAccountFlagDefaultMock.mockReturnValue(false); + useTransactionPayFiatPaymentMock.mockReturnValue(undefined); + }); + + it('defaults to crypto', () => { + const { result } = runHook(); + + expect(result.current).toEqual({ + presented: 'crypto', + selected: 'crypto', + switchCount: 0, + }); + }); + + it('returns money-account when payment override is MoneyAccount', () => { + selectPaymentOverrideMock.mockReturnValue(PaymentOverride.MoneyAccount); + + const { result } = runHook(); + + expect(result.current.selected).toBe('money-account'); + expect(result.current.presented).toBe('money-account'); + }); + + it('returns money-account when isDefaultMoneyAccount flag is true', () => { + useIsMoneyAccountFlagDefaultMock.mockReturnValue(true); + + const { result } = runHook(); + + expect(result.current.selected).toBe('money-account'); + }); + + it('returns perps when perps balance selected for perpsDepositAndOrder', () => { + useIsPerpsBalanceSelectedMock.mockReturnValue(true); + + const { result } = runHook({ + type: TransactionType.perpsDepositAndOrder, + }); + + expect(result.current.selected).toBe('perps'); + }); + + it('returns crypto when perps balance selected but not perpsDepositAndOrder', () => { + useIsPerpsBalanceSelectedMock.mockReturnValue(true); + + const { result } = runHook({ type: TransactionType.perpsDeposit }); + + expect(result.current.selected).toBe('crypto'); + }); + + it('returns predict when predict balance selected for predictDepositAndOrder', () => { + selectPredictSelectedPaymentTokenMock.mockReturnValue(null); + + const { result } = runHook({ + type: TransactionType.predictDepositAndOrder, + }); + + expect(result.current.selected).toBe('predict'); + }); + + it('returns bank-card when fiat payment is selected', () => { + useTransactionPayFiatPaymentMock.mockReturnValue({ + selectedPaymentMethodId: 'pm_123', + } as never); + + const { result } = runHook(); + + expect(result.current.selected).toBe('bank-card'); + }); + + it('money-account takes priority over perps and predict', () => { + selectPaymentOverrideMock.mockReturnValue(PaymentOverride.MoneyAccount); + useIsPerpsBalanceSelectedMock.mockReturnValue(true); + selectPredictSelectedPaymentTokenMock.mockReturnValue(null); + + const { result } = runHook({ + type: TransactionType.perpsDepositAndOrder, + }); + + expect(result.current.selected).toBe('money-account'); + }); + + it('returns null presented when hasPayToken is false', () => { + const { result } = runHook({ hasPayToken: false }); + + expect(result.current.presented).toBeNull(); + expect(result.current.switchCount).toBe(0); + }); + + it('captures presented on first render and preserves it', () => { + const { result, rerender } = runHook(); + + expect(result.current.presented).toBe('crypto'); + + selectPaymentOverrideMock.mockReturnValue(PaymentOverride.MoneyAccount); + rerender({}); + + expect(result.current.presented).toBe('crypto'); + expect(result.current.selected).toBe('money-account'); + }); + + it('increments switchCount on section change', () => { + const { result, rerender } = runHook(); + + expect(result.current.switchCount).toBe(0); + + selectPaymentOverrideMock.mockReturnValue(PaymentOverride.MoneyAccount); + rerender({}); + + expect(result.current.switchCount).toBe(1); + + selectPaymentOverrideMock.mockReturnValue(undefined); + rerender({}); + + expect(result.current.switchCount).toBe(2); + }); + + it('does not increment switchCount when section stays the same', () => { + const { result, rerender } = runHook(); + + rerender({}); + rerender({}); + + expect(result.current.switchCount).toBe(0); + }); +}); diff --git a/app/components/Views/confirmations/hooks/pay/usePaySectionSourceMetrics.ts b/app/components/Views/confirmations/hooks/pay/usePaySectionSourceMetrics.ts new file mode 100644 index 00000000000..ca6b24d42f1 --- /dev/null +++ b/app/components/Views/confirmations/hooks/pay/usePaySectionSourceMetrics.ts @@ -0,0 +1,78 @@ +import { useRef } from 'react'; +import { useSelector } from 'react-redux'; +import { TransactionType } from '@metamask/transaction-controller'; +import { PaymentOverride } from '@metamask/transaction-pay-controller'; +import { RootState } from '../../../../../reducers'; +import { selectPaymentOverrideByTransactionId } from '../../../../../selectors/transactionPayController'; +import { useIsPerpsBalanceSelected } from '../../../../UI/Perps/hooks/useIsPerpsBalanceSelected'; +import { selectPredictSelectedPaymentToken } from '../../../../UI/Predict/selectors/predictController'; +import { useIsMoneyAccountFlagDefault } from './useIsMoneyAccountFlagDefault'; +import { useTransactionPayFiatPayment } from './useTransactionPayData'; +import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; +import { hasTransactionType } from '../../utils/transaction'; +import { PayWithSectionId } from '../../components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet.types'; +import { useSectionTracking } from './useSectionTracking'; + +export type { SectionTrackingResult as PaySectionSourceMetrics } from './useSectionTracking'; + +export function usePaySectionSourceMetrics(hasPayToken: boolean) { + const transactionMeta = useTransactionMetadataRequest(); + const transactionId = transactionMeta?.id ?? ''; + + const paymentOverride = useSelector((state: RootState) => + selectPaymentOverrideByTransactionId(state, transactionId), + ); + const isPerpsBalanceSelected = useIsPerpsBalanceSelected(); + const predictSelectedPaymentToken = useSelector( + selectPredictSelectedPaymentToken, + ); + const isPredictBalanceSelected = predictSelectedPaymentToken === null; + const fiatPayment = useTransactionPayFiatPayment(); + const hasFiatPaymentSelected = Boolean(fiatPayment?.selectedPaymentMethodId); + const isDefaultMoneyAccount = useIsMoneyAccountFlagDefault(); + + const isPerpsDepositAndOrder = hasTransactionType(transactionMeta, [ + TransactionType.perpsDepositAndOrder, + ]); + const isPredictDepositAndOrder = hasTransactionType(transactionMeta, [ + TransactionType.predictDepositAndOrder, + ]); + + // Track whether any payment override has been applied + const overrideAppliedRef = useRef(false); + if (paymentOverride !== undefined) { + overrideAppliedRef.current = true; + } + + const isMoneyAccountActive = + paymentOverride === PaymentOverride.MoneyAccount || + (isDefaultMoneyAccount && !overrideAppliedRef.current); + + const currentSection = getActiveSectionId({ + isMoneyAccountActive, + isPerpsBalanceSelected: isPerpsDepositAndOrder && isPerpsBalanceSelected, + isPredictBalanceSelected: + isPredictDepositAndOrder && isPredictBalanceSelected, + hasFiatPaymentSelected, + }); + + return useSectionTracking(currentSection, hasPayToken); +} + +function getActiveSectionId({ + isMoneyAccountActive, + isPerpsBalanceSelected, + isPredictBalanceSelected, + hasFiatPaymentSelected, +}: { + isMoneyAccountActive: boolean; + isPerpsBalanceSelected: boolean; + isPredictBalanceSelected: boolean; + hasFiatPaymentSelected: boolean; +}): PayWithSectionId { + if (isMoneyAccountActive) return 'money-account'; + if (isPerpsBalanceSelected) return 'perps'; + if (isPredictBalanceSelected) return 'predict'; + if (hasFiatPaymentSelected) return 'bank-card'; + return 'crypto'; +} diff --git a/app/components/Views/confirmations/hooks/pay/useSectionTracking.ts b/app/components/Views/confirmations/hooks/pay/useSectionTracking.ts new file mode 100644 index 00000000000..3b5c4919377 --- /dev/null +++ b/app/components/Views/confirmations/hooks/pay/useSectionTracking.ts @@ -0,0 +1,36 @@ +import { useRef } from 'react'; +import { PayWithSectionId } from '../../components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet.types'; + +export interface SectionTrackingResult { + presented: PayWithSectionId | null; + selected: PayWithSectionId; + switchCount: number; +} + +export function useSectionTracking( + currentSection: PayWithSectionId, + isActive: boolean, +): SectionTrackingResult { + const presentedRef = useRef(undefined); + if (!presentedRef.current && isActive) { + presentedRef.current = currentSection; + } + + const previousRef = useRef(undefined); + const switchCountRef = useRef(0); + + if (isActive) { + if (previousRef.current === undefined) { + previousRef.current = currentSection; + } else if (previousRef.current !== currentSection) { + switchCountRef.current += 1; + previousRef.current = currentSection; + } + } + + return { + presented: presentedRef.current ?? null, + selected: currentSection, + switchCount: switchCountRef.current, + }; +} diff --git a/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.test.ts b/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.test.ts index 5914481b92d..62ace40a4cf 100644 --- a/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.test.ts +++ b/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.test.ts @@ -15,6 +15,7 @@ import { act } from '@testing-library/react-native'; import { updateConfirmationMetric } from '../../../../../core/redux/slices/confirmationMetrics'; import { TransactionType } from '@metamask/transaction-controller'; import { + PaymentOverride, TransactionPayQuote, TransactionPayRequiredToken, TransactionPayStrategy, @@ -23,10 +24,15 @@ import { Json } from '@metamask/utils'; import { useTransactionPayQuotes, useTransactionPayRequiredTokens, + useTransactionPayFiatPayment, } from './useTransactionPayData'; import { useTransactionPayAvailableTokens } from './useTransactionPayAvailableTokens'; import { useAccountTokens } from '../send/useAccountTokens'; import { AssetType } from '../../types/token'; +import { selectPaymentOverrideByTransactionId } from '../../../../../selectors/transactionPayController'; +import { useIsPerpsBalanceSelected } from '../../../../UI/Perps/hooks/useIsPerpsBalanceSelected'; +import { selectPredictSelectedPaymentToken } from '../../../../UI/Predict/selectors/predictController'; +import { useIsMoneyAccountFlagDefault } from './useIsMoneyAccountFlagDefault'; jest.mock('./useTransactionPayToken'); jest.mock('../useTokenAmount'); @@ -34,6 +40,9 @@ jest.mock('../../../../../selectors/transactionPayController'); jest.mock('../pay/useTransactionPayData'); jest.mock('./useTransactionPayAvailableTokens'); jest.mock('../send/useAccountTokens'); +jest.mock('../../../../UI/Perps/hooks/useIsPerpsBalanceSelected'); +jest.mock('../../../../UI/Predict/selectors/predictController'); +jest.mock('./useIsMoneyAccountFlagDefault'); const mockSelectConfirmationMetricsById = jest.fn(); @@ -91,6 +100,19 @@ describe('useTransactionPayMetrics', () => { ); const useAccountTokensMock = jest.mocked(useAccountTokens); + const selectPaymentOverrideMock = jest.mocked( + selectPaymentOverrideByTransactionId, + ); + const useIsPerpsBalanceSelectedMock = jest.mocked(useIsPerpsBalanceSelected); + const selectPredictSelectedPaymentTokenMock = jest.mocked( + selectPredictSelectedPaymentToken, + ); + const useIsMoneyAccountFlagDefaultMock = jest.mocked( + useIsMoneyAccountFlagDefault, + ); + const useTransactionPayFiatPaymentMock = jest.mocked( + useTransactionPayFiatPayment, + ); beforeEach(() => { jest.resetAllMocks(); @@ -118,6 +140,15 @@ describe('useTransactionPayMetrics', () => { availableTokens: [{}, {}, {}, {}, {}] as AssetType[], hasTokens: true, }); + + selectPaymentOverrideMock.mockReturnValue(undefined); + useIsPerpsBalanceSelectedMock.mockReturnValue(false); + selectPredictSelectedPaymentTokenMock.mockReturnValue({ + address: '0x123', + chainId: '0x1', + }); + useIsMoneyAccountFlagDefaultMock.mockReturnValue(false); + useTransactionPayFiatPaymentMock.mockReturnValue(undefined); }); it('dispatches empty properties if no pay token selected', async () => { @@ -234,6 +265,27 @@ describe('useTransactionPayMetrics', () => { }); }); + it('includes simulation_sending_assets_total_value for predict deposit and order', async () => { + useTransactionPayTokenMock.mockReturnValue({ + payToken: PAY_TOKEN_MOCK, + setPayToken: noop, + } as ReturnType); + + runHook({ type: TransactionType.predictDepositAndOrder }); + + await act(async () => noop()); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: expect.objectContaining({ + simulation_sending_assets_total_value: 1.23, + }), + sensitiveProperties: {}, + }, + }); + }); + it('includes simulation_sending_assets_total_value for money account deposit', async () => { useTransactionPayTokenMock.mockReturnValue({ payToken: PAY_TOKEN_MOCK, @@ -448,4 +500,590 @@ describe('useTransactionPayMetrics', () => { }); }); }); + + describe('mm_pay_section_source', () => { + beforeEach(() => { + useTransactionPayTokenMock.mockReturnValue({ + payToken: PAY_TOKEN_MOCK, + setPayToken: noop, + } as ReturnType); + }); + + it('defaults to crypto when no override is active', async () => { + runHook(); + + await act(async () => noop()); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: expect.objectContaining({ + mm_pay_section_source_presented: 'crypto', + mm_pay_section_source_selected: 'crypto', + mm_pay_section_source_switch_count: 0, + }), + sensitiveProperties: {}, + }, + }); + }); + + it('is money-account when payment override is MoneyAccount', async () => { + selectPaymentOverrideMock.mockReturnValue(PaymentOverride.MoneyAccount); + + runHook(); + + await act(async () => noop()); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: expect.objectContaining({ + mm_pay_section_source_presented: 'money-account', + mm_pay_section_source_selected: 'money-account', + }), + sensitiveProperties: {}, + }, + }); + }); + + it('is money-account when isDefaultMoneyAccount flag is true', async () => { + useIsMoneyAccountFlagDefaultMock.mockReturnValue(true); + + runHook(); + + await act(async () => noop()); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: expect.objectContaining({ + mm_pay_section_source_presented: 'money-account', + mm_pay_section_source_selected: 'money-account', + }), + sensitiveProperties: {}, + }, + }); + }); + + it('is perps when perps balance selected for perpsDepositAndOrder', async () => { + useIsPerpsBalanceSelectedMock.mockReturnValue(true); + + runHook({ type: TransactionType.perpsDepositAndOrder }); + + await act(async () => noop()); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: expect.objectContaining({ + mm_pay_section_source_presented: 'perps', + mm_pay_section_source_selected: 'perps', + }), + sensitiveProperties: {}, + }, + }); + }); + + it('is crypto when perps balance selected but not perpsDepositAndOrder', async () => { + useIsPerpsBalanceSelectedMock.mockReturnValue(true); + + runHook({ type: TransactionType.perpsDeposit }); + + await act(async () => noop()); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: expect.objectContaining({ + mm_pay_section_source_selected: 'crypto', + }), + sensitiveProperties: {}, + }, + }); + }); + + it('is predict when predict balance selected for predictDepositAndOrder', async () => { + selectPredictSelectedPaymentTokenMock.mockReturnValue(null); + + runHook({ type: TransactionType.predictDepositAndOrder }); + + await act(async () => noop()); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: expect.objectContaining({ + mm_pay_section_source_presented: 'predict', + mm_pay_section_source_selected: 'predict', + }), + sensitiveProperties: {}, + }, + }); + }); + + it('is bank-card when fiat payment is selected', async () => { + useTransactionPayFiatPaymentMock.mockReturnValue({ + selectedPaymentMethodId: 'pm_123', + } as never); + + runHook(); + + await act(async () => noop()); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: expect.objectContaining({ + mm_pay_section_source_presented: 'bank-card', + mm_pay_section_source_selected: 'bank-card', + }), + sensitiveProperties: {}, + }, + }); + }); + + it('is not included when no pay token', async () => { + useTransactionPayTokenMock.mockReturnValue({ + payToken: undefined, + setPayToken: noop as never, + }); + + runHook(); + + await act(async () => noop()); + + const calledProps = ( + updateConfirmationMetricMock.mock.calls[0]?.[0] as { + params: { properties: Record }; + } + )?.params?.properties; + + expect(calledProps).not.toHaveProperty('mm_pay_section_source_presented'); + expect(calledProps).not.toHaveProperty('mm_pay_section_source_selected'); + expect(calledProps).not.toHaveProperty( + 'mm_pay_section_source_switch_count', + ); + }); + + it('increments switch count on section change', async () => { + const { rerender } = runHook(); + + await act(async () => noop()); + + expect(updateConfirmationMetricMock).toHaveBeenLastCalledWith({ + id: transactionIdMock, + params: { + properties: expect.objectContaining({ + mm_pay_section_source_presented: 'crypto', + mm_pay_section_source_selected: 'crypto', + mm_pay_section_source_switch_count: 0, + }), + sensitiveProperties: {}, + }, + }); + + selectPaymentOverrideMock.mockReturnValue(PaymentOverride.MoneyAccount); + + rerender({}); + + await act(async () => noop()); + + expect(updateConfirmationMetricMock).toHaveBeenLastCalledWith({ + id: transactionIdMock, + params: { + properties: expect.objectContaining({ + mm_pay_section_source_presented: 'crypto', + mm_pay_section_source_selected: 'money-account', + mm_pay_section_source_switch_count: 1, + }), + sensitiveProperties: {}, + }, + }); + }); + + it('money-account override takes priority over perps and predict', async () => { + selectPaymentOverrideMock.mockReturnValue(PaymentOverride.MoneyAccount); + useIsPerpsBalanceSelectedMock.mockReturnValue(true); + selectPredictSelectedPaymentTokenMock.mockReturnValue(null); + + runHook({ type: TransactionType.perpsDepositAndOrder }); + + await act(async () => noop()); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: expect.objectContaining({ + mm_pay_section_source_selected: 'money-account', + }), + sensitiveProperties: {}, + }, + }); + }); + }); + + describe('mm_pay_section_recipient', () => { + beforeEach(() => { + useTransactionPayTokenMock.mockReturnValue({ + payToken: PAY_TOKEN_MOCK, + setPayToken: noop, + } as ReturnType); + }); + + it('is perps for perpsDeposit', async () => { + runHook({ type: TransactionType.perpsDeposit }); + + await act(async () => noop()); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: expect.objectContaining({ + mm_pay_section_recipient_presented: 'perps', + mm_pay_section_recipient_selected: 'perps', + mm_pay_section_recipient_switch_count: 0, + }), + sensitiveProperties: {}, + }, + }); + }); + + it('is perps for perpsDepositAndOrder', async () => { + runHook({ type: TransactionType.perpsDepositAndOrder }); + + await act(async () => noop()); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: expect.objectContaining({ + mm_pay_section_recipient_presented: 'perps', + mm_pay_section_recipient_selected: 'perps', + }), + sensitiveProperties: {}, + }, + }); + }); + + it('is predict for predictDeposit', async () => { + runHook({ type: TransactionType.predictDeposit }); + + await act(async () => noop()); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: expect.objectContaining({ + mm_pay_section_recipient_presented: 'predict', + mm_pay_section_recipient_selected: 'predict', + }), + sensitiveProperties: {}, + }, + }); + }); + + it('is money-account for moneyAccountDeposit', async () => { + runHook({ type: TransactionType.moneyAccountDeposit }); + + await act(async () => noop()); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: expect.objectContaining({ + mm_pay_section_recipient_presented: 'money-account', + mm_pay_section_recipient_selected: 'money-account', + }), + sensitiveProperties: {}, + }, + }); + }); + + it('uses PayWithBottomSheet state for perpsWithdraw', async () => { + runHook({ type: TransactionType.perpsWithdraw }); + + await act(async () => noop()); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: expect.objectContaining({ + mm_pay_section_recipient_presented: 'crypto', + mm_pay_section_recipient_selected: 'crypto', + mm_pay_section_recipient_switch_count: 0, + }), + sensitiveProperties: {}, + }, + }); + }); + + it('uses PayWithBottomSheet state for predictWithdraw', async () => { + runHook({ type: TransactionType.predictWithdraw }); + + await act(async () => noop()); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: expect.objectContaining({ + mm_pay_section_recipient_selected: 'crypto', + }), + sensitiveProperties: {}, + }, + }); + }); + + it('uses PayWithBottomSheet state for moneyAccountWithdraw', async () => { + selectPaymentOverrideMock.mockReturnValue(PaymentOverride.MoneyAccount); + + runHook({ type: TransactionType.moneyAccountWithdraw }); + + await act(async () => noop()); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: expect.objectContaining({ + mm_pay_section_recipient_selected: 'money-account', + }), + sensitiveProperties: {}, + }, + }); + }); + + it('tracks recipient switch count for withdrawals', async () => { + const { rerender } = runHook({ type: TransactionType.perpsWithdraw }); + + await act(async () => noop()); + + expect(updateConfirmationMetricMock).toHaveBeenLastCalledWith({ + id: transactionIdMock, + params: { + properties: expect.objectContaining({ + mm_pay_section_recipient_presented: 'crypto', + mm_pay_section_recipient_selected: 'crypto', + mm_pay_section_recipient_switch_count: 0, + }), + sensitiveProperties: {}, + }, + }); + + selectPaymentOverrideMock.mockReturnValue(PaymentOverride.MoneyAccount); + + rerender({}); + + await act(async () => noop()); + + expect(updateConfirmationMetricMock).toHaveBeenLastCalledWith({ + id: transactionIdMock, + params: { + properties: expect.objectContaining({ + mm_pay_section_recipient_presented: 'crypto', + mm_pay_section_recipient_selected: 'money-account', + mm_pay_section_recipient_switch_count: 1, + }), + sensitiveProperties: {}, + }, + }); + }); + + it('is not included when no pay token', async () => { + useTransactionPayTokenMock.mockReturnValue({ + payToken: undefined, + setPayToken: noop as never, + }); + + runHook(); + + await act(async () => noop()); + + const calledProps = ( + updateConfirmationMetricMock.mock.calls[0]?.[0] as { + params: { properties: Record }; + } + )?.params?.properties; + + expect(calledProps).not.toHaveProperty( + 'mm_pay_section_recipient_presented', + ); + expect(calledProps).not.toHaveProperty( + 'mm_pay_section_recipient_selected', + ); + expect(calledProps).not.toHaveProperty( + 'mm_pay_section_recipient_switch_count', + ); + }); + }); + + describe('mm_pay_entry_point', () => { + beforeEach(() => { + useTransactionPayTokenMock.mockReturnValue({ + payToken: PAY_TOKEN_MOCK, + setPayToken: noop, + } as ReturnType); + }); + + it('is perps for perpsDeposit', async () => { + runHook({ type: TransactionType.perpsDeposit }); + + await act(async () => noop()); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: expect.objectContaining({ + mm_pay_entry_point: 'perps', + }), + sensitiveProperties: {}, + }, + }); + }); + + it('is perps for perpsDepositAndOrder', async () => { + runHook({ type: TransactionType.perpsDepositAndOrder }); + + await act(async () => noop()); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: expect.objectContaining({ + mm_pay_entry_point: 'perps', + }), + sensitiveProperties: {}, + }, + }); + }); + + it('is perps for perpsWithdraw', async () => { + runHook({ type: TransactionType.perpsWithdraw }); + + await act(async () => noop()); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: expect.objectContaining({ + mm_pay_entry_point: 'perps', + }), + sensitiveProperties: {}, + }, + }); + }); + + it('is predict for predictDeposit', async () => { + runHook({ type: TransactionType.predictDeposit }); + + await act(async () => noop()); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: expect.objectContaining({ + mm_pay_entry_point: 'predict', + }), + sensitiveProperties: {}, + }, + }); + }); + + it('is predict for predictDepositAndOrder', async () => { + runHook({ type: TransactionType.predictDepositAndOrder }); + + await act(async () => noop()); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: expect.objectContaining({ + mm_pay_entry_point: 'predict', + }), + sensitiveProperties: {}, + }, + }); + }); + + it('is money_account for moneyAccountDeposit', async () => { + runHook({ type: TransactionType.moneyAccountDeposit }); + + await act(async () => noop()); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: expect.objectContaining({ + mm_pay_entry_point: 'money_account', + }), + sensitiveProperties: {}, + }, + }); + }); + + it('is money_account for moneyAccountWithdraw', async () => { + runHook({ type: TransactionType.moneyAccountWithdraw }); + + await act(async () => noop()); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: expect.objectContaining({ + mm_pay_entry_point: 'money_account', + }), + sensitiveProperties: {}, + }, + }); + }); + + it('is money_hub for musdConversion', async () => { + runHook({ type: TransactionType.musdConversion }); + + await act(async () => noop()); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: expect.objectContaining({ + mm_pay_entry_point: 'money_hub', + }), + sensitiveProperties: {}, + }, + }); + }); + + it('is null for unrecognized transaction types', async () => { + runHook({ type: TransactionType.simpleSend }); + + await act(async () => noop()); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: expect.objectContaining({ + mm_pay_entry_point: null, + }), + sensitiveProperties: {}, + }, + }); + }); + + it('is not included when no pay token', async () => { + useTransactionPayTokenMock.mockReturnValue({ + payToken: undefined, + setPayToken: noop as never, + }); + + runHook(); + + await act(async () => noop()); + + const calledProps = ( + updateConfirmationMetricMock.mock.calls[0]?.[0] as { + params: { properties: Record }; + } + )?.params?.properties; + + expect(calledProps).not.toHaveProperty('mm_pay_entry_point'); + }); + }); }); diff --git a/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.ts b/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.ts index 040f68455b9..6e879dfe74c 100644 --- a/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.ts +++ b/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.ts @@ -19,6 +19,8 @@ import { } from './useTransactionPayData'; import { useTransactionPayAvailableTokens } from './useTransactionPayAvailableTokens'; import { useAccountTokens } from '../send/useAccountTokens'; +import { usePaySectionSourceMetrics } from './usePaySectionSourceMetrics'; +import { usePaySectionRecipientMetrics } from './usePaySectionRecipientMetrics'; /** * Dispatches UI-only mm_pay_* properties to confirmationMetrics. @@ -45,6 +47,10 @@ export function useTransactionPayMetrics() { selectConfirmationMetricsById(state, transactionId), ); + const hasPayToken = !!payToken; + const source = usePaySectionSourceMetrics(hasPayToken); + const recipient = usePaySectionRecipientMetrics(source.selected, hasPayToken); + const hasQuotes = (quotes?.length ?? 0) > 0; if (hasQuotes && !hasLoadedQuoteRef.current) { @@ -82,12 +88,25 @@ export function useTransactionPayMetrics() { properties.mm_pay_quote_loaded = hasLoadedQuoteRef.current; properties.mm_pay_chain_highest_balance_caip = highestBalanceChainId ?? null; + + properties.mm_pay_section_source_presented = source.presented; + properties.mm_pay_section_source_selected = source.selected; + properties.mm_pay_section_source_switch_count = source.switchCount; + + properties.mm_pay_section_recipient_presented = recipient.presented; + properties.mm_pay_section_recipient_selected = recipient.selected; + properties.mm_pay_section_recipient_switch_count = recipient.switchCount; + + properties.mm_pay_entry_point = getEntryPoint(transactionMeta) ?? null; } if ( payToken && (hasTransactionType(transactionMeta, [TransactionType.perpsDeposit]) || hasTransactionType(transactionMeta, [TransactionType.predictDeposit]) || + hasTransactionType(transactionMeta, [ + TransactionType.predictDepositAndOrder, + ]) || hasTransactionType(transactionMeta, [ TransactionType.moneyAccountDeposit, ])) @@ -146,3 +165,45 @@ function useHighestBalanceCaipChainId(): string | undefined { return highestChainId; }, [tokens]); } + +type MmPayEntryPoint = + | 'money_account' + | 'perps' + | 'predict' + | 'money_hub' + | 'activity'; + +const ENTRY_POINT_MAP: [TransactionType[], MmPayEntryPoint][] = [ + [ + [ + TransactionType.perpsDeposit, + TransactionType.perpsDepositAndOrder, + TransactionType.perpsWithdraw, + ], + 'perps', + ], + [ + [ + TransactionType.predictDeposit, + TransactionType.predictDepositAndOrder, + TransactionType.predictWithdraw, + ], + 'predict', + ], + [ + [TransactionType.moneyAccountDeposit, TransactionType.moneyAccountWithdraw], + 'money_account', + ], + [[TransactionType.musdConversion], 'money_hub'], +]; + +function getEntryPoint( + transactionMeta: Parameters[0], +): MmPayEntryPoint | undefined { + for (const [types, entryPoint] of ENTRY_POINT_MAP) { + if (hasTransactionType(transactionMeta, types)) { + return entryPoint; + } + } + return undefined; +} diff --git a/app/components/Views/confirmations/hooks/transactions/useTransactionCustomAmount.ts b/app/components/Views/confirmations/hooks/transactions/useTransactionCustomAmount.ts index fc12b75f5f6..7b536c00408 100644 --- a/app/components/Views/confirmations/hooks/transactions/useTransactionCustomAmount.ts +++ b/app/components/Views/confirmations/hooks/transactions/useTransactionCustomAmount.ts @@ -78,6 +78,9 @@ export function useTransactionCustomAmount({ const isMoneyAccountWithdraw = hasTransactionType(transactionMeta, [ TransactionType.moneyAccountWithdraw, ]); + const isMoneyAccountDeposit = hasTransactionType(transactionMeta, [ + TransactionType.moneyAccountDeposit, + ]); const tokenAddress = getTokenAddress(transactionMeta); const payTokenFiatRate = useTokenFiatRate(tokenAddress, chainId, currency) ?? 1; @@ -207,7 +210,10 @@ export function useTransactionCustomAmount({ // vmUSD fiat total). Keeping isMaxAmount false routes the typed // amount through as token.amountRaw. const shouldSetMax = - percentage === 100 && !isPerpsWithdraw && !isMoneyAccountWithdraw; + percentage === 100 && + !isPerpsWithdraw && + !isMoneyAccountWithdraw && + !isMoneyAccountDeposit; if (shouldSetMax) { setIsMax(true); @@ -222,6 +228,7 @@ export function useTransactionCustomAmount({ isMaxAmount, isPerpsWithdraw, isMoneyAccountWithdraw, + isMoneyAccountDeposit, setIsMax, setConfirmationMetric, ], diff --git a/app/components/Views/confirmations/utils/transaction.test.ts b/app/components/Views/confirmations/utils/transaction.test.ts index 3aafe9581e8..908f23c798c 100644 --- a/app/components/Views/confirmations/utils/transaction.test.ts +++ b/app/components/Views/confirmations/utils/transaction.test.ts @@ -10,6 +10,7 @@ import { getErrorMessage, getSeverity, hasGasFeeTokenSelected, + getTransactionType, hasTransactionType, isRevokeDelegationTransaction, isTransactionMarkedAsGasFeeSponsored, @@ -180,6 +181,60 @@ describe('get4ByteCode', () => { }); }); +describe('getTransactionType', () => { + it('returns undefined for undefined input', () => { + expect(getTransactionType(undefined)).toBeUndefined(); + }); + + it('returns direct type for regular transactions', () => { + const txMeta = { + type: TransactionType.perpsDeposit, + } as TransactionMeta; + + expect(getTransactionType(txMeta)).toBe(TransactionType.perpsDeposit); + }); + + it('returns nested type when nested transactions exist', () => { + const txMeta = { + type: TransactionType.batch, + nestedTransactions: [{ type: TransactionType.predictDeposit }], + } as TransactionMeta; + + expect(getTransactionType(txMeta)).toBe(TransactionType.predictDeposit); + }); + + it('returns direct type when nested transactions have no type', () => { + const txMeta = { + type: TransactionType.simpleSend, + nestedTransactions: [{}], + } as TransactionMeta; + + expect(getTransactionType(txMeta)).toBe(TransactionType.simpleSend); + }); + + it('returns first nested type that has a type', () => { + const txMeta = { + type: TransactionType.batch, + nestedTransactions: [ + {}, + { type: TransactionType.perpsWithdraw }, + { type: TransactionType.predictWithdraw }, + ], + } as TransactionMeta; + + expect(getTransactionType(txMeta)).toBe(TransactionType.perpsWithdraw); + }); + + it('returns direct type when nestedTransactions is empty', () => { + const txMeta = { + type: TransactionType.perpsDeposit, + nestedTransactions: [], + } as unknown as TransactionMeta; + + expect(getTransactionType(txMeta)).toBe(TransactionType.perpsDeposit); + }); +}); + describe('hasTransactionType', () => { it('returns true if transaction type matches', () => { const txMeta = { diff --git a/app/components/Views/confirmations/utils/transaction.ts b/app/components/Views/confirmations/utils/transaction.ts index f419748c5f4..14c3dc76d34 100644 --- a/app/components/Views/confirmations/utils/transaction.ts +++ b/app/components/Views/confirmations/utils/transaction.ts @@ -133,6 +133,18 @@ export function resolveTransactionType( return nestedType ?? transaction.type; } +export function getTransactionType( + transactionMeta: TransactionMeta | undefined, +): string | undefined { + if (!transactionMeta) return undefined; + const { type, nestedTransactions } = transactionMeta; + if (nestedTransactions?.length) { + const nestedType = nestedTransactions.find((tx) => tx.type)?.type; + if (nestedType) return nestedType; + } + return type; +} + export function hasTransactionType( transactionMeta: TransactionMeta | undefined, types: readonly TransactionType[], diff --git a/app/components/hooks/useNetworkSelection/useNetworkSelection.test.ts b/app/components/hooks/useNetworkSelection/useNetworkSelection.test.ts index 49e0da076f6..33d254abe81 100644 --- a/app/components/hooks/useNetworkSelection/useNetworkSelection.test.ts +++ b/app/components/hooks/useNetworkSelection/useNetworkSelection.test.ts @@ -18,7 +18,6 @@ import { } from '../../../selectors/networkController'; import { selectInternalAccounts } from '../../../selectors/accountsController'; import Engine from '../../../core/Engine'; -import NavigationService from '../../../core/NavigationService'; const mockEnableNetwork = jest.fn(); const mockDisableNetwork = jest.fn(); @@ -162,26 +161,6 @@ jest.mock('../../../selectors/networkController', () => ({ selectNetworkConfigurationsByCaipChainId: jest.fn(), })); -jest.mock('../../../core/NavigationService', () => { - const mockNavigate = jest.fn(); - return { - navigation: { - navigate: mockNavigate, - }, - default: { - navigation: { - navigate: mockNavigate, - }, - }, - }; -}); - -jest.mock('../../../core/SnapKeyring/MultichainWalletSnapClient', () => ({ - WalletClientType: { - Bitcoin: 'bitcoin', - }, -})); - jest.mock('../../../core/Multichain/utils', () => ({ isNonEvmChainId: jest.fn( (chainId: string) => @@ -196,9 +175,7 @@ jest.mock('../../../constants/navigation/Routes', () => ({ MODAL: { ROOT_MODAL_FLOW: 'RootModalFlow', }, - SHEET: { - ADD_ACCOUNT: 'AddAccount', - }, + SHEET: {}, })); describe('useNetworkSelection', () => { @@ -1204,50 +1181,6 @@ describe('useNetworkSelection', () => { }); }); - it('selectCustomNetwork creates new account when no Bitcoin account exists', async () => { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectPopularNetworkConfigurationsByCaipChainId) { - return [ - { - caipChainId: 'eip155:1' as CaipChainId, - chainId: '0x1', - name: 'Ethereum Mainnet', - }, - { - caipChainId: bitcoinMainnet, - chainId: 'btc-mainnet', - name: 'Bitcoin Mainnet', - }, - ]; - } - if (selector === selectNetworkConfigurationsByCaipChainId) { - return mockNetworkConfigurations; - } - if (selector === selectInternalAccounts) { - return []; - } - return undefined; - }); - - const { result } = renderHook(() => - useNetworkSelection({ networks: mockNetworks }), - ); - - await result.current.selectCustomNetwork(bitcoinMainnet); - - expect(NavigationService.navigation.navigate).toHaveBeenCalledWith( - 'RootModalFlow', - { - screen: 'AddAccount', - params: { - clientType: 'bitcoin', - scope: bitcoinMainnet, - }, - }, - ); - expect(mockEnableNetwork).not.toHaveBeenCalled(); - }); - it('selectCustomNetwork sets selected address when Bitcoin account exists', async () => { const setSelectedAddressSpy = jest.spyOn(Engine, 'setSelectedAddress'); @@ -1815,54 +1748,6 @@ describe('useNetworkSelection', () => { }); }); - describe('NavigationService integration', () => { - it('handles NavigationService navigation failures gracefully', async () => { - const bitcoinMainnet = - 'bip122:000000000019d6689c085ae165831e93' as CaipChainId; - - // Mock no Bitcoin account exists - mockUseSelector.mockImplementation((selector) => { - if (selector === selectPopularNetworkConfigurationsByCaipChainId) { - return [ - { - caipChainId: bitcoinMainnet, - chainId: 'btc-mainnet', - name: 'Bitcoin Mainnet', - }, - ]; - } - if (selector === selectInternalAccounts) { - return []; // No Bitcoin accounts - } - return undefined; - }); - - // Mock NavigationService to throw - const mockNavigate = jest.fn().mockImplementation(() => { - throw new Error('Navigation failed'); - }); - (NavigationService.navigation.navigate as jest.Mock) = mockNavigate; - - (isCaipChainId as unknown as jest.Mock).mockReturnValue(true); - (parseCaipChainId as jest.Mock).mockReturnValue({ - namespace: 'bip122', - reference: '000000000019d6689c085ae165831e93', - }); - - const { result } = renderHook(() => - useNetworkSelection({ networks: mockNetworks }), - ); - - // Should throw when navigation fails - await expect( - result.current.selectCustomNetwork(bitcoinMainnet), - ).rejects.toThrow('Navigation failed'); - - expect(mockNavigate).toHaveBeenCalled(); - expect(mockEnableNetwork).not.toHaveBeenCalled(); - }); - }); - describe('type safety and boundary conditions', () => { it('handles maximum safe integer in chain ID', () => { jest.clearAllMocks(); diff --git a/app/components/hooks/useNetworkSelection/useNetworkSelection.ts b/app/components/hooks/useNetworkSelection/useNetworkSelection.ts index 4fa700f821d..c1ec1450b1d 100644 --- a/app/components/hooks/useNetworkSelection/useNetworkSelection.ts +++ b/app/components/hooks/useNetworkSelection/useNetworkSelection.ts @@ -19,9 +19,6 @@ import { POPULAR_NETWORK_CHAIN_IDS } from '../../../constants/popular-networks'; import Engine from '../../../core/Engine'; ///: BEGIN:ONLY_INCLUDE_IF(bitcoin) import { selectInternalAccounts } from '../../../selectors/accountsController'; -import Routes from '../../../constants/navigation/Routes'; -import NavigationService from '../../../core/NavigationService'; -import { WalletClientType } from '../../../core/SnapKeyring/MultichainWalletSnapClient'; ///: END:ONLY_INCLUDE_IF interface UseNetworkSelectionOptions { @@ -118,23 +115,7 @@ export const useNetworkSelection = ({ account.scopes.includes(chainId), ); - if (chainId.includes(KnownCaipNamespace.Bip122)) { - // if the network is bitcoin and there is no bitcoin account in the scope - // create a new bitcoin account (Bitcoin accounts are different per network) - if (!bitcoAccountInScope) { - // TODO: I cannot cancel or go back from the add account screen - NavigationService.navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { - screen: Routes.SHEET.ADD_ACCOUNT, - params: { - clientType: WalletClientType.Bitcoin, - scope: chainId, - }, - }); - - return; - } - // if the network is bitcoin and there is a bitcoin account in the scope - // set the selected address to the bitcoin account + if (chainId.includes(KnownCaipNamespace.Bip122) && bitcoAccountInScope) { Engine.setSelectedAddress(bitcoAccountInScope.address); } ///: END:ONLY_INCLUDE_IF diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index a26e5441588..320d36dfcc3 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -238,7 +238,6 @@ const Routes = { SHEET: { ACCOUNT_SELECTOR: 'AccountSelector', ADDRESS_SELECTOR: 'AddressSelector', - ADD_ACCOUNT: 'AddAccount', ADD_WALLET: 'AddWallet', AMBIGUOUS_ADDRESS: 'AmbiguousAddress', BASIC_FUNCTIONALITY: 'BasicFunctionality', diff --git a/app/constants/urls.test.ts b/app/constants/urls.test.ts new file mode 100644 index 00000000000..9c0d124ccd9 --- /dev/null +++ b/app/constants/urls.test.ts @@ -0,0 +1,37 @@ +import { buildVipPrioritySupportUrl, METAMASK_SUPPORT_URL } from './urls'; + +describe('buildVipPrioritySupportUrl', () => { + it('appends VIP priority and account query params to the default support URL', () => { + const account = '0xAbC0000000000000000000000000000000000123'; + + expect(buildVipPrioritySupportUrl(account)).toBe( + `${METAMASK_SUPPORT_URL}&priority=vip&account=${encodeURIComponent(account)}`, + ); + }); + + it('uses ? as the separator when the base URL has no query string', () => { + const baseUrl = 'https://support.metamask.io/'; + + expect(buildVipPrioritySupportUrl('0xabc', baseUrl)).toBe( + `${baseUrl}?priority=vip&account=0xabc`, + ); + }); + + it('uses & as the separator when the base URL already has query params', () => { + const baseUrl = 'https://support.metamask.io/?utm_source=mobile_app'; + + expect(buildVipPrioritySupportUrl('0xabc', baseUrl)).toBe( + `${baseUrl}&priority=vip&account=0xabc`, + ); + }); + + it('URL-encodes the account address', () => { + const account = '0xabc&foo=bar?baz'; + + expect( + buildVipPrioritySupportUrl(account, 'https://support.example.com'), + ).toBe( + `https://support.example.com?priority=vip&account=${encodeURIComponent(account)}`, + ); + }); +}); diff --git a/app/constants/urls.ts b/app/constants/urls.ts index d1129058c9a..24c97eab113 100644 --- a/app/constants/urls.ts +++ b/app/constants/urls.ts @@ -57,6 +57,21 @@ export const LENDING_FAQ_URL = `https://support.metamask.io/manage-crypto/earn/l // Rewards export const REWARDS_LEARN_MORE_URL = `https://support.metamask.io/manage-crypto/metamask-rewards/${MOBILE_UTM}`; +// Builds the priority-support help-center URL for VIP users. Includes a fixed +// `priority=vip` tag so the support-side automation can fast-path the request to a +// human, plus the user's account address so that automation can verify enrollment via +// the existing support API (GET /support/subscriptions/check?account=, which keys off +// the account address and returns tier/subscription status). +// NOTE: `priority=vip` is a placeholder value pending support/Intercom confirmation; +// `account` matches the support endpoint's query-param name. +export const buildVipPrioritySupportUrl = ( + account: string, + baseUrl: string = METAMASK_SUPPORT_URL, +) => { + const separator = baseUrl.includes('?') ? '&' : '?'; + return `${baseUrl}${separator}priority=vip&account=${encodeURIComponent(account)}`; +}; + // Perps export const PERPS_LEARN_MORE_URL = `https://support.metamask.io/manage-crypto/trade/perps/${MOBILE_UTM}`; export const PERPS_ADL_URL = `https://support.metamask.io/manage-crypto/trade/perps/leverage-and-liquidation/${MOBILE_UTM}#what-is-auto-deleveraging-adl`; diff --git a/app/core/DeeplinkManager/handlers/legacy/handleCreateAccountUrl.ts b/app/core/DeeplinkManager/handlers/legacy/handleCreateAccountUrl.ts index 21c0b8f7661..1911c1137ab 100644 --- a/app/core/DeeplinkManager/handlers/legacy/handleCreateAccountUrl.ts +++ b/app/core/DeeplinkManager/handlers/legacy/handleCreateAccountUrl.ts @@ -5,35 +5,10 @@ import { selectAccountsWithNativeBalanceByChainId } from '../../../../selectors/ import { BridgeViewMode } from '../../../../components/UI/Bridge/types'; import { BridgeRouteParams } from '../../../../components/UI/Bridge/hooks/useSwapBridgeNavigation'; import { MetaMetricsSwapsEventSource } from '@metamask/bridge-controller'; -import { WalletClientType } from '../../../SnapKeyring/MultichainWalletSnapClient'; -import { - BtcScope, - SolScope, - ///: BEGIN:ONLY_INCLUDE_IF(tron) - TrxScope, - ///: END:ONLY_INCLUDE_IF -} from '@metamask/keyring-api'; import BigNumber from 'bignumber.js'; import { getNativeSourceToken } from '../../../../components/UI/Bridge/utils/tokenUtils'; import NavigationService from '../../../NavigationService'; -const getClientType = (chainId: string) => { - let clientType: WalletClientType; - if (Object.values(BtcScope).includes(chainId as BtcScope)) { - clientType = WalletClientType.Bitcoin; - } else if (Object.values(SolScope).includes(chainId as SolScope)) { - clientType = WalletClientType.Solana; - ///: BEGIN:ONLY_INCLUDE_IF(tron) - } else if (Object.values(TrxScope).includes(chainId as TrxScope)) { - clientType = WalletClientType.Tron; - ///: END:ONLY_INCLUDE_IF - } else { - throw new Error(`Unsupported chainId: ${chainId}`); - } - - return clientType; -}; - export function handleCreateAccountUrl({ path }: { path: string }) { const chainId = new URLSearchParams(path).get('chainId'); @@ -50,18 +25,6 @@ export function handleCreateAccountUrl({ path }: { path: string }) { const hasAccountsInScope = Object.keys(accountsBalanceInScope).length > 0; - if (!hasAccountsInScope) { - // if there are no accounts in the scope, show the modal to create an account - NavigationService.navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { - screen: Routes.SHEET.ADD_ACCOUNT, - params: { - clientType: getClientType(chainId), - scope: chainId, - }, - }); - return; - } - const accountIdWithNativeBalanceGreaterThanZero = Object.keys( accountsBalanceInScope, ).find((accountId) => { diff --git a/app/core/Engine/controllers/ramps-controller/event-handlers/analytics.test.ts b/app/core/Engine/controllers/ramps-controller/event-handlers/analytics.test.ts index 9021dd50354..3e31e3399a9 100644 --- a/app/core/Engine/controllers/ramps-controller/event-handlers/analytics.test.ts +++ b/app/core/Engine/controllers/ramps-controller/event-handlers/analytics.test.ts @@ -4,6 +4,11 @@ import { } from '@metamask/ramps-controller'; import { analytics } from '../../../../../util/analytics/analytics'; import Logger from '../../../../../util/Logger'; +import { + __resetHeadlessOrderContextRegistryForTests, + getHeadlessOrderContext, + setHeadlessOrderContext, +} from '../headlessOrderContextRegistry'; import { handleOrderStatusChangedForMetrics } from './analytics'; const mockTrackEvent = jest.fn(); @@ -16,12 +21,12 @@ jest.mock('../../../../../util/analytics/analytics', () => ({ jest.mock('../../../../Analytics', () => ({ MetaMetricsEvents: { - ONRAMP_PURCHASE_COMPLETED: { category: 'On-ramp Purchase Completed' }, - ONRAMP_PURCHASE_FAILED: { category: 'On-ramp Purchase Failed' }, ONRAMP_PURCHASE_CANCELLED: { category: 'On-ramp Purchase Cancelled' }, OFFRAMP_PURCHASE_COMPLETED: { category: 'Off-ramp Purchase Completed' }, OFFRAMP_PURCHASE_FAILED: { category: 'Off-ramp Purchase Failed' }, OFFRAMP_PURCHASE_CANCELLED: { category: 'Off-ramp Purchase Cancelled' }, + RAMPS_TRANSACTION_COMPLETED: { category: 'Ramps Transaction Completed' }, + RAMPS_TRANSACTION_FAILED: { category: 'Ramps Transaction Failed' }, }, })); @@ -57,10 +62,15 @@ const createMockOrder = (overrides: Partial = {}): RampsOrder => ({ links: [], logos: { light: '', dark: '', height: 24, width: 79 }, }, - cryptoCurrency: { symbol: 'ETH' }, + cryptoCurrency: { symbol: 'ETH', assetId: 'eip155:1/slip44:60' }, fiatCurrency: { symbol: 'USD' }, paymentMethod: { id: 'card-1', name: 'Card' }, fiatAmountInUsd: 100, + region: 'US', + exchangeRate: 200, + networkFees: 1, + partnerFees: 2, + statusDescription: 'card_declined', ...overrides, }); @@ -68,10 +78,14 @@ describe('handleOrderStatusChangedForMetrics', () => { beforeEach(() => { jest.clearAllMocks(); jest.mocked(analytics.trackEvent).mockImplementation(mockTrackEvent); + // MANDATORY: clear the module-level registry so a headless context seeded + // by one test cannot leak into the BUY/SELL/missing-fields tests and flip + // their expected events (they assume no entry). + __resetHeadlessOrderContextRegistryForTests(); }); describe('BUY orders', () => { - it('tracks ONRAMP_PURCHASE_COMPLETED with calculated exchange rate', () => { + it('tracks RAMPS_TRANSACTION_COMPLETED with the unified deposit-shaped payload', () => { const order = createMockOrder({ status: Status.Completed }); handleOrderStatusChangedForMetrics({ @@ -81,23 +95,27 @@ describe('handleOrderStatusChangedForMetrics', () => { expect(mockTrackEvent).toHaveBeenCalledTimes(1); const trackedEvent = mockTrackEvent.mock.calls[0][0]; - expect(trackedEvent.name).toBe('On-ramp Purchase Completed'); + expect(trackedEvent.name).toBe('Ramps Transaction Completed'); expect(trackedEvent.properties).toEqual({ - amount: 100, - amount_in_usd: 100, - chain_id_destination: 'eip155:1', - crypto_out: '0.5', - currency_destination: 'ETH', - currency_source: 'USD', - exchange_rate: 190, - order_type: 'BUY', + ramp_type: 'UNIFIED_BUY_2', + amount_source: 100, + amount_destination: 0.5, + exchange_rate: 200, payment_method_id: 'card-1', + country: 'US', + chain_id: 'eip155:1', + currency_destination: 'eip155:1/slip44:60', + currency_destination_symbol: 'ETH', + currency_destination_network: 'Ethereum', + currency_source: 'USD', provider_onramp: 'Transak', + gas_fee: 1, + processing_fee: 2, total_fee: 5, }); }); - it('tracks ONRAMP_PURCHASE_FAILED for Failed status', () => { + it('tracks RAMPS_TRANSACTION_FAILED for Failed status with the unified payload + error_message', () => { const order = createMockOrder({ status: Status.Failed }); handleOrderStatusChangedForMetrics({ @@ -107,13 +125,18 @@ describe('handleOrderStatusChangedForMetrics', () => { expect(mockTrackEvent).toHaveBeenCalledWith( expect.objectContaining({ - name: 'On-ramp Purchase Failed', - properties: expect.objectContaining({ order_type: 'BUY' }), + name: 'Ramps Transaction Failed', + properties: expect.objectContaining({ + ramp_type: 'UNIFIED_BUY_2', + chain_id: 'eip155:1', + provider_onramp: 'Transak', + error_message: 'card_declined', + }), }), ); }); - it('tracks ONRAMP_PURCHASE_FAILED for IdExpired status', () => { + it('tracks RAMPS_TRANSACTION_FAILED for IdExpired status', () => { const order = createMockOrder({ status: Status.IdExpired }); handleOrderStatusChangedForMetrics({ @@ -123,8 +146,11 @@ describe('handleOrderStatusChangedForMetrics', () => { expect(mockTrackEvent).toHaveBeenCalledWith( expect.objectContaining({ - name: 'On-ramp Purchase Failed', - properties: expect.objectContaining({ order_type: 'BUY' }), + name: 'Ramps Transaction Failed', + properties: expect.objectContaining({ + ramp_type: 'UNIFIED_BUY_2', + error_message: 'card_declined', + }), }), ); }); @@ -146,6 +172,76 @@ describe('handleOrderStatusChangedForMetrics', () => { }); }); + describe('DEPOSIT orders (Transak native on-ramp)', () => { + // The V2 unified API returns orderType: 'DEPOSIT' for native flows + // (e.g. Transak + Apple Pay). DEPOSIT must map to the on-ramp events, + // not the off-ramp events. See TRAM-3534. + it('tracks RAMPS_TRANSACTION_COMPLETED for DEPOSIT orders', () => { + const order = createMockOrder({ + orderType: 'DEPOSIT', + status: Status.Completed, + }); + + handleOrderStatusChangedForMetrics({ + order, + previousStatus: Status.Pending, + }); + + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Ramps Transaction Completed', + properties: expect.objectContaining({ + ramp_type: 'UNIFIED_BUY_2', + chain_id: 'eip155:1', + country: 'US', + provider_onramp: 'Transak', + }), + }), + ); + }); + + it('tracks RAMPS_TRANSACTION_FAILED for DEPOSIT orders', () => { + const order = createMockOrder({ + orderType: 'DEPOSIT', + status: Status.Failed, + }); + + handleOrderStatusChangedForMetrics({ + order, + previousStatus: Status.Pending, + }); + + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Ramps Transaction Failed', + properties: expect.objectContaining({ + ramp_type: 'UNIFIED_BUY_2', + error_message: 'card_declined', + }), + }), + ); + }); + + it('tracks ONRAMP_PURCHASE_CANCELLED for DEPOSIT orders', () => { + const order = createMockOrder({ + orderType: 'DEPOSIT', + status: Status.Cancelled, + }); + + handleOrderStatusChangedForMetrics({ + order, + previousStatus: Status.Pending, + }); + + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'On-ramp Purchase Cancelled', + properties: expect.objectContaining({ order_type: 'DEPOSIT' }), + }), + ); + }); + }); + describe('SELL orders', () => { it('tracks OFFRAMP_PURCHASE_COMPLETED', () => { const order = createMockOrder({ @@ -209,6 +305,301 @@ describe('handleOrderStatusChangedForMetrics', () => { }); }); + describe('headless orders (TRAM-3623 AC5)', () => { + // A headless order carries a context entry (written at confirm time by + // useTransakRouting). When such an order reaches a terminal failure, this + // metrics handler - the SINGLE orderStatusChanged metrics subscriber - emits + // the tagged RAMPS_TRANSACTION_FAILED and suppresses the generic emit, so it + // cannot double-emit with the generic buy/deposit failure event. + const createHeadlessOrder = ( + overrides: Partial = {}, + ): RampsOrder => + createMockOrder({ + success: false, + orderType: 'DEPOSIT', + status: Status.Failed, + statusDescription: 'Payment was declined by the bank', + exchangeRate: 2000, + networkFees: 1, + partnerFees: 2, + cryptoCurrency: { symbol: 'ETH', assetId: 'eip155:1/slip44:60' }, + ...overrides, + }); + + it('emits EXACTLY ONE event, RAMPS_TRANSACTION_FAILED (tagged HEADLESS) and NOT OFFRAMP_PURCHASE_FAILED, for a headless Failed order', () => { + setHeadlessOrderContext('order-1', { + rampSurface: 'money_account', + region: 'us-ca', + }); + const order = createHeadlessOrder({ status: Status.Failed }); + + handleOrderStatusChangedForMetrics({ + order, + previousStatus: Status.Pending, + }); + + // The core de-dup guarantee: a single emit, the tagged failure event, and + // never the mis-classified off-ramp event the generic path would produce. + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + const trackedEvent = mockTrackEvent.mock.calls[0][0]; + expect(trackedEvent.name).toBe('Ramps Transaction Failed'); + expect(trackedEvent.properties).toEqual({ + ramp_type: 'HEADLESS', + ramp_surface: 'money_account', + region: 'us-ca', + country: 'us-ca', + error_message: 'Payment was declined by the bank', + provider_onramp: 'Transak', + amount_source: 100, + amount_destination: 0.5, + exchange_rate: 2000, + gas_fee: 1, + processing_fee: 2, + total_fee: 5, + payment_method_id: 'card-1', + chain_id: 'eip155:1', + currency_destination: 'eip155:1/slip44:60', + currency_source: 'USD', + }); + expect(getHeadlessOrderContext('order-1')).toBeUndefined(); + }); + + it('emits for IdExpired too and deletes the entry', () => { + setHeadlessOrderContext('order-1', { + rampSurface: 'perps', + region: 'fr', + }); + const order = createHeadlessOrder({ status: Status.IdExpired }); + + handleOrderStatusChangedForMetrics({ + order, + previousStatus: Status.Pending, + }); + + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + expect(mockTrackEvent.mock.calls[0][0].properties).toEqual( + expect.objectContaining({ + ramp_type: 'HEADLESS', + ramp_surface: 'perps', + }), + ); + expect(getHeadlessOrderContext('order-1')).toBeUndefined(); + }); + + it('falls back to a token error_message when statusDescription is absent', () => { + setHeadlessOrderContext('order-1', { region: 'us-ca' }); + const order = createHeadlessOrder({ + status: Status.Failed, + statusDescription: undefined, + }); + + handleOrderStatusChangedForMetrics({ + order, + previousStatus: Status.Pending, + }); + + expect(mockTrackEvent.mock.calls[0][0].properties.error_message).toBe( + 'transaction_failed', + ); + }); + + it('emits provider_onramp as an empty string when provider is missing', () => { + setHeadlessOrderContext('order-1', { region: 'us-ca' }); + const order = createHeadlessOrder({ + status: Status.Failed, + provider: undefined, + }); + + handleOrderStatusChangedForMetrics({ + order, + previousStatus: Status.Pending, + }); + + expect(mockTrackEvent.mock.calls[0][0].properties.provider_onramp).toBe( + '', + ); + }); + + it('does NOT emit a HEADLESS-tagged RAMPS_TRANSACTION_FAILED for an order with no context entry: a non-headless BUY emits the generic UNIFIED_BUY_2-tagged event (TRAM-3534)', () => { + const order = createMockOrder({ status: Status.Failed }); + + handleOrderStatusChangedForMetrics({ + order, + previousStatus: Status.Pending, + }); + + // No registry entry: the headless fold is inert and the generic emit + // runs. Per TRAM-3534, V2 buys/deposits emit RAMPS_TRANSACTION_FAILED + // with ramp_type: 'UNIFIED_BUY_2' (not 'HEADLESS'). The de-dup is + // expressed by the ramp_type discriminator, not by event name. + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + const trackedEvent = mockTrackEvent.mock.calls[0][0]; + expect(trackedEvent.name).toBe('Ramps Transaction Failed'); + expect(trackedEvent.properties.ramp_type).toBe('UNIFIED_BUY_2'); + expect(trackedEvent.properties.ramp_type).not.toBe('HEADLESS'); + }); + + it('is idempotent: a second identical Failed event finds no entry and does not re-emit the tagged event', () => { + setHeadlessOrderContext('order-1', { + rampSurface: 'money_account', + region: 'us-ca', + }); + const order = createHeadlessOrder({ status: Status.Failed }); + + handleOrderStatusChangedForMetrics({ + order, + previousStatus: Status.Pending, + }); + handleOrderStatusChangedForMetrics({ + order, + previousStatus: Status.Pending, + }); + + // Emit-then-delete: the HEADLESS-tagged failure event fires exactly + // once. The second call finds no entry, so it falls through to the + // generic emit (UNIFIED_BUY_2-tagged per TRAM-3534) and never re-emits + // the HEADLESS-tagged event. The de-dup is on ramp_type, not on name. + const headlessTaggedEmits = mockTrackEvent.mock.calls.filter( + (call) => + call[0].name === 'Ramps Transaction Failed' && + call[0].properties.ramp_type === 'HEADLESS', + ); + expect(headlessTaggedEmits).toHaveLength(1); + expect(getHeadlessOrderContext('order-1')).toBeUndefined(); + }); + + it('still deletes the entry even when trackEvent throws (no double-emit on retry) and logs', () => { + setHeadlessOrderContext('order-1', { region: 'us-ca' }); + mockTrackEvent.mockImplementationOnce(() => { + throw new Error('tracking failed'); + }); + const order = createHeadlessOrder({ status: Status.Failed }); + + handleOrderStatusChangedForMetrics({ + order, + previousStatus: Status.Pending, + }); + + expect((Logger as jest.Mocked).error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + message: + 'RampsController: Failed to track headless RAMPS_TRANSACTION_FAILED', + }), + ); + expect(getHeadlessOrderContext('order-1')).toBeUndefined(); + + // A retried poll of the same failed order finds no entry, so it + // never re-emits the HEADLESS-tagged event (even though the first + // emit threw). The retry will fall through to the generic + // UNIFIED_BUY_2 emit per TRAM-3534, which is fine — only the + // HEADLESS-tagged event is the one we de-dup on. + handleOrderStatusChangedForMetrics({ + order, + previousStatus: Status.Pending, + }); + const headlessTaggedEmits = mockTrackEvent.mock.calls.filter( + (call) => + call[0].name === 'Ramps Transaction Failed' && + call[0].properties.ramp_type === 'HEADLESS', + ); + expect(headlessTaggedEmits).toHaveLength(1); + }); + + it('deletes the entry on Completed and (Option B) still fires the generic completion event', () => { + setHeadlessOrderContext('order-1', { region: 'us-ca' }); + const order = createHeadlessOrder({ status: Status.Completed }); + + handleOrderStatusChangedForMetrics({ + order, + previousStatus: Status.Pending, + }); + + // Option B: the generic completion emit still fires (no tagged COMPLETED). + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + expect(mockTrackEvent.mock.calls[0][0].name).not.toBe( + 'Ramps Transaction Failed', + ); + expect(getHeadlessOrderContext('order-1')).toBeUndefined(); + }); + + it('deletes the entry on Cancelled and (Option B) falls through to the generic emit', () => { + setHeadlessOrderContext('order-1', { region: 'us-ca' }); + const order = createHeadlessOrder({ status: Status.Cancelled }); + + handleOrderStatusChangedForMetrics({ + order, + previousStatus: Status.Pending, + }); + + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + expect(mockTrackEvent.mock.calls[0][0].name).not.toBe( + 'Ramps Transaction Failed', + ); + expect(getHeadlessOrderContext('order-1')).toBeUndefined(); + }); + + it('keeps the entry and does not emit on a non-terminal status', () => { + setHeadlessOrderContext('order-1', { region: 'us-ca' }); + const order = createHeadlessOrder({ status: Status.Pending }); + + handleOrderStatusChangedForMetrics({ + order, + previousStatus: Status.Created, + }); + + expect(mockTrackEvent).not.toHaveBeenCalled(); + expect(getHeadlessOrderContext('order-1')).toEqual({ region: 'us-ca' }); + }); + + it('matches a full-path set against the bare providerOrderId the subscriber receives', () => { + // Confirm-time write used a full path; the controller delivers the bare code. + setHeadlessOrderContext('/providers/transak/orders/order-1', { + rampSurface: 'prediction', + region: 'de', + }); + const order = createHeadlessOrder({ + status: Status.Failed, + providerOrderId: 'order-1', + }); + + handleOrderStatusChangedForMetrics({ + order, + previousStatus: Status.Pending, + }); + + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + expect(mockTrackEvent.mock.calls[0][0].properties).toEqual( + expect.objectContaining({ + ramp_type: 'HEADLESS', + ramp_surface: 'prediction', + region: 'de', + }), + ); + }); + + it('no-ops the HEADLESS path on a headless Failed order when the registry is empty (simulating an app relaunch)', () => { + // No setHeadlessOrderContext call: the module Map is empty as it would + // be after the app process restarts. Under Option B the + // same-session-only gap is by design; the handler must NOT emit a + // HEADLESS-tagged event. The generic emit still runs for the DEPOSIT + // order and emits RAMPS_TRANSACTION_FAILED with ramp_type: + // 'UNIFIED_BUY_2' per TRAM-3534 (not 'HEADLESS') — that's the correct + // post-relaunch behavior, and the de-dup is on ramp_type. + const order = createHeadlessOrder({ status: Status.Failed }); + + handleOrderStatusChangedForMetrics({ + order, + previousStatus: Status.Pending, + }); + + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + const trackedEvent = mockTrackEvent.mock.calls[0][0]; + expect(trackedEvent.properties.ramp_type).not.toBe('HEADLESS'); + expect(trackedEvent.properties.ramp_type).toBe('UNIFIED_BUY_2'); + }); + }); + it('does not track for non-terminal statuses', () => { const nonTerminalStatuses = [ Status.Pending, @@ -230,10 +621,11 @@ describe('handleOrderStatusChangedForMetrics', () => { } }); - it('handles exchange rate calculation when cryptoAmount is 0', () => { + it('handles exchange rate calculation when cryptoAmount is 0 and order has no exchangeRate', () => { const order = createMockOrder({ status: Status.Completed, cryptoAmount: '0', + exchangeRate: undefined, }); handleOrderStatusChangedForMetrics({ @@ -266,6 +658,79 @@ describe('handleOrderStatusChangedForMetrics', () => { expect(properties.payment_method_id).toBe(''); }); + it('falls back to defaults when region, network, fees, and statusDescription are missing on a failed BUY', () => { + const order = createMockOrder({ + status: Status.Failed, + region: undefined, + network: undefined, + networkFees: undefined, + partnerFees: undefined, + statusDescription: undefined, + }); + + handleOrderStatusChangedForMetrics({ + order, + previousStatus: Status.Pending, + }); + + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + const { properties } = mockTrackEvent.mock.calls[0][0]; + expect(properties.country).toBe(''); + expect(properties.chain_id).toBe(''); + expect(properties.currency_destination_network).toBeUndefined(); + expect(properties.gas_fee).toBe(0); + expect(properties.processing_fee).toBe(0); + expect(properties.error_message).toBe('transaction_failed'); + }); + + it('falls back to defaults for legacy SELL params when network and provider are missing', () => { + const order = createMockOrder({ + orderType: 'SELL', + status: Status.Failed, + network: undefined, + provider: undefined, + }); + + handleOrderStatusChangedForMetrics({ + order, + previousStatus: Status.Pending, + }); + + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Off-ramp Purchase Failed', + properties: expect.objectContaining({ + chain_id_source: '', + provider_offramp: '', + }), + }), + ); + }); + + it('falls back to empty strings for SELL legacy currency_source/destination when cryptoCurrency and fiatCurrency are missing', () => { + const order = createMockOrder({ + orderType: 'SELL', + status: Status.Completed, + cryptoCurrency: undefined, + fiatCurrency: undefined, + }); + + handleOrderStatusChangedForMetrics({ + order, + previousStatus: Status.Pending, + }); + + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Off-ramp Purchase Completed', + properties: expect.objectContaining({ + currency_source: '', + currency_destination: '', + }), + }), + ); + }); + it('logs error when trackEvent throws', () => { mockTrackEvent.mockImplementationOnce(() => { throw new Error('tracking failed'); diff --git a/app/core/Engine/controllers/ramps-controller/event-handlers/analytics.ts b/app/core/Engine/controllers/ramps-controller/event-handlers/analytics.ts index a160c0a1069..6ecf532ce8c 100644 --- a/app/core/Engine/controllers/ramps-controller/event-handlers/analytics.ts +++ b/app/core/Engine/controllers/ramps-controller/event-handlers/analytics.ts @@ -7,14 +7,58 @@ import { MetaMetricsEvents } from '../../../../Analytics'; import { AnalyticsEventBuilder } from '../../../../../util/analytics/AnalyticsEventBuilder'; import { analytics } from '../../../../../util/analytics/analytics'; import Logger from '../../../../../util/Logger'; +import { extractOrderCode } from '../../../../../components/UI/Ramp/utils/extractOrderCode'; +import { + deleteHeadlessOrderContext, + getHeadlessOrderContext, + type HeadlessOrderContext, +} from '../headlessOrderContextRegistry'; +// Type-only import so `react` is not pulled into core; mirrors the emission in +// `useAnalytics` without importing the hook. +import type { AnalyticsEvents } from '../../../../../components/UI/Ramp/Deposit/types/analytics'; + +/** + * Builds the `RAMPS_TRANSACTION_FAILED` payload for a headless order. Mirrors + * the headless `RAMPS_TRANSACTION_CONFIRMED` payload emitted by + * `useTransakRouting` (NOT the buy-branch of `handleOrderStatusChangedForMetrics`, + * which uses a different ONRAMP/OFFRAMP vocabulary). `params` is explicitly + * typed so the payload stays in lockstep with the schema. + */ +function buildHeadlessTransactionFailedParams( + order: RampsOrder, + context: HeadlessOrderContext, +): AnalyticsEvents['RAMPS_TRANSACTION_FAILED'] { + return { + ramp_type: 'HEADLESS', + ramp_surface: context.rampSurface, + region: context.region, + country: context.region, + error_message: order.statusDescription || 'transaction_failed', + provider_onramp: order.provider?.name || '', + amount_source: Number(order.fiatAmount), + amount_destination: Number(order.cryptoAmount), + exchange_rate: Number(order.exchangeRate ?? 0), + gas_fee: Number(order.networkFees ?? 0), + processing_fee: Number(order.partnerFees ?? 0), + total_fee: Number(order.totalFeesFiat), + payment_method_id: order.paymentMethod?.id || '', + chain_id: order.network?.chainId || '', + currency_destination: order.cryptoCurrency?.assetId || '', + currency_source: order.fiatCurrency?.symbol || '', + }; +} function buildV2AnalyticsPayload( order: RampsOrder, _previousStatus: RampsOrderStatus, ) { - const isBuy = order.orderType === 'BUY'; + const isBuy = order.orderType === 'BUY' || order.orderType === 'DEPOSIT'; - const baseParams = { + // Legacy aggregator-shaped params, used for OFFRAMP_PURCHASE_* + // (sell completion/failure) and ONRAMP_PURCHASE_CANCELLED (buy cancellation). + // These event names are scheduled for deprecation; their typed contract + // is still the legacy on-ramp/off-ramp shape. + const legacyParams = { amount: order.fiatAmount, currency_source: isBuy ? (order.fiatCurrency?.symbol ?? '') @@ -35,46 +79,79 @@ function buildV2AnalyticsPayload( }), }; + // Unified ramps transaction payload for V2 buys. Matches the shape the + // deposit flow already emits under RAMPS_TRANSACTION_* so a single Mixpanel + // event name maps to a single schema regardless of provider. + const cryptoAmount = Number(order.cryptoAmount); + const feeTotal = Number(order.totalFeesFiat); + const computedExchangeRate = + cryptoAmount > 0 ? (Number(order.fiatAmount) - feeTotal) / cryptoAmount : 0; + + const unifiedBuyBase = { + ramp_type: 'UNIFIED_BUY_2' as const, + amount_source: order.fiatAmount, + amount_destination: cryptoAmount, + exchange_rate: order.exchangeRate ?? computedExchangeRate, + payment_method_id: order.paymentMethod?.id ?? '', + country: order.region ?? '', + chain_id: order.network?.chainId ?? '', + currency_destination: order.cryptoCurrency?.assetId ?? '', + currency_destination_symbol: order.cryptoCurrency?.symbol, + currency_destination_network: order.network?.name, + currency_source: order.fiatCurrency?.symbol ?? '', + provider_onramp: order.provider?.name ?? '', + }; + + const unifiedBuyFees = { + gas_fee: Number(order.networkFees ?? 0), + processing_fee: Number(order.partnerFees ?? 0), + total_fee: feeTotal, + }; + switch (order.status) { case Status.Completed: { - const cryptoAmount = Number(order.cryptoAmount); - const feeTotal = Number(order.totalFeesFiat); - const exchangeRate = - cryptoAmount > 0 - ? (Number(order.fiatAmount) - feeTotal) / cryptoAmount - : 0; - + if (isBuy) { + return { + event: MetaMetricsEvents.RAMPS_TRANSACTION_COMPLETED, + params: { ...unifiedBuyBase, ...unifiedBuyFees }, + }; + } return { - event: isBuy - ? MetaMetricsEvents.ONRAMP_PURCHASE_COMPLETED - : MetaMetricsEvents.OFFRAMP_PURCHASE_COMPLETED, + event: MetaMetricsEvents.OFFRAMP_PURCHASE_COMPLETED, params: { - ...baseParams, + ...legacyParams, total_fee: feeTotal, - exchange_rate: exchangeRate, + exchange_rate: computedExchangeRate, amount_in_usd: order.fiatAmountInUsd, - ...(isBuy - ? { crypto_out: order.cryptoAmount } - : { fiat_out: order.fiatAmount }), + fiat_out: order.fiatAmount, }, }; } case Status.Failed: - case Status.IdExpired: + case Status.IdExpired: { + if (isBuy) { + return { + event: MetaMetricsEvents.RAMPS_TRANSACTION_FAILED, + params: { + ...unifiedBuyBase, + ...unifiedBuyFees, + error_message: order.statusDescription ?? 'transaction_failed', + }, + }; + } return { - event: isBuy - ? MetaMetricsEvents.ONRAMP_PURCHASE_FAILED - : MetaMetricsEvents.OFFRAMP_PURCHASE_FAILED, - params: baseParams, + event: MetaMetricsEvents.OFFRAMP_PURCHASE_FAILED, + params: legacyParams, }; + } case Status.Cancelled: return { event: isBuy ? MetaMetricsEvents.ONRAMP_PURCHASE_CANCELLED : MetaMetricsEvents.OFFRAMP_PURCHASE_CANCELLED, - params: baseParams, + params: legacyParams, }; default: @@ -89,6 +166,52 @@ export function handleOrderStatusChangedForMetrics({ order: RampsOrder; previousStatus: RampsOrderStatus; }): void { + // TRAM-3623 AC5: a headless order carries a context entry (written at confirm + // time by `useTransakRouting`). Handle its terminal failure here, as the + // SINGLE `orderStatusChanged` metrics subscriber, so it cannot double-emit + // `RAMPS_TRANSACTION_FAILED` with the generic buy/deposit emit below (which + // TRAM-3534 #31207 also maps deposits to). Placed before the generic emit and + // returning on the failure path is the de-dup. + const orderCode = extractOrderCode(order.providerOrderId); + const headlessContext = getHeadlessOrderContext(orderCode); + if (headlessContext) { + if (order.status === Status.Failed || order.status === Status.IdExpired) { + try { + const params = buildHeadlessTransactionFailedParams( + order, + headlessContext, + ); + analytics.trackEvent( + AnalyticsEventBuilder.createEventBuilder( + MetaMetricsEvents.RAMPS_TRANSACTION_FAILED, + ) + .addProperties({ ...params }) + .build(), + ); + } catch (error) { + Logger.error(error as Error, { + message: + 'RampsController: Failed to track headless RAMPS_TRANSACTION_FAILED', + }); + } finally { + // Delete even if the emit threw, so a retried poll cannot double-emit. + deleteHeadlessOrderContext(orderCode); + } + // Suppress the generic emit for this headless failure (the de-dup). + return; + } + if ( + order.status === Status.Completed || + order.status === Status.Cancelled + ) { + // Bound the map on terminal success/cancel; do NOT emit a tagged + // COMPLETED (no schema for it). Option B: fall through to the generic + // emit so the existing controller completion signal is preserved. + deleteHeadlessOrderContext(orderCode); + } + // Non-terminal headless: fall through (the generic emit no-ops for those). + } + const analyticsPayload = buildV2AnalyticsPayload(order, previousStatus); if (analyticsPayload) { diff --git a/app/core/Engine/controllers/ramps-controller/headlessOrderContextRegistry.test.ts b/app/core/Engine/controllers/ramps-controller/headlessOrderContextRegistry.test.ts new file mode 100644 index 00000000000..f013e330331 --- /dev/null +++ b/app/core/Engine/controllers/ramps-controller/headlessOrderContextRegistry.test.ts @@ -0,0 +1,83 @@ +import { + __resetHeadlessOrderContextRegistryForTests, + deleteHeadlessOrderContext, + getHeadlessOrderContext, + setHeadlessOrderContext, +} from './headlessOrderContextRegistry'; + +describe('headlessOrderContextRegistry', () => { + beforeEach(() => { + __resetHeadlessOrderContextRegistryForTests(); + }); + + it('stores and retrieves a context by order code', () => { + setHeadlessOrderContext('abc-123', { + rampSurface: 'perps', + region: 'us-ca', + }); + + expect(getHeadlessOrderContext('abc-123')).toEqual({ + rampSurface: 'perps', + region: 'us-ca', + }); + }); + + it('returns undefined for an unknown order', () => { + expect(getHeadlessOrderContext('never-set')).toBeUndefined(); + }); + + it('deletes a stored context', () => { + setHeadlessOrderContext('abc-123', { region: 'fr' }); + deleteHeadlessOrderContext('abc-123'); + + expect(getHeadlessOrderContext('abc-123')).toBeUndefined(); + }); + + it('preserves an absent rampSurface (region-only context)', () => { + setHeadlessOrderContext('abc-123', { region: 'gb' }); + + expect(getHeadlessOrderContext('abc-123')).toEqual({ region: 'gb' }); + }); + + describe('extractOrderCode normalization', () => { + it('normalizes the key on set so a bare-code get matches a full-path set', () => { + setHeadlessOrderContext('/providers/transak/orders/abc-123', { + rampSurface: 'money_account', + region: 'us-ca', + }); + + expect(getHeadlessOrderContext('abc-123')).toEqual({ + rampSurface: 'money_account', + region: 'us-ca', + }); + }); + + it('normalizes the key on get so a full-path get matches a bare-code set', () => { + setHeadlessOrderContext('abc-123', { + rampSurface: 'prediction', + region: 'de', + }); + + expect( + getHeadlessOrderContext('/providers/transak/orders/abc-123'), + ).toEqual({ rampSurface: 'prediction', region: 'de' }); + }); + + it('normalizes the key on delete so a full-path delete removes a bare-code set', () => { + setHeadlessOrderContext('abc-123', { region: 'us-ca' }); + deleteHeadlessOrderContext('/providers/transak/orders/abc-123'); + + expect(getHeadlessOrderContext('abc-123')).toBeUndefined(); + }); + }); + + it('reset clears all entries', () => { + setHeadlessOrderContext('a', { region: 'us' }); + setHeadlessOrderContext('b', { region: 'fr' }); + + __resetHeadlessOrderContextRegistryForTests(); + + expect(getHeadlessOrderContext('a')).toBeUndefined(); + expect(getHeadlessOrderContext('b')).toBeUndefined(); + }); +}); diff --git a/app/core/Engine/controllers/ramps-controller/headlessOrderContextRegistry.ts b/app/core/Engine/controllers/ramps-controller/headlessOrderContextRegistry.ts new file mode 100644 index 00000000000..e86abe530e7 --- /dev/null +++ b/app/core/Engine/controllers/ramps-controller/headlessOrderContextRegistry.ts @@ -0,0 +1,92 @@ +import type { RampSurface } from '../../../../components/UI/Ramp/Deposit/types/analytics'; +import { extractOrderCode } from '../../../../components/UI/Ramp/utils/extractOrderCode'; + +/** + * Headless order-context registry (TRAM-3623 AC5). + * + * Late/terminal headless failures are observable only via + * `RampsController:orderStatusChanged` (Engine-level polling), and the + * `RampsOrder` delivered there carries no `ramp_surface` and no headless + * marker. The headless session is already torn down by terminal time, so the + * context needed to tag `RAMPS_TRANSACTION_FAILED` (which `ramp_surface`, which + * `region`) must be carried from order-creation to the terminal event. This + * module is that carrier: `useTransakRouting` writes the context when a + * headless order is confirmed, and `handleOrderStatusChangedForMetrics` reads it + * when the order later fails. + * + * THIS IS OPTION B (in-memory, same-session only). The map is module state, so + * it is EMPTY after an app relaunch. Consequence: a headless order that fails + * AFTER the app has been closed and reopened is NOT tagged - the handler + * finds no entry and no-ops (rather than emitting an untagged/mis-tagged + * event). Same-session terminal failures are fully covered. + * + * TODO(Plan A): to also cover the app-closed / late-failure case, replace this + * module Map with a persisted redux slice. A new top-level reducer key + * auto-persists via the root blacklist in `app/store/persistConfig` (no + * allowlist add, no migration). Because it would persist by default and is only + * cleaned on a terminal status that may never arrive (stuck-Pending, + * `removeOrder` without a terminal event), Plan A MUST add an eviction policy: + * a per-entry `createdAt` + GC-on-write evicting entries older than N days + * (mirror `sessionRegistry`'s `STALE_SESSION_TTL_MS`) plus a size cap. See the + * plan doc (`tram-3623-wire-transaction-failed.md`). + */ +export interface HeadlessOrderContext { + rampSurface?: RampSurface; + region: string; +} + +/** + * Keyed by `extractOrderCode(providerOrderId)`. The key is normalized via + * `extractOrderCode` on BOTH set and get so a write of a full path (e.g. + * `/providers/transak/orders/abc-123`) and a later read of the bare code + * (`abc-123`, the form the RampsController stores and the handler receives) + * resolve to the same entry. + */ +const headlessOrderContexts = new Map(); + +/** + * Stores the headless context for an order. Called from the headless-gated + * CONFIRMED branches in `useTransakRouting`. + * + * @param providerOrderId - The order id (full path or bare code); normalized. + * @param context - The headless context to carry to the terminal event. + */ +export function setHeadlessOrderContext( + providerOrderId: string, + context: HeadlessOrderContext, +): void { + headlessOrderContexts.set(extractOrderCode(providerOrderId), context); +} + +/** + * Looks up the headless context for an order. Returns `undefined` when the + * order was not headless (or after an app relaunch under Option B). + * + * @param providerOrderId - The order id (full path or bare code); normalized. + * @returns The stored context, or `undefined` when absent. + */ +export function getHeadlessOrderContext( + providerOrderId: string, +): HeadlessOrderContext | undefined { + return headlessOrderContexts.get(extractOrderCode(providerOrderId)); +} + +/** + * Removes the headless context for an order. Called by + * `handleOrderStatusChangedForMetrics` on every terminal status of a headless + * order (after emitting on failure, and on Completed/Cancelled) so the map does + * not grow within a session. + * + * @param providerOrderId - The order id (full path or bare code); normalized. + */ +export function deleteHeadlessOrderContext(providerOrderId: string): void { + headlessOrderContexts.delete(extractOrderCode(providerOrderId)); +} + +/** + * Test-only helper. Clears the registry between tests so entries do not leak + * from one test into another. + */ +export function __resetHeadlessOrderContextRegistryForTests(): void { + headlessOrderContexts.clear(); +} diff --git a/app/core/Engine/controllers/transaction-controller/metrics_properties/base.test.ts b/app/core/Engine/controllers/transaction-controller/metrics_properties/base.test.ts index 1df3ef755da..c55391713ba 100644 --- a/app/core/Engine/controllers/transaction-controller/metrics_properties/base.test.ts +++ b/app/core/Engine/controllers/transaction-controller/metrics_properties/base.test.ts @@ -151,4 +151,30 @@ describe('getTransactionTypeValue', () => { ])('returns %s for standalone relay deposit type %s', (expected, txType) => { expect(getTransactionTypeValue(txType)).toBe(expected); }); + + it.each([ + [ + 'standalone predictDepositAndOrder', + { + type: TransactionType.predictDepositAndOrder, + } as TransactionMeta, + ], + [ + 'PWAT batch with nested predictDepositAndOrder', + { + type: TransactionType.batch, + nestedTransactions: [ + { type: TransactionType.tokenMethodApprove }, + { type: TransactionType.predictDepositAndOrder }, + ], + } as TransactionMeta, + ], + ])( + 'returns predict_deposit_and_order for %s', + (_name, mockTransactionMeta) => { + expect( + getTransactionTypeValue(mockTransactionMeta.type, mockTransactionMeta), + ).toBe('predict_deposit_and_order'); + }, + ); }); diff --git a/app/core/Engine/controllers/transaction-controller/metrics_properties/base.ts b/app/core/Engine/controllers/transaction-controller/metrics_properties/base.ts index f3892d459c0..4769590b833 100644 --- a/app/core/Engine/controllers/transaction-controller/metrics_properties/base.ts +++ b/app/core/Engine/controllers/transaction-controller/metrics_properties/base.ts @@ -62,6 +62,14 @@ export function getTransactionTypeValue( return 'predict_deposit'; } + if ( + hasTransactionType(transactionMeta, [ + TransactionType.predictDepositAndOrder, + ]) + ) { + return 'predict_deposit_and_order'; + } + if (hasTransactionType(transactionMeta, [TransactionType.predictWithdraw])) { return 'predict_withdraw'; } diff --git a/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.test.ts b/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.test.ts index d849fea411d..b62cca36a42 100644 --- a/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.test.ts +++ b/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.test.ts @@ -482,6 +482,38 @@ describe('Metamask Pay Metrics', () => { }); }); + it('sets polymarket_account_created as true for PWAT deposit-and-order with matching nested transaction', () => { + request.transactionMeta.nestedTransactions = [ + { type: TransactionType.predictDepositAndOrder }, + { data: '0xa1884d2c1234' }, + ]; + + const result = getMetaMaskPayProperties(request); + + expect(result).toStrictEqual({ + properties: { + polymarket_account_created: true, + }, + sensitiveProperties: {}, + }); + }); + + it('sets polymarket_account_created as false for PWAT deposit-and-order with no matching nested transaction', () => { + request.transactionMeta.nestedTransactions = [ + { type: TransactionType.predictDepositAndOrder }, + { data: '0xa1884d2d' }, + ]; + + const result = getMetaMaskPayProperties(request); + + expect(result).toStrictEqual({ + properties: { + polymarket_account_created: false, + }, + sensitiveProperties: {}, + }); + }); + it('derives base properties from metamaskPay metadata', () => { request.transactionMeta.metamaskPay = { chainId: '0x3', @@ -643,6 +675,35 @@ describe('Metamask Pay Metrics', () => { }); }); + it('derives mm_pay and mm_pay_use_case for PWAT batch with nested predictDepositAndOrder', () => { + request.transactionMeta.type = TransactionType.batch; + request.transactionMeta.nestedTransactions = [ + { type: TransactionType.predictDepositAndOrder }, + ]; + request.transactionMeta.metamaskPay = { + chainId: '0x89', + tokenAddress: '0x123', + }; + + getStateMock.mockReturnValue({ + engine: { + backgroundState: { + TokensController: { allTokens: {} }, + }, + }, + } as never); + + const result = getMetaMaskPayProperties(request); + + expect(result).toStrictEqual({ + properties: expect.objectContaining({ + mm_pay: true, + mm_pay_use_case: 'predict_deposit_and_order', + }), + sensitiveProperties: {}, + }); + }); + it('derives fee and value properties from TransactionPayController state', () => { request.transactionMeta.type = TransactionType.predictWithdraw; request.transactionMeta.metamaskPay = { diff --git a/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.ts b/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.ts index 11e090c0531..edb6aadb6fc 100644 --- a/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.ts +++ b/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.ts @@ -25,12 +25,14 @@ const PAY_TYPES = [ TransactionType.perpsDeposit, TransactionType.perpsWithdraw, TransactionType.predictDeposit, + TransactionType.predictDepositAndOrder, TransactionType.predictWithdraw, ]; const USE_CASE_MAP: [TransactionType[], string][] = [ [[TransactionType.predictWithdraw], 'predict_withdraw'], [[TransactionType.predictDeposit], 'predict_deposit'], + [[TransactionType.predictDepositAndOrder], 'predict_deposit_and_order'], [[TransactionType.perpsDeposit], 'perps_deposit'], [[TransactionType.perpsWithdraw], 'perps_withdraw'], [[TransactionType.moneyAccountDeposit], 'money_account_deposit'], @@ -52,7 +54,12 @@ export const getMetaMaskPayProperties: TransactionMetricsBuilder = ({ tx.requiredTransactionIds?.includes(transactionId), ); - if (hasTransactionType(transactionMeta, [TransactionType.predictDeposit])) { + if ( + hasTransactionType(transactionMeta, [ + TransactionType.predictDeposit, + TransactionType.predictDepositAndOrder, + ]) + ) { properties.polymarket_account_created = ( transactionMeta?.nestedTransactions ?? [] ).some((t) => t.data?.startsWith(FOUR_BYTE_SAFE_PROXY_CREATE)); diff --git a/app/core/Engine/controllers/transaction-controller/transaction-controller-init.test.ts b/app/core/Engine/controllers/transaction-controller/transaction-controller-init.test.ts index 145061fa8e8..84c9c43c8f8 100644 --- a/app/core/Engine/controllers/transaction-controller/transaction-controller-init.test.ts +++ b/app/core/Engine/controllers/transaction-controller/transaction-controller-init.test.ts @@ -250,10 +250,7 @@ describe('Transaction Controller Init', () => { slippage: 0.005, stxDisabled: false, enableDepositWalletWithdraw: false, - enablePerpsMoneyAccountTransactions: false, - enablePredictMoneyAccountTransactions: false, - enableMoneyHomePagePerpsTransaction: false, - enableMoneyHomePagePredictTransaction: false, + enableMoneyAccountTransactions: {}, }); payHookClassMock.mockReturnValue({ @@ -410,10 +407,7 @@ describe('Transaction Controller Init', () => { slippage: 0.005, stxDisabled: true, enableDepositWalletWithdraw: false, - enablePerpsMoneyAccountTransactions: false, - enablePredictMoneyAccountTransactions: false, - enableMoneyHomePagePerpsTransaction: false, - enableMoneyHomePagePredictTransaction: false, + enableMoneyAccountTransactions: {}, }); const hooks = testConstructorOption('hooks'); @@ -432,10 +426,7 @@ describe('Transaction Controller Init', () => { slippage: 0.005, stxDisabled: false, enableDepositWalletWithdraw: false, - enablePerpsMoneyAccountTransactions: false, - enablePredictMoneyAccountTransactions: false, - enableMoneyHomePagePerpsTransaction: false, - enableMoneyHomePagePredictTransaction: false, + enableMoneyAccountTransactions: {}, }); const hooks = testConstructorOption('hooks'); diff --git a/app/core/Engine/messengers/rewards-controller-messenger/index.ts b/app/core/Engine/messengers/rewards-controller-messenger/index.ts index d4ea782a053..e88695a0e31 100644 --- a/app/core/Engine/messengers/rewards-controller-messenger/index.ts +++ b/app/core/Engine/messengers/rewards-controller-messenger/index.ts @@ -221,6 +221,7 @@ export function getRewardsControllerMessenger( 'RewardsDataService:getDefaultRewardsEnvUrl', 'RewardsDataService:getBenefits', 'RewardsDataService:getVIPDashboard', + 'RewardsDataService:getVipRefereeDashboard', 'RewardsDataService:getVipFees', 'RewardsDataService:postBenefitImpression', 'RewardsDataService:getClientVersionRequirements', diff --git a/app/selectors/featureFlagController/confirmations/index.test.ts b/app/selectors/featureFlagController/confirmations/index.test.ts index d05951bb805..3240313a4a9 100644 --- a/app/selectors/featureFlagController/confirmations/index.test.ts +++ b/app/selectors/featureFlagController/confirmations/index.test.ts @@ -17,10 +17,7 @@ import { PAY_FIAT_MAX_DELAY_MINUTES_FOR_PAYMENT_METHODS, selectMetaMaskPayHardwareFlags, PAY_ENABLE_DEPOSIT_WALLET_WITHDRAW_DEFAULT, - PAY_ENABLE_PERPS_MONEY_ACCOUNT_TRANSACTIONS_DEFAULT, - PAY_ENABLE_PREDICT_MONEY_ACCOUNT_TRANSACTIONS_DEFAULT, - PAY_ENABLE_MONEY_HOME_PAGE_PERPS_TRANSACTION_DEFAULT, - PAY_ENABLE_MONEY_HOME_PAGE_PREDICT_TRANSACTION_DEFAULT, + PAY_ENABLE_MONEY_ACCOUNT_TRANSACTIONS_DEFAULT, PAY_DEFAULT_PAY_SELECTED_SECTION_DEFAULT, PAY_HARDWARE_ENABLED_DEFAULT, PreferredToken, @@ -590,123 +587,35 @@ describe('selectMetaMaskPayFlags extended flags', () => { ); }); - it('returns default enablePerpsMoneyAccountTransactions when flag is absent', () => { + it('returns default enableMoneyAccountTransactions when flag is absent', () => { const state = cloneDeep(mockedEmptyFlagsState); expect( - selectMetaMaskPayFlags(state).enablePerpsMoneyAccountTransactions, - ).toEqual(PAY_ENABLE_PERPS_MONEY_ACCOUNT_TRANSACTIONS_DEFAULT); + selectMetaMaskPayFlags(state).enableMoneyAccountTransactions, + ).toEqual(PAY_ENABLE_MONEY_ACCOUNT_TRANSACTIONS_DEFAULT); }); - it('returns enablePerpsMoneyAccountTransactions from flag value', () => { + it('returns enableMoneyAccountTransactions from flag value', () => { const state = cloneDeep(mockedEmptyFlagsState); state.engine.backgroundState.RemoteFeatureFlagController.remoteFeatureFlags = { confirmations_pay_extended: { - enablePerpsMoneyAccountTransactions: true, - }, - }; - - expect( - selectMetaMaskPayFlags(state).enablePerpsMoneyAccountTransactions, - ).toBe(true); - }); - - it('returns default enablePredictMoneyAccountTransactions when flag is absent', () => { - const state = cloneDeep(mockedEmptyFlagsState); - - expect( - selectMetaMaskPayFlags(state).enablePredictMoneyAccountTransactions, - ).toEqual(PAY_ENABLE_PREDICT_MONEY_ACCOUNT_TRANSACTIONS_DEFAULT); - }); - - it('returns enablePredictMoneyAccountTransactions from flag value', () => { - const state = cloneDeep(mockedEmptyFlagsState); - state.engine.backgroundState.RemoteFeatureFlagController.remoteFeatureFlags = - { - confirmations_pay_extended: { - enablePredictMoneyAccountTransactions: true, + enableMoneyAccountTransactions: { + perpsDeposit: true, + perpsWithdraw: true, + predictDeposit: true, + predictWithdraw: true, + }, }, }; expect( - selectMetaMaskPayFlags(state).enablePredictMoneyAccountTransactions, - ).toBe(true); - }); - - describe('enableMoneyHomePagePerpsTransaction', () => { - it('returns default (false) when remote flag is absent', () => { - const state = cloneDeep(mockedEmptyFlagsState); - - expect( - selectMetaMaskPayFlags(state).enableMoneyHomePagePerpsTransaction, - ).toEqual(PAY_ENABLE_MONEY_HOME_PAGE_PERPS_TRANSACTION_DEFAULT); - }); - - it('returns true when remote flag is true', () => { - const state = cloneDeep(mockedEmptyFlagsState); - state.engine.backgroundState.RemoteFeatureFlagController.remoteFeatureFlags = - { - confirmations_pay_extended: { - enablePerpsMoneyAccountTransactions: true, - }, - }; - - expect( - selectMetaMaskPayFlags(state).enableMoneyHomePagePerpsTransaction, - ).toBe(true); - }); - - it('returns false when remote flag is false', () => { - const state = cloneDeep(mockedEmptyFlagsState); - state.engine.backgroundState.RemoteFeatureFlagController.remoteFeatureFlags = - { - confirmations_pay_extended: { - enablePerpsMoneyAccountTransactions: false, - }, - }; - - expect( - selectMetaMaskPayFlags(state).enableMoneyHomePagePerpsTransaction, - ).toBe(false); - }); - }); - - describe('enableMoneyHomePagePredictTransaction', () => { - it('returns default (false) when remote flag is absent', () => { - const state = cloneDeep(mockedEmptyFlagsState); - - expect( - selectMetaMaskPayFlags(state).enableMoneyHomePagePredictTransaction, - ).toEqual(PAY_ENABLE_MONEY_HOME_PAGE_PREDICT_TRANSACTION_DEFAULT); - }); - - it('returns true when remote flag is true', () => { - const state = cloneDeep(mockedEmptyFlagsState); - state.engine.backgroundState.RemoteFeatureFlagController.remoteFeatureFlags = - { - confirmations_pay_extended: { - enablePredictMoneyAccountTransactions: true, - }, - }; - - expect( - selectMetaMaskPayFlags(state).enableMoneyHomePagePredictTransaction, - ).toBe(true); - }); - - it('returns false when remote flag is false', () => { - const state = cloneDeep(mockedEmptyFlagsState); - state.engine.backgroundState.RemoteFeatureFlagController.remoteFeatureFlags = - { - confirmations_pay_extended: { - enablePredictMoneyAccountTransactions: false, - }, - }; - - expect( - selectMetaMaskPayFlags(state).enableMoneyHomePagePredictTransaction, - ).toBe(false); + selectMetaMaskPayFlags(state).enableMoneyAccountTransactions, + ).toEqual({ + perpsDeposit: true, + perpsWithdraw: true, + predictDeposit: true, + predictWithdraw: true, }); }); diff --git a/app/selectors/featureFlagController/confirmations/index.ts b/app/selectors/featureFlagController/confirmations/index.ts index 80a61b90770..a83395deff9 100644 --- a/app/selectors/featureFlagController/confirmations/index.ts +++ b/app/selectors/featureFlagController/confirmations/index.ts @@ -16,10 +16,10 @@ export const PAY_FIAT_ENABLED_TRANSACTION_TYPES = []; export const PAY_FIAT_MAX_DELAY_MINUTES_FOR_PAYMENT_METHODS = 10; export const PAY_HARDWARE_ENABLED_DEFAULT = false; export const PAY_ENABLE_DEPOSIT_WALLET_WITHDRAW_DEFAULT = false; -export const PAY_ENABLE_PERPS_MONEY_ACCOUNT_TRANSACTIONS_DEFAULT = false; -export const PAY_ENABLE_PREDICT_MONEY_ACCOUNT_TRANSACTIONS_DEFAULT = false; -export const PAY_ENABLE_MONEY_HOME_PAGE_PERPS_TRANSACTION_DEFAULT = false; -export const PAY_ENABLE_MONEY_HOME_PAGE_PREDICT_TRANSACTION_DEFAULT = false; +export const PAY_ENABLE_MONEY_ACCOUNT_TRANSACTIONS_DEFAULT: Record< + string, + boolean +> = {}; export const PAY_DEFAULT_PAY_SELECTED_SECTION_DEFAULT: string | undefined = undefined; export const SLIPPAGE_DEFAULT = 0.005; @@ -62,10 +62,7 @@ export interface MetaMaskPayFlags { export interface MetaMaskPayExtendedFlags { enableDepositWalletWithdraw: boolean; - enablePerpsMoneyAccountTransactions: boolean; - enablePredictMoneyAccountTransactions: boolean; - enableMoneyHomePagePerpsTransaction: boolean; - enableMoneyHomePagePredictTransaction: boolean; + enableMoneyAccountTransactions: Record; defaultPaySelectedSection?: string; } @@ -140,21 +137,11 @@ export const selectMetaMaskPayFlags = createSelector( (metaMaskPayExtendedFlags?.enableDepositWalletWithdraw as boolean) ?? PAY_ENABLE_DEPOSIT_WALLET_WITHDRAW_DEFAULT; - const enablePerpsMoneyAccountTransactions = - (metaMaskPayExtendedFlags?.enablePerpsMoneyAccountTransactions as boolean) ?? - PAY_ENABLE_PERPS_MONEY_ACCOUNT_TRANSACTIONS_DEFAULT; - - const enablePredictMoneyAccountTransactions = - (metaMaskPayExtendedFlags?.enablePredictMoneyAccountTransactions as boolean) ?? - PAY_ENABLE_PREDICT_MONEY_ACCOUNT_TRANSACTIONS_DEFAULT; - - const enableMoneyHomePagePerpsTransaction = - (metaMaskPayExtendedFlags?.enablePerpsMoneyAccountTransactions as boolean) ?? - PAY_ENABLE_MONEY_HOME_PAGE_PERPS_TRANSACTION_DEFAULT; - - const enableMoneyHomePagePredictTransaction = - (metaMaskPayExtendedFlags?.enablePredictMoneyAccountTransactions as boolean) ?? - PAY_ENABLE_MONEY_HOME_PAGE_PREDICT_TRANSACTION_DEFAULT; + const enableMoneyAccountTransactions = + (metaMaskPayExtendedFlags?.enableMoneyAccountTransactions as Record< + string, + boolean + >) ?? PAY_ENABLE_MONEY_ACCOUNT_TRANSACTIONS_DEFAULT; const defaultPaySelectedSection = (metaMaskPayExtendedFlags?.defaultPaySelectedSection as string) ?? @@ -168,10 +155,7 @@ export const selectMetaMaskPayFlags = createSelector( slippage, stxDisabled, enableDepositWalletWithdraw, - enablePerpsMoneyAccountTransactions, - enablePredictMoneyAccountTransactions, - enableMoneyHomePagePerpsTransaction, - enableMoneyHomePagePredictTransaction, + enableMoneyAccountTransactions, defaultPaySelectedSection, }; }, diff --git a/app/util/transactions/hooks/index.test.ts b/app/util/transactions/hooks/index.test.ts index fce4ac6b94b..199f2c9f529 100644 --- a/app/util/transactions/hooks/index.test.ts +++ b/app/util/transactions/hooks/index.test.ts @@ -125,10 +125,7 @@ describe('getTransactionControllerHooks', () => { bufferStep: 0.025, bufferSubsequent: 0.05, enableDepositWalletWithdraw: false, - enableMoneyHomePagePerpsTransaction: false, - enableMoneyHomePagePredictTransaction: false, - enablePredictMoneyAccountTransactions: false, - enablePerpsMoneyAccountTransactions: false, + enableMoneyAccountTransactions: {}, slippage: 0.005, stxDisabled: false, }); diff --git a/fingerprint.config.js b/fingerprint.config.js index 84ebfd0f722..343185bbe09 100644 --- a/fingerprint.config.js +++ b/fingerprint.config.js @@ -81,6 +81,11 @@ const config = { filePath: './scripts/build.sh', reasons: ['Detect build configuration changes.'], }, + { + type: 'dir', + filePath: './scripts/inpage-bridge', + reasons: ['Detect inpage provider changes bundled into native apps.'], + }, { type: 'file', filePath: './scripts/setup.mjs', diff --git a/locales/languages/de.json b/locales/languages/de.json index c923dc2a9db..090da3370dd 100644 --- a/locales/languages/de.json +++ b/locales/languages/de.json @@ -2333,7 +2333,6 @@ "home": { "live_now_title": "Jetzt live", "categories_placeholder": "Kategorien", - "popular_today_placeholder": "Heute beliebt", "trending_placeholder": "Angesagt" }, "homepage_discovery": { diff --git a/locales/languages/el.json b/locales/languages/el.json index f4b76e6580d..b98551668a0 100644 --- a/locales/languages/el.json +++ b/locales/languages/el.json @@ -2333,7 +2333,6 @@ "home": { "live_now_title": "Ζωντανά τώρα", "categories_placeholder": "Κατηγορίες", - "popular_today_placeholder": "Δημοφιλή σήμερα", "trending_placeholder": "Δημοφιλή" }, "homepage_discovery": { diff --git a/locales/languages/en.json b/locales/languages/en.json index 1299c8bd920..724fa475ec7 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -2339,14 +2339,12 @@ "home": { "live_now_title": "Live now", "categories_placeholder": "Categories", - "popular_today_placeholder": "Popular today", "trending_placeholder": "Trending" }, "homepage_discovery": { "btc_title": "BTC Price: {{price}}", "btc_price_to_beat": "Price to beat: {{price}}", "btc_live": "Live", - "nba_2026_champion_title": "NBA 2026 Champion", "fifa_world_cup_2026_title": "FIFA World Cup 2026", "events_in_total": "{{count}} events in total", "events_in_total_overflow": "{{count}}+ markets in total", @@ -7100,7 +7098,8 @@ "link_card_sheet_description_no_apy": "Link your card so you can spend your Money balance and earn mUSD back on purchases.", "link_card_sheet_cta": "Link card", "manage_card": "Manage", - "avail_balance": "Avail. balance" + "avail_balance": "Avail. balance", + "verification_pending": "Your identity verification is being reviewed. This typically takes less than 12 hours." }, "what_you_get": { "title": "What you get", @@ -7161,7 +7160,15 @@ "deposit_failed_body_convert": "Unable to convert. Try again.", "deposit_failed_body_add_musd": "Unable to add funds. Try again.", "withdraw_failed_title": "Transfer failed", - "withdraw_failed_body": "Unable to transfer funds. Try again." + "withdraw_failed_body": "Unable to transfer funds. Try again.", + "send_in_progress_title": "Sending funds", + "send_success_title": "Funds sent", + "send_success_body": "{{amount}} is available in {{destination}}.", + "send_success_body_no_amount": "Available in {{destination}}.", + "send_failed_title": "Send failed", + "send_failed_body": "Unable to send funds. Try again.", + "send_destination_perps": "Perps", + "send_destination_predict": "Predict" }, "apy_tooltip": { "title": "Annual Percentage Yield (APY)", @@ -8948,6 +8955,8 @@ "referee_perps_volume_label": "Perps volume", "referee_error_title": "We couldn’t load your VIP referee details", "referee_error_description": "Check your connection and try again.", + "referee_contact_support": "Contact support", + "referee_priority_support_disclaimer": "By contacting priority support you will connect your wallet.", "referral_tag_label": "VIP", "last_updated": "Last updated: {{time}}" }, diff --git a/locales/languages/es.json b/locales/languages/es.json index 3e496a025bf..84630cf2f4b 100644 --- a/locales/languages/es.json +++ b/locales/languages/es.json @@ -2333,7 +2333,6 @@ "home": { "live_now_title": "En vivo ahora", "categories_placeholder": "Categorías", - "popular_today_placeholder": "Populares hoy", "trending_placeholder": "Tendencias" }, "homepage_discovery": { diff --git a/locales/languages/fr.json b/locales/languages/fr.json index 3bfba2eea14..616ce346227 100644 --- a/locales/languages/fr.json +++ b/locales/languages/fr.json @@ -2333,7 +2333,6 @@ "home": { "live_now_title": "Disponible dès maintenant", "categories_placeholder": "Catégories", - "popular_today_placeholder": "Populaires aujourd’hui", "trending_placeholder": "Tendance" }, "homepage_discovery": { diff --git a/locales/languages/hi.json b/locales/languages/hi.json index fba9852f7f0..a3a19bb8824 100644 --- a/locales/languages/hi.json +++ b/locales/languages/hi.json @@ -2333,7 +2333,6 @@ "home": { "live_now_title": "अब लाइव है", "categories_placeholder": "कैटेगरीज़", - "popular_today_placeholder": "आजकल लोकप्रिय", "trending_placeholder": "ट्रेंडिंग" }, "homepage_discovery": { diff --git a/locales/languages/id.json b/locales/languages/id.json index 0d5371fe431..0d34fa0b793 100644 --- a/locales/languages/id.json +++ b/locales/languages/id.json @@ -2333,7 +2333,6 @@ "home": { "live_now_title": "Sedang live", "categories_placeholder": "Kategori", - "popular_today_placeholder": "Populer saat ini", "trending_placeholder": "Sedang tren" }, "homepage_discovery": { diff --git a/locales/languages/ja.json b/locales/languages/ja.json index f9712a338df..836747ed120 100644 --- a/locales/languages/ja.json +++ b/locales/languages/ja.json @@ -2333,7 +2333,6 @@ "home": { "live_now_title": "現在進行中", "categories_placeholder": "Categories", - "popular_today_placeholder": "Popular today", "trending_placeholder": "人気上昇中" }, "homepage_discovery": { diff --git a/locales/languages/ko.json b/locales/languages/ko.json index a6f41cf9e2c..6eb62c72c20 100644 --- a/locales/languages/ko.json +++ b/locales/languages/ko.json @@ -2333,7 +2333,6 @@ "home": { "live_now_title": "지금 진행 중", "categories_placeholder": "카테고리", - "popular_today_placeholder": "오늘 인기 항목", "trending_placeholder": "인기" }, "homepage_discovery": { diff --git a/locales/languages/pt.json b/locales/languages/pt.json index c59e5b8b61a..182a987839f 100644 --- a/locales/languages/pt.json +++ b/locales/languages/pt.json @@ -2333,7 +2333,6 @@ "home": { "live_now_title": "Ao vivo agora", "categories_placeholder": "Categorias", - "popular_today_placeholder": "Popular hoje", "trending_placeholder": "Tendência" }, "homepage_discovery": { diff --git a/locales/languages/ru.json b/locales/languages/ru.json index 8d20be70b6b..d12f54d676e 100644 --- a/locales/languages/ru.json +++ b/locales/languages/ru.json @@ -2333,7 +2333,6 @@ "home": { "live_now_title": "Уже активно", "categories_placeholder": "Категории", - "popular_today_placeholder": "Популярно сегодня", "trending_placeholder": "Популярное" }, "homepage_discovery": { diff --git a/locales/languages/tl.json b/locales/languages/tl.json index 2b811b611ff..1dc79f1e181 100644 --- a/locales/languages/tl.json +++ b/locales/languages/tl.json @@ -2333,7 +2333,6 @@ "home": { "live_now_title": "Live ngayon", "categories_placeholder": "Mga Kategorya", - "popular_today_placeholder": "Sikat ngayon", "trending_placeholder": "Nagte-trend" }, "homepage_discovery": { diff --git a/locales/languages/tr.json b/locales/languages/tr.json index 8cf4975e1ea..c9e3b0d9833 100644 --- a/locales/languages/tr.json +++ b/locales/languages/tr.json @@ -2333,7 +2333,6 @@ "home": { "live_now_title": "Şimdi canlı", "categories_placeholder": "Kategoriler", - "popular_today_placeholder": "Bugün popüler", "trending_placeholder": "Trend" }, "homepage_discovery": { diff --git a/locales/languages/vi.json b/locales/languages/vi.json index f7f6fe57d06..da3fde37ef2 100644 --- a/locales/languages/vi.json +++ b/locales/languages/vi.json @@ -2333,7 +2333,6 @@ "home": { "live_now_title": "Đang diễn ra", "categories_placeholder": "Danh mục", - "popular_today_placeholder": "Phổ biến hôm nay", "trending_placeholder": "Xu hướng" }, "homepage_discovery": { diff --git a/locales/languages/zh.json b/locales/languages/zh.json index 6e155365015..e5197c219c3 100644 --- a/locales/languages/zh.json +++ b/locales/languages/zh.json @@ -2333,7 +2333,6 @@ "home": { "live_now_title": "现已上线", "categories_placeholder": "类别", - "popular_today_placeholder": "今日热门", "trending_placeholder": "趋势" }, "homepage_discovery": { diff --git a/package.json b/package.json index 8e1e3b7d45d..11d1c630797 100644 --- a/package.json +++ b/package.json @@ -246,6 +246,7 @@ "@metamask/authenticated-user-storage": "^2.0.0", "@metamask/base-controller": "^9.0.1", "@metamask/bitcoin-wallet-snap": "^1.12.0", + "@metamask/bitcoin-wallet-standard": "^1.0.0", "@metamask/bridge-controller": "^75.1.0", "@metamask/bridge-status-controller": "^72.1.0", "@metamask/chain-agnostic-permission": "^1.5.0", @@ -299,7 +300,7 @@ "@metamask/money-account-controller": "^0.2.0", "@metamask/money-account-upgrade-controller": "^2.0.5", "@metamask/multichain-account-service": "^10.0.2", - "@metamask/multichain-api-client": "^0.10.1", + "@metamask/multichain-api-client": "^0.11.0", "@metamask/multichain-api-middleware": "^3.1.1", "@metamask/multichain-network-controller": "^3.1.0", "@metamask/multichain-transactions-controller": "^7.1.0", @@ -578,6 +579,7 @@ "@metamask/providers": "^18.3.1", "@metamask/skills": "^0.1.0", "@metamask/test-dapp": "9.5.0", + "@metamask/test-dapp-bitcoin": "^0.2.0", "@metamask/test-dapp-multichain": "^0.17.1", "@metamask/test-dapp-solana": "^0.3.0", "@octokit/rest": "^21.0.0", diff --git a/scripts/inpage-bridge/src/bitcoinWalletStandard.js b/scripts/inpage-bridge/src/bitcoinWalletStandard.js new file mode 100644 index 00000000000..f40e3bb93e3 --- /dev/null +++ b/scripts/inpage-bridge/src/bitcoinWalletStandard.js @@ -0,0 +1,14 @@ +const { getMultichainClient, getWindowPostMessageTransport } = require('@metamask/multichain-api-client'); +const { registerBitcoinWalletStandard } = require('@metamask/bitcoin-wallet-standard'); + +const injectBitcoinWalletStandard = () => { + const multichainClient = getMultichainClient({ + transport: getWindowPostMessageTransport(), + }); + registerBitcoinWalletStandard({ + client: multichainClient, + walletName: process.env.METAMASK_BUILD_NAME, + }); +}; + +export default injectBitcoinWalletStandard; \ No newline at end of file diff --git a/scripts/inpage-bridge/src/index.js b/scripts/inpage-bridge/src/index.js index 59f842a5784..0a3d0ebe4b0 100644 --- a/scripts/inpage-bridge/src/index.js +++ b/scripts/inpage-bridge/src/index.js @@ -1,10 +1,12 @@ import injectInpageProvider from './provider'; import injectSolanaWalletStandard from './solanaWalletStandard'; +import injectBitcoinWalletStandard from './bitcoinWalletStandard'; if (shouldInject()) { injectInpageProvider(); injectSolanaWalletStandard(); + injectBitcoinWalletStandard(); start(); } diff --git a/tests/feature-flags/feature-flag-registry.ts b/tests/feature-flags/feature-flag-registry.ts index fa98cf005ac..0ef103cf04b 100644 --- a/tests/feature-flags/feature-flag-registry.ts +++ b/tests/feature-flags/feature-flag-registry.ts @@ -3996,17 +3996,6 @@ export const FEATURE_FLAG_REGISTRY: Record = { status: FeatureFlagStatus.Active, }, - predictHomepageDiscoveryNbaChampionEnabled: { - name: 'predictHomepageDiscoveryNbaChampionEnabled', - type: FeatureFlagType.Remote, - inProd: true, - productionDefault: { - enabled: false, - minimumVersion: '7.77.0', - }, - status: FeatureFlagStatus.Active, - }, - coreMCU747AbtestPredictPositionsEmptyState: { name: 'coreMCU747AbtestPredictPositionsEmptyState', type: FeatureFlagType.Remote, diff --git a/tests/flows/bitcoin-connection.flow.ts b/tests/flows/bitcoin-connection.flow.ts new file mode 100644 index 00000000000..f0b87aea010 --- /dev/null +++ b/tests/flows/bitcoin-connection.flow.ts @@ -0,0 +1,48 @@ +import BrowserView from '../page-objects/Browser/BrowserView'; +import ConnectBottomSheet from '../page-objects/Browser/ConnectBottomSheet'; +import ConnectedAccountsModal from '../page-objects/Browser/ConnectedAccountsModal'; +import BitcoinTestDApp from '../page-objects/Browser/BitcoinTestDapp'; +import Assertions from '../framework/Assertions'; +import { navigateToBrowserView } from './browser.flow'; + +export const account1Short = 'bc1q...yump'; + +export const signedMessageStandard = + '271486823211510961053671593213121630193816218985222159577512714825421410039180175314517251232893341221418210218699217951999213157247101787166209162102702122391448617217326133312849199212551951866224105551902392399522115638752383224419341608718511954214130102'; + +/** + * Connects the Bitcoin test dapp to the wallet. + * + * @param options.selectAllAccounts - Whether we connect with all accounts or only the default one + */ +export const connectBitcoinTestDapp = async ( + options: { + selectAllAccounts?: boolean; + assert?: () => Promise; + } = {}, +): Promise => { + const { selectAllAccounts, assert } = options; + + const header = BitcoinTestDApp.getHeader(); + await header.connect(); + await header.selectMetaMask(); + await header.selectStandard(); + + if (selectAllAccounts) { + await ConnectedAccountsModal.tapAccountListBottomSheet(); + await ConnectBottomSheet.tapSelectAllButton(); + await ConnectBottomSheet.tapAccountConnectMultiSelectButton(); + } + + if (assert) { + await assert(); + } + + await ConnectBottomSheet.tapConnectButton(); +}; + +export const navigateToBitcoinTestDApp = async (): Promise => { + await navigateToBrowserView(); + await Assertions.expectElementToBeVisible(BrowserView.browserScreenID); + await BitcoinTestDApp.navigateToBitcoinTestDApp(); +}; diff --git a/tests/flows/solana-connection.flow.ts b/tests/flows/solana-connection.flow.ts index 144930e1488..9443151c2d1 100644 --- a/tests/flows/solana-connection.flow.ts +++ b/tests/flows/solana-connection.flow.ts @@ -43,11 +43,3 @@ export const navigateToSolanaTestDApp = async (): Promise => { await Assertions.expectElementToBeVisible(BrowserView.browserScreenID); await SolanaTestDApp.navigateToSolanaTestDApp(); }; - -export const assertIsSignedTransaction = async (signedTransaction: string) => { - if (!/^.{88}$/.test(signedTransaction)) { - throw new Error( - `Signed transaction does not match regex: ${signedTransaction}`, - ); - } -}; diff --git a/tests/framework/Constants.ts b/tests/framework/Constants.ts index 332bf0765cf..d886a716225 100644 --- a/tests/framework/Constants.ts +++ b/tests/framework/Constants.ts @@ -87,6 +87,15 @@ export const DEFAULT_SOLANA_TEST_DAPP_PATH = path.join( 'dist', ); +export const DEFAULT_BITCOIN_TEST_DAPP_PATH = path.join( + '..', + '..', + 'node_modules', + '@metamask', + 'test-dapp-bitcoin', + 'dist', +); + export const DEFAULT_BROWSER_PLAYGROUND_PATH = path.join( '..', '..', @@ -121,6 +130,7 @@ export enum DappVariants { TEST_DAPP = 'test-dapp', MULTICHAIN_TEST_DAPP = 'multichain-test-dapp', SOLANA_TEST_DAPP = 'solana-test-dapp', + BITCOIN_TEST_DAPP = 'bitcoin-test-dapp', BROWSER_PLAYGROUND = 'browser-playground', } @@ -134,6 +144,9 @@ export const TestDapps = { [DappVariants.SOLANA_TEST_DAPP]: { dappPath: path.resolve(__dirname, DEFAULT_SOLANA_TEST_DAPP_PATH), }, + [DappVariants.BITCOIN_TEST_DAPP]: { + dappPath: path.resolve(__dirname, DEFAULT_BITCOIN_TEST_DAPP_PATH), + }, [DappVariants.BROWSER_PLAYGROUND]: { dappPath: path.resolve(__dirname, DEFAULT_BROWSER_PLAYGROUND_PATH), }, diff --git a/tests/framework/fixtures/FixtureHelper.ts b/tests/framework/fixtures/FixtureHelper.ts index bbbbb062711..e34d6f17d91 100644 --- a/tests/framework/fixtures/FixtureHelper.ts +++ b/tests/framework/fixtures/FixtureHelper.ts @@ -121,6 +121,17 @@ async function handleDapps( }), ); break; + case DappVariants.BITCOIN_TEST_DAPP: + dappServer.push( + new DappServer({ + dappCounter: i, + rootDirectory: + dapp.dappPath || + TestDapps[DappVariants.BITCOIN_TEST_DAPP].dappPath, + dappVariant: DappVariants.BITCOIN_TEST_DAPP, + }), + ); + break; case DappVariants.BROWSER_PLAYGROUND: dappServer.push( new DappServer({ diff --git a/tests/framework/services/providers/browserstack/BrowserStackConfigBuilder.ts b/tests/framework/services/providers/browserstack/BrowserStackConfigBuilder.ts index e4e33ce3160..5e655de6b5b 100644 --- a/tests/framework/services/providers/browserstack/BrowserStackConfigBuilder.ts +++ b/tests/framework/services/providers/browserstack/BrowserStackConfigBuilder.ts @@ -33,7 +33,7 @@ export class BrowserStackConfigBuilder { const appBsUrl = this.project.use.app?.buildPath; const device = this.project.use.device as BrowserStackConfig; const isLocal = process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true'; - const geoLocation = process.env.BROWSERSTACK_GEO_LOCATION || 'ES'; + const geoLocation = process.env.BROWSERSTACK_GEO_LOCATION || 'SE'; if (!appBsUrl) { throw new Error('BrowserStack app URL (buildPath) is required'); diff --git a/tests/page-objects/Browser/BitcoinTestDapp.ts b/tests/page-objects/Browser/BitcoinTestDapp.ts new file mode 100644 index 00000000000..5b0e31afc56 --- /dev/null +++ b/tests/page-objects/Browser/BitcoinTestDapp.ts @@ -0,0 +1,248 @@ +import { dataTestIds } from '@metamask/test-dapp-bitcoin'; +import { getDappUrl } from '../../framework/fixtures/FixtureUtils'; +import Matchers from '../../framework/Matchers'; +import { BrowserViewSelectorsIDs } from '../../../app/components/Views/BrowserTab/BrowserView.testIds'; +import Gestures from '../../framework/Gestures'; +import Browser from './BrowserView'; +import Utilities, { BASE_DEFAULTS } from '../../framework/Utilities'; +/** + * Get a test element by data-testid + * @param dataTestId - The data-testid of the element + * @param options.tag - The tag of the element having the data-testid attribute (e.g. 'div', 'input', etc.). Defaults to 'div' + * @param options.extraXPath - The extra xpath to the element (e.g. '/div/button'), useful for accessing elements we aren't able to assign a data-testid to + * @returns The test element + */ +function getTestElement( + dataTestId: string, + options: { extraXPath?: string; tag?: string } = {}, +): Promise { + const { tag = 'div', extraXPath = '' } = options; + const xpath = `//${tag}[@data-testid="${dataTestId}"]${extraXPath}`; + + return Matchers.getElementByXPath( + BrowserViewSelectorsIDs.BROWSER_WEBVIEW_ID, + xpath, + ); +} + +class BitcoinTestDapp { + get connectButtonSelector(): WebElement { + return getTestElement(dataTestIds.testPage.header.connect, { + tag: 'button', + }); + } + + get disconnectButtonSelector(): WebElement { + return getTestElement(dataTestIds.testPage.header.disconnect, { + tag: 'button', + }); + } + + get walletButtonSelector(): WebElement { + return getTestElement( + dataTestIds.testPage.walletSelectionModal.walletOption, + { + tag: 'button', + }, + ); + } + + get standardButtonSelector(): WebElement { + return getTestElement( + dataTestIds.testPage.walletSelectionModal.standardButton, + { + tag: 'button', + }, + ); + } + + get signMessageButtonSelector(): WebElement { + return getTestElement(dataTestIds.testPage.signMessage.signMessage, { + tag: 'button', + }); + } + + get confirmSignMessageButtonSelector(): WebElement { + return Matchers.getElementByText('Approve'); + } + + async navigateToBitcoinTestDApp(): Promise { + await Browser.tapUrlInputBox(); + + await Browser.navigateToURL(getDappUrl(0)); + + await waitFor(element(by.id(BrowserViewSelectorsIDs.BROWSER_WEBVIEW_ID))) + .toBeVisible() + .withTimeout(10000); + + await this.waitForDappLoaded(); + } + + async reloadBitcoinTestDApp(): Promise { + await Browser.reloadTab(); + await this.waitForDappLoaded(); + } + + /** + * Tap a button in the WebView + */ + async tapButton(webElement: WebElement): Promise { + await Utilities.executeWithRetry( + async () => { + // eslint-disable-next-line jest/valid-expect, @typescript-eslint/no-explicit-any + await (expect(await webElement) as any).toExist(); + await (await webElement).tap(); + }, + { + timeout: BASE_DEFAULTS.timeout, + description: 'Tap Bitcoin test dapp button', + }, + ); + } + + async waitForDappLoaded(): Promise { + await Utilities.executeWithRetry( + async () => { + await this.getHeader().getConnectionStatus(); + }, + { + timeout: 30_000, + description: 'Bitcoin test dapp to load', + }, + ); + } + + async waitForWalletOption(): Promise { + await Utilities.executeWithRetry( + async () => { + // eslint-disable-next-line jest/valid-expect, @typescript-eslint/no-explicit-any + await (expect(await this.walletButtonSelector) as any).toExist(); + }, + { + timeout: BASE_DEFAULTS.timeout, + description: 'Bitcoin test dapp wallet option to appear', + }, + ); + } + + async openWalletSelectionModal(): Promise { + await this.tapButton(this.connectButtonSelector); + + try { + await this.waitForWalletOption(); + } catch (error) { + await this.reloadBitcoinTestDApp(); + await this.tapButton(this.connectButtonSelector); + await this.waitForWalletOption(); + } + } + + getHeader() { + return { + connect: async () => { + await this.openWalletSelectionModal(); + }, + disconnect: async () => { + await this.tapButton(this.disconnectButtonSelector); + }, + selectMetaMask: async () => { + await this.tapButton(this.walletButtonSelector); + }, + selectStandard: async () => { + await this.tapButton(this.standardButtonSelector); + }, + getConnectionStatus: async () => { + const connectionStatusDiv = await getTestElement( + dataTestIds.testPage.header.connectionStatus, + ); + return await connectionStatusDiv.getText(); + }, + getAccount: async () => { + const account = await getTestElement( + dataTestIds.testPage.header.account, + { extraXPath: '/a' }, + ); + return await account.getText(); + }, + }; + } + + async signMessage(): Promise { + await this.tapButton(this.signMessageButtonSelector); + } + + async verifyConnectedAccount(connectionStatus: string): Promise { + await Utilities.executeWithRetry( + async () => { + const account = await getTestElement( + dataTestIds.testPage.header.account, + { + extraXPath: '/a', + }, + ); + const actualText = await account.getText(); + + if (actualText !== connectionStatus) { + throw new Error( + `Expected text containing "${connectionStatus}" but got "${actualText}"`, + ); + } + }, + { + timeout: BASE_DEFAULTS.timeout, + description: 'Verify connection status', + }, + ); + } + + async verifyConnectionStatus(connectionStatus: string): Promise { + await Utilities.executeWithRetry( + async () => { + const connectionStatusDiv = await getTestElement( + dataTestIds.testPage.header.connectionStatus, + ); + const actualText = await connectionStatusDiv.getText(); + + if (actualText !== connectionStatus) { + throw new Error( + `Expected text containing "${connectionStatus}" but got "${actualText}"`, + ); + } + }, + { + timeout: BASE_DEFAULTS.timeout, + description: 'Verify connection status', + }, + ); + } + + async verifySignedMessage(signedMessage: string): Promise { + await Utilities.executeWithRetry( + async () => { + const signedMessageElement = await getTestElement( + dataTestIds.testPage.signMessage.signedMessage, + { + tag: 'pre', + }, + ); + const actualText = await signedMessageElement.getText(); + + if (actualText !== signedMessage) { + throw new Error( + `Expected text containing "${signedMessage}" but got "${actualText}"`, + ); + } + }, + { + timeout: BASE_DEFAULTS.timeout, + description: 'Verify signed message', + }, + ); + } + + async confirmSignMessage(): Promise { + await Gestures.waitAndTap(this.confirmSignMessageButtonSelector); + } +} + +export default new BitcoinTestDapp(); diff --git a/tests/page-objects/wallet/AddNewAccountSheet.ts b/tests/page-objects/wallet/AddNewAccountSheet.ts deleted file mode 100644 index 1ed33397e04..00000000000 --- a/tests/page-objects/wallet/AddNewAccountSheet.ts +++ /dev/null @@ -1,15 +0,0 @@ -import Matchers from '../../framework/Matchers'; -import Gestures from '../../framework/Gestures'; -import { AddNewAccountIds } from '../../../app/components/Views/AddNewAccount/AddHdAccount.testIds'; - -class AddNewAccountSheet { - get confirmButton() { - return Matchers.getElementByID(AddNewAccountIds.CONFIRM); - } - - async tapConfirmButton() { - await Gestures.waitAndTap(this.confirmButton); - } -} - -export default new AddNewAccountSheet(); diff --git a/tests/page-objects/wallet/MultiSrp/AddAccountToSrp/AddNewHdAccountComponent.ts b/tests/page-objects/wallet/MultiSrp/AddAccountToSrp/AddNewHdAccountComponent.ts deleted file mode 100644 index 6e13ed7ac9b..00000000000 --- a/tests/page-objects/wallet/MultiSrp/AddAccountToSrp/AddNewHdAccountComponent.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { AddNewAccountIds } from '../../../../../app/components/Views/AddNewAccount/AddHdAccount.testIds'; -import Matchers from '../../../../framework/Matchers'; -import Gestures from '../../../../framework/Gestures'; -import { IndexableNativeElement } from 'detox/detox'; - -class AddNewHdAccountComponent { - get container() { - return Matchers.getElementByID(AddNewAccountIds.CONTAINER); - } - - get srpSelector() { - return Matchers.getElementByID(AddNewAccountIds.SRP_SELECTOR); - } - - get cancelButton() { - return Matchers.getElementByID(AddNewAccountIds.CANCEL); - } - - get confirmButton() { - return Matchers.getElementByID(AddNewAccountIds.CONFIRM); - } - - get nameInput() { - return Matchers.getElementByID(AddNewAccountIds.NAME_INPUT); - } - - async tapSrpSelector() { - await Gestures.waitAndTap(this.srpSelector); - } - - async tapCancel() { - await Gestures.waitAndTap(this.cancelButton); - } - - async tapConfirm() { - await Gestures.waitAndTap(this.confirmButton, { - elemDescription: 'Confirm button on Add New HD Account screen', - }); - } - - async enterName(accountName: string) { - await Gestures.typeText( - this.nameInput as Promise, - accountName, - { - elemDescription: 'Account name input field', - clearFirst: true, - hideKeyboard: true, - delay: 2000, - }, - ); - } -} - -export default new AddNewHdAccountComponent(); diff --git a/tests/performance/login/import-multiple-srps.spec.ts b/tests/performance/login/import-multiple-srps.spec.ts index 57893649e97..322d40423cd 100644 --- a/tests/performance/login/import-multiple-srps.spec.ts +++ b/tests/performance/login/import-multiple-srps.spec.ts @@ -11,8 +11,7 @@ import PlaywrightGestures from '../../framework/PlaywrightGestures'; /* Scenario 4: Import SRP with +50 accounts, SRP 1, SRP 2, SRP 3 */ perfTest.describe(PerformanceAccountList, () => { perfTest.setTimeout(30 * 60 * 1000); - perfTest.skip( - // skipped because of the account sync issue + perfTest( 'Import SRP with +50 accounts, SRP 1, SRP 2, SRP 3', { tag: '@accounts-team' }, async ({ currentDeviceDetails, driver, performanceTracker }) => { diff --git a/tests/performance/login/predict/predict-deposit.spec.ts b/tests/performance/login/predict/predict-deposit.spec.ts index 329e041224f..accdb932fc6 100644 --- a/tests/performance/login/predict/predict-deposit.spec.ts +++ b/tests/performance/login/predict/predict-deposit.spec.ts @@ -49,7 +49,7 @@ perfTest.describe(`${Performance} ${PerformancePredict}`, () => { // Timer 2: Open deposit screen const timer2 = new TimerHelper( 'Time since user taps Add Funds button until Predict Deposit screen is visible', - { ios: 1000, android: 5000 }, + { ios: 1000, android: 6000 }, currentDeviceDetails.platform, ); @@ -71,9 +71,6 @@ perfTest.describe(`${Performance} ${PerformancePredict}`, () => { await TransactionPayConfirmation.tapKeyboardContinueButton(); await timer4.measure(async () => { - await PlaywrightAssertions.expectElementToBeVisible( - asPlaywrightElement(TransactionPayConfirmation.total), - ); await PlaywrightAssertions.expectElementToBeVisible( asPlaywrightElement(TransactionPayConfirmation.transactionFee), ); diff --git a/tests/performance/onboarding/seedless-apple-onboarding.spec.ts b/tests/performance/onboarding/seedless-apple-onboarding.spec.ts index ee430e9cf48..5798de9a7ec 100644 --- a/tests/performance/onboarding/seedless-apple-onboarding.spec.ts +++ b/tests/performance/onboarding/seedless-apple-onboarding.spec.ts @@ -127,7 +127,11 @@ test.describe(`${Performance} ${System} ${PerformanceOnboarding}`, () => { await CreatePasswordView.reEnterPassword(password); await PlaywrightGestures.hideKeyboard(); - await CreatePasswordView.ensureMarketingOptInChecked(); + try { + await CreatePasswordView.ensureMarketingOptInChecked(); + } catch (error) { + console.error('Error ensuring marketing opt-in checked:', error); + } await PlaywrightGestures.hideKeyboard(); await CreatePasswordView.tapCreatePasswordButton(); diff --git a/tests/performance/onboarding/seedless-google-onboarding.spec.ts b/tests/performance/onboarding/seedless-google-onboarding.spec.ts index 330cd6d3611..e2e480d3e83 100644 --- a/tests/performance/onboarding/seedless-google-onboarding.spec.ts +++ b/tests/performance/onboarding/seedless-google-onboarding.spec.ts @@ -128,7 +128,11 @@ test.describe(`${Performance} ${System} ${PerformanceOnboarding}`, () => { await CreatePasswordView.enterPassword(password); await CreatePasswordView.reEnterPassword(password); await PlaywrightGestures.hideKeyboard(); - await CreatePasswordView.ensureMarketingOptInChecked(); + try { + await CreatePasswordView.ensureMarketingOptInChecked(); + } catch (error) { + console.error('Error ensuring marketing opt-in checked:', error); + } await CreatePasswordView.tapCreatePasswordButton(); await timer4.measure(async () => { diff --git a/tests/regression/multichain/permissions/accounts/permission-system-revoke-multiple.spec.ts b/tests/regression/multichain/permissions/accounts/permission-system-revoke-multiple.spec.ts index 7191f090412..4127f15318c 100644 --- a/tests/regression/multichain/permissions/accounts/permission-system-revoke-multiple.spec.ts +++ b/tests/regression/multichain/permissions/accounts/permission-system-revoke-multiple.spec.ts @@ -10,7 +10,6 @@ import FixtureBuilder from '../../../../framework/fixtures/FixtureBuilder'; import { withFixtures } from '../../../../framework/fixtures/FixtureHelper'; import Assertions from '../../../../framework/Assertions'; import { RegressionNetworkExpansion } from '../../../../tags'; -import AddNewAccountSheet from '../../../../page-objects/wallet/AddNewAccountSheet'; import { DappVariants } from '../../../../framework/Constants'; const AccountTwoText = 'Account 2'; @@ -49,9 +48,7 @@ describe(RegressionNetworkExpansion('Account Permission Management'), () => { ToastModal.notificationTitle, ); await ConnectedAccountsModal.tapConnectMoreAccountsButton(); - await AccountListBottomSheet.tapAddAccountButton(); - await AccountListBottomSheet.tapAddEthereumAccountButton(); - await AddNewAccountSheet.tapConfirmButton(); + await AccountListBottomSheet.tapAddAccountButtonV2(); if (device.getPlatform() === 'android') { await Assertions.expectTextDisplayed(AccountTwoText); } diff --git a/tests/selectors/Browser/BitcoinTestDapp.selectors.ts b/tests/selectors/Browser/BitcoinTestDapp.selectors.ts new file mode 100644 index 00000000000..fca0be23977 --- /dev/null +++ b/tests/selectors/Browser/BitcoinTestDapp.selectors.ts @@ -0,0 +1,7 @@ +/** + * IDs for web elements in the Bitcoin Test Dapp + */ +export interface BitcoinTestDappWebIDs { + WALLET_BUTTON: string; + CONFIRM_SIGN_MESSAGE_BUTTON: string; +} diff --git a/tests/smoke/multichain/btc-wallet-standard/connect.spec.ts b/tests/smoke/multichain/btc-wallet-standard/connect.spec.ts new file mode 100644 index 00000000000..5c2248abf4c --- /dev/null +++ b/tests/smoke/multichain/btc-wallet-standard/connect.spec.ts @@ -0,0 +1,74 @@ +import { SmokeNetworkExpansion } from '../../../tags'; +import BitcoinTestDapp from '../../../page-objects/Browser/BitcoinTestDapp'; +import { + account1Short, + connectBitcoinTestDapp, + navigateToBitcoinTestDApp, +} from '../../../flows/bitcoin-connection.flow'; +import { loginToApp } from '../../../flows/wallet.flow'; +import { withFixtures } from '../../../framework/fixtures/FixtureHelper'; +import FixtureBuilder from '../../../framework/fixtures/FixtureBuilder'; +import { DappVariants } from '../../../framework/Constants'; + +describe(SmokeNetworkExpansion('Bitcoin Wallet Standard E2E - Connect'), () => { + beforeAll(async () => { + jest.setTimeout(150000); + }); + + it('Connects & disconnects from Bitcoin test dapp', async () => { + await withFixtures( + { + fixture: new FixtureBuilder().build(), + restartDevice: true, + dapps: [ + { + dappVariant: DappVariants.BITCOIN_TEST_DAPP, + }, + ], + }, + async () => { + await loginToApp(); + await navigateToBitcoinTestDApp(); + + await connectBitcoinTestDapp(); + + // Check we're connected + await BitcoinTestDapp.verifyConnectedAccount(account1Short); + await BitcoinTestDapp.verifyConnectionStatus('Connected'); + + const header = BitcoinTestDapp.getHeader(); + await header.disconnect(); + + await BitcoinTestDapp.verifyConnectionStatus('Not connected'); + }, + ); + }); + + it('Stays connected after page refresh', async () => { + await withFixtures( + { + fixture: new FixtureBuilder().build(), + restartDevice: true, + dapps: [ + { + dappVariant: DappVariants.BITCOIN_TEST_DAPP, + }, + ], + }, + async () => { + await loginToApp(); + await navigateToBitcoinTestDApp(); + + await connectBitcoinTestDapp(); + + await BitcoinTestDapp.verifyConnectedAccount(account1Short); + await BitcoinTestDapp.verifyConnectionStatus('Connected'); + + await BitcoinTestDapp.reloadBitcoinTestDApp(); + + await BitcoinTestDapp.verifyConnectedAccount(account1Short); + await BitcoinTestDapp.verifyConnectionStatus('Connected'); + }, + ); + }); +}); diff --git a/tests/smoke/multichain/btc-wallet-standard/signMessage.spec.ts b/tests/smoke/multichain/btc-wallet-standard/signMessage.spec.ts new file mode 100644 index 00000000000..2ff51b0011e --- /dev/null +++ b/tests/smoke/multichain/btc-wallet-standard/signMessage.spec.ts @@ -0,0 +1,45 @@ +import { SmokeNetworkExpansion } from '../../../tags'; +import BitcoinTestDapp from '../../../page-objects/Browser/BitcoinTestDapp'; +import { + signedMessageStandard, + connectBitcoinTestDapp, + navigateToBitcoinTestDApp, +} from '../../../flows/bitcoin-connection.flow'; +import { loginToApp } from '../../../flows/wallet.flow'; +import { withFixtures } from '../../../framework/fixtures/FixtureHelper'; +import FixtureBuilder from '../../../framework/fixtures/FixtureBuilder'; +import { DappVariants } from '../../../framework/Constants'; + +describe( + SmokeNetworkExpansion('Bitcoin Wallet Standard E2E - Sign Message'), + () => { + beforeAll(async () => { + jest.setTimeout(150000); + }); + + it('Signs a message', async () => { + await withFixtures( + { + fixture: new FixtureBuilder().build(), + restartDevice: true, + dapps: [ + { + dappVariant: DappVariants.BITCOIN_TEST_DAPP, + }, + ], + }, + async () => { + await loginToApp(); + await navigateToBitcoinTestDApp(); + + await connectBitcoinTestDapp(); + + await BitcoinTestDapp.signMessage(); + await BitcoinTestDapp.confirmSignMessage(); + + await BitcoinTestDapp.verifySignedMessage(signedMessageStandard); + }, + ); + }); + }, +); diff --git a/tests/smoke/multichain/permissions/accounts/permission-system-update-permissions.failing.ts b/tests/smoke/multichain/permissions/accounts/permission-system-update-permissions.failing.ts index 4ed7c429913..ae91bb2ed4a 100644 --- a/tests/smoke/multichain/permissions/accounts/permission-system-update-permissions.failing.ts +++ b/tests/smoke/multichain/permissions/accounts/permission-system-update-permissions.failing.ts @@ -12,7 +12,6 @@ import { NetworkNonPemittedBottomSheetSelectorsText } from '../../../../../app/c import NetworkListModal from '../../../../page-objects/Network/NetworkListModal'; import ToastModal from '../../../../page-objects/wallet/ToastModal'; import AccountListBottomSheet from '../../../../page-objects/wallet/AccountListBottomSheet'; -import AddNewAccountSheet from '../../../../page-objects/wallet/AddNewAccountSheet'; import FixtureBuilder from '../../../../framework/fixtures/FixtureBuilder'; import { withFixtures } from '../../../../framework/fixtures/FixtureHelper'; import { DappVariants } from '../../../../framework/Constants'; @@ -119,9 +118,7 @@ describe(SmokeNetworkAbstractions('Chain Permission Management'), () => { // connect more accounts through the "connect more accounts" button await ConnectedAccountsModal.tapConnectMoreAccountsButton(); - await AccountListBottomSheet.tapAddAccountButton(); - await AccountListBottomSheet.tapAddEthereumAccountButton(); - await AddNewAccountSheet.tapConfirmButton(); + await AccountListBottomSheet.tapAddAccountButtonV2(); await Assertions.expectTextDisplayed(accountTwoText); await AccountListBottomSheet.tapAccountIndex(0); @@ -137,9 +134,7 @@ describe(SmokeNetworkAbstractions('Chain Permission Management'), () => { // create the third account await ConnectedAccountsModal.tapManagePermissionsButton(); await ConnectedAccountsModal.tapAccountListBottomSheet(); - await AccountListBottomSheet.tapAddAccountButton(); - await AccountListBottomSheet.tapAddEthereumAccountButton(); - await AddNewAccountSheet.tapConfirmButton(); + await AccountListBottomSheet.tapAddAccountButtonV2(); // connect the third account await AccountListBottomSheet.tapAccountIndex(0); // only the third account is not connected. diff --git a/yarn.lock b/yarn.lock index b91edf85ecb..159dd2ff0c5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3776,6 +3776,44 @@ __metadata: languageName: node linkType: hard +"@exodus/bitcoin-wallet-standard-chains@npm:^0.0.0": + version: 0.0.0 + resolution: "@exodus/bitcoin-wallet-standard-chains@npm:0.0.0" + dependencies: + "@wallet-standard/base": "npm:^1.0.1" + checksum: 10/1193d95599431af7e772eaa1967502871063b1025bfe43443b7c37c0de9fbd6225cf7977a68eef1a43302ede05f0f3a634f01c5e060b53b1e240e8e250a2f156 + languageName: node + linkType: hard + +"@exodus/bitcoin-wallet-standard-core@npm:^0.0.0": + version: 0.0.0 + resolution: "@exodus/bitcoin-wallet-standard-core@npm:0.0.0" + dependencies: + "@exodus/bitcoin-wallet-standard-chains": "npm:^0.0.0" + "@exodus/bitcoin-wallet-standard-features": "npm:^0.0.0" + checksum: 10/2294b086cba5b8eb0e0880c8e15f44d7c11bb08f314bbeacda50ee7cfbdc464e6aaefe8ab56b1ed527bc7bb4e83f94425e8ee8bd3c92c3c2e5729284a19c9a3f + languageName: node + linkType: hard + +"@exodus/bitcoin-wallet-standard-features@npm:^0.0.0": + version: 0.0.0 + resolution: "@exodus/bitcoin-wallet-standard-features@npm:0.0.0" + dependencies: + "@wallet-standard/base": "npm:^1.0.1" + "@wallet-standard/features": "npm:^1.0.3" + checksum: 10/877b9dd4965a6b3bd05c2e06e3af0f3a50467ceb4709a09164679a0e383dbf4817170b2c1b6fd1d1b42f70942fd2b4fcfc52929a8d434d232f333610d182beec + languageName: node + linkType: hard + +"@exodus/bitcoin-wallet-standard@npm:^0.0.0": + version: 0.0.0 + resolution: "@exodus/bitcoin-wallet-standard@npm:0.0.0" + dependencies: + "@exodus/bitcoin-wallet-standard-core": "npm:^0.0.0" + checksum: 10/d723cfc104145334eee7b63afa0698d984b15dc1aae9a3015eb92efd377d4fef765bc6c732582957a3bfa76b1bbac776b2a30384e84266c7ba49160d359361f6 + languageName: node + linkType: hard + "@expensify/react-native-wallet@npm:^0.1.15": version: 0.1.15 resolution: "@expensify/react-native-wallet@npm:0.1.15" @@ -8069,6 +8107,21 @@ __metadata: languageName: node linkType: hard +"@metamask/bitcoin-wallet-standard@npm:^1.0.0": + version: 1.0.0 + resolution: "@metamask/bitcoin-wallet-standard@npm:1.0.0" + dependencies: + "@exodus/bitcoin-wallet-standard": "npm:^0.0.0" + "@metamask/multichain-api-client": "npm:^0.11.0" + "@wallet-standard/base": "npm:^1.1.0" + "@wallet-standard/features": "npm:^1.1.0" + "@wallet-standard/wallet": "npm:^1.1.0" + bs58: "npm:^6.0.0" + jsontokens: "npm:^4.0.1" + checksum: 10/c0484f16aa4e290a47eab7b6a17afd08028a7b1d3d684b17114865b39f53120ef587aa854567659f5fac520bd2b1a5acf0d88e5528a73ea957e2399a2cb05f03 + languageName: node + linkType: hard + "@metamask/bridge-controller@npm:^75.0.0, @metamask/bridge-controller@npm:^75.1.0, @metamask/bridge-controller@npm:^75.1.1": version: 75.1.1 resolution: "@metamask/bridge-controller@npm:75.1.1" @@ -9218,10 +9271,10 @@ __metadata: languageName: node linkType: hard -"@metamask/multichain-api-client@npm:^0.10.1": - version: 0.10.1 - resolution: "@metamask/multichain-api-client@npm:0.10.1" - checksum: 10/adedb90f433cd619eee54943197dc32eb30e461173e928ea698ec70bf8c6780d9ac0bb9b12a22a17f984170841c071d228aa49e6988d5060e8a30e99c433a8f5 +"@metamask/multichain-api-client@npm:^0.11.0": + version: 0.11.0 + resolution: "@metamask/multichain-api-client@npm:0.11.0" + checksum: 10/fee3751b8bfdce08db37be205aa2d78de6544060ccbe57ff790ddf9c0aad830ee6cfb1844e4ea838ce97f0b7851badd0271cb0ec32ac42d3f7d4cfa15ccb1298 languageName: node linkType: hard @@ -10209,6 +10262,23 @@ __metadata: languageName: node linkType: hard +"@metamask/test-dapp-bitcoin@npm:^0.2.0": + version: 0.2.0 + resolution: "@metamask/test-dapp-bitcoin@npm:0.2.0" + dependencies: + "@wallet-standard/base": "npm:^1.1.0" + "@wallet-standard/core": "npm:^1.1.1" + bitcoinjs-lib: "npm:^7.0.0" + buffer: "npm:^6.0.3" + react: "npm:^18.3.1" + react-dom: "npm:^18.3.1" + sats-connect-v3: "npm:sats-connect@^3.5.0" + sats-connect-v4: "npm:sats-connect@^4.0.0" + vite-plugin-node-polyfills: "npm:^0.22.0" + checksum: 10/6a85979e7800f472e75596eebe6ecc916da6de0638db55e7a2714946306bf507dedc063c715b4c75c4e71176c70fd169cfb183552ca3af110bddffc81e7aeb93 + languageName: node + linkType: hard + "@metamask/test-dapp-multichain@npm:^0.17.1": version: 0.17.1 resolution: "@metamask/test-dapp-multichain@npm:0.17.1" @@ -10681,6 +10751,13 @@ __metadata: languageName: node linkType: hard +"@noble/secp256k1@npm:^1.6.3": + version: 1.7.2 + resolution: "@noble/secp256k1@npm:1.7.2" + checksum: 10/ce1651f63ebf9269990dbc1002410d63e2f4379fcbf2bb7a567740997852a1fbdb353929e497cd9d09549c28e3edb036ac3905ff20ac5249ad32e64a852af955 + languageName: node + linkType: hard + "@nodable/entities@npm:^2.1.0": version: 2.1.0 resolution: "@nodable/entities@npm:2.1.0" @@ -13358,6 +13435,61 @@ __metadata: languageName: node linkType: hard +"@sats-connect/core@npm:0.10.0": + version: 0.10.0 + resolution: "@sats-connect/core@npm:0.10.0" + dependencies: + axios: "npm:1.12.0" + bitcoin-address-validation: "npm:3.0.0" + buffer: "npm:6.0.3" + jsontokens: "npm:4.0.1" + peerDependencies: + valibot: 1.1.0 + checksum: 10/8c9a25adf995057529a909a8752942dad4fcb822ef4edbd2529e1987e96278de069f43f37aa26a127133597aae2f02e6d5cd9742a1c3ca1df085f55ed3bbe04d + languageName: node + linkType: hard + +"@sats-connect/core@npm:0.6.11": + version: 0.6.11 + resolution: "@sats-connect/core@npm:0.6.11" + dependencies: + axios: "npm:1.8.4" + bitcoin-address-validation: "npm:2.2.3" + buffer: "npm:6.0.3" + jsontokens: "npm:4.0.1" + peerDependencies: + valibot: 1.1.0 + checksum: 10/d7ef2429e471e233aca933b768a585bee6516c19c0d1fe2174e3a0e49bb2e5d4846f1627da2ca0ed3d4e2052c2a6d9804a0638f45b4a8445dba8ba51abbe8e0e + languageName: node + linkType: hard + +"@sats-connect/make-default-provider-config@npm:0.0.10": + version: 0.0.10 + resolution: "@sats-connect/make-default-provider-config@npm:0.0.10" + dependencies: + "@sats-connect/ui": "npm:^0.0.6" + bowser: "npm:^2.11.0" + peerDependencies: + "@sats-connect/core": "*" + typescript: ^5.0.0 + checksum: 10/3d7a50a562f3e8c4421a06eb7cdd720669ecac3e94ef769062fcc05fd90e517f267eff6b3c22313d88bb99d4bf111bdefe9a8fd8595ee143a03ab9df638271a5 + languageName: node + linkType: hard + +"@sats-connect/ui@npm:0.0.7": + version: 0.0.7 + resolution: "@sats-connect/ui@npm:0.0.7" + checksum: 10/bbe5a702de824edda0ced3404e4be93761bf35253d9444188dc126eef6f4ddc6b585f0736250b2aa0ea7d3c81e42dcd762393611a355e76c310474b26d94bc49 + languageName: node + linkType: hard + +"@sats-connect/ui@npm:^0.0.6": + version: 0.0.6 + resolution: "@sats-connect/ui@npm:0.0.6" + checksum: 10/d227f354386478f63ca892c5d25b6d445a6803f735d4b861ce271531cecf59797763a33dabcbb5f5d22838a64b5a46fd378d749b60ba0c453406c2c023b92eb9 + languageName: node + linkType: hard + "@scure/base@npm:1.2.6, @scure/base@npm:^1.0.0, @scure/base@npm:^1.1.1, @scure/base@npm:^1.1.3, @scure/base@npm:~1.2.5": version: 1.2.6 resolution: "@scure/base@npm:1.2.6" @@ -23095,6 +23227,13 @@ __metadata: languageName: node linkType: hard +"base58-js@npm:^3.0.2": + version: 3.0.3 + resolution: "base58-js@npm:3.0.3" + checksum: 10/62bad85b233290102ec3acdd47f604a3f657e20c1cb8a2de960c645f5f34d4a15806ece82e0fec75af5c66ea020d37be3c9daa00b92652570b7c43c4ecf7224a + languageName: node + linkType: hard + "base58check@npm:^2.0.0": version: 2.0.0 resolution: "base58check@npm:2.0.0" @@ -23279,6 +23418,16 @@ __metadata: languageName: node linkType: hard +"bip174@npm:^3.0.0": + version: 3.0.0 + resolution: "bip174@npm:3.0.0" + dependencies: + uint8array-tools: "npm:^0.0.9" + varuint-bitcoin: "npm:^2.0.0" + checksum: 10/b698437e5461cd1f02fc2f24bc074684909cf59ecd85da246000473d88c7a9bf7c2fe62b88ac84af33c3c1c60b17d7a85fb17e5cfa17d0053fb3d9fa0f7ea844 + languageName: node + linkType: hard + "bip66@npm:^2.0.0": version: 2.0.0 resolution: "bip66@npm:2.0.0" @@ -23297,6 +23446,17 @@ __metadata: languageName: node linkType: hard +"bitcoin-address-validation@npm:3.0.0": + version: 3.0.0 + resolution: "bitcoin-address-validation@npm:3.0.0" + dependencies: + base58-js: "npm:^3.0.2" + bech32: "npm:^2.0.0" + sha256-uint8array: "npm:^0.10.3" + checksum: 10/3703f27501ea9c3bebad2c5a0448850a5c13f4d2df419ae72bfc458ff658283d29cc24e4cb34b99d5c6b5dec4bb58b3897456934213efd7b6f758bc87c81293b + languageName: node + linkType: hard + "bitcoin-ops@npm:^1.3.0, bitcoin-ops@npm:^1.4.1": version: 1.4.1 resolution: "bitcoin-ops@npm:1.4.1" @@ -23304,6 +23464,21 @@ __metadata: languageName: node linkType: hard +"bitcoinjs-lib@npm:^7.0.0": + version: 7.0.1 + resolution: "bitcoinjs-lib@npm:7.0.1" + dependencies: + "@noble/hashes": "npm:^1.2.0" + bech32: "npm:^2.0.0" + bip174: "npm:^3.0.0" + bs58check: "npm:^4.0.0" + uint8array-tools: "npm:^0.0.9" + valibot: "npm:^1.2.0" + varuint-bitcoin: "npm:^2.0.0" + checksum: 10/3656f9c64f38adc946cd6e099faf3e479910c6f1baf1f8188329c73d1b202b260e63e505f201eb0522ef3830f11854804c811f9d89bae50c6fa75d7a7a20f530 + languageName: node + linkType: hard + "bl@npm:^4.0.3, bl@npm:^4.1.0": version: 4.1.0 resolution: "bl@npm:4.1.0" @@ -33834,6 +34009,17 @@ __metadata: languageName: node linkType: hard +"jsontokens@npm:4.0.1, jsontokens@npm:^4.0.1": + version: 4.0.1 + resolution: "jsontokens@npm:4.0.1" + dependencies: + "@noble/hashes": "npm:^1.1.2" + "@noble/secp256k1": "npm:^1.6.3" + base64-js: "npm:^1.5.1" + checksum: 10/b1b2dbd29b7f491f31934bd5385555ae11b76ce853141d6b3bb39d54045d0e654d3cdcc159a02765b017b63766b4f7cdb7b1b394be570636c7b942f5f9e781f2 + languageName: node + linkType: hard + "jsonwebtoken@npm:9.0.0": version: 9.0.0 resolution: "jsonwebtoken@npm:9.0.0" @@ -35223,6 +35409,7 @@ __metadata: "@metamask/auto-changelog": "npm:^5.3.0" "@metamask/base-controller": "npm:^9.0.1" "@metamask/bitcoin-wallet-snap": "npm:^1.12.0" + "@metamask/bitcoin-wallet-standard": "npm:^1.0.0" "@metamask/bridge-controller": "npm:^75.1.0" "@metamask/bridge-status-controller": "npm:^72.1.0" "@metamask/browser-passworder": "npm:^5.0.0" @@ -35283,7 +35470,7 @@ __metadata: "@metamask/money-account-controller": "npm:^0.2.0" "@metamask/money-account-upgrade-controller": "npm:^2.0.5" "@metamask/multichain-account-service": "npm:^10.0.2" - "@metamask/multichain-api-client": "npm:^0.10.1" + "@metamask/multichain-api-client": "npm:^0.11.0" "@metamask/multichain-api-middleware": "npm:^3.1.1" "@metamask/multichain-network-controller": "npm:^3.1.0" "@metamask/multichain-transactions-controller": "npm:^7.1.0" @@ -35334,6 +35521,7 @@ __metadata: "@metamask/superstruct": "npm:^3.2.1" "@metamask/swappable-obj-proxy": "npm:^2.1.0" "@metamask/test-dapp": "npm:9.5.0" + "@metamask/test-dapp-bitcoin": "npm:^0.2.0" "@metamask/test-dapp-multichain": "npm:^0.17.1" "@metamask/test-dapp-solana": "npm:^0.3.0" "@metamask/transaction-controller": "npm:^68.0.0" @@ -42332,6 +42520,30 @@ __metadata: languageName: node linkType: hard +"sats-connect-v3@npm:sats-connect@^3.5.0": + version: 3.6.1 + resolution: "sats-connect@npm:3.6.1" + dependencies: + "@sats-connect/core": "npm:0.6.11" + "@sats-connect/make-default-provider-config": "npm:0.0.10" + "@sats-connect/ui": "npm:0.0.7" + valibot: "npm:1.1.0" + checksum: 10/0f6098b0578d4b223729b6164019fafdd84e52859ff7532daf8cdfa6c287ddbfb38139db7cecc49a12d47b4b830777dea39840cef7dc15f61c3187c32d5e1ccc + languageName: node + linkType: hard + +"sats-connect-v4@npm:sats-connect@^4.0.0": + version: 4.2.1 + resolution: "sats-connect@npm:4.2.1" + dependencies: + "@sats-connect/core": "npm:0.10.0" + "@sats-connect/make-default-provider-config": "npm:0.0.10" + "@sats-connect/ui": "npm:0.0.7" + valibot: "npm:1.1.0" + checksum: 10/89dd2c367d348d23e8f8ad8acf2ffb41cc5881722b1b9afde4b674e1280fae2383e412bdc7751641c877a412fcad91684ebb8e5a26695b8f3167944d908df3cc + languageName: node + linkType: hard + "sax@npm:>=0.6.0": version: 1.2.4 resolution: "sax@npm:1.2.4" @@ -45438,6 +45650,13 @@ __metadata: languageName: node linkType: hard +"uint8array-tools@npm:^0.0.9": + version: 0.0.9 + resolution: "uint8array-tools@npm:0.0.9" + checksum: 10/ea924e6d574f8c24d94400f0635eeffbeb0190fd2e4b781bb8e43b089c799a3d8c11c9b75d7c68524bc7e18f7ffc3d942f122ecf729746ba51612a8463848943 + languageName: node + linkType: hard + "uint8arrays@npm:3.1.0": version: 3.1.0 resolution: "uint8arrays@npm:3.1.0" @@ -46275,7 +46494,19 @@ __metadata: languageName: node linkType: hard -"valibot@npm:1.3.1": +"valibot@npm:1.1.0": + version: 1.1.0 + resolution: "valibot@npm:1.1.0" + peerDependencies: + typescript: ">=5" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10/667c99e3ec2825ea97c20f37add7fe7eb9d15fb338c5f49c166ffba13a6f4516c77c060083e1c6bda77a2275c4d37400b711d284bbea03fd4ae160bbca34112c + languageName: node + linkType: hard + +"valibot@npm:1.3.1, valibot@npm:^1.2.0": version: 1.3.1 resolution: "valibot@npm:1.3.1" peerDependencies: @@ -46362,7 +46593,7 @@ __metadata: languageName: node linkType: hard -"varuint-bitcoin@npm:2.0.0": +"varuint-bitcoin@npm:2.0.0, varuint-bitcoin@npm:^2.0.0": version: 2.0.0 resolution: "varuint-bitcoin@npm:2.0.0" dependencies: