diff --git a/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.test.tsx b/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.test.tsx index c50b458e01a..9ba6d9e7854 100644 --- a/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.test.tsx +++ b/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.test.tsx @@ -1,11 +1,21 @@ import React from 'react'; -import { render, fireEvent } from '@testing-library/react-native'; +import { fireEvent } from '@testing-library/react-native'; +import type { CaipChainId } from '@metamask/utils'; import TronUnstakedBanner from './TronUnstakedBanner'; import { strings } from '../../../../../../../locales/i18n'; import useTronClaimUnstakedTrx from '../../../hooks/useTronClaimUnstakedTrx'; import useEarnToasts from '../../../hooks/useEarnToasts'; +import renderWithProvider from '../../../../../../util/test/renderWithProvider'; +import { selectTronClaimUnstakedTrxButtonEnabled } from '../../../../../../selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled'; import { TronUnstakedBannerTestIds } from './TronUnstakedBanner.testIds'; +jest.mock( + '../../../../../../selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled', + () => ({ + selectTronClaimUnstakedTrxButtonEnabled: jest.fn(), + }), +); + jest.mock('../../../hooks/useTronClaimUnstakedTrx'); const mockUseTronClaimUnstakedTrx = useTronClaimUnstakedTrx as jest.MockedFunction< @@ -23,11 +33,18 @@ jest.mock('../../../hooks/useEarnToasts'); }, }); +const mockSelectTronClaimUnstakedTrxButtonEnabled = + selectTronClaimUnstakedTrxButtonEnabled as unknown as jest.Mock; + +const renderBanner = (props: { amount: string; chainId: CaipChainId }) => + renderWithProvider(, undefined, false); + describe('TronUnstakedBanner', () => { const mockHandleClaimUnstakedTrx = jest.fn(); beforeEach(() => { jest.clearAllMocks(); + mockSelectTronClaimUnstakedTrxButtonEnabled.mockReturnValue(true); mockUseTronClaimUnstakedTrx.mockReturnValue({ handleClaimUnstakedTrx: mockHandleClaimUnstakedTrx, isSubmitting: false, @@ -42,9 +59,10 @@ describe('TronUnstakedBanner', () => { }); it('renders the title with the given amount', () => { - const { getByText } = render( - , - ); + const { getByText } = renderBanner({ + amount: '100', + chainId: 'tron:728126428', + }); const expectedTitle = strings('stake.tron.unstaked_banner.title', { amount: '100', @@ -53,9 +71,10 @@ describe('TronUnstakedBanner', () => { }); it('renders the description', () => { - const { getByText } = render( - , - ); + const { getByText } = renderBanner({ + amount: '100', + chainId: 'tron:728126428', + }); const expectedDescription = strings( 'stake.tron.unstaked_banner.description', @@ -63,20 +82,38 @@ describe('TronUnstakedBanner', () => { expect(getByText(expectedDescription)).toBeOnTheScreen(); }); - it('renders the Withdraw button', () => { - const { getByTestId } = render( - , - ); + it('renders the claim button when tronClaimUnstakedTrxButtonEnabled is true', () => { + const { getByTestId } = renderBanner({ + amount: '100', + chainId: 'tron:728126428', + }); expect( getByTestId(TronUnstakedBannerTestIds.CLAIM_BUTTON), ).toBeOnTheScreen(); }); + it('does not render the claim button when tronClaimUnstakedTrxButtonEnabled is false', () => { + mockSelectTronClaimUnstakedTrxButtonEnabled.mockReturnValue(false); + + const { getByText, queryByTestId } = renderBanner({ + amount: '100', + chainId: 'tron:728126428', + }); + + expect( + queryByTestId(TronUnstakedBannerTestIds.CLAIM_BUTTON), + ).not.toBeOnTheScreen(); + expect( + getByText(strings('stake.tron.unstaked_banner.description')), + ).toBeOnTheScreen(); + }); + it('calls handleClaimUnstakedTrx when button is pressed', () => { - const { getByTestId } = render( - , - ); + const { getByTestId } = renderBanner({ + amount: '100', + chainId: 'tron:728126428', + }); fireEvent.press(getByTestId(TronUnstakedBannerTestIds.CLAIM_BUTTON)); expect(mockHandleClaimUnstakedTrx).toHaveBeenCalledTimes(1); @@ -89,9 +126,10 @@ describe('TronUnstakedBanner', () => { errors: undefined, }); - const { getByTestId } = render( - , - ); + const { getByTestId } = renderBanner({ + amount: '100', + chainId: 'tron:728126428', + }); const button = getByTestId(TronUnstakedBannerTestIds.CLAIM_BUTTON); expect(button.props.accessibilityState?.disabled).toBe(true); @@ -104,7 +142,7 @@ describe('TronUnstakedBanner', () => { errors: ['InsufficientBalance'], }); - render(); + renderBanner({ amount: '100', chainId: 'tron:728126428' }); expect(mockFailedToastFn).toHaveBeenCalledWith(['InsufficientBalance']); expect(mockShowToast).toHaveBeenCalledWith(mockFailedToastResult); @@ -117,14 +155,14 @@ describe('TronUnstakedBanner', () => { errors: [], }); - render(); + renderBanner({ amount: '100', chainId: 'tron:728126428' }); expect(mockFailedToastFn).toHaveBeenCalledWith([]); expect(mockShowToast).toHaveBeenCalledWith(mockFailedToastResult); }); it('does not show error toast when there are no errors', () => { - render(); + renderBanner({ amount: '100', chainId: 'tron:728126428' }); expect(mockShowToast).not.toHaveBeenCalled(); }); diff --git a/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.tsx b/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.tsx index 5e245729048..65c6ba3d3d1 100644 --- a/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.tsx +++ b/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.tsx @@ -1,4 +1,5 @@ import React, { useEffect } from 'react'; +import { useSelector } from 'react-redux'; import type { CaipChainId } from '@metamask/utils'; import { strings } from '../../../../../../../locales/i18n'; import Banner, { @@ -13,6 +14,7 @@ import { } from '@metamask/design-system-react-native'; import useTronClaimUnstakedTrx from '../../../hooks/useTronClaimUnstakedTrx'; import useEarnToasts from '../../../hooks/useEarnToasts'; +import { selectTronClaimUnstakedTrxButtonEnabled } from '../../../../../../selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled'; import { TronUnstakedBannerTestIds } from './TronUnstakedBanner.testIds'; interface TronUnstakedBannerProps { @@ -21,6 +23,7 @@ interface TronUnstakedBannerProps { } const TronUnstakedBanner = ({ amount, chainId }: TronUnstakedBannerProps) => { + const showClaimButton = useSelector(selectTronClaimUnstakedTrxButtonEnabled); const { handleClaimUnstakedTrx, isSubmitting, errors } = useTronClaimUnstakedTrx({ chainId }); const { showToast, EarnToastOptions } = useEarnToasts(); @@ -41,19 +44,21 @@ const TronUnstakedBanner = ({ amount, chainId }: TronUnstakedBannerProps) => { <> {strings('stake.tron.unstaked_banner.description')} - + {showClaimButton ? ( + + ) : null} } /> diff --git a/app/components/UI/Perps/utils/wait.test.ts b/app/components/UI/Perps/utils/wait.test.ts index aea87705d79..ac38e76a03a 100644 --- a/app/components/UI/Perps/utils/wait.test.ts +++ b/app/components/UI/Perps/utils/wait.test.ts @@ -13,14 +13,14 @@ describe('wait', () => { const promise = wait(100); jest.advanceTimersByTime(100); await promise; - expect(promise).resolves.toBeUndefined(); + await expect(promise).resolves.toBeUndefined(); }); it('should handle zero duration', async () => { const promise = wait(0); jest.advanceTimersByTime(0); await promise; - expect(promise).resolves.toBeUndefined(); + await expect(promise).resolves.toBeUndefined(); }); it('should return a Promise that resolves to undefined', async () => { diff --git a/app/constants/featureFlags.ts b/app/constants/featureFlags.ts index a439f406657..67da83992fe 100644 --- a/app/constants/featureFlags.ts +++ b/app/constants/featureFlags.ts @@ -16,6 +16,7 @@ export enum FeatureFlagNames { tokenDetailsV2ButtonLayout = 'tokenDetailsV2ButtonLayout', complianceEnabled = 'complianceEnabled', legacyIosGoogleConfigEnabled = 'legacyIosGoogleConfigEnabled', + tronClaimUnstakedTrxButtonEnabled = 'tronClaimUnstakedTrxButtonEnabled', } export const DEFAULT_FEATURE_FLAG_VALUES: Partial< @@ -24,4 +25,5 @@ export const DEFAULT_FEATURE_FLAG_VALUES: Partial< [FeatureFlagNames.assetsDefiPositionsEnabled]: true, [FeatureFlagNames.tokenDetailsV2Buttons]: false, [FeatureFlagNames.tokenDetailsV2ButtonLayout]: false, + [FeatureFlagNames.tronClaimUnstakedTrxButtonEnabled]: false, }; diff --git a/app/selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled/index.test.ts b/app/selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled/index.test.ts new file mode 100644 index 00000000000..55cb6066b58 --- /dev/null +++ b/app/selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled/index.test.ts @@ -0,0 +1,49 @@ +import { Json } from '@metamask/utils'; +import { selectTronClaimUnstakedTrxButtonEnabled } from '.'; +import { + DEFAULT_FEATURE_FLAG_VALUES, + FeatureFlagNames, +} from '../../../constants/featureFlags'; + +describe('Tron claim unstaked TRX button enabled feature flag selector', () => { + describe('selectTronClaimUnstakedTrxButtonEnabled', () => { + it('returns true when remote flag is explicitly true', () => { + const result = selectTronClaimUnstakedTrxButtonEnabled.resultFunc({ + [FeatureFlagNames.tronClaimUnstakedTrxButtonEnabled]: true, + }); + + expect(result).toBe(true); + }); + + it('returns false when remote flag is explicitly false', () => { + const result = selectTronClaimUnstakedTrxButtonEnabled.resultFunc({ + [FeatureFlagNames.tronClaimUnstakedTrxButtonEnabled]: false, + }); + + expect(result).toBe(false); + }); + + it('returns default value when remote flag is not set', () => { + const result = selectTronClaimUnstakedTrxButtonEnabled.resultFunc({}); + + expect(result).toBe( + DEFAULT_FEATURE_FLAG_VALUES[ + FeatureFlagNames.tronClaimUnstakedTrxButtonEnabled + ], + ); + }); + + it('returns default value when remote flag is undefined', () => { + const result = selectTronClaimUnstakedTrxButtonEnabled.resultFunc({ + [FeatureFlagNames.tronClaimUnstakedTrxButtonEnabled]: + undefined as unknown as Json, + }); + + expect(result).toBe( + DEFAULT_FEATURE_FLAG_VALUES[ + FeatureFlagNames.tronClaimUnstakedTrxButtonEnabled + ], + ); + }); + }); +}); diff --git a/app/selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled/index.ts b/app/selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled/index.ts new file mode 100644 index 00000000000..390dbe3a5a1 --- /dev/null +++ b/app/selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled/index.ts @@ -0,0 +1,17 @@ +import { createSelector } from 'reselect'; +import { selectRemoteFeatureFlags } from '..'; +import { + DEFAULT_FEATURE_FLAG_VALUES, + FeatureFlagNames, +} from '../../../constants/featureFlags'; + +export const selectTronClaimUnstakedTrxButtonEnabled = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => + Boolean( + remoteFeatureFlags[FeatureFlagNames.tronClaimUnstakedTrxButtonEnabled] ?? + DEFAULT_FEATURE_FLAG_VALUES[ + FeatureFlagNames.tronClaimUnstakedTrxButtonEnabled + ], + ), +); diff --git a/tests/feature-flags/feature-flag-registry.test.ts b/tests/feature-flags/feature-flag-registry.test.ts index ed238d077e2..a86028cd6b1 100644 --- a/tests/feature-flags/feature-flag-registry.test.ts +++ b/tests/feature-flags/feature-flag-registry.test.ts @@ -85,6 +85,7 @@ describe('Feature Flag Registry', () => { expect(flagNames).toContain('bridgeConfigV2'); expect(flagNames).toContain('bitcoinAccounts'); expect(flagNames).toContain('tronAccounts'); + expect(flagNames).toContain('tronClaimUnstakedTrxButtonEnabled'); }); }); diff --git a/tests/feature-flags/feature-flag-registry.ts b/tests/feature-flags/feature-flag-registry.ts index 2c696a8020c..c16c13a2fb3 100644 --- a/tests/feature-flags/feature-flag-registry.ts +++ b/tests/feature-flags/feature-flag-registry.ts @@ -62,7 +62,7 @@ export interface FeatureFlagRegistryEntry { * Remote flag values are stored in the exact format returned by the production * client-config API, so they can be served directly by the E2E mock server. * - * Production defaults last synced: 2026-03-02 + * Production defaults last synced: 2026-03-25 * Source: https://client-config.api.cx.metamask.io/v1/flags?client=mobile&distribution=main&environment=prod */ export const FEATURE_FLAG_REGISTRY: Record = { @@ -3618,6 +3618,14 @@ export const FEATURE_FLAG_REGISTRY: Record = { status: FeatureFlagStatus.Active, }, + tronClaimUnstakedTrxButtonEnabled: { + name: 'tronClaimUnstakedTrxButtonEnabled', + type: FeatureFlagType.Remote, + inProd: true, + productionDefault: false, + status: FeatureFlagStatus.Active, + }, + tronStaking: { name: 'tronStaking', type: FeatureFlagType.Remote,