diff --git a/CHANGELOG.md b/CHANGELOG.md index 29a67c3c648..54895ba1644 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [7.71.0] + +### Added + +- Added backend-provided intent typedData for signing intent swap txs (#25913) +- Added Security & Trust section to Token Details page showing risk level, contract security features, buy/sell tax, token distribution, and official links powered by Blockaid (#27073) +- Added a "Withdraw" button to the unstaked TRX banner so users can claim TRX that has completed the lock period (#27076) +- Added handling for aggregated balance on the new home page (#27172) +- Added LD flags to consume price impact threshold (#27196) +- Added Segment event tracking for mUSD Quick Convert flow and enriched generic Transaction\* events for mUSD conversion transactions (#27305) +- Improved bridge/swap quote expiry experience; expired quotes now remain visible inline with a prompt to refresh, replacing a separate modal flow (#27340) +- Added support for ramps providers such as PayPal, Robinhood & Coinbase that use a different checkout browser (#27364) +- Added authentication for transaction submission to sentinel and transaction API (#27410) +- Added skeleton loading indicator to NFT grid items while images are loading (#27413) +- Embedded the metal card checkout flow into the Card onboarding/sign-up flow (#27420) +- Added attention badge on Card button (#27425) +- Added a new tab for users to see their NFTs and fixed NFT flicker on that view (#27437) +- Added press opacity feedback to NFT grid items (#27488) +- Applied a minimum $0.01 threshold for showing the "Claim bonus" CTA for Merkl rewards so that amounts below the threshold show the 3% bonus label instead (#27522) +- Updated Predict withdraw to default to the user’s last used destination token before falling back to the remote preferred token (#27532) +- Enabled campaigns view under feature flag (#27556) +- Redirected buy deeplinks to the new Ramps Buy flow when Ramps Unified V2 is enabled; deprecated cash deposit deeplinks (#27557) +- Restored mUSD claimable bonus claim section on asset overview screen (#27567) +- Added campaign opt-in flow with details and mechanics screens in the Rewards section (#27619) +- Updated Ramp buy flow modal headers and typography to use shared compact header and design system components (#27627) +- Migrated Card authentication to CardController with new `useCardAuth` hook for controller-based auth flow (#27656) +- Extracted Card supported-country check into `selectIsUserInSupportedCardCountry` selector (#27695) +- Updated mUSD aggregated balance row to redirect to the Cash tokens list when the user holds mUSD on any network (#27703) + +### Changed + +- Removed deprecated payment request (#27519) +- Updated earn balance row layout (logo size, badge size, balance/percentage placement) and added privacy mode support for StakingBalance and EarnLendingBalance (#27457) +- Refactored Card onboarding to use the `useRegions` hook instead of Redux `selectedCountry` for region/country data (#27539) +- Adjusted spacing in homepage (#27637) + +### Fixed + +- Fixed a bug where closing the "Token not available" modal left the user in a stuck state instead of navigating back to the token selection screen (#27277) +- Fixed false "Token Not Available" errors during Buy flow when payment methods are still loading after provider change; fixed missing "Token Not Available" modal in home buy flow; fixed crash when navigating back from "Token Not Available" modal in token info buy flow (#27448) +- Fixed token row display on homepage to show price and variation separated by a dot for consistency with token list items (#27449) +- Fixed stop loss banner rendering issue (#27458) +- Fixed Order Details screen displaying excessive decimal places for crypto amounts after ramp purchases (#27469) +- Fixed remove network confirmation header casing to sentence case (#27480) +- Fixed the custom network header trash icon color to match other trash icons in the app (#27481) +- Fixed a bug where the RPC URL field in network details could appear focused after blur and had inconsistent typography between states (#27482) +- Fixed RAMP_INTERNAL_BUILD default for OTA push (#27507) +- Fixed a bug where Perps activity could appear blank after reopening the Activity screen from Perps home (#27509) +- Fixed universal link handling for redirect-oauth (#27511) +- Fixed Network Details so network name is required and no longer labeled optional (#27541) +- Fixed onboarding import button text being invisible in dark mode; ensured both CTAs have proper contrast in dark mode (#27550) +- Removed a stale feature-flag gate so the Networks menu item is always available (#27591) +- Fixed MegaETH explorer button to display "View on Megaeth Explorer" instead of "View on Megaeth" (#27592) +- Fixed padding in security screen header (#27621) +- Fixed TokenList crash when switching networks (#27655) +- Fixed miscategorization of BRENTOIL and other non-crypto instruments appearing in the "Explore Crypto" section on Perps Home (#27699) + ## [7.70.1] ### Fixed @@ -11015,7 +11072,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#957](https://github.com/MetaMask/metamask-mobile/pull/957): fix timeouts (#957) - [#954](https://github.com/MetaMask/metamask-mobile/pull/954): Bugfix: onboarding navigation (#954) -[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.70.1...HEAD +[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.71.0...HEAD +[7.71.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.70.1...v7.71.0 [7.70.1]: https://github.com/MetaMask/metamask-mobile/compare/v7.70.0...v7.70.1 [7.70.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.69.1...v7.70.0 [7.69.1]: https://github.com/MetaMask/metamask-mobile/compare/v7.69.0...v7.69.1 diff --git a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx index 47b45f0e55b..f67f1253362 100644 --- a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx +++ b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx @@ -21,9 +21,9 @@ import { Button, ButtonVariant, ButtonSize, + TextColor, } from '@metamask/design-system-react-native'; import { useStyles } from '../../../../../component-library/hooks'; -import { TextColor } from '../../../../../component-library/components/Texts/Text'; import { strings } from '../../../../../../locales/i18n'; import { formatPnl, formatPercentage } from '../../utils/formatUtils'; import Routes from '../../../../../constants/navigation/Routes'; @@ -141,10 +141,9 @@ const PerpsHomeView = () => { const { positionsSubtitle, positionsSubtitleColor, positionsSubtitleSuffix } = useMemo(() => { const pnlNum = parseFloat(unrealizedPnl); - const isPnlZero = BigNumber(unrealizedPnl).isZero(); - // Only show subtitle when there are positions and P&L is non-zero - if (!hasPositions || isPnlZero) { + // Open (filled) positions only — hide when flat so spacing matches homepage sections + if (!hasPositions) { return { positionsSubtitle: undefined, positionsSubtitleColor: undefined, @@ -154,12 +153,11 @@ const PerpsHomeView = () => { const color = pnlNum > 0 - ? TextColor.Success + ? TextColor.SuccessDefault : pnlNum < 0 - ? TextColor.Error - : TextColor.Alternative; + ? TextColor.ErrorDefault + : TextColor.TextDefault; - // Format: "-$18.47 (2.1%)" colored + "Unrealized PnL" in default color const subtitle = `${formatPnl(pnlNum)} (${formatPercentage(roe, 1)})`; const suffix = strings('perps.unrealized_pnl'); diff --git a/app/components/UI/Perps/components/PerpsHomeSection/PerpsHomeSection.test.tsx b/app/components/UI/Perps/components/PerpsHomeSection/PerpsHomeSection.test.tsx index 45da601dc46..2562890db3f 100644 --- a/app/components/UI/Perps/components/PerpsHomeSection/PerpsHomeSection.test.tsx +++ b/app/components/UI/Perps/components/PerpsHomeSection/PerpsHomeSection.test.tsx @@ -4,7 +4,7 @@ import { View, Text } from 'react-native'; import PerpsHomeSection from './PerpsHomeSection'; import { PerpsHomeSectionTestIds } from './PerpsHomeSection.testIds'; -import { TextColor } from '../../../../../component-library/components/Texts/Text'; +import { TextColor } from '@metamask/design-system-react-native'; describe('PerpsHomeSection', () => { const mockSkeleton = () => ; @@ -445,7 +445,7 @@ describe('PerpsHomeSection', () => { { { = ({ title, subtitle, - subtitleColor = TextColor.Alternative, + subtitleColor = TextColor.TextDefault, subtitleSuffix, subtitleTestID, isLoading, @@ -146,29 +148,34 @@ const PerpsHomeSection: React.FC = ({ ) : undefined } - twClassName="px-0 mb-2" + twClassName="px-0 mb-0" /> - {/* Subtitle - NOT pressable */} - {subtitle && ( - - {subtitle} - {subtitleSuffix && ( - - {' '} - {subtitleSuffix} - - )} - - )} + {/* Value + muted label: same row as wallet homepage unrealized P&L (8px gap). */} + {subtitle && subtitleSuffix ? ( + + ) : subtitle ? ( + + + {subtitle} + + + ) : null} {/* Section Content */} diff --git a/app/components/UI/Predict/Predict.testIds.ts b/app/components/UI/Predict/Predict.testIds.ts index 82c1f704f36..83a90b9f0ca 100644 --- a/app/components/UI/Predict/Predict.testIds.ts +++ b/app/components/UI/Predict/Predict.testIds.ts @@ -80,6 +80,14 @@ export const getPredictFeedMockSelector = { // PREDICT MARKET DETAILS SELECTORS // ======================================== +export type PredictMarketDetailsTabKey = 'positions' | 'outcomes' | 'about'; + +export const getPredictMarketDetailsSelector = { + tabBarTab: (tabKey: PredictMarketDetailsTabKey) => + `predict-market-details-tab-bar-tab-${tabKey}`, + icon: (name: string) => `icon-${name}`, +} as const; + export const PredictMarketDetailsSelectorsIDs = { // Main screen SCREEN: 'predict-market-details-screen', @@ -96,9 +104,9 @@ export const PredictMarketDetailsSelectorsIDs = { OUTCOMES_TAB: 'predict-market-details-outcomes-tab', // Tab labels - POSITIONS_TAB_LABEL: 'predict-market-details-tab-bar-tab-0', - OUTCOMES_TAB_LABEL: 'predict-market-details-tab-bar-tab-1', - ABOUT_TAB_LABEL: 'predict-market-details-tab-bar-tab-2', + POSITIONS_TAB_LABEL: getPredictMarketDetailsSelector.tabBarTab('positions'), + OUTCOMES_TAB_LABEL: getPredictMarketDetailsSelector.tabBarTab('outcomes'), + ABOUT_TAB_LABEL: getPredictMarketDetailsSelector.tabBarTab('about'), // Tab content containers ABOUT_TAB_CONTENT: 'about-tab-content', @@ -119,11 +127,6 @@ export const PredictMarketDetailsSelectorsIDs = { 'predict-details-buttons-skeleton-button-1', } as const; -export const getPredictMarketDetailsSelector = { - tabBarTab: (index: number) => `predict-market-details-tab-bar-tab-${index}`, - icon: (name: string) => `icon-${name}`, -} as const; - export const PredictMarketDetailsSelectorsText = { // Tab content containers ABOUT_TAB_TEXT: 'About', diff --git a/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx b/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx index 259adb6888a..6bca1d097b8 100644 --- a/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx +++ b/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx @@ -49,7 +49,11 @@ import { usePredictPositions } from '../../hooks/usePredictPositions'; import { selectPredictWonPositions } from '../../selectors/predictController'; import { PredictPosition } from '../../types'; import { PredictNavigationParamList } from '../../types/navigation'; -import { formatPercentage, formatPrice } from '../../utils/format'; +import { + formatPercentage, + formatPredictUnrealizedPnLStringParts, + formatPrice, +} from '../../utils/format'; import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import PredictClaimButton from '../PredictActionButtons/PredictClaimButton'; import { PredictEventValues } from '../../constants/eventNames'; @@ -163,16 +167,10 @@ const PredictPositionsHeader = forwardRef< const unrealizedAmount = unrealizedPnL?.cashUpnl ?? 0; const unrealizedPercent = unrealizedPnL?.percentUpnl ?? 0; - - const formatAmount = (amount: number) => { - const sign = amount >= 0 ? '+' : '-'; - return `${sign}$${Math.abs(amount).toFixed(2)}`; - }; - - const formatPercent = (percent: number) => { - const sign = percent >= 0 ? '+' : ''; - return `${sign}${formatPercentage(percent)}`; - }; + const unrealizedPnLDisplayParts = formatPredictUnrealizedPnLStringParts({ + cashUpnl: unrealizedAmount, + percentUpnl: unrealizedPercent, + }); const hasClaimableAmount = wonPositions.length > 0 && totalClaimableAmount !== undefined; @@ -315,10 +313,10 @@ const PredictPositionsHeader = forwardRef< isHidden={privacyMode} length={SensitiveTextLength.Long} > - {strings('predict.unrealized_pnl_value', { - amount: formatAmount(unrealizedAmount), - percent: formatPercent(unrealizedPercent), - })} + {strings( + 'predict.unrealized_pnl_value', + unrealizedPnLDisplayParts, + )} )} diff --git a/app/components/UI/Predict/utils/format.test.ts b/app/components/UI/Predict/utils/format.test.ts index af58869975c..16403cea49c 100644 --- a/app/components/UI/Predict/utils/format.test.ts +++ b/app/components/UI/Predict/utils/format.test.ts @@ -11,6 +11,7 @@ import { calculateNetAmount, formatPriceWithSubscriptNotation, formatGameStartTime, + formatPredictUnrealizedPnLStringParts, } from './format'; import { Recurrence, PredictSeries } from '../types'; @@ -2118,6 +2119,35 @@ describe('format utils', () => { }); }); + describe('formatPredictUnrealizedPnLStringParts', () => { + it('formats positive cash and percent with explicit + on percent', () => { + expect( + formatPredictUnrealizedPnLStringParts({ + cashUpnl: 95.39, + percentUpnl: 9.4, + }), + ).toEqual({ amount: '+$95.39', percent: '+9.4%' }); + }); + + it('formats negative cash and percent', () => { + expect( + formatPredictUnrealizedPnLStringParts({ + cashUpnl: -10.5, + percentUpnl: -3.25, + }), + ).toEqual({ amount: '-$10.50', percent: '-3.25%' }); + }); + + it('formats zero with + on cash and percent (matches positions header)', () => { + expect( + formatPredictUnrealizedPnLStringParts({ + cashUpnl: 0, + percentUpnl: 0, + }), + ).toEqual({ amount: '+$0.00', percent: '+0%' }); + }); + }); + describe('formatGameStartTime', () => { // Store original Intl.DateTimeFormat const OriginalDateTimeFormat = Intl.DateTimeFormat; diff --git a/app/components/UI/Predict/utils/format.ts b/app/components/UI/Predict/utils/format.ts index 13797d2890d..ff41d9524d1 100644 --- a/app/components/UI/Predict/utils/format.ts +++ b/app/components/UI/Predict/utils/format.ts @@ -64,6 +64,22 @@ export const formatPercentage = ( return `${formatted}%`; }; +/** + * Builds `amount` / `percent` for `strings('predict.unrealized_pnl_value', …)`. + * Same rules as PredictPositionsHeader: signed cash, signed % via `formatPercentage`. + */ +export function formatPredictUnrealizedPnLStringParts(data: { + cashUpnl: number; + percentUpnl: number; +}): { amount: string; percent: string } { + const { cashUpnl, percentUpnl } = data; + const amountSign = cashUpnl >= 0 ? '+' : '-'; + const amount = `${amountSign}$${Math.abs(cashUpnl).toFixed(2)}`; + const percentSign = percentUpnl >= 0 ? '+' : ''; + const percent = `${percentSign}${formatPercentage(percentUpnl)}`; + return { amount, percent }; +} + /** * Formats a price value as USD currency with rounding up to nearest cent * @param price - Raw numeric price value diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx index b6b9731536e..568630de21a 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx @@ -805,7 +805,7 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(); const aboutTab = screen.getByTestId( - getPredictMarketDetailsSelector.tabBarTab(1), + getPredictMarketDetailsSelector.tabBarTab('about'), ); fireEvent.press(aboutTab); @@ -818,7 +818,7 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(); const aboutTab = screen.getByTestId( - getPredictMarketDetailsSelector.tabBarTab(1), + getPredictMarketDetailsSelector.tabBarTab('about'), ); fireEvent.press(aboutTab); @@ -834,7 +834,7 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(); const aboutTab = screen.getByTestId( - getPredictMarketDetailsSelector.tabBarTab(1), + getPredictMarketDetailsSelector.tabBarTab('about'), ); fireEvent.press(aboutTab); @@ -848,7 +848,7 @@ describe('PredictMarketDetails', () => { const { mockNavigate } = setupPredictMarketDetailsTest(); const aboutTab = screen.getByTestId( - getPredictMarketDetailsSelector.tabBarTab(1), + getPredictMarketDetailsSelector.tabBarTab('about'), ); fireEvent.press(aboutTab); @@ -1177,7 +1177,7 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(); const aboutTab = screen.getByTestId( - getPredictMarketDetailsSelector.tabBarTab(1), + getPredictMarketDetailsSelector.tabBarTab('about'), ); fireEvent.press(aboutTab); @@ -1351,7 +1351,7 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(marketWithoutEndDate); const aboutTab = screen.getByTestId( - getPredictMarketDetailsSelector.tabBarTab(1), + getPredictMarketDetailsSelector.tabBarTab('about'), ); fireEvent.press(aboutTab); @@ -1423,7 +1423,7 @@ describe('PredictMarketDetails', () => { // Switch to Positions tab (index 0 when positions exist) const positionsTab = screen.getByTestId( - getPredictMarketDetailsSelector.tabBarTab(0), + getPredictMarketDetailsSelector.tabBarTab('positions'), ); fireEvent.press(positionsTab); @@ -1606,7 +1606,7 @@ describe('PredictMarketDetails', () => { // Switch to Positions tab (index 0 when positions exist) const positionsTab = screen.getByTestId( - getPredictMarketDetailsSelector.tabBarTab(0), + getPredictMarketDetailsSelector.tabBarTab('positions'), ); fireEvent.press(positionsTab); @@ -1641,7 +1641,7 @@ describe('PredictMarketDetails', () => { // Switch to Positions tab (index 0 when positions exist) const positionsTab = screen.getByTestId( - getPredictMarketDetailsSelector.tabBarTab(0), + getPredictMarketDetailsSelector.tabBarTab('positions'), ); fireEvent.press(positionsTab); @@ -1788,7 +1788,7 @@ describe('PredictMarketDetails', () => { // Switch to Positions tab (index 0 when positions exist) const positionsTab = screen.getByTestId( - getPredictMarketDetailsSelector.tabBarTab(0), + getPredictMarketDetailsSelector.tabBarTab('positions'), ); fireEvent.press(positionsTab); @@ -1821,7 +1821,7 @@ describe('PredictMarketDetails', () => { // Switch to Positions tab (index 0 when positions exist) const positionsTab = screen.getByTestId( - getPredictMarketDetailsSelector.tabBarTab(0), + getPredictMarketDetailsSelector.tabBarTab('positions'), ); fireEvent.press(positionsTab); @@ -1854,7 +1854,7 @@ describe('PredictMarketDetails', () => { // Switch to Positions tab (index 0 when positions exist) const positionsTab = screen.getByTestId( - getPredictMarketDetailsSelector.tabBarTab(0), + getPredictMarketDetailsSelector.tabBarTab('positions'), ); fireEvent.press(positionsTab); @@ -2221,7 +2221,7 @@ describe('PredictMarketDetails', () => { // Switch to Positions tab (index 0 when positions exist) const positionsTab = screen.getByTestId( - getPredictMarketDetailsSelector.tabBarTab(0), + getPredictMarketDetailsSelector.tabBarTab('positions'), ); fireEvent.press(positionsTab); @@ -2661,7 +2661,7 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(closedMarket); const aboutTab = screen.getByTestId( - getPredictMarketDetailsSelector.tabBarTab(1), + getPredictMarketDetailsSelector.tabBarTab('about'), ); fireEvent.press(aboutTab); @@ -2697,7 +2697,7 @@ describe('PredictMarketDetails', () => { ); const aboutTabWithPositions = screen.getByTestId( - getPredictMarketDetailsSelector.tabBarTab(2), + getPredictMarketDetailsSelector.tabBarTab('about'), ); fireEvent.press(aboutTabWithPositions); diff --git a/app/components/UI/Predict/views/PredictMarketDetails/components/PredictMarketDetailsTabBar/PredictMarketDetailsTabBar.tsx b/app/components/UI/Predict/views/PredictMarketDetails/components/PredictMarketDetailsTabBar/PredictMarketDetailsTabBar.tsx index 12cdc948d56..3478bfb5637 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/components/PredictMarketDetailsTabBar/PredictMarketDetailsTabBar.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/components/PredictMarketDetailsTabBar/PredictMarketDetailsTabBar.tsx @@ -9,12 +9,14 @@ import { TextVariant, } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { PredictMarketDetailsSelectorsIDs } from '../../../../Predict.testIds'; - -type TabKey = 'positions' | 'outcomes' | 'about'; +import { + getPredictMarketDetailsSelector, + PredictMarketDetailsSelectorsIDs, + type PredictMarketDetailsTabKey, +} from '../../../../Predict.testIds'; export interface PredictMarketDetailsTabBarProps { - tabs: { label: string; key: TabKey }[]; + tabs: { label: string; key: PredictMarketDetailsTabKey }[]; activeTab: number | null; onTabPress: (tabIndex: number) => void; } @@ -41,7 +43,7 @@ const PredictMarketDetailsTabBar = memo( 'w-1/3 py-3', activeTab === index ? 'border-b-2 border-default' : '', )} - testID={`${PredictMarketDetailsSelectorsIDs.TAB_BAR}-tab-${index}`} + testID={getPredictMarketDetailsSelector.tabBarTab(tab.key)} > ({ orders: [], isInitialLoading: false, })), + usePerpsLiveAccount: jest.fn(() => ({ + account: null, + isInitialLoading: false, + })), usePerpsMarkets: jest.fn(() => ({ markets: [], isLoading: false, @@ -108,6 +112,24 @@ jest.mock('../../UI/Predict/selectors/featureFlags', () => ({ selectPredictEnabledFlag: jest.fn(() => true), })); +jest.mock('@tanstack/react-query', () => { + const actual = jest.requireActual('@tanstack/react-query'); + return { + ...actual, + useQueryClient: jest.fn(() => ({ + invalidateQueries: jest.fn(() => Promise.resolve()), + })), + }; +}); + +jest.mock('../../UI/Predict/hooks/useUnrealizedPnL', () => ({ + useUnrealizedPnL: jest.fn(() => ({ + data: null, + isLoading: false, + error: null, + })), +})); + jest.mock('../../UI/Predict/hooks/usePredictPositions', () => ({ usePredictPositions: () => ({ data: [], diff --git a/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.test.tsx b/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.test.tsx index 68db99dab93..16f1e7254d0 100644 --- a/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.test.tsx +++ b/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.test.tsx @@ -48,6 +48,13 @@ jest.mock('../../../../UI/Perps/hooks', () => ({ orders: [], isInitialLoading: false, })), + usePerpsLiveAccount: jest.fn(() => ({ + account: { + unrealizedPnl: '95.39', + returnOnEquity: '9.4', + }, + isInitialLoading: false, + })), usePerpsMarkets: jest.fn(() => ({ markets: [], isLoading: false, @@ -170,8 +177,12 @@ jest.mock('./components/PerpsMarketTileCard', () => { }; }); -const { usePerpsLivePositions, usePerpsLiveOrders, usePerpsMarkets } = - jest.requireMock('../../../../UI/Perps/hooks'); +const { + usePerpsLivePositions, + usePerpsLiveOrders, + usePerpsMarkets, + usePerpsLiveAccount, +} = jest.requireMock('../../../../UI/Perps/hooks'); const makePosition = (overrides: Record = {}) => ({ symbol: 'BTC', @@ -290,6 +301,45 @@ describe('PerpsSection', () => { expect(screen.getByText('ETH 40X position')).toBeOnTheScreen(); }); + it('shows aggregate unrealized P&L row when user has filled positions', () => { + usePerpsLivePositions.mockReturnValue({ + positions: [makePosition()], + isInitialLoading: false, + }); + usePerpsLiveAccount.mockReturnValue({ + account: { + unrealizedPnl: '95.39', + returnOnEquity: '9.4', + }, + isInitialLoading: false, + }); + + renderWithProvider( + , + ); + + expect( + screen.getByTestId('homepage-perps-unrealized-pnl'), + ).toBeOnTheScreen(); + }); + + it('does not show unrealized P&L row when user has only open orders', () => { + usePerpsLivePositions.mockReturnValue({ + positions: [], + isInitialLoading: false, + }); + usePerpsLiveOrders.mockReturnValue({ + orders: [makeOrder()], + isInitialLoading: false, + }); + + renderWithProvider( + , + ); + + expect(screen.queryByTestId('homepage-perps-unrealized-pnl')).toBeNull(); + }); + it('renders multiple position rows', () => { usePerpsLivePositions.mockReturnValue({ positions: [ diff --git a/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.tsx b/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.tsx index c156d473d31..7bac10e0309 100644 --- a/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.tsx +++ b/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.tsx @@ -12,6 +12,7 @@ import { useNavigation, type NavigationProp } from '@react-navigation/native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { Box } from '@metamask/design-system-react-native'; import { useSelector } from 'react-redux'; +import { selectPrivacyMode } from '../../../../../selectors/preferencesController'; import { type PerpsMarketData, type Position, @@ -27,7 +28,12 @@ import { usePerpsLivePositions, usePerpsLiveOrders, usePerpsMarkets, + usePerpsLiveAccount, } from '../../../../UI/Perps/hooks'; +import { + formatPnl, + formatPercentage, +} from '../../../../UI/Perps/utils/formatUtils'; import { usePerpsConnection } from '../../../../UI/Perps/hooks/usePerpsConnection'; import { filterAndSortMarkets } from '../../../../UI/Perps/utils/filterAndSortMarkets'; import { @@ -48,6 +54,9 @@ import useHomeViewedEvent, { HomeSectionNames, } from '../../hooks/useHomeViewedEvent'; import type { PerpsSectionProps } from './PerpsSectionWithProvider'; +import HomepageSectionUnrealizedPnlRow, { + type HomepageUnrealizedPnlTone, +} from '../../components/HomepageSectionUnrealizedPnlRow'; const MAX_ITEMS = 5; const MAX_TRENDING_MARKETS = 5; @@ -71,12 +80,18 @@ const PerpsSection = forwardRef( const { error: connectionError, reconnectWithNewContext } = usePerpsConnection(); const { track } = usePerpsEventTracking(); + const privacyMode = useSelector(selectPrivacyMode); const { positions, isInitialLoading: positionsLoading } = usePerpsLivePositions({ throttleMs: HOMEPAGE_THROTTLE_MS, }); + const { account: perpsAccount, isInitialLoading: perpsAccountLoading } = + usePerpsLiveAccount({ + throttleMs: HOMEPAGE_THROTTLE_MS, + }); + const { orders, isInitialLoading: ordersLoading } = usePerpsLiveOrders({ hideTpSl: true, throttleMs: HOMEPAGE_THROTTLE_MS, @@ -112,11 +127,28 @@ const PerpsSection = forwardRef( ); const hasItems = displayPositions.length > 0 || displayOrders.length > 0; + const hasFilledPositions = positions.length > 0; // When user has no positions/orders, keep skeleton visible until markets load. const pendingTrending = !showSkeleton && !hasItems && marketsLoading; const showTrending = !showSkeleton && !hasItems && !marketsLoading; + const showHomepageUnrealizedPnl = + !showSkeleton && !pendingTrending && hasFilledPositions && !privacyMode; + + const homepageUnrealizedPnl = useMemo(() => { + if (!showHomepageUnrealizedPnl) { + return null; + } + const unrealizedPnl = perpsAccount?.unrealizedPnl ?? '0'; + const roe = parseFloat(perpsAccount?.returnOnEquity || '0'); + const pnlNum = parseFloat(unrealizedPnl); + const valueText = `${formatPnl(pnlNum)} (${formatPercentage(roe, 1)})`; + const tone: HomepageUnrealizedPnlTone = + pnlNum > 0 ? 'positive' : pnlNum < 0 ? 'negative' : 'neutral'; + return { valueText, tone }; + }, [perpsAccount, showHomepageUnrealizedPnl]); + const safeWatchlistSymbols = useMemo( () => watchlistSymbols ?? [], [watchlistSymbols], @@ -271,7 +303,18 @@ const PerpsSection = forwardRef( return ( - + + + {showHomepageUnrealizedPnl && ( + + )} + {showSkeleton || pendingTrending ? ( diff --git a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx index 003a201ca44..c472dbaff2d 100644 --- a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx +++ b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx @@ -25,6 +25,29 @@ jest.mock('../../../../UI/Predict/hooks/usePredictClaim', () => ({ usePredictClaim: () => ({ claim: mockClaim }), })); +jest.mock('../../../../UI/Predict/hooks/useUnrealizedPnL', () => ({ + useUnrealizedPnL: jest.fn(() => ({ + data: { cashUpnl: 10, percentUpnl: 5, user: '0x0' }, + isLoading: false, + error: null, + })), +})); + +jest.mock('../../../../../selectors/preferencesController', () => ({ + ...jest.requireActual('../../../../../selectors/preferencesController'), + selectPrivacyMode: () => false, +})); + +jest.mock('@tanstack/react-query', () => { + const actual = jest.requireActual('@tanstack/react-query'); + return { + ...actual, + useQueryClient: jest.fn(() => ({ + invalidateQueries: jest.fn(() => Promise.resolve()), + })), + }; +}); + // Mock the hooks jest.mock('./hooks', () => ({ usePredictMarketsForHomepage: jest.fn(() => ({ diff --git a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.tsx b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.tsx index b73b8fc97d9..ddea412a829 100644 --- a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.tsx +++ b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.tsx @@ -2,8 +2,10 @@ import React, { forwardRef, useCallback, useImperativeHandle, + useMemo, useRef, } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { ScrollView, View } from 'react-native'; import { useNavigation, NavigationProp } from '@react-navigation/native'; import { useSelector } from 'react-redux'; @@ -14,6 +16,7 @@ import Routes from '../../../../../constants/navigation/Routes'; import { WalletViewSelectorsIDs } from '../../../../Views/Wallet/WalletView.testIds'; import { SectionRefreshHandle } from '../../types'; import { selectPredictEnabledFlag } from '../../../../UI/Predict/selectors/featureFlags'; +import { selectPrivacyMode } from '../../../../../selectors/preferencesController'; import { strings } from '../../../../../../locales/i18n'; import { usePredictMarketsForHomepage, @@ -26,14 +29,24 @@ import { PredictPositionRowSkeleton, } from './components'; import ViewMoreCard from '../../components/ViewMoreCard'; -import type { PredictPosition } from '../../../../UI/Predict/types'; +import type { + PredictPosition, + UnrealizedPnL, +} from '../../../../UI/Predict/types'; import type { PredictNavigationParamList } from '../../../../UI/Predict/types/navigation'; import { PredictEventValues } from '../../../../UI/Predict/constants/eventNames'; import { PredictClaimButton } from '../../../../UI/Predict/components/PredictActionButtons'; import { usePredictClaim } from '../../../../UI/Predict/hooks/usePredictClaim'; +import { useUnrealizedPnL } from '../../../../UI/Predict/hooks/useUnrealizedPnL'; +import { predictQueries } from '../../../../UI/Predict/queries'; +import { getEvmAccountFromSelectedAccountGroup } from '../../../../UI/Predict/utils/accounts'; +import { formatPredictUnrealizedPnLStringParts } from '../../../../UI/Predict/utils/format'; import useHomeViewedEvent, { HomeSectionNames, } from '../../hooks/useHomeViewedEvent'; +import HomepageSectionUnrealizedPnlRow, { + type HomepageUnrealizedPnlTone, +} from '../../components/HomepageSectionUnrealizedPnlRow'; const MAX_MARKETS_DISPLAYED = 5; @@ -48,6 +61,48 @@ interface PredictionsSectionProps { totalSectionsLoaded: number; } +interface PredictHomepageUnrealizedPnlRowState { + show: boolean; + isLoading: boolean; + valueText?: string; + tone: HomepageUnrealizedPnlTone; +} + +function getPredictHomepageUnrealizedPnlRowState(input: { + hasPositions: boolean; + privacyMode: boolean; + isPnlLoading: boolean; + pnl: UnrealizedPnL | null | undefined; +}): PredictHomepageUnrealizedPnlRowState { + const { hasPositions, privacyMode, isPnlLoading, pnl } = input; + + if (!hasPositions || privacyMode) { + return { show: false, isLoading: false, tone: 'neutral' }; + } + if (isPnlLoading) { + return { show: true, isLoading: true, tone: 'neutral' }; + } + if (!pnl) { + return { show: false, isLoading: false, tone: 'neutral' }; + } + + const cashUpnl = pnl.cashUpnl ?? 0; + const valueText = strings( + 'predict.unrealized_pnl_value', + formatPredictUnrealizedPnLStringParts({ + cashUpnl, + percentUpnl: pnl.percentUpnl ?? 0, + }), + ); + + return { + show: true, + isLoading: false, + valueText, + tone: cashUpnl > 0 ? 'positive' : cashUpnl < 0 ? 'negative' : 'neutral', + }; +} + /** * PredictionsSection - Displays prediction content on the homepage * @@ -66,6 +121,8 @@ const PredictionsSection = forwardRef< const navigation = useNavigation>(); const isPredictEnabled = useSelector(selectPredictEnabledFlag); + const privacyMode = useSelector(selectPrivacyMode); + const queryClient = useQueryClient(); const title = strings('homepage.sections.predictions'); const { claim } = usePredictClaim(); @@ -94,6 +151,29 @@ const PredictionsSection = forwardRef< // Determine if user has positions const hasPositions = positions.length > 0; + const { + data: predictUnrealizedPnL, + isLoading: isPredictUnrealizedPnLLoading, + } = useUnrealizedPnL({ + enabled: hasPositions, + }); + + const predictHomepageUnrealizedPnl = useMemo( + () => + getPredictHomepageUnrealizedPnlRowState({ + hasPositions, + privacyMode, + isPnlLoading: isPredictUnrealizedPnLLoading, + pnl: predictUnrealizedPnL, + }), + [ + hasPositions, + privacyMode, + isPredictUnrealizedPnLLoading, + predictUnrealizedPnL, + ], + ); + const isLoading = isLoadingPositions || isLoadingMarkets; const hasError = @@ -127,8 +207,14 @@ const PredictionsSection = forwardRef< }); const refresh = useCallback(async () => { - await Promise.all([refetchPositions(), refetchMarkets()]); - }, [refetchPositions, refetchMarkets]); + const addr = getEvmAccountFromSelectedAccountGroup()?.address; + const invalidatePnl = addr + ? queryClient.invalidateQueries({ + queryKey: predictQueries.unrealizedPnL.keys.byAddress(addr), + }) + : Promise.resolve(); + await Promise.all([refetchPositions(), refetchMarkets(), invalidatePnl]); + }, [queryClient, refetchPositions, refetchMarkets]); useImperativeHandle(ref, () => ({ refresh }), [refresh]); @@ -167,13 +253,24 @@ const PredictionsSection = forwardRef< return ( - + + {predictHomepageUnrealizedPnl.show && ( + )} - /> + {isLoadingPositions ? ( <> diff --git a/app/components/Views/Homepage/components/HomepageSectionUnrealizedPnlRow/HomepageSectionUnrealizedPnlRow.test.tsx b/app/components/Views/Homepage/components/HomepageSectionUnrealizedPnlRow/HomepageSectionUnrealizedPnlRow.test.tsx new file mode 100644 index 00000000000..b58758df027 --- /dev/null +++ b/app/components/Views/Homepage/components/HomepageSectionUnrealizedPnlRow/HomepageSectionUnrealizedPnlRow.test.tsx @@ -0,0 +1,186 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react-native'; +import { TextColor } from '@metamask/design-system-react-native'; +import HomepageSectionUnrealizedPnlRow from './HomepageSectionUnrealizedPnlRow'; + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ + style: (...args: string[]) => ({ testStyle: args.join(' ') }), + }), +})); + +jest.mock('../../../../../component-library/components-temp/Skeleton', () => { + const { View } = jest.requireActual('react-native'); + return { + Skeleton: (props: { width: number; height: number }) => ( + + ), + }; +}); + +describe('HomepageSectionUnrealizedPnlRow', () => { + it('renders null when valueText is undefined', () => { + const { toJSON } = render( + , + ); + expect(toJSON()).toBeNull(); + }); + + it('renders null when valueText is empty string', () => { + const { toJSON } = render( + , + ); + expect(toJSON()).toBeNull(); + }); + + it('renders skeleton when isLoading is true', () => { + render( + , + ); + expect(screen.getByTestId('skeleton')).toBeOnTheScreen(); + expect(screen.getByTestId('pnl-row')).toBeOnTheScreen(); + expect(screen.queryByText('Unrealized P&L')).toBeNull(); + }); + + it('renders value and label when valueText is provided', () => { + render( + , + ); + expect(screen.getByText('+$95.39 (+9.4%)')).toBeOnTheScreen(); + expect(screen.getByText('Unrealized P&L')).toBeOnTheScreen(); + }); + + it('derives value/label testIDs from testID prop', () => { + render( + , + ); + expect(screen.getByTestId('pnl')).toBeOnTheScreen(); + expect(screen.getByTestId('pnl-value')).toBeOnTheScreen(); + expect(screen.getByTestId('pnl-label')).toBeOnTheScreen(); + }); + + it('uses explicit valueTestID and labelTestID over derived ones', () => { + render( + , + ); + expect(screen.getByTestId('custom-value')).toBeOnTheScreen(); + expect(screen.getByTestId('custom-label')).toBeOnTheScreen(); + expect(screen.queryByTestId('pnl-value')).toBeNull(); + expect(screen.queryByTestId('pnl-label')).toBeNull(); + }); + + it('does not set testIDs on value/label when testID is not provided', () => { + render(); + expect(screen.getByText('+$10')).toBeOnTheScreen(); + expect(screen.getByText('P&L')).toBeOnTheScreen(); + }); + + describe('toneToColor mapping', () => { + it('applies success color for positive tone', () => { + render( + , + ); + expect(screen.getByTestId('val')).toBeOnTheScreen(); + }); + + it('applies error color for negative tone', () => { + render( + , + ); + expect(screen.getByTestId('val')).toBeOnTheScreen(); + }); + + it('applies default text color for neutral tone (default)', () => { + render( + , + ); + expect(screen.getByTestId('val')).toBeOnTheScreen(); + }); + }); + + it('uses valueColor prop over tone-derived color', () => { + render( + , + ); + expect(screen.getByTestId('val')).toBeOnTheScreen(); + }); + + it('passes paddingHorizontal=0 without error', () => { + render( + , + ); + expect(screen.getByTestId('row')).toBeOnTheScreen(); + }); + + it('passes marginTop=1 without error', () => { + render( + , + ); + expect(screen.getByTestId('row')).toBeOnTheScreen(); + }); + + it('renders skeleton with marginTop and paddingHorizontal when loading', () => { + render( + , + ); + expect(screen.getByTestId('skeleton')).toBeOnTheScreen(); + expect(screen.getByTestId('loading-row')).toBeOnTheScreen(); + }); +}); diff --git a/app/components/Views/Homepage/components/HomepageSectionUnrealizedPnlRow/HomepageSectionUnrealizedPnlRow.tsx b/app/components/Views/Homepage/components/HomepageSectionUnrealizedPnlRow/HomepageSectionUnrealizedPnlRow.tsx new file mode 100644 index 00000000000..2a24244ea4c --- /dev/null +++ b/app/components/Views/Homepage/components/HomepageSectionUnrealizedPnlRow/HomepageSectionUnrealizedPnlRow.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { + Box, + Text, + TextColor, + TextVariant, + BoxFlexDirection, + BoxAlignItems, + BoxFlexWrap, + FontWeight, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; + +export type HomepageUnrealizedPnlTone = 'positive' | 'negative' | 'neutral'; + +const toneToColor = (tone: HomepageUnrealizedPnlTone): TextColor => { + if (tone === 'positive') { + return TextColor.SuccessDefault; + } + if (tone === 'negative') { + return TextColor.ErrorDefault; + } + return TextColor.TextDefault; +}; + +export interface HomepageSectionUnrealizedPnlRowProps { + /** Muted label after the value (e.g. Unrealized P&L) */ + label: string; + /** When true, shows a placeholder instead of value + label */ + isLoading?: boolean; + /** Main numeric segment, e.g. +$95.39 (+9.4%) */ + valueText?: string; + tone?: HomepageUnrealizedPnlTone; + /** + * When set, used for the value text color instead of deriving from `tone` + * (Perps home “Your positions” line passes design-system colors from account state). + */ + valueColor?: TextColor; + /** Container test id (homepage sections). */ + testID?: string; + /** Value segment test id; default `${testID}-value` when `testID` is set. */ + valueTestID?: string; + /** Label segment test id; default `${testID}-label` when `testID` is set. */ + labelTestID?: string; + /** + * Horizontal padding (design-system spacing). `4` = 16px — wallet homepage; + * `0` when the parent section already applies horizontal inset (Perps “Your positions”). + */ + paddingHorizontal?: 0 | 4; + /** `1` = 4px below title when the parent does not use `gap` (Perps home section). */ + marginTop?: 1; +} + +/** + * Section sub-row: colored unrealized P&L value + muted label. + * Used on wallet homepage (Perps / Predict) and Perps tab “Your positions”. + * Spacing: 8px gap between value and label; optional `marginTop` 4px below title when needed. + */ +const HomepageSectionUnrealizedPnlRow: React.FC< + HomepageSectionUnrealizedPnlRowProps +> = ({ + label, + isLoading, + valueText, + tone = 'neutral', + valueColor: valueColorProp, + testID, + valueTestID: valueTestIDProp, + labelTestID: labelTestIDProp, + paddingHorizontal = 4, + marginTop, +}) => { + const tw = useTailwind(); + const resolvedValueColor = valueColorProp ?? toneToColor(tone); + const valueTestID = + valueTestIDProp ?? (testID ? `${testID}-value` : undefined); + const labelTestID = + labelTestIDProp ?? (testID ? `${testID}-label` : undefined); + + if (isLoading) { + return ( + + + + ); + } + + if (!valueText) { + return null; + } + + return ( + + + + {valueText} + + + {label} + + + + ); +}; + +export default HomepageSectionUnrealizedPnlRow; diff --git a/app/components/Views/Homepage/components/HomepageSectionUnrealizedPnlRow/index.ts b/app/components/Views/Homepage/components/HomepageSectionUnrealizedPnlRow/index.ts new file mode 100644 index 00000000000..bf0bf5f51e1 --- /dev/null +++ b/app/components/Views/Homepage/components/HomepageSectionUnrealizedPnlRow/index.ts @@ -0,0 +1,5 @@ +export { + default, + type HomepageSectionUnrealizedPnlRowProps, + type HomepageUnrealizedPnlTone, +} from './HomepageSectionUnrealizedPnlRow'; diff --git a/app/components/Views/confirmations/components/network-filter/network-filter.testIds.ts b/app/components/Views/confirmations/components/network-filter/network-filter.testIds.ts new file mode 100644 index 00000000000..8071d656a9f --- /dev/null +++ b/app/components/Views/confirmations/components/network-filter/network-filter.testIds.ts @@ -0,0 +1,4 @@ +export const NETWORK_FILTER_ALL_TEST_ID = 'transaction-pay-network-filter-all'; + +export const getNetworkFilterTestId = (chainId: string): string => + `transaction-pay-network-filter-${chainId}`; diff --git a/app/components/Views/confirmations/components/network-filter/network-filter.tsx b/app/components/Views/confirmations/components/network-filter/network-filter.tsx index 570e2436a9e..2573285b932 100644 --- a/app/components/Views/confirmations/components/network-filter/network-filter.tsx +++ b/app/components/Views/confirmations/components/network-filter/network-filter.tsx @@ -20,6 +20,10 @@ import { NETWORK_FILTER_ALL, } from '../../hooks/send/useNetworkFilter'; import { ScrollView } from 'react-native-gesture-handler'; +import { + getNetworkFilterTestId, + NETWORK_FILTER_ALL_TEST_ID, +} from './network-filter.testIds'; interface NetworkFilterTabProps { label: string; @@ -27,6 +31,7 @@ interface NetworkFilterTabProps { isSelected: boolean; onPress: () => void; showIcon?: boolean; + testID: string; } const NetworkFilterTab: React.FC = ({ @@ -35,6 +40,7 @@ const NetworkFilterTab: React.FC = ({ isSelected, onPress, showIcon = false, + testID, }) => { const tw = useTailwind(); @@ -49,6 +55,7 @@ const NetworkFilterTab: React.FC = ({ ) } hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }} + testID={testID} > {showIcon && imageSource && ( @@ -135,6 +142,7 @@ export const NetworkFilter: React.FC = ({ isSelected={selectedNetworkFilter === NETWORK_FILTER_ALL} onPress={() => setSelectedNetworkFilter(NETWORK_FILTER_ALL)} showIcon={false} + testID={NETWORK_FILTER_ALL_TEST_ID} /> {/* Individual Network Tabs */} @@ -146,6 +154,7 @@ export const NetworkFilter: React.FC = ({ isSelected={selectedNetworkFilter === network.chainId} onPress={() => setSelectedNetworkFilter(network.chainId)} showIcon + testID={getNetworkFilterTestId(network.chainId)} /> ))} diff --git a/app/constants/data-services.ts b/app/constants/data-services.ts new file mode 100644 index 00000000000..6649192899f --- /dev/null +++ b/app/constants/data-services.ts @@ -0,0 +1,2 @@ +// A list of all names of data services available in the client. +export const DATA_SERVICES: string[] = []; diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index b9453dbcf22..b0685f25056 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -233,6 +233,9 @@ enum EVENT_NAME { HARDWARE_WALLET_ADD_ACCOUNT = 'Hardware Wallet Account Connected', HARDWARE_WALLET_FORGOTTEN = 'Hardware Wallet Forgotten', HARDWARE_WALLET_ERROR = 'Hardware Wallet Connection Failed', + HARDWARE_WALLET_RECOVERY_MODAL_VIEWED = 'Hardware Wallet Recovery Modal Viewed', + HARDWARE_WALLET_RECOVERY_CTA_CLICKED = 'Hardware Wallet Recovery CTA Clicked', + HARDWARE_WALLET_RECOVERY_SUCCESS_MODAL_VIEWED = 'Hardware Wallet Recovery Success Modal Viewed', // Tokens TOKEN_DETECTED = 'Token Detected', @@ -984,6 +987,15 @@ const events = { ), HARDWARE_WALLET_FORGOTTEN: generateOpt(EVENT_NAME.HARDWARE_WALLET_FORGOTTEN), HARDWARE_WALLET_ERROR: generateOpt(EVENT_NAME.HARDWARE_WALLET_ERROR), + HARDWARE_WALLET_RECOVERY_MODAL_VIEWED: generateOpt( + EVENT_NAME.HARDWARE_WALLET_RECOVERY_MODAL_VIEWED, + ), + HARDWARE_WALLET_RECOVERY_CTA_CLICKED: generateOpt( + EVENT_NAME.HARDWARE_WALLET_RECOVERY_CTA_CLICKED, + ), + HARDWARE_WALLET_RECOVERY_SUCCESS_MODAL_VIEWED: generateOpt( + EVENT_NAME.HARDWARE_WALLET_RECOVERY_SUCCESS_MODAL_VIEWED, + ), TOKEN_DETECTED: generateOpt(EVENT_NAME.TOKEN_DETECTED), TOKEN_IMPORT_CLICKED: generateOpt(EVENT_NAME.TOKEN_IMPORT_CLICKED), diff --git a/app/core/HardwareWallet/HardwareWalletProvider.test.tsx b/app/core/HardwareWallet/HardwareWalletProvider.test.tsx index bd595ad0092..52f0d15aee0 100644 --- a/app/core/HardwareWallet/HardwareWalletProvider.test.tsx +++ b/app/core/HardwareWallet/HardwareWalletProvider.test.tsx @@ -51,6 +51,17 @@ jest.mock('./adapters', () => ({ createAdapter: jest.fn(() => mockAdapterInstance), })); +jest.mock('../../components/hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: jest.fn(), + createEventBuilder: jest.fn().mockReturnValue({ + addProperties: jest.fn().mockReturnValue({ + build: jest.fn().mockReturnValue({ name: 'built-event' }), + }), + }), + }), +})); + jest.mock('@ledgerhq/react-native-hw-transport-ble', () => ({ __esModule: true, default: { @@ -150,8 +161,7 @@ describe('HardwareWalletProvider', () => { describe('wallet type detection', () => { it('detects hardware wallet from selected account', async () => { - const mockAccount = { address: '0x1234' }; - mockUseSelector.mockReturnValue(mockAccount); + mockUseSelector.mockReturnValue({ address: '0x1234' }); mockGetHardwareWalletType.mockReturnValue(HardwareWalletType.Ledger); const { getByTestId } = renderProvider(); @@ -197,8 +207,6 @@ describe('HardwareWalletProvider', () => { renderProvider(); - // The provider always creates an adapter - for non-hardware accounts it creates - // a NonHardwareAdapter (passthrough) by calling createAdapter(null, ...) expect(mockCreateAdapter).toHaveBeenCalledWith( null, expect.objectContaining({ @@ -629,7 +637,6 @@ describe('HardwareWalletProvider', () => { it('updates wallet type when set', async () => { mockUseSelector.mockReturnValue(null); - mockGetHardwareWalletType.mockReturnValue(undefined); const { result } = renderHook(() => useTestActions(), { wrapper: ({ children }: { children: React.ReactNode }) => ( diff --git a/app/core/HardwareWallet/HardwareWalletProvider.tsx b/app/core/HardwareWallet/HardwareWalletProvider.tsx index 5268ada9023..e682937b481 100644 --- a/app/core/HardwareWallet/HardwareWalletProvider.tsx +++ b/app/core/HardwareWallet/HardwareWalletProvider.tsx @@ -1,4 +1,10 @@ -import React, { ReactNode, useCallback, useMemo, useRef } from 'react'; +import React, { + ReactNode, + useCallback, + useMemo, + useRef, + useState, +} from 'react'; import HardwareWalletContext from './contexts/HardwareWalletContext'; import { HardwareWalletBottomSheet } from './components'; @@ -12,6 +18,11 @@ import { } from './hooks'; import { ConnectionStatus } from '@metamask/hw-wallet-sdk'; import DevLogger from '../SDKConnect/utils/DevLogger'; +import { + HardwareWalletAnalyticsFlow, + useHardwareWalletAnalytics, +} from './analytics'; +import { useAnalyticsFlowFromApproval } from './analytics/useAnalyticsFlowFromApproval'; interface HardwareWalletProviderProps { children: ReactNode; @@ -69,6 +80,29 @@ export const HardwareWalletProvider: React.FC = ({ updateConnectionState, }); + const awaitingConfirmationRejectRef = useRef<(() => void) | null>(null); + const operationTypeRef = useRef<'transaction' | 'message' | null>(null); + + const [analyticsFlow, setAnalyticsFlow] = useState( + HardwareWalletAnalyticsFlow.Connection, + ); + + const derivedAnalyticsFlow = useAnalyticsFlowFromApproval(); + const derivedAnalyticsFlowRef = useRef(derivedAnalyticsFlow); + derivedAnalyticsFlowRef.current = derivedAnalyticsFlow; + + const { trackCTAClicked, resetAnalyticsState } = useHardwareWalletAnalytics({ + connectionState, + walletType: effectiveWalletType, + flow: analyticsFlow, + deviceModel: deviceSelection.selectedDevice?.name ?? null, + }); + + const handleFlowStart = useCallback(() => { + resetAnalyticsState(); + setAnalyticsFlow(derivedAnalyticsFlowRef.current); + }, [resetAnalyticsState]); + const { ensureDeviceReady, connect, @@ -85,10 +119,9 @@ export const HardwareWalletProvider: React.FC = ({ createAdapterWithCallbacks, initializeAdapter, checkTransportEnabledOrShowError, + onFlowStart: handleFlowStart, }); - const awaitingConfirmationRejectRef = useRef<(() => void) | null>(null); - const showHardwareWalletError = useCallback( (error: unknown) => { DevLogger.log('[HardwareWallet] showHardwareWalletError:', error); @@ -104,6 +137,7 @@ export const HardwareWalletProvider: React.FC = ({ operationType, ); awaitingConfirmationRejectRef.current = onReject ?? null; + operationTypeRef.current = operationType; updateConnectionState({ status: ConnectionStatus.AwaitingConfirmation, @@ -117,9 +151,15 @@ export const HardwareWalletProvider: React.FC = ({ const hideAwaitingConfirmation = useCallback(() => { DevLogger.log('[HardwareWallet] hideAwaitingConfirmation'); awaitingConfirmationRejectRef.current = null; + operationTypeRef.current = null; updateConnectionState({ status: ConnectionStatus.Disconnected }); }, [updateConnectionState]); + const handleCloseFlow = useCallback(() => { + setAnalyticsFlow(HardwareWalletAnalyticsFlow.Connection); + closeFlow(); + }, [closeFlow]); + const handleAwaitingConfirmationCancel = useCallback(() => { DevLogger.log('[HardwareWallet] handleAwaitingConfirmationCancel'); // eslint-disable-next-line no-empty-function @@ -164,9 +204,10 @@ export const HardwareWalletProvider: React.FC = ({ selectDevice={selectDevice} rescan={rescan} connect={connect} - onClose={closeFlow} + onClose={handleCloseFlow} onAwaitingConfirmationCancel={handleAwaitingConfirmationCancel} onConnectionSuccess={handleConnectionSuccess} + onCTAClicked={trackCTAClicked} /> ); diff --git a/app/core/HardwareWallet/analytics/helpers.test.ts b/app/core/HardwareWallet/analytics/helpers.test.ts new file mode 100644 index 00000000000..6f17db5f33c --- /dev/null +++ b/app/core/HardwareWallet/analytics/helpers.test.ts @@ -0,0 +1,365 @@ +import { + ErrorCode, + ConnectionStatus, + HardwareWalletType, + HardwareWalletError, + Severity, + Category, + type HardwareWalletConnectionState, +} from '@metamask/hw-wallet-sdk'; +import { TransactionType } from '@metamask/transaction-controller'; +import { + HardwareWalletAnalyticsErrorType, + HardwareWalletAnalyticsFlow, + getAnalyticsErrorType, + getErrorTypeFromConnectionState, + getAnalyticsDeviceType, + getErrorDetails, + getAnalyticsFlowFromApproval, +} from './helpers'; + +describe('analytics helpers', () => { + describe('getAnalyticsErrorType', () => { + it('maps AuthenticationDeviceLocked to Device Locked', () => { + expect(getAnalyticsErrorType(ErrorCode.AuthenticationDeviceLocked)).toBe( + HardwareWalletAnalyticsErrorType.DeviceLocked, + ); + }); + + it('maps AuthenticationDeviceBlocked to Device Locked', () => { + expect(getAnalyticsErrorType(ErrorCode.AuthenticationDeviceBlocked)).toBe( + HardwareWalletAnalyticsErrorType.DeviceLocked, + ); + }); + + it('maps DeviceDisconnected to Device Disconnected', () => { + expect(getAnalyticsErrorType(ErrorCode.DeviceDisconnected)).toBe( + HardwareWalletAnalyticsErrorType.DeviceDisconnected, + ); + }); + + it('maps DeviceNotFound to Device Disconnected', () => { + expect(getAnalyticsErrorType(ErrorCode.DeviceNotFound)).toBe( + HardwareWalletAnalyticsErrorType.DeviceDisconnected, + ); + }); + + it('maps DeviceNotReady to Device Disconnected', () => { + expect(getAnalyticsErrorType(ErrorCode.DeviceNotReady)).toBe( + HardwareWalletAnalyticsErrorType.DeviceDisconnected, + ); + }); + + it('maps DeviceUnresponsive to Device Disconnected', () => { + expect(getAnalyticsErrorType(ErrorCode.DeviceUnresponsive)).toBe( + HardwareWalletAnalyticsErrorType.DeviceDisconnected, + ); + }); + + it('maps ConnectionClosed to Device Disconnected', () => { + expect(getAnalyticsErrorType(ErrorCode.ConnectionClosed)).toBe( + HardwareWalletAnalyticsErrorType.DeviceDisconnected, + ); + }); + + it('maps ConnectionTimeout to Device Disconnected', () => { + expect(getAnalyticsErrorType(ErrorCode.ConnectionTimeout)).toBe( + HardwareWalletAnalyticsErrorType.DeviceDisconnected, + ); + }); + + it('maps DeviceStateEthAppClosed to Ethereum App Not Opened', () => { + expect(getAnalyticsErrorType(ErrorCode.DeviceStateEthAppClosed)).toBe( + HardwareWalletAnalyticsErrorType.EthereumAppNotOpened, + ); + }); + + it('maps DeviceMissingCapability to Ethereum App Not Opened', () => { + expect(getAnalyticsErrorType(ErrorCode.DeviceMissingCapability)).toBe( + HardwareWalletAnalyticsErrorType.EthereumAppNotOpened, + ); + }); + + it('maps BluetoothDisabled to Bluetooth Disabled', () => { + expect(getAnalyticsErrorType(ErrorCode.BluetoothDisabled)).toBe( + HardwareWalletAnalyticsErrorType.BluetoothDisabled, + ); + }); + + it('maps PermissionBluetoothDenied to Bluetooth Disabled', () => { + expect(getAnalyticsErrorType(ErrorCode.PermissionBluetoothDenied)).toBe( + HardwareWalletAnalyticsErrorType.BluetoothDisabled, + ); + }); + + it('maps DeviceStateBlindSignNotSupported to Blind Signing Not Enabled', () => { + expect( + getAnalyticsErrorType(ErrorCode.DeviceStateBlindSignNotSupported), + ).toBe(HardwareWalletAnalyticsErrorType.BlindSigningNotEnabled); + }); + + it('maps Unknown to Generic Error', () => { + expect(getAnalyticsErrorType(ErrorCode.Unknown)).toBe( + HardwareWalletAnalyticsErrorType.GenericError, + ); + }); + + it('maps UserRejected to Generic Error', () => { + expect(getAnalyticsErrorType(ErrorCode.UserRejected)).toBe( + HardwareWalletAnalyticsErrorType.GenericError, + ); + }); + + it('maps unmapped codes to Generic Error', () => { + expect(getAnalyticsErrorType(999 as ErrorCode)).toBe( + HardwareWalletAnalyticsErrorType.GenericError, + ); + }); + }); + + describe('getErrorTypeFromConnectionState', () => { + it('returns error type for ErrorState status', () => { + const error = new HardwareWalletError('Disconnected', { + code: ErrorCode.DeviceDisconnected, + severity: Severity.Err, + category: Category.Connection, + userMessage: 'Disconnected', + }); + + const state: HardwareWalletConnectionState = { + status: ConnectionStatus.ErrorState, + error, + }; + + expect(getErrorTypeFromConnectionState(state)).toBe( + HardwareWalletAnalyticsErrorType.DeviceDisconnected, + ); + }); + + it('returns Ethereum App Not Opened for AwaitingApp status', () => { + const state: HardwareWalletConnectionState = { + status: ConnectionStatus.AwaitingApp, + deviceId: 'device-123', + appName: 'Ethereum', + }; + + expect(getErrorTypeFromConnectionState(state)).toBe( + HardwareWalletAnalyticsErrorType.EthereumAppNotOpened, + ); + }); + + it('returns null for Disconnected status', () => { + const state: HardwareWalletConnectionState = { + status: ConnectionStatus.Disconnected, + }; + + expect(getErrorTypeFromConnectionState(state)).toBeNull(); + }); + + it('returns null for Ready status', () => { + const state: HardwareWalletConnectionState = { + status: ConnectionStatus.Ready, + }; + + expect(getErrorTypeFromConnectionState(state)).toBeNull(); + }); + + it('returns null for Connecting status', () => { + const state: HardwareWalletConnectionState = { + status: ConnectionStatus.Connecting, + }; + + expect(getErrorTypeFromConnectionState(state)).toBeNull(); + }); + }); + + describe('getAnalyticsDeviceType', () => { + it('returns Ledger for Ledger type', () => { + expect(getAnalyticsDeviceType(HardwareWalletType.Ledger)).toBe('Ledger'); + }); + + it('returns Trezor for Trezor type', () => { + expect(getAnalyticsDeviceType(HardwareWalletType.Trezor)).toBe('Trezor'); + }); + + it('returns QR Hardware for Qr type', () => { + expect(getAnalyticsDeviceType(HardwareWalletType.Qr)).toBe('QR Hardware'); + }); + + it('returns OneKey for OneKey type', () => { + expect(getAnalyticsDeviceType(HardwareWalletType.OneKey)).toBe('OneKey'); + }); + + it('returns Lattice for Lattice type', () => { + expect(getAnalyticsDeviceType(HardwareWalletType.Lattice)).toBe( + 'Lattice', + ); + }); + + it('returns Unknown for null', () => { + expect(getAnalyticsDeviceType(null)).toBe('Unknown'); + }); + + it('returns Unknown for Unknown type', () => { + expect(getAnalyticsDeviceType(HardwareWalletType.Unknown)).toBe( + 'Unknown', + ); + }); + }); + + describe('getErrorDetails', () => { + it('returns error_code and error_message from ErrorState', () => { + const error = new HardwareWalletError('Device disconnected', { + code: ErrorCode.DeviceDisconnected, + severity: Severity.Err, + category: Category.Connection, + userMessage: 'Your device was disconnected', + }); + + const state: HardwareWalletConnectionState = { + status: ConnectionStatus.ErrorState, + error, + }; + + const result = getErrorDetails(state); + expect(result.error_code).toBe(String(ErrorCode.DeviceDisconnected)); + expect(result.error_message).toBe('Your device was disconnected'); + }); + + it('returns details from AwaitingApp state', () => { + const state: HardwareWalletConnectionState = { + status: ConnectionStatus.AwaitingApp, + deviceId: 'device-123', + appName: 'Ethereum', + }; + + const result = getErrorDetails(state); + expect(result.error_code).toBe(String(ErrorCode.DeviceStateEthAppClosed)); + expect(result.error_message).toContain('Ethereum'); + }); + + it('defaults appName to Ethereum when not provided', () => { + const state: HardwareWalletConnectionState = { + status: ConnectionStatus.AwaitingApp, + deviceId: 'device-123', + }; + + const result = getErrorDetails(state); + expect(result.error_message).toContain('Ethereum'); + }); + + it('returns empty strings for non-error states', () => { + const state: HardwareWalletConnectionState = { + status: ConnectionStatus.Disconnected, + }; + + const result = getErrorDetails(state); + expect(result.error_code).toBe(''); + expect(result.error_message).toBe(''); + }); + }); + + describe('getAnalyticsFlowFromApproval', () => { + it('returns Message for personal_sign', () => { + expect( + getAnalyticsFlowFromApproval({ approvalType: 'personal_sign' }), + ).toBe(HardwareWalletAnalyticsFlow.Message); + }); + + it('returns Message for eth_signTypedData', () => { + expect( + getAnalyticsFlowFromApproval({ approvalType: 'eth_signTypedData' }), + ).toBe(HardwareWalletAnalyticsFlow.Message); + }); + + it('returns Send for simpleSend', () => { + expect( + getAnalyticsFlowFromApproval({ + approvalType: 'transaction', + transactionType: TransactionType.simpleSend, + }), + ).toBe(HardwareWalletAnalyticsFlow.Send); + }); + + it('returns Send for tokenMethodTransfer', () => { + expect( + getAnalyticsFlowFromApproval({ + approvalType: 'transaction', + transactionType: TransactionType.tokenMethodTransfer, + }), + ).toBe(HardwareWalletAnalyticsFlow.Send); + }); + + it('returns Send for tokenMethodTransferFrom', () => { + expect( + getAnalyticsFlowFromApproval({ + approvalType: 'transaction', + transactionType: TransactionType.tokenMethodTransferFrom, + }), + ).toBe(HardwareWalletAnalyticsFlow.Send); + }); + + it('returns Swaps for swap', () => { + expect( + getAnalyticsFlowFromApproval({ + approvalType: 'transaction', + transactionType: TransactionType.swap, + }), + ).toBe(HardwareWalletAnalyticsFlow.Swaps); + }); + + it('returns Swaps for swapApproval', () => { + expect( + getAnalyticsFlowFromApproval({ + approvalType: 'transaction', + transactionType: TransactionType.swapApproval, + }), + ).toBe(HardwareWalletAnalyticsFlow.Swaps); + }); + + it('returns Swaps for bridge', () => { + expect( + getAnalyticsFlowFromApproval({ + approvalType: 'transaction', + transactionType: TransactionType.bridge, + }), + ).toBe(HardwareWalletAnalyticsFlow.Swaps); + }); + + it('returns Swaps for bridgeApproval', () => { + expect( + getAnalyticsFlowFromApproval({ + approvalType: 'transaction', + transactionType: TransactionType.bridgeApproval, + }), + ).toBe(HardwareWalletAnalyticsFlow.Swaps); + }); + + it('returns Transaction for contractInteraction', () => { + expect( + getAnalyticsFlowFromApproval({ + approvalType: 'transaction', + transactionType: TransactionType.contractInteraction, + }), + ).toBe(HardwareWalletAnalyticsFlow.Transaction); + }); + + it('returns Connection for transaction without type', () => { + expect( + getAnalyticsFlowFromApproval({ approvalType: 'transaction' }), + ).toBe(HardwareWalletAnalyticsFlow.Connection); + }); + + it('returns Connection when no approvalType', () => { + expect(getAnalyticsFlowFromApproval({})).toBe( + HardwareWalletAnalyticsFlow.Connection, + ); + }); + + it('returns Connection for undefined approvalType', () => { + expect(getAnalyticsFlowFromApproval({ approvalType: undefined })).toBe( + HardwareWalletAnalyticsFlow.Connection, + ); + }); + }); +}); diff --git a/app/core/HardwareWallet/analytics/helpers.ts b/app/core/HardwareWallet/analytics/helpers.ts new file mode 100644 index 00000000000..45cec023278 --- /dev/null +++ b/app/core/HardwareWallet/analytics/helpers.ts @@ -0,0 +1,188 @@ +import { + ErrorCode, + HardwareWalletType, + HardwareWalletConnectionState, + ConnectionStatus, +} from '@metamask/hw-wallet-sdk'; +import { ApprovalType } from '@metamask/controller-utils'; +import { TransactionType } from '@metamask/transaction-controller'; + +/** + * Analytics flow locations for hardware wallet interactions. + */ +export enum HardwareWalletAnalyticsFlow { + Connection = 'Connection', + Send = 'Send', + Swaps = 'Swaps', + Transaction = 'Transaction', + Message = 'Message', +} + +const SIGNATURE_APPROVAL_TYPES = new Set([ + 'personal_sign', + 'eth_signTypedData', +]); + +const SEND_TRANSACTION_TYPES = new Set([ + TransactionType.simpleSend, + TransactionType.tokenMethodTransfer, + TransactionType.tokenMethodTransferFrom, + TransactionType.tokenMethodSafeTransferFrom, +]); + +const SWAP_TRANSACTION_TYPES = new Set([ + TransactionType.swap, + TransactionType.swapApproval, + TransactionType.swapAndSend, + TransactionType.bridge, + TransactionType.bridgeApproval, +]); + +/** + * Derives the analytics flow from the current pending approval. + * + * @param approvalType - The pending approval's `type` string (e.g. 'transaction', 'personal_sign'). + * @param transactionType - The transaction type from TransactionMeta, if available. + * @returns The analytics flow to report as `location`. + */ +export function getAnalyticsFlowFromApproval({ + approvalType, + transactionType, +}: { + approvalType?: string; + transactionType?: TransactionType; +}): HardwareWalletAnalyticsFlow { + if (!approvalType) { + return HardwareWalletAnalyticsFlow.Connection; + } + + if (SIGNATURE_APPROVAL_TYPES.has(approvalType)) { + return HardwareWalletAnalyticsFlow.Message; + } + + if (approvalType === ApprovalType.Transaction && transactionType) { + if (SEND_TRANSACTION_TYPES.has(transactionType)) { + return HardwareWalletAnalyticsFlow.Send; + } + if (SWAP_TRANSACTION_TYPES.has(transactionType)) { + return HardwareWalletAnalyticsFlow.Swaps; + } + return HardwareWalletAnalyticsFlow.Transaction; + } + + return HardwareWalletAnalyticsFlow.Connection; +} + +/** + * Normalized error type categories for analytics. + * Matches the segment schema enum for `error_type`. + */ +export enum HardwareWalletAnalyticsErrorType { + DeviceLocked = 'Device Locked', + DeviceDisconnected = 'Device Disconnected', + EthereumAppNotOpened = 'Ethereum App Not Opened', + BluetoothDisabled = 'Bluetooth Disabled', + BlindSigningNotEnabled = 'Blind Signing Not Enabled', + GenericError = 'Generic Error', +} + +/** + * Maps an SDK ErrorCode to the analytics error_type category. + */ +export function getAnalyticsErrorType( + errorCode: ErrorCode, +): HardwareWalletAnalyticsErrorType { + switch (errorCode) { + case ErrorCode.AuthenticationDeviceLocked: + case ErrorCode.AuthenticationDeviceBlocked: + return HardwareWalletAnalyticsErrorType.DeviceLocked; + + case ErrorCode.DeviceDisconnected: + case ErrorCode.DeviceNotFound: + case ErrorCode.DeviceNotReady: + case ErrorCode.DeviceUnresponsive: + case ErrorCode.ConnectionClosed: + case ErrorCode.ConnectionTimeout: + return HardwareWalletAnalyticsErrorType.DeviceDisconnected; + + case ErrorCode.DeviceStateEthAppClosed: + case ErrorCode.DeviceMissingCapability: + return HardwareWalletAnalyticsErrorType.EthereumAppNotOpened; + + case ErrorCode.BluetoothDisabled: + case ErrorCode.PermissionBluetoothDenied: + return HardwareWalletAnalyticsErrorType.BluetoothDisabled; + + case ErrorCode.DeviceStateBlindSignNotSupported: + return HardwareWalletAnalyticsErrorType.BlindSigningNotEnabled; + + default: + return HardwareWalletAnalyticsErrorType.GenericError; + } +} + +/** + * Derives the analytics error_type from the current connection state. + * Returns null for non-error states. + */ +export function getErrorTypeFromConnectionState( + connectionState: HardwareWalletConnectionState, +): HardwareWalletAnalyticsErrorType | null { + if (connectionState.status === ConnectionStatus.ErrorState) { + return getAnalyticsErrorType(connectionState.error.code); + } + if (connectionState.status === ConnectionStatus.AwaitingApp) { + return HardwareWalletAnalyticsErrorType.EthereumAppNotOpened; + } + return null; +} + +/** + * Maps HardwareWalletType to the capitalised manufacturer name + * expected by the segment schema `device_type` property. + */ +export function getAnalyticsDeviceType( + walletType: HardwareWalletType | null, +): string { + switch (walletType) { + case HardwareWalletType.Ledger: + return 'Ledger'; + case HardwareWalletType.Trezor: + return 'Trezor'; + case HardwareWalletType.OneKey: + return 'OneKey'; + case HardwareWalletType.Lattice: + return 'Lattice'; + case HardwareWalletType.Qr: + return 'QR Hardware'; + default: + return 'Unknown'; + } +} + +export interface ErrorDetails { + error_code: string; + error_message: string; +} + +/** + * Extracts `error_code` and `error_message` from the current connection state. + */ +export function getErrorDetails( + connectionState: HardwareWalletConnectionState, +): ErrorDetails { + if (connectionState.status === ConnectionStatus.ErrorState) { + const { error } = connectionState; + return { + error_code: String(error.code), + error_message: error.userMessage ?? 'No error message', + }; + } + if (connectionState.status === ConnectionStatus.AwaitingApp) { + return { + error_code: String(ErrorCode.DeviceStateEthAppClosed), + error_message: `Open ${connectionState.appName ?? 'Ethereum'} app on device`, + }; + } + return { error_code: '', error_message: '' }; +} diff --git a/app/core/HardwareWallet/analytics/index.ts b/app/core/HardwareWallet/analytics/index.ts new file mode 100644 index 00000000000..3c51279adcc --- /dev/null +++ b/app/core/HardwareWallet/analytics/index.ts @@ -0,0 +1,11 @@ +export { + HardwareWalletAnalyticsFlow, + HardwareWalletAnalyticsErrorType, + getAnalyticsFlowFromApproval, + getAnalyticsErrorType, + getErrorTypeFromConnectionState, + getAnalyticsDeviceType, + getErrorDetails, +} from './helpers'; + +export { useHardwareWalletAnalytics } from './useHardwareWalletAnalytics'; diff --git a/app/core/HardwareWallet/analytics/useAnalyticsFlowFromApproval.test.ts b/app/core/HardwareWallet/analytics/useAnalyticsFlowFromApproval.test.ts new file mode 100644 index 00000000000..b6a838c3a6c --- /dev/null +++ b/app/core/HardwareWallet/analytics/useAnalyticsFlowFromApproval.test.ts @@ -0,0 +1,155 @@ +import { ApprovalType } from '@metamask/controller-utils'; +import { TransactionType } from '@metamask/transaction-controller'; +import { + renderHookWithProvider, + DeepPartial, +} from '../../../util/test/renderWithProvider'; +import { RootState } from '../../../reducers'; +import { useAnalyticsFlowFromApproval } from './useAnalyticsFlowFromApproval'; +import { HardwareWalletAnalyticsFlow } from './helpers'; + +const MOCK_TX_ID = 'tx-id-123'; + +const createStateWithApproval = ( + approvalType: string, + transactionType?: TransactionType, +): DeepPartial => ({ + engine: { + backgroundState: { + ApprovalController: { + pendingApprovals: { + [MOCK_TX_ID]: { + id: MOCK_TX_ID, + type: approvalType, + }, + }, + }, + TransactionController: { + transactions: transactionType + ? [{ id: MOCK_TX_ID, type: transactionType }] + : [], + }, + }, + }, +}); + +const EMPTY_APPROVAL_STATE: DeepPartial = { + engine: { + backgroundState: { + ApprovalController: { + pendingApprovals: {}, + }, + TransactionController: { + transactions: [], + }, + }, + }, +}; + +describe('useAnalyticsFlowFromApproval', () => { + it('returns Connection when no pending approvals exist', () => { + const { result } = renderHookWithProvider( + () => useAnalyticsFlowFromApproval(), + { state: EMPTY_APPROVAL_STATE }, + ); + + expect(result.current).toBe(HardwareWalletAnalyticsFlow.Connection); + }); + + it('returns Message for personal_sign approval', () => { + const { result } = renderHookWithProvider( + () => useAnalyticsFlowFromApproval(), + { state: createStateWithApproval('personal_sign') }, + ); + + expect(result.current).toBe(HardwareWalletAnalyticsFlow.Message); + }); + + it('returns Message for eth_signTypedData approval', () => { + const { result } = renderHookWithProvider( + () => useAnalyticsFlowFromApproval(), + { state: createStateWithApproval('eth_signTypedData') }, + ); + + expect(result.current).toBe(HardwareWalletAnalyticsFlow.Message); + }); + + it('returns Send for simpleSend transaction', () => { + const { result } = renderHookWithProvider( + () => useAnalyticsFlowFromApproval(), + { + state: createStateWithApproval( + ApprovalType.Transaction, + TransactionType.simpleSend, + ), + }, + ); + + expect(result.current).toBe(HardwareWalletAnalyticsFlow.Send); + }); + + it('returns Send for tokenMethodTransfer transaction', () => { + const { result } = renderHookWithProvider( + () => useAnalyticsFlowFromApproval(), + { + state: createStateWithApproval( + ApprovalType.Transaction, + TransactionType.tokenMethodTransfer, + ), + }, + ); + + expect(result.current).toBe(HardwareWalletAnalyticsFlow.Send); + }); + + it('returns Swaps for swap transaction', () => { + const { result } = renderHookWithProvider( + () => useAnalyticsFlowFromApproval(), + { + state: createStateWithApproval( + ApprovalType.Transaction, + TransactionType.swap, + ), + }, + ); + + expect(result.current).toBe(HardwareWalletAnalyticsFlow.Swaps); + }); + + it('returns Swaps for bridge transaction', () => { + const { result } = renderHookWithProvider( + () => useAnalyticsFlowFromApproval(), + { + state: createStateWithApproval( + ApprovalType.Transaction, + TransactionType.bridge, + ), + }, + ); + + expect(result.current).toBe(HardwareWalletAnalyticsFlow.Swaps); + }); + + it('returns Transaction for contractInteraction', () => { + const { result } = renderHookWithProvider( + () => useAnalyticsFlowFromApproval(), + { + state: createStateWithApproval( + ApprovalType.Transaction, + TransactionType.contractInteraction, + ), + }, + ); + + expect(result.current).toBe(HardwareWalletAnalyticsFlow.Transaction); + }); + + it('returns Connection for unrecognized approval type', () => { + const { result } = renderHookWithProvider( + () => useAnalyticsFlowFromApproval(), + { state: createStateWithApproval('unknown_type') }, + ); + + expect(result.current).toBe(HardwareWalletAnalyticsFlow.Connection); + }); +}); diff --git a/app/core/HardwareWallet/analytics/useAnalyticsFlowFromApproval.ts b/app/core/HardwareWallet/analytics/useAnalyticsFlowFromApproval.ts new file mode 100644 index 00000000000..a3d6b6d8bc7 --- /dev/null +++ b/app/core/HardwareWallet/analytics/useAnalyticsFlowFromApproval.ts @@ -0,0 +1,32 @@ +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { selectPendingApprovals } from '../../../selectors/approvalController'; +import { selectTransactionMetadataById } from '../../../selectors/transactionController'; +import type { RootState } from '../../../reducers'; +import { + HardwareWalletAnalyticsFlow, + getAnalyticsFlowFromApproval, +} from './helpers'; + +/** + * Derives the current analytics flow from the first pending approval in the + * Redux store. Returns `Connection` when no approval is pending (e.g. during + * account creation). Used by `HardwareWalletProvider` to automatically set + * the analytics flow context when `ensureDeviceReady` is called. + */ +export function useAnalyticsFlowFromApproval(): HardwareWalletAnalyticsFlow { + const pendingApprovals = useSelector(selectPendingApprovals); + const firstApproval = Object.values(pendingApprovals ?? {})[0]; + const transactionMetadata = useSelector((state: RootState) => + selectTransactionMetadataById(state, firstApproval?.id ?? ''), + ); + + return useMemo( + () => + getAnalyticsFlowFromApproval({ + approvalType: firstApproval?.type, + transactionType: transactionMetadata?.type, + }), + [firstApproval?.type, transactionMetadata?.type], + ); +} diff --git a/app/core/HardwareWallet/analytics/useHardwareWalletAnalytics.test.ts b/app/core/HardwareWallet/analytics/useHardwareWalletAnalytics.test.ts new file mode 100644 index 00000000000..4e4bd3ff5c8 --- /dev/null +++ b/app/core/HardwareWallet/analytics/useHardwareWalletAnalytics.test.ts @@ -0,0 +1,579 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { + HardwareWalletError, + ErrorCode, + Severity, + Category, + HardwareWalletType, + ConnectionStatus, + type HardwareWalletConnectionState, +} from '@metamask/hw-wallet-sdk'; +import { useHardwareWalletAnalytics } from './useHardwareWalletAnalytics'; +import { + HardwareWalletAnalyticsErrorType, + HardwareWalletAnalyticsFlow, +} from './helpers'; +import { MetaMetricsEvents } from '../../Analytics'; + +const mockTrackEvent = jest.fn(); +const mockBuild = jest.fn().mockReturnValue({ name: 'built-event' }); +const mockAddProperties = jest.fn().mockReturnValue({ build: mockBuild }); +const mockCreateEventBuilder = jest.fn().mockReturnValue({ + addProperties: mockAddProperties, +}); + +jest.mock('../../../components/hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + +function createError( + code: ErrorCode, + message = 'Test error', +): HardwareWalletError { + return new HardwareWalletError(message, { + code, + severity: Severity.Err, + category: Category.Connection, + userMessage: message, + }); +} + +describe('useHardwareWalletAnalytics', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const defaultOptions = { + connectionState: { + status: ConnectionStatus.Disconnected, + } as HardwareWalletConnectionState, + walletType: HardwareWalletType.Ledger, + flow: HardwareWalletAnalyticsFlow.Connection, + deviceModel: 'Nano X', + }; + + describe('Recovery Modal Viewed', () => { + it('fires when transitioning to ErrorState', () => { + const { rerender } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { initialProps: defaultOptions }, + ); + + const errorState: HardwareWalletConnectionState = { + status: ConnectionStatus.ErrorState, + error: createError(ErrorCode.DeviceDisconnected, 'Disconnected'), + }; + + rerender({ ...defaultOptions, connectionState: errorState }); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_RECOVERY_MODAL_VIEWED, + ); + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + location: 'Connection', + device_type: 'Ledger', + device_model: 'Nano X', + error_type: HardwareWalletAnalyticsErrorType.DeviceDisconnected, + error_type_view_count: 1, + error_code: String(ErrorCode.DeviceDisconnected), + error_message: 'Disconnected', + }), + ); + expect(mockTrackEvent).toHaveBeenCalled(); + }); + + it('fires when transitioning to AwaitingApp', () => { + const { rerender } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { initialProps: defaultOptions }, + ); + + const awaitingApp: HardwareWalletConnectionState = { + status: ConnectionStatus.AwaitingApp, + deviceId: 'device-123', + appName: 'Ethereum', + }; + + rerender({ ...defaultOptions, connectionState: awaitingApp }); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_RECOVERY_MODAL_VIEWED, + ); + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + error_type: HardwareWalletAnalyticsErrorType.EthereumAppNotOpened, + error_type_view_count: 1, + }), + ); + }); + + it('does not fire when status does not change', () => { + const errorState: HardwareWalletConnectionState = { + status: ConnectionStatus.ErrorState, + error: createError(ErrorCode.DeviceDisconnected), + }; + + const { rerender } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { + initialProps: { ...defaultOptions, connectionState: errorState }, + }, + ); + + mockTrackEvent.mockClear(); + mockCreateEventBuilder.mockClear(); + + rerender({ ...defaultOptions, connectionState: errorState }); + + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + + it('omits device_model when null', () => { + const { rerender } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { + initialProps: { ...defaultOptions, deviceModel: null }, + }, + ); + + const errorState: HardwareWalletConnectionState = { + status: ConnectionStatus.ErrorState, + error: createError(ErrorCode.DeviceDisconnected), + }; + + rerender({ + ...defaultOptions, + deviceModel: null, + connectionState: errorState, + }); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.not.objectContaining({ + device_model: expect.anything(), + }), + ); + }); + }); + + describe('error_type_view_count', () => { + it('increments for the same error type', () => { + const { rerender } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { initialProps: defaultOptions }, + ); + + const errorState: HardwareWalletConnectionState = { + status: ConnectionStatus.ErrorState, + error: createError(ErrorCode.DeviceDisconnected), + }; + + rerender({ ...defaultOptions, connectionState: errorState }); + expect(mockAddProperties).toHaveBeenLastCalledWith( + expect.objectContaining({ error_type_view_count: 1 }), + ); + + rerender({ + ...defaultOptions, + connectionState: { status: ConnectionStatus.Connecting }, + }); + + mockAddProperties.mockClear(); + + rerender({ ...defaultOptions, connectionState: errorState }); + expect(mockAddProperties).toHaveBeenLastCalledWith( + expect.objectContaining({ error_type_view_count: 2 }), + ); + }); + + it('starts at 1 for a different error type', () => { + const { rerender } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { initialProps: defaultOptions }, + ); + + rerender({ + ...defaultOptions, + connectionState: { + status: ConnectionStatus.ErrorState, + error: createError(ErrorCode.DeviceDisconnected), + }, + }); + expect(mockAddProperties).toHaveBeenLastCalledWith( + expect.objectContaining({ + error_type: HardwareWalletAnalyticsErrorType.DeviceDisconnected, + error_type_view_count: 1, + }), + ); + + rerender({ + ...defaultOptions, + connectionState: { status: ConnectionStatus.Connecting }, + }); + + mockAddProperties.mockClear(); + + rerender({ + ...defaultOptions, + connectionState: { + status: ConnectionStatus.ErrorState, + error: createError(ErrorCode.BluetoothDisabled), + }, + }); + expect(mockAddProperties).toHaveBeenLastCalledWith( + expect.objectContaining({ + error_type: HardwareWalletAnalyticsErrorType.BluetoothDisabled, + error_type_view_count: 1, + }), + ); + }); + + it('resets on success', () => { + const { rerender } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { initialProps: defaultOptions }, + ); + + rerender({ + ...defaultOptions, + connectionState: { + status: ConnectionStatus.ErrorState, + error: createError(ErrorCode.DeviceDisconnected), + }, + }); + + rerender({ + ...defaultOptions, + connectionState: { status: ConnectionStatus.Ready }, + }); + + rerender({ + ...defaultOptions, + connectionState: { status: ConnectionStatus.Disconnected }, + }); + + mockAddProperties.mockClear(); + + rerender({ + ...defaultOptions, + connectionState: { + status: ConnectionStatus.ErrorState, + error: createError(ErrorCode.DeviceDisconnected), + }, + }); + expect(mockAddProperties).toHaveBeenLastCalledWith( + expect.objectContaining({ error_type_view_count: 1 }), + ); + }); + }); + + describe('Recovery Success Modal Viewed', () => { + it('fires when transitioning to Ready after an error', () => { + const { rerender } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { initialProps: defaultOptions }, + ); + + rerender({ + ...defaultOptions, + connectionState: { + status: ConnectionStatus.ErrorState, + error: createError(ErrorCode.DeviceDisconnected, 'Disconnected'), + }, + }); + + mockCreateEventBuilder.mockClear(); + mockAddProperties.mockClear(); + mockTrackEvent.mockClear(); + + rerender({ + ...defaultOptions, + connectionState: { status: ConnectionStatus.Ready }, + }); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_RECOVERY_SUCCESS_MODAL_VIEWED, + ); + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + location: 'Connection', + device_type: 'Ledger', + device_model: 'Nano X', + error_type: HardwareWalletAnalyticsErrorType.DeviceDisconnected, + error_type_view_count: 1, + error_code: String(ErrorCode.DeviceDisconnected), + error_message: 'Disconnected', + }), + ); + }); + + it('fires without error properties when no preceding error occurred', () => { + const { rerender } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { initialProps: defaultOptions }, + ); + + rerender({ + ...defaultOptions, + connectionState: { status: ConnectionStatus.Ready }, + }); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_RECOVERY_SUCCESS_MODAL_VIEWED, + ); + expect(mockAddProperties).toHaveBeenCalledWith( + expect.not.objectContaining({ + error_type: expect.anything(), + error_type_view_count: expect.anything(), + }), + ); + }); + }); + + describe('CTA Clicked', () => { + it('fires with correct properties', () => { + const errorState: HardwareWalletConnectionState = { + status: ConnectionStatus.ErrorState, + error: createError(ErrorCode.BluetoothDisabled, 'BT off'), + }; + + const { result } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { + initialProps: { ...defaultOptions, connectionState: errorState }, + }, + ); + + mockCreateEventBuilder.mockClear(); + mockAddProperties.mockClear(); + mockTrackEvent.mockClear(); + + act(() => { + result.current.trackCTAClicked(); + }); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_RECOVERY_CTA_CLICKED, + ); + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + location: 'Connection', + device_type: 'Ledger', + device_model: 'Nano X', + error_type: HardwareWalletAnalyticsErrorType.BluetoothDisabled, + error_type_view_count: 1, + error_code: String(ErrorCode.BluetoothDisabled), + error_message: 'BT off', + }), + ); + }); + + it('does not fire when not in an error state', () => { + const { result } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { initialProps: defaultOptions }, + ); + + mockTrackEvent.mockClear(); + + act(() => { + result.current.trackCTAClicked(); + }); + + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + + it('fires for AwaitingApp state', () => { + const awaitingApp: HardwareWalletConnectionState = { + status: ConnectionStatus.AwaitingApp, + deviceId: 'device-123', + appName: 'Ethereum', + }; + + const { result } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { + initialProps: { ...defaultOptions, connectionState: awaitingApp }, + }, + ); + + mockCreateEventBuilder.mockClear(); + mockAddProperties.mockClear(); + mockTrackEvent.mockClear(); + + act(() => { + result.current.trackCTAClicked(); + }); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + error_type: HardwareWalletAnalyticsErrorType.EthereumAppNotOpened, + }), + ); + }); + }); + + describe('flow variants', () => { + it('tracks Transaction flow', () => { + const { rerender } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { + initialProps: { + ...defaultOptions, + flow: HardwareWalletAnalyticsFlow.Transaction, + }, + }, + ); + + rerender({ + ...defaultOptions, + flow: HardwareWalletAnalyticsFlow.Transaction, + connectionState: { + status: ConnectionStatus.ErrorState, + error: createError(ErrorCode.DeviceDisconnected), + }, + }); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ location: 'Transaction' }), + ); + }); + + it('tracks Message flow', () => { + const { rerender } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { + initialProps: { + ...defaultOptions, + flow: HardwareWalletAnalyticsFlow.Message, + }, + }, + ); + + rerender({ + ...defaultOptions, + flow: HardwareWalletAnalyticsFlow.Message, + connectionState: { + status: ConnectionStatus.ErrorState, + error: createError(ErrorCode.DeviceDisconnected), + }, + }); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ location: 'Message' }), + ); + }); + + it('tracks Send flow', () => { + const { rerender } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { + initialProps: { + ...defaultOptions, + flow: HardwareWalletAnalyticsFlow.Send, + }, + }, + ); + + rerender({ + ...defaultOptions, + flow: HardwareWalletAnalyticsFlow.Send, + connectionState: { + status: ConnectionStatus.ErrorState, + error: createError(ErrorCode.DeviceDisconnected), + }, + }); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ location: 'Send' }), + ); + }); + + it('tracks Swaps flow', () => { + const { rerender } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { + initialProps: { + ...defaultOptions, + flow: HardwareWalletAnalyticsFlow.Swaps, + }, + }, + ); + + rerender({ + ...defaultOptions, + flow: HardwareWalletAnalyticsFlow.Swaps, + connectionState: { + status: ConnectionStatus.ErrorState, + error: createError(ErrorCode.DeviceDisconnected), + }, + }); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ location: 'Swaps' }), + ); + }); + + it('defaults to Connection flow', () => { + const { rerender } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { initialProps: defaultOptions }, + ); + + rerender({ + ...defaultOptions, + connectionState: { + status: ConnectionStatus.ErrorState, + error: createError(ErrorCode.DeviceDisconnected), + }, + }); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ location: 'Connection' }), + ); + }); + }); + + describe('spurious disconnect mid-flow', () => { + it('preserves error state across disconnect so success includes it', () => { + const { rerender } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { initialProps: defaultOptions }, + ); + + rerender({ + ...defaultOptions, + connectionState: { + status: ConnectionStatus.ErrorState, + error: createError(ErrorCode.DeviceDisconnected), + }, + }); + + rerender({ + ...defaultOptions, + connectionState: { status: ConnectionStatus.Disconnected }, + }); + + mockCreateEventBuilder.mockClear(); + mockAddProperties.mockClear(); + + rerender({ + ...defaultOptions, + connectionState: { status: ConnectionStatus.Ready }, + }); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_RECOVERY_SUCCESS_MODAL_VIEWED, + ); + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + error_type: HardwareWalletAnalyticsErrorType.DeviceDisconnected, + error_type_view_count: 1, + }), + ); + }); + }); +}); diff --git a/app/core/HardwareWallet/analytics/useHardwareWalletAnalytics.ts b/app/core/HardwareWallet/analytics/useHardwareWalletAnalytics.ts new file mode 100644 index 00000000000..803658f0998 --- /dev/null +++ b/app/core/HardwareWallet/analytics/useHardwareWalletAnalytics.ts @@ -0,0 +1,177 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { + HardwareWalletConnectionState, + HardwareWalletType, + ConnectionStatus, +} from '@metamask/hw-wallet-sdk'; +import { useAnalytics } from '../../../components/hooks/useAnalytics/useAnalytics'; +import { MetaMetricsEvents } from '../../Analytics'; +import { + HardwareWalletAnalyticsErrorType, + HardwareWalletAnalyticsFlow, + getErrorTypeFromConnectionState, + getAnalyticsDeviceType, + getErrorDetails, + type ErrorDetails, +} from './helpers'; + +interface UseHardwareWalletAnalyticsOptions { + connectionState: HardwareWalletConnectionState; + walletType: HardwareWalletType | null; + flow: HardwareWalletAnalyticsFlow; + deviceModel: string | null; +} + +interface UseHardwareWalletAnalyticsResult { + trackCTAClicked: () => void; + resetAnalyticsState: () => void; +} + +/** + * Tracks hardware wallet recovery analytics events by reacting to + * `connectionState` transitions. + * + * - **Recovery Modal Viewed** – fires each time the user enters an + * error or "awaiting app" state. + * - **Recovery CTA Clicked** – fires when the user taps + * Continue/Retry from the error or awaiting-app screen. + * - **Recovery Success Modal Viewed** – fires every time the device + * reaches the Ready state. Error-related properties are only + * included when the user recovered from a preceding error. + * + * `error_type_view_count` is tracked per error_type and resets on + * success or when the flow is dismissed. + */ +export function useHardwareWalletAnalytics({ + connectionState, + walletType, + flow, + deviceModel, +}: UseHardwareWalletAnalyticsOptions): UseHardwareWalletAnalyticsResult { + const { trackEvent, createEventBuilder } = useAnalytics(); + + const viewCountsRef = useRef>(new Map()); + const lastErrorTypeRef = useRef( + null, + ); + const lastErrorTypeViewCountRef = useRef(0); + const lastErrorDetailsRef = useRef({ + error_code: '', + error_message: '', + }); + const prevStatusRef = useRef(ConnectionStatus.Disconnected); + + const resetAnalyticsState = useCallback(() => { + viewCountsRef.current.clear(); + lastErrorTypeRef.current = null; + lastErrorTypeViewCountRef.current = 0; + lastErrorDetailsRef.current = { error_code: '', error_message: '' }; + }, []); + + useEffect(() => { + const currentStatus = connectionState.status; + const prevStatus = prevStatusRef.current; + prevStatusRef.current = currentStatus; + + if (currentStatus === prevStatus) return; + + const isRecoveryState = + currentStatus === ConnectionStatus.ErrorState || + currentStatus === ConnectionStatus.AwaitingApp; + + const isSuccessState = currentStatus === ConnectionStatus.Ready; + + if (isRecoveryState) { + const errorType = getErrorTypeFromConnectionState(connectionState); + if (!errorType) return; + + const currentCount = viewCountsRef.current.get(errorType) ?? 0; + const newCount = currentCount + 1; + viewCountsRef.current.set(errorType, newCount); + + const errorDetails = getErrorDetails(connectionState); + + lastErrorTypeRef.current = errorType; + lastErrorTypeViewCountRef.current = newCount; + lastErrorDetailsRef.current = errorDetails; + + trackEvent( + createEventBuilder( + MetaMetricsEvents.HARDWARE_WALLET_RECOVERY_MODAL_VIEWED, + ) + .addProperties({ + location: flow, + device_type: getAnalyticsDeviceType(walletType), + ...(deviceModel && { device_model: deviceModel }), + error_type: errorType, + error_type_view_count: newCount, + error_code: errorDetails.error_code, + error_message: errorDetails.error_message, + }) + .build(), + ); + } + + if (isSuccessState) { + const lastErrorType = lastErrorTypeRef.current; + + trackEvent( + createEventBuilder( + MetaMetricsEvents.HARDWARE_WALLET_RECOVERY_SUCCESS_MODAL_VIEWED, + ) + .addProperties({ + location: flow, + device_type: getAnalyticsDeviceType(walletType), + ...(deviceModel && { device_model: deviceModel }), + ...(lastErrorType && { + error_type: lastErrorType, + error_type_view_count: lastErrorTypeViewCountRef.current, + error_code: lastErrorDetailsRef.current.error_code, + error_message: lastErrorDetailsRef.current.error_message, + }), + }) + .build(), + ); + + resetAnalyticsState(); + } + }, [ + connectionState, + walletType, + flow, + deviceModel, + trackEvent, + createEventBuilder, + resetAnalyticsState, + ]); + + const trackCTAClicked = useCallback(() => { + const errorType = getErrorTypeFromConnectionState(connectionState); + if (!errorType) return; + + const errorDetails = getErrorDetails(connectionState); + + trackEvent( + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_RECOVERY_CTA_CLICKED) + .addProperties({ + location: flow, + device_type: getAnalyticsDeviceType(walletType), + ...(deviceModel && { device_model: deviceModel }), + error_type: errorType, + error_type_view_count: viewCountsRef.current.get(errorType) ?? 1, + error_code: errorDetails.error_code, + error_message: errorDetails.error_message, + }) + .build(), + ); + }, [ + connectionState, + walletType, + flow, + deviceModel, + trackEvent, + createEventBuilder, + ]); + + return { trackCTAClicked, resetAnalyticsState }; +} diff --git a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/HardwareWalletBottomSheet.tsx b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/HardwareWalletBottomSheet.tsx index 67f2c47520e..7c3eac4651b 100644 --- a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/HardwareWalletBottomSheet.tsx +++ b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/HardwareWalletBottomSheet.tsx @@ -52,6 +52,8 @@ export interface HardwareWalletBottomSheetProps { onConnectionSuccess?: () => void; /** Callback when user cancels during awaiting confirmation state */ onAwaitingConfirmationCancel?: () => void; + /** Callback fired when the user taps the CTA on an error/recovery screen. */ + onCTAClicked?: () => void; } /** @@ -79,6 +81,7 @@ export const HardwareWalletBottomSheet: React.FC< successAutoDismissMs = 1000, onConnectionSuccess, onAwaitingConfirmationCancel, + onCTAClicked, }) => { const { colors } = useTheme(); const styles = useMemo(() => createStyles(colors), [colors]); @@ -124,12 +127,14 @@ export const HardwareWalletBottomSheet: React.FC< }, [onAwaitingConfirmationCancel]); const handleErrorContinue = useCallback(async () => { + onCTAClicked?.(); await retryEnsureDeviceReady(); - }, [retryEnsureDeviceReady]); + }, [retryEnsureDeviceReady, onCTAClicked]); const handleErrorDismiss = useCallback(() => { + onCTAClicked?.(); onClose(); - }, [onClose]); + }, [onClose, onCTAClicked]); const handleSuccessDismiss = useCallback(() => { onConnectionSuccess?.(); diff --git a/app/core/HardwareWallet/hooks/useDeviceConnectionFlow.ts b/app/core/HardwareWallet/hooks/useDeviceConnectionFlow.ts index f0215fc79bb..be9d09f5f38 100644 --- a/app/core/HardwareWallet/hooks/useDeviceConnectionFlow.ts +++ b/app/core/HardwareWallet/hooks/useDeviceConnectionFlow.ts @@ -26,6 +26,8 @@ interface UseDeviceConnectionFlowOptions { checkTransportEnabledOrShowError: ( adapter: HardwareWalletAdapter, ) => Promise; + /** Called at the start of each new ensureDeviceReady flow. */ + onFlowStart?: () => void; } interface UseDeviceConnectionFlowResult { @@ -52,6 +54,7 @@ export const useDeviceConnectionFlow = ({ createAdapterWithCallbacks, initializeAdapter, checkTransportEnabledOrShowError, + onFlowStart, }: UseDeviceConnectionFlowOptions): UseDeviceConnectionFlowResult => { const pendingReadyResolveRef = useRef<((ready: boolean) => void) | null>( null, @@ -188,6 +191,8 @@ export const useDeviceConnectionFlow = ({ targetDeviceId, ); + onFlowStart?.(); + if (pendingReadyResolveRef.current) { DevLogger.log( '[HardwareWallet] Abandoning previous pending readiness check (not resolving)', @@ -253,6 +258,7 @@ export const useDeviceConnectionFlow = ({ tryEnsureReady, checkTransportEnabledOrShowError, createBlockingPromise, + onFlowStart, ], ); diff --git a/app/core/HardwareWallet/hooks/useHardwareWalletStateManager.ts b/app/core/HardwareWallet/hooks/useHardwareWalletStateManager.ts index 1094a8a45d0..5bc77111a0e 100644 --- a/app/core/HardwareWallet/hooks/useHardwareWalletStateManager.ts +++ b/app/core/HardwareWallet/hooks/useHardwareWalletStateManager.ts @@ -85,12 +85,9 @@ export const useHardwareWalletStateManager = const selectedAccount = useSelector(selectSelectedInternalAccount); - const walletType = useMemo((): HardwareWalletType | null => { - if (!selectedAccount?.address) { - return null; - } - return getHardwareWalletTypeForAddress(selectedAccount.address) ?? null; - }, [selectedAccount?.address]); + const walletType: HardwareWalletType | null = selectedAccount?.address + ? (getHardwareWalletTypeForAddress(selectedAccount.address) ?? null) + : null; const state = useMemo( () => ({ diff --git a/app/core/ReactQueryService/ReactQueryService.test.ts b/app/core/ReactQueryService/ReactQueryService.test.ts index 22660415165..7f2112b1d10 100644 --- a/app/core/ReactQueryService/ReactQueryService.test.ts +++ b/app/core/ReactQueryService/ReactQueryService.test.ts @@ -7,8 +7,8 @@ import { import { addEventListener as addNetInfoEventListener } from '@react-native-community/netinfo'; import { ReactQueryService } from './ReactQueryService'; -jest.mock('@tanstack/react-query', () => ({ - QueryClient: jest.fn().mockImplementation(() => ({ clear: jest.fn() })), +jest.mock('@tanstack/query-core', () => ({ + ...jest.requireActual('@tanstack/query-core'), focusManager: { setFocused: jest.fn() }, onlineManager: { setEventListener: jest.fn() }, })); @@ -43,14 +43,15 @@ describe('ReactQueryService', () => { describe('constructor', () => { it('creates a QueryClient with expected default options', () => { - expect(QueryClient).toHaveBeenCalledWith({ - defaultOptions: { - queries: { - staleTime: 1000 * 60 * 5, - retry: 2, - cacheTime: 1000 * 60 * 60 * 24, - }, + // @ts-expect-error Accessing private property. + expect(service.queryClient.defaultOptions).toStrictEqual({ + queries: { + staleTime: 1000 * 60 * 5, + retry: 2, + cacheTime: 1000 * 60 * 60 * 24, + queryFn: expect.any(Function), }, + mutations: undefined, }); }); @@ -169,9 +170,11 @@ describe('ReactQueryService', () => { }); it('clears the query client cache', () => { + const spy = jest.spyOn(service.queryClient, 'clear'); + service.destroy(); - expect(service.queryClient.clear).toHaveBeenCalled(); + expect(spy).toHaveBeenCalled(); }); }); }); diff --git a/app/core/ReactQueryService/ReactQueryService.ts b/app/core/ReactQueryService/ReactQueryService.ts index afb6a28aa08..7cafe136e5b 100644 --- a/app/core/ReactQueryService/ReactQueryService.ts +++ b/app/core/ReactQueryService/ReactQueryService.ts @@ -8,6 +8,29 @@ import { addEventListener as addNetInfoEventListener, type NetInfoState, } from '@react-native-community/netinfo'; +import { createUIQueryClient } from '@metamask/react-data-query'; +import { Json } from '@metamask/utils'; +import { MessengerActions, MessengerEvents } from '@metamask/messenger'; +import Engine from '../Engine/Engine'; +import { RootMessenger } from '../Engine/types'; +import { DATA_SERVICES } from '../../constants/data-services'; + +type ActionType = MessengerActions['type']; +type EventType = MessengerEvents['type']; + +type JsonSubscriptionCallback = (data: Json) => void; + +const adapter = { + call: async (method: string, ...params: Json[]) => + // @ts-expect-error Target requires 1 element(s) but source may have fewer. + Engine.controllerMessenger.call(method as ActionType, ...params) as Json, + subscribe: (event: string, callback: JsonSubscriptionCallback) => { + Engine.controllerMessenger.subscribe(event as EventType, callback); + }, + unsubscribe: (event: string, callback: JsonSubscriptionCallback) => { + Engine.controllerMessenger.unsubscribe(event as EventType, callback); + }, +}; export class ReactQueryService { queryClient: QueryClient; @@ -16,7 +39,7 @@ export class ReactQueryService { #netInfoUnsubscribe?: () => void; constructor() { - this.queryClient = new QueryClient({ + this.queryClient = createUIQueryClient(DATA_SERVICES, adapter, { defaultOptions: { queries: { // Mobile users often trigger re-renders or navigate back/forth frequently. diff --git a/package.json b/package.json index 30899efefc5..c154d6956ff 100644 --- a/package.json +++ b/package.json @@ -281,6 +281,7 @@ "@metamask/profile-metrics-controller": "^3.1.0", "@metamask/profile-sync-controller": "^28.0.0", "@metamask/ramps-controller": "^12.0.1", + "@metamask/react-data-query": "^0.2.0", "@metamask/react-native-acm": "1.2.0", "@metamask/react-native-actionsheet": "2.4.2", "@metamask/react-native-button": "^3.0.0", diff --git a/yarn.lock b/yarn.lock index 2509c541b63..95827944b76 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7883,6 +7883,19 @@ __metadata: languageName: node linkType: hard +"@metamask/base-data-service@npm:^0.1.0": + version: 0.1.1 + resolution: "@metamask/base-data-service@npm:0.1.1" + dependencies: + "@metamask/controller-utils": "npm:^11.19.0" + "@metamask/messenger": "npm:^1.0.0" + "@metamask/utils": "npm:^11.9.0" + "@tanstack/query-core": "npm:^4.43.0" + fast-deep-equal: "npm:^3.1.3" + checksum: 10/b746cfaad6625d61e0d5e3202488d1f3139ef1e4b8f04c7b07a3e0fbe474c1307c73bacb1cf5d9288e946719137c52f1bc1fd40e9e44800f65b8729bbed7311d + languageName: node + linkType: hard + "@metamask/bitcoin-wallet-snap@npm:^1.10.0": version: 1.10.0 resolution: "@metamask/bitcoin-wallet-snap@npm:1.10.0" @@ -8890,6 +8903,13 @@ __metadata: languageName: node linkType: hard +"@metamask/messenger@npm:^1.0.0": + version: 1.0.0 + resolution: "@metamask/messenger@npm:1.0.0" + checksum: 10/ab1219a922d5acc86f2b1b557d79c75ca0c5f42572f50da8a2337bc5c8feb1ae95c0aaa2d2ee55b677acd4401fb2cc9c2dbacca7513edcddf20d88fb73fa7bea + languageName: node + linkType: hard + "@metamask/metamask-eth-abis@npm:3.1.1, @metamask/metamask-eth-abis@npm:^3.1.1": version: 3.1.1 resolution: "@metamask/metamask-eth-abis@npm:3.1.1" @@ -9433,6 +9453,22 @@ __metadata: languageName: node linkType: hard +"@metamask/react-data-query@npm:^0.2.0": + version: 0.2.0 + resolution: "@metamask/react-data-query@npm:0.2.0" + dependencies: + "@metamask/base-data-service": "npm:^0.1.0" + "@metamask/utils": "npm:^11.9.0" + "@tanstack/query-core": "npm:^4.43.0" + "@tanstack/react-query": "npm:^4.43.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-native: "*" + checksum: 10/c54a9943bb68ad46ad9964864d4ee81604e61216ebed00026a1a4681a1e1091b6e413b9812d25e38d028493f3c75e91c8102b9d1c0232f409cd6f1dd4d22b211 + languageName: node + linkType: hard + "@metamask/react-native-acm@npm:1.2.0": version: 1.2.0 resolution: "@metamask/react-native-acm@npm:1.2.0" @@ -17427,7 +17463,7 @@ __metadata: languageName: node linkType: hard -"@tanstack/query-core@npm:4.43.0": +"@tanstack/query-core@npm:4.43.0, @tanstack/query-core@npm:^4.43.0": version: 4.43.0 resolution: "@tanstack/query-core@npm:4.43.0" checksum: 10/c2a5a151c7adaea8311e01a643255f31946ae3164a71567ba80048242821ae14043f13f5516b695baebe5ea7e4b2cf717fd60908a929d18a5c5125fee925ff67 @@ -35612,6 +35648,7 @@ __metadata: "@metamask/profile-sync-controller": "npm:^28.0.0" "@metamask/providers": "npm:^18.3.1" "@metamask/ramps-controller": "npm:^12.0.1" + "@metamask/react-data-query": "npm:^0.2.0" "@metamask/react-native-acm": "npm:1.2.0" "@metamask/react-native-actionsheet": "npm:2.4.2" "@metamask/react-native-button": "npm:^3.0.0"