Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions app/components/UI/Earn/hooks/useMerklClaimStatus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,23 @@ import { ToastVariants } from '../../../../component-library/components/Toast/To
import { IconName } from '../../../../component-library/components/Icons/Icon';
import { NotificationFeedbackType } from 'expo-haptics';
import { MERKL_CLAIM_ORIGIN } from '../components/MerklRewards/constants';
import Logger from '../../../../util/Logger';
import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics';
import { getUnclaimedAmountForMerklClaimTx } from '../utils/musd';
import { MetaMetricsEvents } from '../../../../core/Analytics/MetaMetrics.events';

// Mock all external dependencies
jest.mock('../../../../core/Engine');
jest.mock('./useEarnToasts');
jest.mock('../../../../util/Logger', () => ({
error: jest.fn(),
}));
jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({
useAnalytics: jest.fn(),
}));
jest.mock('../utils/musd', () => ({
getUnclaimedAmountForMerklClaimTx: jest.fn(),
}));

type TransactionStatusUpdatedHandler = (event: {
transactionMeta: TransactionMeta;
Expand All @@ -31,6 +41,13 @@ const mockUnsubscribe = jest.fn<
[string, TransactionStatusUpdatedHandler]
>();
const mockUseEarnToasts = jest.mocked(useEarnToasts);
const mockUseAnalytics = jest.mocked(useAnalytics);
const mockGetUnclaimedAmountForMerklClaimTx = jest.mocked(
getUnclaimedAmountForMerklClaimTx,
);
const mockLoggerError = jest.mocked(Logger.error);
const mockTrackEvent = jest.fn();
const mockCreateEventBuilder = jest.fn();

// Mock controller methods
const mockUpdateBalances = jest.fn().mockResolvedValue(undefined);
Expand Down Expand Up @@ -114,6 +131,36 @@ describe('useMerklClaimStatus', () => {
},
};

const createMockEventBuilder = () => {
const builder = {
addProperties: jest.fn(),
build: jest.fn(),
};

builder.addProperties.mockImplementation((properties) => {
builder.build.mockReturnValue({
event: MetaMetricsEvents.MUSD_CLAIM_BONUS_STATUS_UPDATED,
properties,
});
return builder;
});

return builder;
};

const flushAsyncWork = async () => {
await act(async () => {
await Promise.resolve();
});
};

const getTrackedProperties = (callIndex: number): Record<string, unknown> => {
const trackedEvent = mockTrackEvent.mock.calls[callIndex]?.[0] as {
properties?: Record<string, unknown>;
};
return trackedEvent?.properties ?? {};
};

beforeEach(() => {
jest.clearAllMocks();
mockUseEarnToasts.mockReturnValue({
Expand All @@ -123,6 +170,11 @@ describe('useMerklClaimStatus', () => {
mockUpdateBalances.mockResolvedValue(undefined);
mockDetectTokens.mockResolvedValue(undefined);
mockRefresh.mockResolvedValue(undefined);
mockCreateEventBuilder.mockImplementation(createMockEventBuilder);
mockUseAnalytics.mockReturnValue({
trackEvent: mockTrackEvent,
createEventBuilder: mockCreateEventBuilder,
} as unknown as ReturnType<typeof useAnalytics>);
});

afterEach(() => {
Expand Down Expand Up @@ -333,4 +385,90 @@ describe('useMerklClaimStatus', () => {

expect(mockShowToast).toHaveBeenCalledWith(mockFailedToast);
});

it('tracks approved bonus claim event with amount_claimed_decimal', async () => {
const transactionMeta = createMockTransactionMeta({
id: 'tx-analytics-approved',
status: TransactionStatus.approved,
});
mockGetUnclaimedAmountForMerklClaimTx.mockResolvedValue({
totalAmountRaw: '100000',
unclaimedRaw: '100000',
contractCallSucceeded: true,
});
renderHook(() => useMerklClaimStatus());
const handler = mockSubscribe.mock.calls[0][1];

handler({ transactionMeta });
await flushAsyncWork();

expect(mockCreateEventBuilder).toHaveBeenCalledWith(
MetaMetricsEvents.MUSD_CLAIM_BONUS_STATUS_UPDATED,
);
expect(mockTrackEvent).toHaveBeenCalledTimes(1);
expect(getTrackedProperties(0)).toMatchObject({
transaction_id: 'tx-analytics-approved',
transaction_status: TransactionStatus.approved,
amount_claimed_decimal: '0.1',
});
});

it('tracks confirmed bonus claim event with cached amount_claimed_decimal', async () => {
const transactionId = 'tx-analytics-cached';
const approvedTransactionMeta = createMockTransactionMeta({
id: transactionId,
status: TransactionStatus.approved,
});
const confirmedTransactionMeta = createMockTransactionMeta({
id: transactionId,
status: TransactionStatus.confirmed,
});
mockGetUnclaimedAmountForMerklClaimTx.mockResolvedValue({
totalAmountRaw: '100000',
unclaimedRaw: '100000',
contractCallSucceeded: true,
});
renderHook(() => useMerklClaimStatus());
const handler = mockSubscribe.mock.calls[0][1];

handler({ transactionMeta: approvedTransactionMeta });
await flushAsyncWork();
handler({ transactionMeta: confirmedTransactionMeta });
await flushAsyncWork();

expect(mockGetUnclaimedAmountForMerklClaimTx).toHaveBeenCalledTimes(1);
expect(mockTrackEvent).toHaveBeenCalledTimes(2);
expect(getTrackedProperties(1)).toMatchObject({
transaction_id: transactionId,
transaction_status: TransactionStatus.confirmed,
amount_claimed_decimal: '0.1',
});
});

it('tracks bonus claim event without amount_claimed_decimal when contract call fails', async () => {
const transactionMeta = createMockTransactionMeta({
id: 'tx-analytics-partial',
status: TransactionStatus.approved,
});
mockGetUnclaimedAmountForMerklClaimTx.mockResolvedValue({
totalAmountRaw: '100000',
unclaimedRaw: '100000',
contractCallSucceeded: false,
error: new Error('contract call failed'),
});
renderHook(() => useMerklClaimStatus());
const handler = mockSubscribe.mock.calls[0][1];

handler({ transactionMeta });
await flushAsyncWork();

expect(mockLoggerError).toHaveBeenCalled();
expect(getTrackedProperties(0)).toMatchObject({
transaction_id: 'tx-analytics-partial',
transaction_status: TransactionStatus.approved,
});
expect(getTrackedProperties(0)).not.toHaveProperty(
'amount_claimed_decimal',
);
});
});
95 changes: 94 additions & 1 deletion app/components/UI/Earn/hooks/useMerklClaimStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ import useEarnToasts from './useEarnToasts';
import { MERKL_CLAIM_ORIGIN } from '../components/MerklRewards/constants';
import { clearMerklRewardsCache } from '../components/MerklRewards/merkl-client';
import Logger from '../../../../util/Logger';
import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics';
import { MetaMetricsEvents } from '../../../../core/Analytics/MetaMetrics.events';
import { calcTokenAmount } from '../../../../util/transactions';
import { MUSD_DECIMALS } from '../constants/musd';
import { getUnclaimedAmountForMerklClaimTx } from '../utils/musd';
import { getNetworkName } from '../utils/network';

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

const { trackEvent, createEventBuilder } = useAnalytics();

// Refresh token balances for the given chainId
const refreshTokenBalances = useCallback(async (chainId: Hex) => {
try {
Expand Down Expand Up @@ -67,6 +76,80 @@ export const useMerklClaimStatus = () => {
}
}, []);

const submitClaimBonusStatusUpdatedEvent = useCallback(
async (transactionMeta: TransactionMeta) => {
try {
const { id: transactionId, status } = transactionMeta;
const baseProperties: Record<string, unknown> = {
transaction_id: transactionId,
transaction_status: status,
transaction_type: transactionMeta.type,
network_chain_id: transactionMeta?.chainId,
network_name: getNetworkName(transactionMeta?.chainId),
};

const cachedClaimAmountRaw =
claimAmountByTransactionIdRef.current.get(transactionId);

if (
(status === TransactionStatus.confirmed ||
status === TransactionStatus.failed ||
status === TransactionStatus.dropped) &&
cachedClaimAmountRaw
) {
baseProperties.amount_claimed_decimal = calcTokenAmount(
cachedClaimAmountRaw,
MUSD_DECIMALS,
).toString();
} else {
const claimAmountResult = await getUnclaimedAmountForMerklClaimTx(
transactionMeta.txParams?.data as string | undefined,
transactionMeta.chainId as Hex,
);

if (!claimAmountResult) {
Logger.error(
new Error('Failed to decode Merkl claim transaction data'),
'useMerklClaimStatus: Failed to decode Merkl claim tx data. Submitting event with partial data.',
);
} else if (claimAmountResult.contractCallSucceeded) {
baseProperties.amount_claimed_decimal = calcTokenAmount(
claimAmountResult.unclaimedRaw,
MUSD_DECIMALS,
).toString();

if (status === TransactionStatus.approved) {
claimAmountByTransactionIdRef.current.set(
transactionId,
claimAmountResult.unclaimedRaw,
);
}
} else {
Logger.error(
claimAmountResult.error ??
new Error(
'Merkl claim contract call failed without explicit error',
),
'useMerklClaimStatus: Failed to get Merkl claim contract data. Submitting event with partial data.',
);
}
}

trackEvent(
createEventBuilder(MetaMetricsEvents.MUSD_CLAIM_BONUS_STATUS_UPDATED)
.addProperties(baseProperties)
.build(),
);
} catch (error) {
Logger.error(
error as Error,
'useMerklClaimStatus: Failed to submit claim bonus status event',
);
}
},
[trackEvent, createEventBuilder],
);

useEffect(() => {
// Capture ref for cleanup to satisfy eslint react-hooks/exhaustive-deps
const pendingTimeouts = pendingTimeoutsRef.current;
Expand All @@ -91,12 +174,14 @@ export const useMerklClaimStatus = () => {

switch (status) {
case TransactionStatus.approved:
submitClaimBonusStatusUpdatedEvent(transactionMeta);
// Show in-progress toast immediately after user confirms
showToast(EarnToastOptions.bonusClaim.inProgress);
shownToastsRef.current.add(toastKey);
break;

case TransactionStatus.confirmed:
submitClaimBonusStatusUpdatedEvent(transactionMeta);
// Show success toast (same as mUSD conversion success per AC)
showToast(EarnToastOptions.bonusClaim.success);
shownToastsRef.current.add(toastKey);
Expand All @@ -115,6 +200,7 @@ export const useMerklClaimStatus = () => {
shownToastsRef.current.delete(
`${transactionId}-${TransactionStatus.confirmed}`,
);
claimAmountByTransactionIdRef.current.delete(transactionId);
pendingTimeouts.delete(timeoutId);
}, 5000);
pendingTimeouts.add(timeoutId);
Expand All @@ -123,6 +209,7 @@ export const useMerklClaimStatus = () => {

case TransactionStatus.failed:
case TransactionStatus.dropped:
submitClaimBonusStatusUpdatedEvent(transactionMeta);
// Dropped = transaction replaced, timed out, or removed from mempool (not confirmed)
showToast(EarnToastOptions.bonusClaim.failed);
shownToastsRef.current.add(toastKey);
Expand All @@ -138,6 +225,7 @@ export const useMerklClaimStatus = () => {
shownToastsRef.current.delete(
`${transactionId}-${TransactionStatus.dropped}`,
);
claimAmountByTransactionIdRef.current.delete(transactionId);
pendingTimeouts.delete(timeoutId);
}, 5000);
pendingTimeouts.add(timeoutId);
Expand All @@ -163,5 +251,10 @@ export const useMerklClaimStatus = () => {
pendingTimeouts.forEach((timeoutId) => clearTimeout(timeoutId));
pendingTimeouts.clear();
};
}, [showToast, EarnToastOptions.bonusClaim, refreshTokenBalances]);
}, [
showToast,
EarnToastOptions.bonusClaim,
refreshTokenBalances,
submitClaimBonusStatusUpdatedEvent,
]);
};
25 changes: 2 additions & 23 deletions app/components/UI/Earn/hooks/useMusdConversionStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ import { safeToChecksumAddress } from '../../../../util/address';
import useEarnToasts from './useEarnToasts';
import { MetaMetricsEvents, useMetrics } from '../../../hooks/useMetrics';
import { decodeTransferData } from '../../../../util/transactions';
import { selectEvmNetworkConfigurationsByChainId } from '../../../../selectors/networkController';
import NetworkList from '../../../../util/networks';
import { TOAST_TRACKING_CLEANUP_DELAY_MS } from '../constants/musd';
import {
trace,
Expand All @@ -24,6 +22,7 @@ import {
} from '../../../../util/trace';
import { store } from '../../../../store';
import { selectTransactionPayQuotesByTransactionId } from '../../../../selectors/transactionPayController';
import { getNetworkName } from '../utils/network';

type PayQuote = TransactionPayQuote<unknown>;

Expand Down Expand Up @@ -135,10 +134,6 @@ function getMusdConversionQuoteTrackingData(transactionMeta: TransactionMeta): {
* navigating away from the conversion screen.
*/
export const useMusdConversionStatus = () => {
const networkConfigurations = useSelector(
selectEvmNetworkConfigurationsByChainId,
);

const { showToast, EarnToastOptions } = useEarnToasts();
const tokensChainsCache = useSelector(selectERC20TokensByChain);

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

const getNetworkName = useCallback(
(chainId?: Hex) => {
if (!chainId) return 'Unknown Network';

const nickname = networkConfigurations[chainId]?.name;

const name = Object.values(NetworkList).find(
(network: { chainId?: Hex; shortName: string }) =>
network.chainId === chainId,
)?.shortName;

return name ?? nickname ?? chainId;
},
[networkConfigurations],
);

const submitConversionEvent = useCallback(
(
transactionMeta: TransactionMeta,
Expand Down Expand Up @@ -240,7 +219,7 @@ export const useMusdConversionStatus = () => {
.build(),
);
},
[createEventBuilder, getNetworkName, trackEvent],
[createEventBuilder, trackEvent],
);

useEffect(() => {
Expand Down
Loading
Loading