diff --git a/app/components/Views/confirmations/components/Confirm/SignatureBlockaidBanner/SignatureBlockaidBanner.test.tsx b/app/components/Views/confirmations/components/Confirm/SignatureBlockaidBanner/SignatureBlockaidBanner.test.tsx index e7d626e3030..b3df9b6b440 100644 --- a/app/components/Views/confirmations/components/Confirm/SignatureBlockaidBanner/SignatureBlockaidBanner.test.tsx +++ b/app/components/Views/confirmations/components/Confirm/SignatureBlockaidBanner/SignatureBlockaidBanner.test.tsx @@ -13,11 +13,15 @@ jest.mock('react-native-gzip', () => ({ })); const mockTrackEvent = jest.fn(); +const mockCreateEventBuilderAddProperties = jest.fn(); + jest.mock('../../../../../hooks/useMetrics', () => ({ useMetrics: () => ({ trackEvent: mockTrackEvent, createEventBuilder: () => ({ - addProperties: () => ({ build: () => ({}) }), + addProperties: mockCreateEventBuilderAddProperties.mockReturnValue({ + build: () => ({}), + }), }), }), })); @@ -36,7 +40,7 @@ const typedSignV1ConfirmationStateWithBlockaidResponse = { ...typedSignV1ConfirmationState.engine.backgroundState, ApprovalController: { pendingApprovals: { - 'fb2029e1-b0ab-11ef-9227-05a11087c334': { + '7e62bcb1-a4e9-11ef-9b51-ddf21c91a998': { ...typedSignApproval, requestData: { ...typedSignApproval.requestData, @@ -71,8 +75,15 @@ describe('Confirm', () => { state: typedSignV1ConfirmationStateWithBlockaidResponse, }, ); + fireEvent.press(getByTestId('accordionheader')); fireEvent.press(getByText('Report an issue')); + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + expect(mockCreateEventBuilderAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + external_link_clicked: 'security_alert_support_link', + }), + ); }); }); diff --git a/app/components/Views/confirmations/hooks/useSignatureMetrics.test.ts b/app/components/Views/confirmations/hooks/useSignatureMetrics.test.ts index 81b42db4be5..05f6b9d1f84 100644 --- a/app/components/Views/confirmations/hooks/useSignatureMetrics.test.ts +++ b/app/components/Views/confirmations/hooks/useSignatureMetrics.test.ts @@ -1,9 +1,10 @@ import { MetaMetricsEvents } from '../../../../core/Analytics'; import { renderHookWithProvider } from '../../../../util/test/renderWithProvider'; import { useSignatureMetrics } from './useSignatureMetrics'; +import { SignatureRequestType, SignatureRequest } from '@metamask/signature-controller'; const mockSigRequest = { - type: 'personal_sign', + type: SignatureRequestType.PersonalSign, messageParams: { data: '0x4578616d706c652060706572736f6e616c5f7369676e60206d657373616765', from: '0x935e73edb9ff52e23bac7f7e043a1ecd06d05477', @@ -16,8 +17,8 @@ const mockSigRequest = { origin: 'metamask.github.io', metamaskId: '76b33b40-7b5c-11ef-bc0a-25bce29dbc09', }, - chainId: '0x0', -}; + chainId: '0x1' as `0x${string}`, +} as const; jest.mock('./useSignatureRequest', () => ({ useSignatureRequest: () => mockSigRequest, @@ -41,7 +42,20 @@ describe('useSignatureMetrics', () => { }); it('should capture metrics events correctly', async () => { const { result } = renderHookWithProvider(() => useSignatureMetrics(), { - state: {}, + state: { + engine: { + backgroundState: { + PreferencesController: { + useTransactionSimulations: true, + }, + SignatureController: { + signatureRequests: { + [mockSigRequest.messageParams.metamaskId]: mockSigRequest, + } as unknown as Record, + }, + }, + }, + }, }); // first call for 'SIGNATURE_REQUESTED' event expect(mockTrackEvent).toHaveBeenCalledTimes(1); diff --git a/app/components/Views/confirmations/hooks/useSignatureMetrics.ts b/app/components/Views/confirmations/hooks/useSignatureMetrics.ts index ec345a42083..f0b5657dade 100644 --- a/app/components/Views/confirmations/hooks/useSignatureMetrics.ts +++ b/app/components/Views/confirmations/hooks/useSignatureMetrics.ts @@ -1,5 +1,4 @@ import { useCallback, useEffect } from 'react'; -import type { Hex } from '@metamask/utils'; import getDecimalChainId from '../../../../util/networks/getDecimalChainId'; import { MetricsEventBuilder } from '../../../../core/Analytics/MetricsEventBuilder'; @@ -10,7 +9,10 @@ import { getBlockaidMetricsParams } from '../../../../util/blockaid'; import { SecurityAlertResponse } from '../components/BlockaidBanner/BlockaidBanner.types'; import { getHostFromUrl } from '../utils/generic'; import { isSignatureRequest } from '../utils/confirm'; +import { getSignatureDecodingEventProps } from '../utils/signatureMetrics'; import { useSignatureRequest } from './useSignatureRequest'; +import { useTypedSignSimulationEnabled } from './useTypedSignSimulationEnabled'; +import { SignatureRequest } from '@metamask/signature-controller'; interface MessageParamsType { meta: Record; @@ -20,11 +22,16 @@ interface MessageParamsType { } const getAnalyticsParams = ( - messageParams: MessageParamsType, - type: string, - chainId?: Hex, + signatureRequest: SignatureRequest, + isSimulationEnabled?: boolean, ) => { - const { meta = {}, from, securityAlertResponse, version } = messageParams; + const { chainId, messageParams, type } = signatureRequest ?? {}; + const { + meta = {}, + from, + securityAlertResponse, + version + } = (messageParams as unknown as MessageParamsType) || {}; return { account_type: getAddressAccountType(from as string), @@ -37,13 +44,15 @@ const getAnalyticsParams = ( ...(securityAlertResponse ? getBlockaidMetricsParams(securityAlertResponse) : {}), + ...getSignatureDecodingEventProps(signatureRequest, isSimulationEnabled), }; }; export const useSignatureMetrics = () => { const signatureRequest = useSignatureRequest(); + const isSimulationEnabled = useTypedSignSimulationEnabled(); - const { chainId, messageParams, type } = signatureRequest ?? {}; + const type = signatureRequest?.type; const captureSignatureMetrics = useCallback( async ( @@ -57,15 +66,14 @@ export const useSignatureMetrics = () => { MetricsEventBuilder.createEventBuilder(event) .addProperties( getAnalyticsParams( - messageParams as unknown as MessageParamsType, - type, - chainId, + signatureRequest as SignatureRequest, + isSimulationEnabled, ), ) .build(), ); }, - [chainId, messageParams, type], + [isSimulationEnabled, type, signatureRequest], ); useEffect(() => { diff --git a/app/components/Views/confirmations/utils/signature.test.ts b/app/components/Views/confirmations/utils/signature.test.ts index 08472ef3f27..1b0a8b5cb39 100644 --- a/app/components/Views/confirmations/utils/signature.test.ts +++ b/app/components/Views/confirmations/utils/signature.test.ts @@ -1,6 +1,6 @@ import { parseTypedDataMessage, isRecognizedPermit } from './signature'; import { PRIMARY_TYPES_PERMIT } from '../constants/signatures'; -import { SignatureRequest } from '@metamask/signature-controller'; +import { SignatureRequest, SignatureRequestType } from '@metamask/signature-controller'; describe('Signature Utils', () => { describe('parseTypedDataMessage', () => { @@ -51,7 +51,8 @@ describe('Signature Utils', () => { data: JSON.stringify({ primaryType: PRIMARY_TYPES_PERMIT[0] }) - } + }, + type: SignatureRequestType.TypedSign } as SignatureRequest; expect(isRecognizedPermit(mockRequest)).toBe(true); @@ -63,7 +64,8 @@ describe('Signature Utils', () => { data: JSON.stringify({ primaryType: 'UnrecognizedType' }) - } + }, + type: SignatureRequestType.TypedSign } as SignatureRequest; expect(isRecognizedPermit(mockRequest)).toBe(false); diff --git a/app/components/Views/confirmations/utils/signature.ts b/app/components/Views/confirmations/utils/signature.ts index 9035a2b3087..5c30d885f78 100644 --- a/app/components/Views/confirmations/utils/signature.ts +++ b/app/components/Views/confirmations/utils/signature.ts @@ -1,4 +1,4 @@ -import { SignatureRequest } from '@metamask/signature-controller'; +import { SignatureRequest, SignatureRequestType } from '@metamask/signature-controller'; import { PRIMARY_TYPES_PERMIT } from '../constants/signatures'; /** @@ -50,7 +50,7 @@ export const parseTypedDataMessage = (dataToParse: string) => { * @param request - The signature request to check */ export const isRecognizedPermit = (request: SignatureRequest) => { - if (!request) { + if (!request || request.type !== SignatureRequestType.TypedSign) { return false; } diff --git a/app/components/Views/confirmations/utils/signatureMetrics.test.ts b/app/components/Views/confirmations/utils/signatureMetrics.test.ts new file mode 100644 index 00000000000..8df940d5d8c --- /dev/null +++ b/app/components/Views/confirmations/utils/signatureMetrics.test.ts @@ -0,0 +1,121 @@ +import { Hex } from '@metamask/utils'; +import { getSignatureDecodingEventProps } from './signatureMetrics'; +import { DecodingDataChangeType, SignatureRequest, SignatureRequestStatus, SignatureRequestType } from '@metamask/signature-controller'; + +const mockSignatureRequest = { + id: 'fb2029e1-b0ab-11ef-9227-05a11087c334', + chainId: '0x1' as Hex, + type: SignatureRequestType.TypedSign, + messageParams: { + data: '{"types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],"Permit":[{"name":"owner","type":"address"},{"name":"spender","type":"address"},{"name":"value","type":"uint256"},{"name":"nonce","type":"uint256"},{"name":"deadline","type":"uint256"}]},"primaryType":"Permit","domain":{"name":"MyToken","version":"1","verifyingContract":"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC","chainId":1},"message":{"owner":"0x935e73edb9ff52e23bac7f7e043a1ecd06d05477","spender":"0x5B38Da6a701c568545dCfcB03FcB875f56beddC4","value":3000,"nonce":0,"deadline":50000000000}}', + from: '0x935e73edb9ff52e23bac7f7e043a1ecd06d05477', + version: 'V4', + requestId: 14, + origin: 'https://metamask.github.io', + metamaskId: 'fb2029e0-b0ab-11ef-9227-05a11087c334', + }, + networkClientId: '1', + status: SignatureRequestStatus.Unapproved, + time: 1733143817088 +} satisfies SignatureRequest; + +describe('signatureMetrics', () => { + describe('getSignatureDecodingEventProps', () => { + it('returns empty object when decoding API is disabled', () => { + const mockRequest = { + ...mockSignatureRequest, + decodingData: { + stateChanges: [], + error: undefined, + }, + } satisfies SignatureRequest; + + const result = getSignatureDecodingEventProps(mockRequest, false); + expect(result).toEqual({}); + }); + + it('returns empty object when no decodingData is present', () => { + const mockRequest = {} as SignatureRequest; + const result = getSignatureDecodingEventProps(mockRequest, true); + expect(result).toEqual({}); + }); + + it('returns no change response when stateChanges are empty', () => { + const mockRequest = { + ...mockSignatureRequest, + decodingData: { + stateChanges: [], + error: undefined, + }, + decodingLoading: false, + } satisfies SignatureRequest; + + const result = getSignatureDecodingEventProps(mockRequest, true); + expect(result).toEqual({ + decoding_change_types: [], + decoding_description: null, + decoding_response: 'NO_CHANGE', + }); + }); + + it('returns loading response when decodingLoading is true', () => { + const mockRequest = { + ...mockSignatureRequest, + decodingData: { + stateChanges: [], + error: undefined, + }, + decodingLoading: true, + } satisfies SignatureRequest; + + const result = getSignatureDecodingEventProps(mockRequest, true); + expect(result).toEqual({ + decoding_change_types: [], + decoding_description: null, + decoding_response: 'decoding_in_progress', + }); + }); + + it('returns error response when error exists', () => { + const mockRequest = { + ...mockSignatureRequest, + decodingData: { + stateChanges: [], + error: { + type: 'ERROR_TYPE', + message: 'Error message', + }, + }, + decodingLoading: false, + } satisfies SignatureRequest; + + const result = getSignatureDecodingEventProps(mockRequest, true); + expect(result).toEqual({ + decoding_change_types: [], + decoding_description: 'Error message', + decoding_response: 'ERROR_TYPE', + }); + }); + + it('returns change response when stateChanges exist', () => { + const mockRequest = { + ...mockSignatureRequest, + decodingData: { + stateChanges: [ + { changeType: DecodingDataChangeType.Approve, assetType: 'ERC20', address: '0x935e73edb9ff52e23bac7f7e043a1ecd06d05477', amount: '12345', contractAddress: '0x6b175474e89094c44da98b954eedeac495271d0f' }, + { changeType: DecodingDataChangeType.Transfer, assetType: 'ERC20', address: '0x935e73edb9ff52e23bac7f7e043a1ecd06d05477', amount: '12345', contractAddress: '0x6b175474e89094c44da98b954eedeac495271d0f' }, + ], + error: undefined, + }, + decodingLoading: false, + } satisfies SignatureRequest; + + const result = getSignatureDecodingEventProps(mockRequest, true); + expect(result).toEqual({ + decoding_change_types: ['APPROVE', 'TRANSFER'], + decoding_description: null, + decoding_response: 'CHANGE', + }); + }); + }); +}); diff --git a/app/components/Views/confirmations/utils/signatureMetrics.ts b/app/components/Views/confirmations/utils/signatureMetrics.ts new file mode 100644 index 00000000000..63ee22647f1 --- /dev/null +++ b/app/components/Views/confirmations/utils/signatureMetrics.ts @@ -0,0 +1,34 @@ +import { DecodingDataStateChange, SignatureRequest } from '@metamask/signature-controller'; + +enum DecodingResponseType { + Change = 'CHANGE', + NoChange = 'NO_CHANGE', + Loading = 'decoding_in_progress', +} + +export const getSignatureDecodingEventProps = (signatureRequest?: SignatureRequest, isDecodingAPIEnabled: boolean = false) => { + const { decodingData, decodingLoading } = signatureRequest || {}; + + if (!isDecodingAPIEnabled || !decodingData) { + return {}; + } + + const { stateChanges, error } = decodingData; + + const changeTypes = (stateChanges ?? []).map( + (change: DecodingDataStateChange) => change.changeType, + ); + + const responseType = error?.type ?? + (changeTypes.length + ? DecodingResponseType.Change + : DecodingResponseType.NoChange); + + return { + decoding_change_types: changeTypes, + decoding_description: decodingData?.error?.message ?? null, + decoding_response: decodingLoading + ? DecodingResponseType.Loading + : responseType, + }; +};