Skip to content

Commit 9402870

Browse files
authored
fix: metrics for hardware wallets cp-7.61.0 (MetaMask#22773)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** This PR standardises the metrics for hardware wallets to align with the extension. The schemas follow the following - https://www.figma.com/design/9sGBc6mZkQVBKitQzsD0iI/Hardware-Wallet-Metrics?node-id=0-1&p=f&t=Q6IqeowrBX3vu5rF-0 <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/MUL-1241?atlOrigin=eyJpIjoiZTNiNWQzNDEzZGMwNDc0OGFlMTc4YTJmYWJmMzQ5NTMiLCJwIjoiaiJ9 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** N/a no user facing changes. <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Unifies hardware wallet analytics with new events and properties across QR and Ledger flows, adds device info/count tracking and permission/error metrics, plus comprehensive tests and utilities. > > - **Analytics (Hardware Wallets)**: > - Replace legacy `LEDGER_*`/QR events with unified `HARDWARE_WALLET_*` events (e.g., `CONNECT_HARDWARE_WALLET`, `HARDWARE_WALLET_ADD_ACCOUNT`, `HARDWARE_WALLET_ERROR`, `HARDWARE_WALLET_ACCOUNT_SELECTOR_OPEN`, `HARDWARE_WALLET_CONNECTION_RETRY`, `HARDWARE_WALLET_MARKETING`, `HARDWARE_WALLET_CONNECT_INSTRUCTIONS`). > - Add common properties: `device_type`, `device_model` (incl. BLE UUID mapping), `connected_device_count`, `hd_path`, and detailed error strings. > - Track permission requests/results (`HARDWARE_WALLET_PERMISSION_REQUEST`) and camera/QR scanning errors. > - Update cancel events to `DAPP_TRANSACTION_CANCELLED` where applicable. > - **Flows Updated**: > - QR: `AnimatedQRScanner`, `ConnectQRHardware` (continue, account selector, add account, forget device, error), `ConnectQRHardware/Instruction` (marketing links). > - Ledger: `LedgerConnect` (instructions, found device, continue/retry, marketing), `LedgerConnect/Scan` (permission/BT errors with model), `LedgerConfirmationModal` (errors/cancel), `LedgerSelectAccount` (account selector open/add/forget/error). > - Account screens: `AccountConnect` and `AccountPermissions` track HW connect with device count; `AccountActions` forget-device metrics per keyring. > - Multichain actions: use `ADD_HARDWARE_WALLET` for HW button. > - **Utilities & Hooks**: > - New `getConnectedDevicesCount` (counts connected HW keyrings). > - New `useLedgerDeviceForAccount` hook for per-account Ledger device context. > - New device utilities: `sanitizeDeviceName`, `ledgerDeviceUUIDToModelName`. > - **Testing & Mocks**: > - Extensive test coverage added/updated for all flows; enhanced `react-native-vision-camera` mock to capture callbacks. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 9c9bf99. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 5ba6d92 commit 9402870

36 files changed

Lines changed: 3976 additions & 215 deletions

File tree

app/__mocks__/react-native-vision-camera.ts

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,53 @@ const mockPermission = {
2424
requestPermission: jest.fn().mockResolvedValue('granted'),
2525
};
2626

27-
const mockCodeScanner = {
28-
codeTypes: ['qr'],
29-
onCodeScanned: jest.fn(),
27+
let capturedOnCodeScanned:
28+
| ((codes: { value: string; type: string }[]) => Promise<void> | void)
29+
| null = null;
30+
let capturedOnError: ((error: Error) => Promise<void> | void) | null = null;
31+
32+
export const resetCapturedCallbacks = () => {
33+
capturedOnCodeScanned = null;
34+
capturedOnError = null;
3035
};
3136

32-
const Camera = React.forwardRef(() => null);
37+
export const getCapturedCallbacks = () => ({
38+
onCodeScanned: capturedOnCodeScanned,
39+
onError: capturedOnError,
40+
});
41+
42+
const Camera = React.forwardRef(
43+
(
44+
props: {
45+
onError?: (error: Error) => Promise<void> | void;
46+
codeScanner?: {
47+
onCodeScanned: (
48+
codes: { value: string; type: string }[],
49+
) => Promise<void> | void;
50+
};
51+
},
52+
_ref: unknown,
53+
) => {
54+
if (props.onError) {
55+
capturedOnError = props.onError;
56+
}
57+
if (props.codeScanner?.onCodeScanned) {
58+
capturedOnCodeScanned = props.codeScanner.onCodeScanned;
59+
}
60+
return React.createElement('View', { testID: 'camera-mock' });
61+
},
62+
);
3363

3464
const useCameraDevice = jest.fn(() => mockDevice);
3565
const useCameraPermission = jest.fn(() => mockPermission);
36-
const useCodeScanner = jest.fn(() => mockCodeScanner);
66+
const useCodeScanner = jest.fn((config) => {
67+
if (config?.onCodeScanned) {
68+
capturedOnCodeScanned = config.onCodeScanned;
69+
}
70+
return {
71+
codeTypes: ['qr'],
72+
onCodeScanned: config?.onCodeScanned || jest.fn(),
73+
};
74+
});
3775

3876
export { Camera, useCameraDevice, useCameraPermission, useCodeScanner };

app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/MultichainAddWalletActions.test.tsx

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import MultichainAddWalletActions from './MultichainAddWalletActions';
66
import { MOCK_ACCOUNTS_CONTROLLER_STATE } from '../../../../util/test/accountsControllerTestUtils';
77
import { MOCK_KEYRING_CONTROLLER } from '../../../../selectors/keyringController/testUtils';
88
import Routes from '../../../../constants/navigation/Routes';
9+
import { MetaMetricsEvents } from '../../../../core/Analytics';
910

1011
const mockedNavigate = jest.fn();
1112
jest.mock('@react-navigation/native', () => {
@@ -18,6 +19,18 @@ jest.mock('@react-navigation/native', () => {
1819
};
1920
});
2021

22+
const mockTrackEvent = jest.fn();
23+
const mockCreateEventBuilder = jest.fn((event) => ({
24+
build: jest.fn(() => event),
25+
}));
26+
27+
jest.mock('../../../../components/hooks/useMetrics', () => ({
28+
useMetrics: () => ({
29+
trackEvent: mockTrackEvent,
30+
createEventBuilder: mockCreateEventBuilder,
31+
}),
32+
}));
33+
2134
const mockInitialState = {
2235
engine: {
2336
backgroundState: {
@@ -147,4 +160,78 @@ describe('MultichainAddWalletActions', () => {
147160
expect(mockedNavigate).toHaveBeenCalledWith(Routes.MULTI_SRP.IMPORT);
148161
expect(mockProps.onBack).toHaveBeenCalled();
149162
});
163+
164+
describe('Analytics', () => {
165+
it('tracks event when import wallet button is pressed', () => {
166+
renderScreen(
167+
() => <MultichainAddWalletActions {...mockProps} />,
168+
{
169+
name: 'MultichainAddWalletActions',
170+
},
171+
{
172+
state: mockInitialState,
173+
},
174+
);
175+
176+
const importWalletButton = screen.getByTestId(
177+
AddAccountBottomSheetSelectorsIDs.IMPORT_SRP_BUTTON,
178+
);
179+
fireEvent.press(importWalletButton);
180+
181+
expect(mockCreateEventBuilder).toHaveBeenCalledWith(
182+
MetaMetricsEvents.IMPORT_SECRET_RECOVERY_PHRASE_CLICKED,
183+
);
184+
expect(mockTrackEvent).toHaveBeenCalledWith(
185+
MetaMetricsEvents.IMPORT_SECRET_RECOVERY_PHRASE_CLICKED,
186+
);
187+
});
188+
189+
it('tracks event when import account button is pressed', () => {
190+
renderScreen(
191+
() => <MultichainAddWalletActions {...mockProps} />,
192+
{
193+
name: 'MultichainAddWalletActions',
194+
},
195+
{
196+
state: mockInitialState,
197+
},
198+
);
199+
200+
const importAccountButton = screen.getByTestId(
201+
AddAccountBottomSheetSelectorsIDs.IMPORT_ACCOUNT_BUTTON,
202+
);
203+
fireEvent.press(importAccountButton);
204+
205+
expect(mockCreateEventBuilder).toHaveBeenCalledWith(
206+
MetaMetricsEvents.ACCOUNTS_IMPORTED_NEW_ACCOUNT,
207+
);
208+
expect(mockTrackEvent).toHaveBeenCalledWith(
209+
MetaMetricsEvents.ACCOUNTS_IMPORTED_NEW_ACCOUNT,
210+
);
211+
});
212+
213+
it('tracks event when hardware wallet button is pressed', () => {
214+
renderScreen(
215+
() => <MultichainAddWalletActions {...mockProps} />,
216+
{
217+
name: 'MultichainAddWalletActions',
218+
},
219+
{
220+
state: mockInitialState,
221+
},
222+
);
223+
224+
const hardwareWalletButton = screen.getByTestId(
225+
AddAccountBottomSheetSelectorsIDs.ADD_HARDWARE_WALLET_BUTTON,
226+
);
227+
fireEvent.press(hardwareWalletButton);
228+
229+
expect(mockCreateEventBuilder).toHaveBeenCalledWith(
230+
MetaMetricsEvents.ADD_HARDWARE_WALLET,
231+
);
232+
expect(mockTrackEvent).toHaveBeenCalledWith(
233+
MetaMetricsEvents.ADD_HARDWARE_WALLET,
234+
);
235+
});
236+
});
150237
});

app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/MultichainAddWalletActions.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ const MultichainAddWalletActions = ({
7474
iconName: IconName.Usb,
7575
testID: AddAccountBottomSheetSelectorsIDs.ADD_HARDWARE_WALLET_BUTTON,
7676
isVisible: true,
77-
analyticsEvent: MetaMetricsEvents.CONNECT_HARDWARE_WALLET,
77+
analyticsEvent: MetaMetricsEvents.ADD_HARDWARE_WALLET,
7878
navigationAction: () => {
7979
navigate(Routes.HW.CONNECT);
8080
onBack();

app/components/UI/LedgerModals/LedgerConfirmationModal.test.tsx

Lines changed: 34 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,6 @@ import { strings } from '../../../../locales/i18n';
1919
import { useMetrics } from '../../hooks/useMetrics';
2020
import { MetaMetricsEvents } from '../../../core/Analytics';
2121
import { fireEvent } from '@testing-library/react-native';
22-
import { HardwareDeviceTypes } from '../../../constants/keyringTypes';
23-
import { MetricsEventBuilder } from '../../../core/Analytics/MetricsEventBuilder';
2422

2523
jest.mock('../../hooks/Ledger/useBluetooth', () => ({
2624
__esModule: true,
@@ -47,9 +45,29 @@ jest.mock('@react-navigation/native', () => ({
4745
jest.mock('../../../components/hooks/useMetrics');
4846

4947
const mockTrackEvent = jest.fn();
48+
const mockCreateEventBuilder = jest.fn();
5049

5150
describe('LedgerConfirmationModal', () => {
5251
beforeEach(() => {
52+
jest.clearAllMocks();
53+
54+
// Mock event builder chain
55+
const mockEventBuilder = {
56+
addProperties: jest.fn().mockReturnThis(),
57+
addSensitiveProperties: jest.fn().mockReturnThis(),
58+
removeProperties: jest.fn().mockReturnThis(),
59+
removeSensitiveProperties: jest.fn().mockReturnThis(),
60+
setSaveDataRecording: jest.fn().mockReturnThis(),
61+
build: jest.fn().mockReturnValue({
62+
name: 'test-event',
63+
properties: {},
64+
sensitiveProperties: {},
65+
saveDataRecording: true,
66+
}),
67+
};
68+
69+
mockCreateEventBuilder.mockReturnValue(mockEventBuilder);
70+
5371
// Mock hook return value
5472
(useBluetoothPermissions as jest.Mock).mockReturnValue({
5573
hasBluetoothPermissions: true,
@@ -71,7 +89,7 @@ describe('LedgerConfirmationModal', () => {
7189

7290
(useMetrics as jest.MockedFn<typeof useMetrics>).mockReturnValue({
7391
trackEvent: mockTrackEvent,
74-
createEventBuilder: MetricsEventBuilder.createEventBuilder,
92+
createEventBuilder: mockCreateEventBuilder,
7593
enable: jest.fn(),
7694
addTraitsToUser: jest.fn(),
7795
createDataDeletionTask: jest.fn(),
@@ -143,10 +161,10 @@ describe('LedgerConfirmationModal', () => {
143161
expect(toJSON()).toMatchSnapshot();
144162
});
145163

146-
it('logs LEDGER_HARDWARE_WALLET_ERROR event when the ledger error occurs', async () => {
164+
it('logs HARDWARE_WALLET_ERROR event when the ledger error occurs', async () => {
147165
const onConfirmation = jest.fn();
148-
149166
const ledgerLogicToRun = jest.fn();
167+
150168
(useLedgerBluetooth as jest.Mock).mockReturnValue({
151169
isSendingLedgerCommands: true,
152170
isAppLaunchConfirmationNeeded: false,
@@ -166,22 +184,11 @@ describe('LedgerConfirmationModal', () => {
166184
/>,
167185
);
168186

169-
// eslint-disable-next-line no-empty-function
170-
await act(async () => {});
171-
172187
expect(onConfirmation).not.toHaveBeenCalled();
173-
174-
expect(mockTrackEvent).toHaveBeenNthCalledWith(
175-
1,
176-
MetricsEventBuilder.createEventBuilder(
177-
MetaMetricsEvents.LEDGER_HARDWARE_WALLET_ERROR,
178-
)
179-
.addProperties({
180-
device_type: HardwareDeviceTypes.LEDGER,
181-
error: 'LEDGER_ETH_APP_NOT_INSTALLED',
182-
})
183-
.build(),
188+
expect(mockCreateEventBuilder).toHaveBeenCalledWith(
189+
MetaMetricsEvents.HARDWARE_WALLET_ERROR,
184190
);
191+
expect(mockTrackEvent).toHaveBeenCalled();
185192
});
186193

187194
it('renders SearchingForDeviceStep when not sending ledger commands', () => {
@@ -351,15 +358,14 @@ describe('LedgerConfirmationModal', () => {
351358
/>,
352359
);
353360

354-
// eslint-disable-next-line no-empty-function
355-
await act(async () => {});
356-
357361
expect(ledgerLogicToRun).toHaveBeenCalledTimes(1);
358362

359363
const retryButton = getByTestId(RETRY_BUTTON);
360-
fireEvent.press(retryButton);
361364

362-
//Retry will run connectLedger again
365+
await act(async () => {
366+
fireEvent.press(retryButton);
367+
});
368+
363369
expect(ledgerLogicToRun).toHaveBeenCalledTimes(2);
364370
});
365371

@@ -380,13 +386,12 @@ describe('LedgerConfirmationModal', () => {
380386
/>,
381387
);
382388

383-
// eslint-disable-next-line no-empty-function
384-
await act(async () => {});
385-
386389
const retryButton = getByTestId(RETRY_BUTTON);
387-
fireEvent.press(retryButton);
388390

389-
//Retry will run connectLedger again
391+
await act(async () => {
392+
fireEvent.press(retryButton);
393+
});
394+
390395
expect(checkPermissions).toHaveBeenCalledTimes(1);
391396
});
392397

@@ -407,9 +412,6 @@ describe('LedgerConfirmationModal', () => {
407412
/>,
408413
);
409414

410-
// eslint-disable-next-line no-empty-function
411-
await act(async () => {});
412-
413415
expect(onConfirmation).toHaveBeenCalled();
414416
});
415417

app/components/UI/LedgerModals/LedgerConfirmationModal.tsx

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ const LedgerConfirmationModal = ({
7979
// Handle a super edge case of the user starting a transaction with the device connected
8080
// After arriving to confirmation the ETH app is not installed anymore this causes a crash.
8181
trackEvent(
82-
createEventBuilder(MetaMetricsEvents.LEDGER_HARDWARE_WALLET_ERROR)
82+
createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ERROR)
8383
.addProperties({
8484
device_type: HardwareDeviceTypes.LEDGER,
8585
error: 'LEDGER_ETH_APP_NOT_INSTALLED',
@@ -95,9 +95,7 @@ const LedgerConfirmationModal = ({
9595
onRejection();
9696
} finally {
9797
trackEvent(
98-
createEventBuilder(
99-
MetaMetricsEvents.LEDGER_HARDWARE_TRANSACTION_CANCELLED,
100-
)
98+
createEventBuilder(MetaMetricsEvents.DAPP_TRANSACTION_CANCELLED)
10199
.addProperties({
102100
device_type: HardwareDeviceTypes.LEDGER,
103101
})
@@ -209,7 +207,7 @@ const LedgerConfirmationModal = ({
209207
}
210208
if (ledgerError !== LedgerCommunicationErrors.UserRefusedConfirmation) {
211209
trackEvent(
212-
createEventBuilder(MetaMetricsEvents.LEDGER_HARDWARE_WALLET_ERROR)
210+
createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ERROR)
213211
.addProperties({
214212
device_type: HardwareDeviceTypes.LEDGER,
215213
error: `${ledgerError}`,
@@ -242,7 +240,7 @@ const LedgerConfirmationModal = ({
242240
}
243241
setPermissionErrorShown(true);
244242
trackEvent(
245-
createEventBuilder(MetaMetricsEvents.LEDGER_HARDWARE_WALLET_ERROR)
243+
createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ERROR)
246244
.addProperties({
247245
device_type: HardwareDeviceTypes.LEDGER,
248246
error: 'LEDGER_BLUETOOTH_PERMISSION_ERR',
@@ -258,7 +256,7 @@ const LedgerConfirmationModal = ({
258256
});
259257

260258
trackEvent(
261-
createEventBuilder(MetaMetricsEvents.LEDGER_HARDWARE_WALLET_ERROR)
259+
createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ERROR)
262260
.addProperties({
263261
device_type: HardwareDeviceTypes.LEDGER,
264262
error: 'LEDGER_BLUETOOTH_CONNECTION_ERR',

0 commit comments

Comments
 (0)