Skip to content

Commit 5a30a48

Browse files
authored
feat: cp-7.66.0 MUSD-347 added tx status and claim amount tracking for merkl bonus claim… (MetaMask#26234)
<!-- 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** Added <!-- 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? --> Adds analytics tracking for Merkl bonus claim transaction status updates. Tracking includes transaction and network context and claimed amount. ## **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: added claim status event tracking for bonus claim flow ## **Related issues** Fixes: [MUSD-347: Add missing claim tracking ahead of release](https://consensyssoftware.atlassian.net/browse/MUSD-347) ## **Manual testing steps** ```gherkin Feature: Merkl bonus claim status event tracking Scenario: user submits and confirms a Merkl bonus claim Given user submits a Merkl bonus claim transaction on a supported network When the transaction status updates from approved to confirmed Then a claim bonus status updated analytics event is tracked for each status And the event includes transaction id, status, type, network chain id, network name, and claimed mUSD amount Scenario: user’s Merkl bonus claim does not complete Given user submits a Merkl bonus claim transaction When the transaction status becomes failed or dropped Then a claim bonus status updated analytics event is tracked with the final status And the event includes the same transaction and network context with claim amount when available ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> N/A ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** N/A - [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] > **Medium Risk** > Adds new analytics events that depend on decoding tx data and making an on-chain contract read, which could affect tracking reliability/performance if decoding/calls fail (though failures degrade to partial events). > > **Overview** > Adds MetaMetrics tracking for Merkl bonus-claim transaction lifecycle in `useMerklClaimStatus`, emitting `MUSD_CLAIM_BONUS_STATUS_UPDATED` on `approved`/`confirmed`/`failed`/`dropped` with transaction + network context and (when available) the claimed mUSD amount. > > Introduces `getUnclaimedAmountForMerklClaimTx` to decode claim calldata and read already-claimed amounts from the Merkl distributor contract, reuses it in the confirmations hook, and factors network-name resolution into a new shared `getNetworkName` utility (adopted by both claim and conversion tracking). Updates/extends unit tests to cover the new tracking, caching behavior, and network-name helper. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 478cc8b. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 0ee635f commit 5a30a48

8 files changed

Lines changed: 403 additions & 51 deletions

File tree

app/components/UI/Earn/hooks/useMerklClaimStatus.test.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,23 @@ import { ToastVariants } from '../../../../component-library/components/Toast/To
1010
import { IconName } from '../../../../component-library/components/Icons/Icon';
1111
import { NotificationFeedbackType } from 'expo-haptics';
1212
import { MERKL_CLAIM_ORIGIN } from '../components/MerklRewards/constants';
13+
import Logger from '../../../../util/Logger';
14+
import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics';
15+
import { getUnclaimedAmountForMerklClaimTx } from '../utils/musd';
16+
import { MetaMetricsEvents } from '../../../../core/Analytics/MetaMetrics.events';
1317

1418
// Mock all external dependencies
1519
jest.mock('../../../../core/Engine');
1620
jest.mock('./useEarnToasts');
1721
jest.mock('../../../../util/Logger', () => ({
1822
error: jest.fn(),
1923
}));
24+
jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({
25+
useAnalytics: jest.fn(),
26+
}));
27+
jest.mock('../utils/musd', () => ({
28+
getUnclaimedAmountForMerklClaimTx: jest.fn(),
29+
}));
2030

2131
type TransactionStatusUpdatedHandler = (event: {
2232
transactionMeta: TransactionMeta;
@@ -31,6 +41,13 @@ const mockUnsubscribe = jest.fn<
3141
[string, TransactionStatusUpdatedHandler]
3242
>();
3343
const mockUseEarnToasts = jest.mocked(useEarnToasts);
44+
const mockUseAnalytics = jest.mocked(useAnalytics);
45+
const mockGetUnclaimedAmountForMerklClaimTx = jest.mocked(
46+
getUnclaimedAmountForMerklClaimTx,
47+
);
48+
const mockLoggerError = jest.mocked(Logger.error);
49+
const mockTrackEvent = jest.fn();
50+
const mockCreateEventBuilder = jest.fn();
3451

3552
// Mock controller methods
3653
const mockUpdateBalances = jest.fn().mockResolvedValue(undefined);
@@ -114,6 +131,36 @@ describe('useMerklClaimStatus', () => {
114131
},
115132
};
116133

134+
const createMockEventBuilder = () => {
135+
const builder = {
136+
addProperties: jest.fn(),
137+
build: jest.fn(),
138+
};
139+
140+
builder.addProperties.mockImplementation((properties) => {
141+
builder.build.mockReturnValue({
142+
event: MetaMetricsEvents.MUSD_CLAIM_BONUS_STATUS_UPDATED,
143+
properties,
144+
});
145+
return builder;
146+
});
147+
148+
return builder;
149+
};
150+
151+
const flushAsyncWork = async () => {
152+
await act(async () => {
153+
await Promise.resolve();
154+
});
155+
};
156+
157+
const getTrackedProperties = (callIndex: number): Record<string, unknown> => {
158+
const trackedEvent = mockTrackEvent.mock.calls[callIndex]?.[0] as {
159+
properties?: Record<string, unknown>;
160+
};
161+
return trackedEvent?.properties ?? {};
162+
};
163+
117164
beforeEach(() => {
118165
jest.clearAllMocks();
119166
mockUseEarnToasts.mockReturnValue({
@@ -123,6 +170,11 @@ describe('useMerklClaimStatus', () => {
123170
mockUpdateBalances.mockResolvedValue(undefined);
124171
mockDetectTokens.mockResolvedValue(undefined);
125172
mockRefresh.mockResolvedValue(undefined);
173+
mockCreateEventBuilder.mockImplementation(createMockEventBuilder);
174+
mockUseAnalytics.mockReturnValue({
175+
trackEvent: mockTrackEvent,
176+
createEventBuilder: mockCreateEventBuilder,
177+
} as unknown as ReturnType<typeof useAnalytics>);
126178
});
127179

128180
afterEach(() => {
@@ -333,4 +385,90 @@ describe('useMerklClaimStatus', () => {
333385

334386
expect(mockShowToast).toHaveBeenCalledWith(mockFailedToast);
335387
});
388+
389+
it('tracks approved bonus claim event with amount_claimed_decimal', async () => {
390+
const transactionMeta = createMockTransactionMeta({
391+
id: 'tx-analytics-approved',
392+
status: TransactionStatus.approved,
393+
});
394+
mockGetUnclaimedAmountForMerklClaimTx.mockResolvedValue({
395+
totalAmountRaw: '100000',
396+
unclaimedRaw: '100000',
397+
contractCallSucceeded: true,
398+
});
399+
renderHook(() => useMerklClaimStatus());
400+
const handler = mockSubscribe.mock.calls[0][1];
401+
402+
handler({ transactionMeta });
403+
await flushAsyncWork();
404+
405+
expect(mockCreateEventBuilder).toHaveBeenCalledWith(
406+
MetaMetricsEvents.MUSD_CLAIM_BONUS_STATUS_UPDATED,
407+
);
408+
expect(mockTrackEvent).toHaveBeenCalledTimes(1);
409+
expect(getTrackedProperties(0)).toMatchObject({
410+
transaction_id: 'tx-analytics-approved',
411+
transaction_status: TransactionStatus.approved,
412+
amount_claimed_decimal: '0.1',
413+
});
414+
});
415+
416+
it('tracks confirmed bonus claim event with cached amount_claimed_decimal', async () => {
417+
const transactionId = 'tx-analytics-cached';
418+
const approvedTransactionMeta = createMockTransactionMeta({
419+
id: transactionId,
420+
status: TransactionStatus.approved,
421+
});
422+
const confirmedTransactionMeta = createMockTransactionMeta({
423+
id: transactionId,
424+
status: TransactionStatus.confirmed,
425+
});
426+
mockGetUnclaimedAmountForMerklClaimTx.mockResolvedValue({
427+
totalAmountRaw: '100000',
428+
unclaimedRaw: '100000',
429+
contractCallSucceeded: true,
430+
});
431+
renderHook(() => useMerklClaimStatus());
432+
const handler = mockSubscribe.mock.calls[0][1];
433+
434+
handler({ transactionMeta: approvedTransactionMeta });
435+
await flushAsyncWork();
436+
handler({ transactionMeta: confirmedTransactionMeta });
437+
await flushAsyncWork();
438+
439+
expect(mockGetUnclaimedAmountForMerklClaimTx).toHaveBeenCalledTimes(1);
440+
expect(mockTrackEvent).toHaveBeenCalledTimes(2);
441+
expect(getTrackedProperties(1)).toMatchObject({
442+
transaction_id: transactionId,
443+
transaction_status: TransactionStatus.confirmed,
444+
amount_claimed_decimal: '0.1',
445+
});
446+
});
447+
448+
it('tracks bonus claim event without amount_claimed_decimal when contract call fails', async () => {
449+
const transactionMeta = createMockTransactionMeta({
450+
id: 'tx-analytics-partial',
451+
status: TransactionStatus.approved,
452+
});
453+
mockGetUnclaimedAmountForMerklClaimTx.mockResolvedValue({
454+
totalAmountRaw: '100000',
455+
unclaimedRaw: '100000',
456+
contractCallSucceeded: false,
457+
error: new Error('contract call failed'),
458+
});
459+
renderHook(() => useMerklClaimStatus());
460+
const handler = mockSubscribe.mock.calls[0][1];
461+
462+
handler({ transactionMeta });
463+
await flushAsyncWork();
464+
465+
expect(mockLoggerError).toHaveBeenCalled();
466+
expect(getTrackedProperties(0)).toMatchObject({
467+
transaction_id: 'tx-analytics-partial',
468+
transaction_status: TransactionStatus.approved,
469+
});
470+
expect(getTrackedProperties(0)).not.toHaveProperty(
471+
'amount_claimed_decimal',
472+
);
473+
});
336474
});

app/components/UI/Earn/hooks/useMerklClaimStatus.ts

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ import useEarnToasts from './useEarnToasts';
99
import { MERKL_CLAIM_ORIGIN } from '../components/MerklRewards/constants';
1010
import { clearMerklRewardsCache } from '../components/MerklRewards/merkl-client';
1111
import Logger from '../../../../util/Logger';
12+
import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics';
13+
import { MetaMetricsEvents } from '../../../../core/Analytics/MetaMetrics.events';
14+
import { calcTokenAmount } from '../../../../util/transactions';
15+
import { MUSD_DECIMALS } from '../constants/musd';
16+
import { getUnclaimedAmountForMerklClaimTx } from '../utils/musd';
17+
import { getNetworkName } from '../utils/network';
1218

1319
/**
1420
* Hook to monitor Merkl bonus claim transaction status and show appropriate toasts
@@ -26,10 +32,13 @@ import Logger from '../../../../util/Logger';
2632
export const useMerklClaimStatus = () => {
2733
const { showToast, EarnToastOptions } = useEarnToasts();
2834
const shownToastsRef = useRef<Set<string>>(new Set());
35+
const claimAmountByTransactionIdRef = useRef<Map<string, string>>(new Map());
2936
const pendingTimeoutsRef = useRef<Set<ReturnType<typeof setTimeout>>>(
3037
new Set(),
3138
);
3239

40+
const { trackEvent, createEventBuilder } = useAnalytics();
41+
3342
// Refresh token balances for the given chainId
3443
const refreshTokenBalances = useCallback(async (chainId: Hex) => {
3544
try {
@@ -67,6 +76,80 @@ export const useMerklClaimStatus = () => {
6776
}
6877
}, []);
6978

79+
const submitClaimBonusStatusUpdatedEvent = useCallback(
80+
async (transactionMeta: TransactionMeta) => {
81+
try {
82+
const { id: transactionId, status } = transactionMeta;
83+
const baseProperties: Record<string, unknown> = {
84+
transaction_id: transactionId,
85+
transaction_status: status,
86+
transaction_type: transactionMeta.type,
87+
network_chain_id: transactionMeta?.chainId,
88+
network_name: getNetworkName(transactionMeta?.chainId),
89+
};
90+
91+
const cachedClaimAmountRaw =
92+
claimAmountByTransactionIdRef.current.get(transactionId);
93+
94+
if (
95+
(status === TransactionStatus.confirmed ||
96+
status === TransactionStatus.failed ||
97+
status === TransactionStatus.dropped) &&
98+
cachedClaimAmountRaw
99+
) {
100+
baseProperties.amount_claimed_decimal = calcTokenAmount(
101+
cachedClaimAmountRaw,
102+
MUSD_DECIMALS,
103+
).toString();
104+
} else {
105+
const claimAmountResult = await getUnclaimedAmountForMerklClaimTx(
106+
transactionMeta.txParams?.data as string | undefined,
107+
transactionMeta.chainId as Hex,
108+
);
109+
110+
if (!claimAmountResult) {
111+
Logger.error(
112+
new Error('Failed to decode Merkl claim transaction data'),
113+
'useMerklClaimStatus: Failed to decode Merkl claim tx data. Submitting event with partial data.',
114+
);
115+
} else if (claimAmountResult.contractCallSucceeded) {
116+
baseProperties.amount_claimed_decimal = calcTokenAmount(
117+
claimAmountResult.unclaimedRaw,
118+
MUSD_DECIMALS,
119+
).toString();
120+
121+
if (status === TransactionStatus.approved) {
122+
claimAmountByTransactionIdRef.current.set(
123+
transactionId,
124+
claimAmountResult.unclaimedRaw,
125+
);
126+
}
127+
} else {
128+
Logger.error(
129+
claimAmountResult.error ??
130+
new Error(
131+
'Merkl claim contract call failed without explicit error',
132+
),
133+
'useMerklClaimStatus: Failed to get Merkl claim contract data. Submitting event with partial data.',
134+
);
135+
}
136+
}
137+
138+
trackEvent(
139+
createEventBuilder(MetaMetricsEvents.MUSD_CLAIM_BONUS_STATUS_UPDATED)
140+
.addProperties(baseProperties)
141+
.build(),
142+
);
143+
} catch (error) {
144+
Logger.error(
145+
error as Error,
146+
'useMerklClaimStatus: Failed to submit claim bonus status event',
147+
);
148+
}
149+
},
150+
[trackEvent, createEventBuilder],
151+
);
152+
70153
useEffect(() => {
71154
// Capture ref for cleanup to satisfy eslint react-hooks/exhaustive-deps
72155
const pendingTimeouts = pendingTimeoutsRef.current;
@@ -91,12 +174,14 @@ export const useMerklClaimStatus = () => {
91174

92175
switch (status) {
93176
case TransactionStatus.approved:
177+
submitClaimBonusStatusUpdatedEvent(transactionMeta);
94178
// Show in-progress toast immediately after user confirms
95179
showToast(EarnToastOptions.bonusClaim.inProgress);
96180
shownToastsRef.current.add(toastKey);
97181
break;
98182

99183
case TransactionStatus.confirmed:
184+
submitClaimBonusStatusUpdatedEvent(transactionMeta);
100185
// Show success toast (same as mUSD conversion success per AC)
101186
showToast(EarnToastOptions.bonusClaim.success);
102187
shownToastsRef.current.add(toastKey);
@@ -115,6 +200,7 @@ export const useMerklClaimStatus = () => {
115200
shownToastsRef.current.delete(
116201
`${transactionId}-${TransactionStatus.confirmed}`,
117202
);
203+
claimAmountByTransactionIdRef.current.delete(transactionId);
118204
pendingTimeouts.delete(timeoutId);
119205
}, 5000);
120206
pendingTimeouts.add(timeoutId);
@@ -123,6 +209,7 @@ export const useMerklClaimStatus = () => {
123209

124210
case TransactionStatus.failed:
125211
case TransactionStatus.dropped:
212+
submitClaimBonusStatusUpdatedEvent(transactionMeta);
126213
// Dropped = transaction replaced, timed out, or removed from mempool (not confirmed)
127214
showToast(EarnToastOptions.bonusClaim.failed);
128215
shownToastsRef.current.add(toastKey);
@@ -138,6 +225,7 @@ export const useMerklClaimStatus = () => {
138225
shownToastsRef.current.delete(
139226
`${transactionId}-${TransactionStatus.dropped}`,
140227
);
228+
claimAmountByTransactionIdRef.current.delete(transactionId);
141229
pendingTimeouts.delete(timeoutId);
142230
}, 5000);
143231
pendingTimeouts.add(timeoutId);
@@ -163,5 +251,10 @@ export const useMerklClaimStatus = () => {
163251
pendingTimeouts.forEach((timeoutId) => clearTimeout(timeoutId));
164252
pendingTimeouts.clear();
165253
};
166-
}, [showToast, EarnToastOptions.bonusClaim, refreshTokenBalances]);
254+
}, [
255+
showToast,
256+
EarnToastOptions.bonusClaim,
257+
refreshTokenBalances,
258+
submitClaimBonusStatusUpdatedEvent,
259+
]);
167260
};

app/components/UI/Earn/hooks/useMusdConversionStatus.ts

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@ import { safeToChecksumAddress } from '../../../../util/address';
1313
import useEarnToasts from './useEarnToasts';
1414
import { MetaMetricsEvents, useMetrics } from '../../../hooks/useMetrics';
1515
import { decodeTransferData } from '../../../../util/transactions';
16-
import { selectEvmNetworkConfigurationsByChainId } from '../../../../selectors/networkController';
17-
import NetworkList from '../../../../util/networks';
1816
import { TOAST_TRACKING_CLEANUP_DELAY_MS } from '../constants/musd';
1917
import {
2018
trace,
@@ -24,6 +22,7 @@ import {
2422
} from '../../../../util/trace';
2523
import { store } from '../../../../store';
2624
import { selectTransactionPayQuotesByTransactionId } from '../../../../selectors/transactionPayController';
25+
import { getNetworkName } from '../utils/network';
2726

2827
type PayQuote = TransactionPayQuote<unknown>;
2928

@@ -135,10 +134,6 @@ function getMusdConversionQuoteTrackingData(transactionMeta: TransactionMeta): {
135134
* navigating away from the conversion screen.
136135
*/
137136
export const useMusdConversionStatus = () => {
138-
const networkConfigurations = useSelector(
139-
selectEvmNetworkConfigurationsByChainId,
140-
);
141-
142137
const { showToast, EarnToastOptions } = useEarnToasts();
143138
const tokensChainsCache = useSelector(selectERC20TokensByChain);
144139

@@ -148,22 +143,6 @@ export const useMusdConversionStatus = () => {
148143
const tokensCacheRef = useRef(tokensChainsCache);
149144
tokensCacheRef.current = tokensChainsCache;
150145

151-
const getNetworkName = useCallback(
152-
(chainId?: Hex) => {
153-
if (!chainId) return 'Unknown Network';
154-
155-
const nickname = networkConfigurations[chainId]?.name;
156-
157-
const name = Object.values(NetworkList).find(
158-
(network: { chainId?: Hex; shortName: string }) =>
159-
network.chainId === chainId,
160-
)?.shortName;
161-
162-
return name ?? nickname ?? chainId;
163-
},
164-
[networkConfigurations],
165-
);
166-
167146
const submitConversionEvent = useCallback(
168147
(
169148
transactionMeta: TransactionMeta,
@@ -240,7 +219,7 @@ export const useMusdConversionStatus = () => {
240219
.build(),
241220
);
242221
},
243-
[createEventBuilder, getNetworkName, trackEvent],
222+
[createEventBuilder, trackEvent],
244223
);
245224

246225
useEffect(() => {

0 commit comments

Comments
 (0)