Skip to content

Commit 17ccd7e

Browse files
feat: metamask pay metrics (#19602)
## **Description** Add initial metrics for MetaMask pay and Perps deposit confirmation. Bump transaction controller to fix Perps deposit using swaps. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: [#5606](MetaMask/MetaMask-planning#5606) [#5774](MetaMask/MetaMask-planning#5774) ## **Manual testing steps** ## **Screenshots/Recordings** ### **Before** ### **After** ## **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.
1 parent f92835f commit 17ccd7e

18 files changed

+1009
-39
lines changed

app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,11 +110,11 @@ const ALERTS_NAME_METRICS: AlertNameMetrics = {
110110
[AlertKeys.Blockaid]: 'blockaid',
111111
[AlertKeys.DomainMismatch]: 'domain_mismatch',
112112
[AlertKeys.InsufficientBalance]: 'insufficient_balance',
113-
[AlertKeys.InsufficientPayTokenBalance]: 'insufficient_pay_token_balance',
114-
[AlertKeys.InsufficientPayTokenNative]: 'insufficient_pay_token_native',
115-
[AlertKeys.NoPayTokenQuotes]: 'no_pay_token_quotes',
113+
[AlertKeys.InsufficientPayTokenBalance]: 'insufficient_funds',
114+
[AlertKeys.InsufficientPayTokenNative]: 'insufficient_funds_for_gas',
115+
[AlertKeys.NoPayTokenQuotes]: 'no_payment_route_available',
116116
[AlertKeys.PendingTransaction]: 'pending_transaction',
117-
[AlertKeys.PerpsDepositMinimum]: 'perps_deposit_minimum',
117+
[AlertKeys.PerpsDepositMinimum]: 'minimum_deposit',
118118
[AlertKeys.PerpsHardwareAccount]: 'perps_hardware_account',
119119
[AlertKeys.SignedOrSubmitted]: 'signed_or_submitted',
120120
};

app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,4 +374,43 @@ describe('useAutomaticTransactionPayToken', () => {
374374
chainId: CHAIN_ID_1_MOCK,
375375
});
376376
});
377+
378+
it('returns number of tokens with sufficient balance', () => {
379+
useTokensWithBalanceMock.mockReturnValue([
380+
{
381+
address: TOKEN_ADDRESS_1_MOCK,
382+
chainId: CHAIN_ID_1_MOCK,
383+
tokenFiatAmount: TOTAL_FIAT_MOCK - 1,
384+
},
385+
{
386+
address: TOKEN_ADDRESS_2_MOCK,
387+
chainId: CHAIN_ID_1_MOCK,
388+
tokenFiatAmount: TOTAL_FIAT_MOCK - 2,
389+
},
390+
{
391+
address: TOKEN_ADDRESS_1_MOCK,
392+
chainId: CHAIN_ID_2_MOCK,
393+
tokenFiatAmount: TOTAL_FIAT_MOCK + 10,
394+
},
395+
{
396+
address: TOKEN_ADDRESS_3_MOCK,
397+
chainId: CHAIN_ID_2_MOCK,
398+
tokenFiatAmount: TOTAL_FIAT_MOCK + 20,
399+
},
400+
{
401+
address: NATIVE_TOKEN_ADDRESS,
402+
chainId: CHAIN_ID_1_MOCK,
403+
tokenFiatAmount: 1,
404+
},
405+
{
406+
address: NATIVE_TOKEN_ADDRESS,
407+
chainId: CHAIN_ID_2_MOCK,
408+
tokenFiatAmount: 1,
409+
},
410+
] as unknown as ReturnType<typeof useTokensWithBalance>);
411+
412+
const { result } = runHook();
413+
414+
expect(result.current.count).toBe(2);
415+
});
377416
});

app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@ export interface BalanceOverride {
2323

2424
export function useAutomaticTransactionPayToken({
2525
balanceOverrides,
26+
countOnly = false,
2627
}: {
2728
balanceOverrides?: BalanceOverride[];
29+
countOnly?: boolean;
2830
} = {}) {
2931
const isUpdated = useRef(false);
3032
const supportedChains = useSelector(selectEnabledSourceChains);
@@ -43,11 +45,12 @@ export function useAutomaticTransactionPayToken({
4345
);
4446

4547
const tokens = useTokensWithBalance({ chainIds });
46-
4748
const isHardwareWallet = isHardwareAccount(from ?? '');
49+
4850
let automaticToken: { address: string; chainId?: string } | undefined;
51+
let count = 0;
4952

50-
if (!isUpdated.current) {
53+
if (!isUpdated.current || countOnly) {
5154
const targetToken =
5255
requiredTokens.find((token) => token.address !== NATIVE_TOKEN_ADDRESS) ??
5356
requiredTokens[0];
@@ -68,6 +71,8 @@ export function useAutomaticTransactionPayToken({
6871
'desc',
6972
);
7073

74+
count = sufficientBalanceTokens.length;
75+
7176
const requiredToken = sufficientBalanceTokens.find(
7277
(token) =>
7378
token.address === targetToken?.address && token.chainId === chainId,
@@ -100,7 +105,12 @@ export function useAutomaticTransactionPayToken({
100105
}
101106

102107
useEffect(() => {
103-
if (isUpdated.current || !automaticToken || !requiredTokens?.length) {
108+
if (
109+
isUpdated.current ||
110+
!automaticToken ||
111+
!requiredTokens?.length ||
112+
countOnly
113+
) {
104114
return;
105115
}
106116

@@ -112,7 +122,9 @@ export function useAutomaticTransactionPayToken({
112122
isUpdated.current = true;
113123

114124
log('Automatically selected pay token', automaticToken);
115-
}, [automaticToken, isUpdated, requiredTokens, setPayToken]);
125+
}, [automaticToken, countOnly, isUpdated, requiredTokens, setPayToken]);
126+
127+
return { count };
116128
}
117129

118130
function isTokenSupported(

app/components/Views/confirmations/hooks/pay/useTransactionBridgeQuotes.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
isQuoteExpired,
2020
} from '../../../../UI/Bridge/utils/quoteUtils';
2121
import { selectBridgeFeatureFlags } from '../../../../../core/redux/slices/bridge';
22+
import { useTransactionPayMetrics } from './useTransactionPayMetrics';
2223

2324
const EXCLUDED_ALERTS = [
2425
AlertKeys.NoPayTokenQuotes,
@@ -42,6 +43,8 @@ export function useTransactionBridgeQuotes() {
4243
const isExpired = useRef(false);
4344
const [refreshIndex, setRefreshIndex] = useState(0);
4445

46+
useTransactionPayMetrics();
47+
4548
useEffect(() => {
4649
if (interval.current) {
4750
clearInterval(interval.current as unknown as number);
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import { merge, noop } from 'lodash';
2+
import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider';
3+
import {
4+
simpleSendTransactionControllerMock,
5+
transactionIdMock,
6+
} from '../../__mocks__/controllers/transaction-controller-mock';
7+
import { useTransactionPayMetrics } from './useTransactionPayMetrics';
8+
import { transactionApprovalControllerMock } from '../../__mocks__/controllers/approval-controller-mock';
9+
import {
10+
otherControllersMock,
11+
tokenAddress1Mock,
12+
} from '../../__mocks__/controllers/other-controllers-mock';
13+
import { useTransactionPayToken } from './useTransactionPayToken';
14+
import { useTokenAmount } from '../useTokenAmount';
15+
import { act } from '@testing-library/react-native';
16+
import {
17+
selectTransactionBridgeQuotesById,
18+
updateConfirmationMetric,
19+
} from '../../../../../core/redux/slices/confirmationMetrics';
20+
import { TransactionType } from '@metamask/transaction-controller';
21+
import { NATIVE_TOKEN_ADDRESS } from '../../constants/tokens';
22+
import { useAutomaticTransactionPayToken } from './useAutomaticTransactionPayToken';
23+
24+
jest.mock('./useTransactionPayToken');
25+
jest.mock('./useAutomaticTransactionPayToken');
26+
jest.mock('../useTokenAmount');
27+
28+
jest.mock('../../../../../core/redux/slices/confirmationMetrics', () => ({
29+
...jest.requireActual('../../../../../core/redux/slices/confirmationMetrics'),
30+
updateConfirmationMetric: jest.fn(),
31+
selectTransactionBridgeQuotesById: jest.fn(),
32+
}));
33+
34+
const CHAIN_ID_MOCK = '0x1';
35+
const TOKEN_AMOUNT_MOCK = '1.23';
36+
37+
const PAY_TOKEN_MOCK = {
38+
address: tokenAddress1Mock,
39+
chainId: CHAIN_ID_MOCK,
40+
symbol: 'TST',
41+
};
42+
43+
function runHook() {
44+
const state = merge(
45+
{},
46+
simpleSendTransactionControllerMock,
47+
transactionApprovalControllerMock,
48+
otherControllersMock,
49+
);
50+
51+
state.engine.backgroundState.TransactionController.transactions[0].type =
52+
TransactionType.perpsDeposit;
53+
54+
return renderHookWithProvider(useTransactionPayMetrics, {
55+
state,
56+
});
57+
}
58+
59+
describe('useTransactionPayMetrics', () => {
60+
const useTransactionPayTokenMock = jest.mocked(useTransactionPayToken);
61+
const useTokenAmountMock = jest.mocked(useTokenAmount);
62+
const updateConfirmationMetricMock = jest.mocked(updateConfirmationMetric);
63+
64+
const useAutomaticTransactionPayTokenMock = jest.mocked(
65+
useAutomaticTransactionPayToken,
66+
);
67+
68+
const selectTransactionBridgeQuotesByIdMock = jest.mocked(
69+
selectTransactionBridgeQuotesById,
70+
);
71+
72+
beforeEach(() => {
73+
jest.resetAllMocks();
74+
75+
useTransactionPayTokenMock.mockReturnValue({
76+
payToken: undefined,
77+
setPayToken: noop,
78+
});
79+
80+
useTokenAmountMock.mockReturnValue({
81+
amountPrecise: TOKEN_AMOUNT_MOCK,
82+
} as ReturnType<typeof useTokenAmount>);
83+
84+
updateConfirmationMetricMock.mockReturnValue({
85+
type: 'test',
86+
} as never);
87+
88+
selectTransactionBridgeQuotesByIdMock.mockReturnValue([] as never[]);
89+
90+
useAutomaticTransactionPayTokenMock.mockReturnValue({
91+
count: 5,
92+
});
93+
});
94+
95+
it('does not update metrics if no pay token selected', async () => {
96+
runHook();
97+
98+
await act(async () => noop());
99+
100+
expect(updateConfirmationMetricMock).toHaveBeenCalledWith({
101+
id: transactionIdMock,
102+
params: {
103+
properties: {},
104+
sensitiveProperties: {},
105+
},
106+
});
107+
});
108+
109+
it('includes pay token properties if pay token selected', async () => {
110+
useTransactionPayTokenMock.mockReturnValue({
111+
payToken: PAY_TOKEN_MOCK,
112+
setPayToken: noop,
113+
} as ReturnType<typeof useTransactionPayToken>);
114+
115+
runHook();
116+
117+
await act(async () => noop());
118+
119+
expect(updateConfirmationMetricMock).toHaveBeenCalledWith({
120+
id: transactionIdMock,
121+
params: {
122+
properties: expect.objectContaining({
123+
mm_pay: true,
124+
mm_pay_token_selected: PAY_TOKEN_MOCK.symbol,
125+
mm_pay_chain_selected: CHAIN_ID_MOCK,
126+
mm_pay_token_presented: PAY_TOKEN_MOCK.symbol,
127+
mm_pay_chain_presented: PAY_TOKEN_MOCK.chainId,
128+
}),
129+
sensitiveProperties: {},
130+
},
131+
});
132+
});
133+
134+
it('includes step properties based on number of quotes', async () => {
135+
useTransactionPayTokenMock.mockReturnValue({
136+
payToken: PAY_TOKEN_MOCK,
137+
setPayToken: noop,
138+
} as ReturnType<typeof useTransactionPayToken>);
139+
140+
selectTransactionBridgeQuotesByIdMock.mockReturnValue([
141+
{} as never,
142+
{} as never,
143+
{} as never,
144+
] as never[]);
145+
146+
runHook();
147+
148+
await act(async () => noop());
149+
150+
expect(updateConfirmationMetricMock).toHaveBeenCalledWith({
151+
id: transactionIdMock,
152+
params: {
153+
properties: expect.objectContaining({
154+
mm_pay_transaction_step_total: 4,
155+
mm_pay_transaction_step: 4,
156+
}),
157+
sensitiveProperties: {},
158+
},
159+
});
160+
});
161+
162+
it('includes perps deposit properties', async () => {
163+
useTransactionPayTokenMock.mockReturnValue({
164+
payToken: PAY_TOKEN_MOCK,
165+
setPayToken: noop,
166+
} as ReturnType<typeof useTransactionPayToken>);
167+
168+
selectTransactionBridgeQuotesByIdMock.mockReturnValue([
169+
{} as never,
170+
{} as never,
171+
{} as never,
172+
] as never[]);
173+
174+
runHook();
175+
176+
await act(async () => noop());
177+
178+
expect(updateConfirmationMetricMock).toHaveBeenCalledWith({
179+
id: transactionIdMock,
180+
params: {
181+
properties: expect.objectContaining({
182+
mm_pay_use_case: 'perps_deposit',
183+
simulation_sending_assets_total_value: TOKEN_AMOUNT_MOCK,
184+
}),
185+
sensitiveProperties: {},
186+
},
187+
});
188+
});
189+
190+
it('includes dust property for non-native quote', async () => {
191+
useTransactionPayTokenMock.mockReturnValue({
192+
payToken: PAY_TOKEN_MOCK,
193+
setPayToken: noop,
194+
} as ReturnType<typeof useTransactionPayToken>);
195+
196+
selectTransactionBridgeQuotesByIdMock.mockReturnValue([
197+
{
198+
quote: {
199+
minDestTokenAmount: '2000000',
200+
},
201+
request: {
202+
targetAmountMinimum: '1500000',
203+
targetTokenAddress: NATIVE_TOKEN_ADDRESS,
204+
},
205+
},
206+
{
207+
quote: {
208+
minDestTokenAmount: '3000000',
209+
},
210+
request: {
211+
targetAmountMinimum: '2500000',
212+
targetTokenAddress: tokenAddress1Mock,
213+
},
214+
},
215+
] as never[]);
216+
217+
runHook();
218+
219+
await act(async () => noop());
220+
221+
expect(updateConfirmationMetricMock).toHaveBeenCalledWith({
222+
id: transactionIdMock,
223+
params: {
224+
properties: expect.objectContaining({
225+
mm_pay_dust_usd: '0.5',
226+
}),
227+
sensitiveProperties: {},
228+
},
229+
});
230+
});
231+
232+
it('includes token size property', async () => {
233+
useTransactionPayTokenMock.mockReturnValue({
234+
payToken: PAY_TOKEN_MOCK,
235+
setPayToken: noop,
236+
} as ReturnType<typeof useTransactionPayToken>);
237+
238+
runHook();
239+
240+
await act(async () => noop());
241+
242+
expect(updateConfirmationMetricMock).toHaveBeenCalledWith({
243+
id: transactionIdMock,
244+
params: {
245+
properties: expect.objectContaining({
246+
mm_pay_payment_token_list_size: 5,
247+
}),
248+
sensitiveProperties: {},
249+
},
250+
});
251+
});
252+
});

0 commit comments

Comments
 (0)