diff --git a/app/components/UI/Earn/hooks/useMerklClaimStatus.test.ts b/app/components/UI/Earn/hooks/useMerklClaimStatus.test.ts index 27999bb4471..0d696491db7 100644 --- a/app/components/UI/Earn/hooks/useMerklClaimStatus.test.ts +++ b/app/components/UI/Earn/hooks/useMerklClaimStatus.test.ts @@ -10,6 +10,10 @@ 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'); @@ -17,6 +21,12 @@ 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; @@ -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); @@ -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 => { + const trackedEvent = mockTrackEvent.mock.calls[callIndex]?.[0] as { + properties?: Record; + }; + return trackedEvent?.properties ?? {}; + }; + beforeEach(() => { jest.clearAllMocks(); mockUseEarnToasts.mockReturnValue({ @@ -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); }); afterEach(() => { @@ -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', + ); + }); }); diff --git a/app/components/UI/Earn/hooks/useMerklClaimStatus.ts b/app/components/UI/Earn/hooks/useMerklClaimStatus.ts index 8fdffbe23fb..ae569ff3cb0 100644 --- a/app/components/UI/Earn/hooks/useMerklClaimStatus.ts +++ b/app/components/UI/Earn/hooks/useMerklClaimStatus.ts @@ -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 @@ -26,10 +32,13 @@ import Logger from '../../../../util/Logger'; export const useMerklClaimStatus = () => { const { showToast, EarnToastOptions } = useEarnToasts(); const shownToastsRef = useRef>(new Set()); + const claimAmountByTransactionIdRef = useRef>(new Map()); const pendingTimeoutsRef = useRef>>( new Set(), ); + const { trackEvent, createEventBuilder } = useAnalytics(); + // Refresh token balances for the given chainId const refreshTokenBalances = useCallback(async (chainId: Hex) => { try { @@ -67,6 +76,80 @@ export const useMerklClaimStatus = () => { } }, []); + const submitClaimBonusStatusUpdatedEvent = useCallback( + async (transactionMeta: TransactionMeta) => { + try { + const { id: transactionId, status } = transactionMeta; + const baseProperties: Record = { + 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; @@ -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); @@ -115,6 +200,7 @@ export const useMerklClaimStatus = () => { shownToastsRef.current.delete( `${transactionId}-${TransactionStatus.confirmed}`, ); + claimAmountByTransactionIdRef.current.delete(transactionId); pendingTimeouts.delete(timeoutId); }, 5000); pendingTimeouts.add(timeoutId); @@ -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); @@ -138,6 +225,7 @@ export const useMerklClaimStatus = () => { shownToastsRef.current.delete( `${transactionId}-${TransactionStatus.dropped}`, ); + claimAmountByTransactionIdRef.current.delete(transactionId); pendingTimeouts.delete(timeoutId); }, 5000); pendingTimeouts.add(timeoutId); @@ -163,5 +251,10 @@ export const useMerklClaimStatus = () => { pendingTimeouts.forEach((timeoutId) => clearTimeout(timeoutId)); pendingTimeouts.clear(); }; - }, [showToast, EarnToastOptions.bonusClaim, refreshTokenBalances]); + }, [ + showToast, + EarnToastOptions.bonusClaim, + refreshTokenBalances, + submitClaimBonusStatusUpdatedEvent, + ]); }; diff --git a/app/components/UI/Earn/hooks/useMusdConversionStatus.ts b/app/components/UI/Earn/hooks/useMusdConversionStatus.ts index a3af42a22c1..79900bcc110 100644 --- a/app/components/UI/Earn/hooks/useMusdConversionStatus.ts +++ b/app/components/UI/Earn/hooks/useMusdConversionStatus.ts @@ -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, @@ -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; @@ -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); @@ -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, @@ -240,7 +219,7 @@ export const useMusdConversionStatus = () => { .build(), ); }, - [createEventBuilder, getNetworkName, trackEvent], + [createEventBuilder, trackEvent], ); useEffect(() => { diff --git a/app/components/UI/Earn/utils/musd.ts b/app/components/UI/Earn/utils/musd.ts index 5b1a10fd15d..bd4a138371e 100644 --- a/app/components/UI/Earn/utils/musd.ts +++ b/app/components/UI/Earn/utils/musd.ts @@ -13,6 +13,7 @@ import { MUSD_TOKEN, MUSD_TOKEN_ADDRESS, } from '../constants/musd'; +import { getClaimedAmountFromContract } from '../components/MerklRewards/merkl-client'; import { DISTRIBUTOR_CLAIM_ABI } from '../components/MerklRewards/constants'; /** @@ -126,6 +127,67 @@ export function convertMusdClaimAmount({ }; } +/** + * Result of resolving the unclaimed amount for a Merkl claim transaction. + */ +export interface GetUnclaimedAmountForMerklClaimTxResult { + /** Total cumulative reward (raw base units) from tx calldata */ + totalAmountRaw: string; + /** Unclaimed amount (total - claimed from contract) in raw base units */ + unclaimedRaw: string; + /** True if the contract call succeeded; false if it failed (caller may omit claimed decimal from analytics) */ + contractCallSucceeded: boolean; + /** Set when contractCallSucceeded is false, for caller to log */ + error?: Error; +} + +/** + * Resolve the unclaimed amount for a Merkl mUSD claim transaction. + * Decodes tx calldata, reads already-claimed from the Merkl distributor contract, + * and returns total and unclaimed raw amounts. + * + * @param txData - Transaction data hex string (txParams.data) + * @param chainId - Chain ID for the contract call + * @returns Result with totalAmountRaw, unclaimedRaw, and contractCallSucceeded, or null if decoding fails + */ +export async function getUnclaimedAmountForMerklClaimTx( + txData: string | undefined, + chainId: Hex, +): Promise { + const claimParams = decodeMerklClaimParams(txData); + if (!claimParams) { + return null; + } + + const totalAmountRaw = claimParams.totalAmount; + const totalBigInt = BigInt(totalAmountRaw); + + try { + const claimedAmount = await getClaimedAmountFromContract( + claimParams.userAddress, + claimParams.tokenAddress as Hex, + chainId, + ); + const claimedBigInt = BigInt(claimedAmount ?? '0'); + const unclaimedRaw = + totalBigInt > claimedBigInt + ? (totalBigInt - claimedBigInt).toString() + : '0'; + return { + totalAmountRaw, + unclaimedRaw, + contractCallSucceeded: true, + }; + } catch (error) { + return { + totalAmountRaw, + unclaimedRaw: totalAmountRaw, + contractCallSucceeded: false, + error: error instanceof Error ? error : new Error(String(error)), + }; + } +} + /** * Decoded Merkl claim transaction parameters */ diff --git a/app/components/UI/Earn/utils/network.test.ts b/app/components/UI/Earn/utils/network.test.ts new file mode 100644 index 00000000000..0ed81e04161 --- /dev/null +++ b/app/components/UI/Earn/utils/network.test.ts @@ -0,0 +1,70 @@ +import { Hex } from '@metamask/utils'; +import { getNetworkName } from './network'; + +const mockNetworkConfigurationsByChainId: Record = {}; +const mockNetworkControllerState = { + networkConfigurationsByChainId: mockNetworkConfigurationsByChainId, +}; + +jest.mock('../../../../core/Engine', () => ({ + __esModule: true, + default: { + get context() { + return { + NetworkController: { + state: mockNetworkControllerState, + }, + }; + }, + }, +})); + +const clearObject = (value: T) => { + Object.keys(value).forEach((key) => { + delete value[key as keyof T]; + }); +}; + +describe('getNetworkName', () => { + beforeEach(() => { + clearObject(mockNetworkConfigurationsByChainId); + }); + + it('returns Unknown Network when chainId is not provided', () => { + const chainId = undefined; + + const networkName = getNetworkName(chainId); + + expect(networkName).toBe('Unknown Network'); + }); + + it('returns network shortName when chainId matches NetworkList', () => { + const chainId = '0x1' as Hex; + mockNetworkConfigurationsByChainId[chainId] = { + name: 'Ethereum Main Network', + }; + + const networkName = getNetworkName(chainId); + + expect(networkName).toBe('Ethereum'); + }); + + it('returns nickname when NetworkList does not contain the chainId', () => { + const chainId = '0x89' as Hex; + mockNetworkConfigurationsByChainId[chainId] = { + name: 'Polygon Mainnet', + }; + + const networkName = getNetworkName(chainId); + + expect(networkName).toBe('Polygon Mainnet'); + }); + + it('returns chainId when neither shortName nor nickname exists', () => { + const chainId = '0x539' as Hex; + + const networkName = getNetworkName(chainId); + + expect(networkName).toBe(chainId); + }); +}); diff --git a/app/components/UI/Earn/utils/network.ts b/app/components/UI/Earn/utils/network.ts new file mode 100644 index 00000000000..4f246b93ca7 --- /dev/null +++ b/app/components/UI/Earn/utils/network.ts @@ -0,0 +1,21 @@ +import { Hex } from '@metamask/utils'; +import NetworkList from '../../../../util/networks'; +import Engine from '../../../../core/Engine'; + +export const getNetworkName = (chainId?: Hex) => { + if (!chainId) return 'Unknown Network'; + + const { NetworkController } = Engine.context; + + const networkConfigurations = + NetworkController.state.networkConfigurationsByChainId; + + const nickname = networkConfigurations[chainId]?.name; + + const name = Object.values(NetworkList).find( + (network: { chainId?: Hex; shortName: string }) => + network.chainId === chainId, + )?.shortName; + + return name ?? nickname ?? chainId; +}; diff --git a/app/components/Views/confirmations/hooks/earn/useMerklClaimAmount.ts b/app/components/Views/confirmations/hooks/earn/useMerklClaimAmount.ts index f25612d4074..85ba6669569 100644 --- a/app/components/Views/confirmations/hooks/earn/useMerklClaimAmount.ts +++ b/app/components/Views/confirmations/hooks/earn/useMerklClaimAmount.ts @@ -9,10 +9,9 @@ import { useMemo } from 'react'; import { useAsyncResult } from '../../../../hooks/useAsyncResult'; import { convertMusdClaimAmount, - decodeMerklClaimParams, ConvertMusdClaimResult, + getUnclaimedAmountForMerklClaimTx, } from '../../../../UI/Earn/utils/musd'; -import { getClaimedAmountFromContract } from '../../../../UI/Earn/components/MerklRewards/merkl-client'; interface MerklClaimAmountResult { /** Whether the async contract call is still pending */ @@ -35,37 +34,23 @@ const useMerklClaimAmount = ( ): MerklClaimAmountResult => { const { chainId, txParams, type: transactionType } = transaction; - // Decode Merkl claim params from the transaction calldata - const claimParams = useMemo(() => { + const { value: claimAmountResult, pending } = useAsyncResult(async () => { if (transactionType !== TransactionType.musdClaim) return null; - return decodeMerklClaimParams(txParams?.data as string); - }, [transactionType, txParams?.data]); - - // Fetch the already-claimed amount from the Merkl distributor contract - // so we can compute unclaimed = total - claimed - const { value: claimedAmount, pending } = useAsyncResult(async () => { - if (!claimParams) return null; - return getClaimedAmountFromContract( - claimParams.userAddress, - claimParams.tokenAddress as Hex, + return getUnclaimedAmountForMerklClaimTx( + txParams?.data as string | undefined, chainId as Hex, ); - }, [claimParams, chainId]); + }, [transactionType, txParams?.data, chainId]); const claimAmount = useMemo(() => { - if (pending || !claimParams) return null; - - const totalRaw = BigInt(claimParams.totalAmount); - const claimedRaw = BigInt(claimedAmount ?? '0'); - const unclaimedRaw = - totalRaw > claimedRaw ? (totalRaw - claimedRaw).toString() : '0'; + if (pending || !claimAmountResult) return null; return convertMusdClaimAmount({ - claimAmountRaw: unclaimedRaw, + claimAmountRaw: claimAmountResult.unclaimedRaw, conversionRate, usdConversionRate, }); - }, [pending, claimParams, claimedAmount, conversionRate, usdConversionRate]); + }, [pending, claimAmountResult, conversionRate, usdConversionRate]); return { pending, claimAmount }; }; diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index 77c5f685f5b..64e33df25d6 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -366,7 +366,6 @@ enum EVENT_NAME { EARN_LENDING_DEPOSIT_MORE_BUTTON_CLICKED = 'Earn Lending Deposit More Button Clicked', EARN_LENDING_WITHDRAW_BUTTON_CLICKED = 'Earn Lending Withdraw Button Clicked', EARN_LENDING_WITHDRAW_CONFIRMATION_BACK_CLICKED = 'Earn Lending Withdraw Confirmation Back Clicked', - MUSD_CLAIM_BONUS_BUTTON_CLICKED = 'mUSD Claim Bonus Button Clicked', // Stake STAKE_BUTTON_CLICKED = 'Stake Button Clicked', @@ -606,6 +605,8 @@ enum EVENT_NAME { MUSD_FULLSCREEN_ANNOUNCEMENT_DISPLAYED = 'mUSD Fullscreen Announcement Displayed', MUSD_FULLSCREEN_ANNOUNCEMENT_BUTTON_CLICKED = 'mUSD Fullscreen Announcement Button Clicked', MUSD_CONVERSION_STATUS_UPDATED = 'mUSD Conversion Status Updated', + MUSD_CLAIM_BONUS_BUTTON_CLICKED = 'mUSD Claim Bonus Button Clicked', + MUSD_CLAIM_BONUS_STATUS_UPDATED = 'mUSD Claim Bonus Status Updated', } export enum HARDWARE_WALLET_BUTTON_TYPE { @@ -1280,9 +1281,6 @@ const events = { EARN_LENDING_WITHDRAW_CONFIRMATION_BACK_CLICKED: generateOpt( EVENT_NAME.EARN_LENDING_WITHDRAW_CONFIRMATION_BACK_CLICKED, ), - MUSD_CLAIM_BONUS_BUTTON_CLICKED: generateOpt( - EVENT_NAME.MUSD_CLAIM_BONUS_BUTTON_CLICKED, - ), // Stake REVIEW_STAKE_BUTTON_CLICKED: generateOpt( EVENT_NAME.REVIEW_STAKE_BUTTON_CLICKED, @@ -1555,6 +1553,12 @@ const events = { MUSD_CONVERSION_STATUS_UPDATED: generateOpt( EVENT_NAME.MUSD_CONVERSION_STATUS_UPDATED, ), + MUSD_CLAIM_BONUS_BUTTON_CLICKED: generateOpt( + EVENT_NAME.MUSD_CLAIM_BONUS_BUTTON_CLICKED, + ), + MUSD_CLAIM_BONUS_STATUS_UPDATED: generateOpt( + EVENT_NAME.MUSD_CLAIM_BONUS_STATUS_UPDATED, + ), }; /** diff --git a/builds.yml b/builds.yml index 66127d98a39..6c7019b27f3 100644 --- a/builds.yml +++ b/builds.yml @@ -120,6 +120,26 @@ _code_fencing_main: &code_fencing_main - bitcoin - tron +# Beta code fencing features (main + beta feature) +_code_fencing_beta: &code_fencing_beta + - beta + - preinstalled-snaps + - keyring-snaps + - multi-srp + - solana + - bitcoin + - tron + +# Experimental code fencing features (main + experimental) +_code_fencing_experimental: &code_fencing_experimental + - preinstalled-snaps + - keyring-snaps + - multi-srp + - solana + - bitcoin + - tron + - experimental + # Flask code fencing features (includes experimental) _code_fencing_flask: &code_fencing_flask - flask @@ -186,7 +206,7 @@ builds: METAMASK_ENVIRONMENT: 'beta' METAMASK_BUILD_TYPE: 'main' secrets: *secrets - code_fencing: *code_fencing_main + code_fencing: *code_fencing_beta remote_feature_flags: *remote_feature_flags # Release candidate @@ -260,7 +280,7 @@ builds: IS_TEST: 'false' MM_ENABLE_SETTINGS_PAGE_DEV_OPTIONS: 'true' secrets: *secrets - code_fencing: *code_fencing_main + code_fencing: *code_fencing_experimental remote_feature_flags: <<: *remote_feature_flags # Override for experimental testing diff --git a/metro.transform.js b/metro.transform.js index 72f3ae4d417..452663b06a0 100644 --- a/metro.transform.js +++ b/metro.transform.js @@ -12,6 +12,8 @@ const svgTransformer = require('react-native-svg-transformer'); // Code fence removal variables const fileExtsToScan = ['.js', '.jsx', '.cjs', '.mjs', '.ts', '.tsx']; + +// All available features that can be used in code fences const availableFeatures = new Set([ 'flask', 'preinstalled-snaps', @@ -26,6 +28,7 @@ const availableFeatures = new Set([ 'experimental', ]); +// Legacy (main) hardcoded feature sets — used when CODE_FENCING_FEATURES is not set (e.g. Bitrise / local) const mainFeatureSet = new Set([ 'preinstalled-snaps', 'keyring-snaps', @@ -53,38 +56,35 @@ const flaskFeatureSet = new Set([ 'solana', 'tron', ]); -// Experimental feature set includes all main features plus experimental const experimentalFeatureSet = new Set([...mainFeatureSet, 'experimental']); /** - * Gets the features for the current build type, used to determine which code - * fences to remove. + * Gets features from METAMASK_BUILD_TYPE + METAMASK_ENVIRONMENT (main branch logic). + * Used when CODE_FENCING_FEATURES is not set (Bitrise or local). * * @returns {Set} The set of features to be included in the build. */ -function getBuildTypeFeatures() { +function getBuildTypeFeaturesFromEnv() { const buildType = process.env.METAMASK_BUILD_TYPE ?? 'main'; const envType = process.env.METAMASK_ENVIRONMENT ?? 'production'; let features; switch (buildType) { - // TODO: Remove uppercase QA once we've consolidated build types case 'qa': case 'QA': case 'main': - // TODO: Refactor this once we've abstracted environment away from build type if (envType === 'exp') { - // Only include experimental features in experimental environment - features = experimentalFeatureSet; + features = new Set(experimentalFeatureSet); break; } - features = envType === 'beta' ? betaFeatureSet : mainFeatureSet; + features = + envType === 'beta' ? new Set(betaFeatureSet) : new Set(mainFeatureSet); break; case 'beta': - features = betaFeatureSet; + features = new Set(betaFeatureSet); break; case 'flask': - features = flaskFeatureSet; + features = new Set(flaskFeatureSet); break; default: throw new Error( @@ -92,12 +92,35 @@ function getBuildTypeFeatures() { ); } - // Add sample-feature only if explicitly enabled via env var + return features; +} + +/** + * Gets the features for the current build type, used to determine which code + * fences to remove. + * + * Default (GH Actions): use CODE_FENCING_FEATURES from env (set by apply-build-config.js from builds.yml). + * Fallback (Bitrise / local): use METAMASK_BUILD_TYPE + METAMASK_ENVIRONMENT with hardcoded sets. + * + * @returns {Set} The set of features to be included in the build. + */ +function getBuildTypeFeatures() { + let featureSet; + + // Prefer GH Actions path: single source of truth from builds.yml + if (process.env.CODE_FENCING_FEATURES) { + const features = JSON.parse(process.env.CODE_FENCING_FEATURES); + featureSet = new Set(features); + } else { + // Fallback for Bitrise / local dev builds + featureSet = getBuildTypeFeaturesFromEnv(); + } + if (process.env.INCLUDE_SAMPLE_FEATURE === 'true') { - features.add('sample-feature'); + featureSet.add('sample-feature'); } - return features; + return featureSet; } /**