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