From f1f9129bb0f415abbda5bdfe9498b1adf7fbf305 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Wed, 25 Mar 2026 16:42:25 +0000 Subject: [PATCH] chore(runway): cherry-pick feat(earn): gate Tron unstaked claim button behind remote flag (#27908) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adds the remote boolean flag **`tronClaimUnstakedTrxButtonEnabled`** so we can hide the **claim** action on the Tron unstaked banner if something goes wrong in production, without removing the banner copy. **Why:** We need a safe kill switch for the claim CTA only. **How:** - Register the flag in `FeatureFlagNames` with default `false` (missing/undefined → button hidden; opt-in). - **`selectTronClaimUnstakedTrxButtonEnabled`** in `app/selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled/` reads merged remote flags (same pattern as other boolean flags). - `TronUnstakedBanner` uses `useSelector(selectTronClaimUnstakedTrxButtonEnabled)` and renders the primary claim button only when the flag is `true`; title and description stay visible when the button is hidden. - Register the flag in **`tests/feature-flags/feature-flag-registry.ts`** (`inProd: true`, `productionDefault: false`) so CI/E2E mocks match production client-config. **Ops:** Ensure **`tronClaimUnstakedTrxButtonEnabled`** exists in LaunchDarkly / client-config; set to **`true`** where the claim button should appear. ## **Changelog** CHANGELOG entry: Added a remote feature flag to control visibility of the Tron unstaked TRX claim button on the token details banner. ## **Related issues** Fixes: NEB-838 ## **Manual testing steps** ```gherkin Feature: Tron unstaked banner claim button behind remote flag Scenario: user views TRX token details with claimable unstaked balance and flag enabled Given a Tron account with TRX ready for withdrawal and remote flag `tronClaimUnstakedTrxButtonEnabled` is true (or overridden in dev tools) When user opens native TRX token details and the unstaked banner is shown Then the banner shows title, description, and the claim button, and tapping claim still triggers the existing flow Scenario: user views TRX token details when flag is off or unset Given the same balance state but `tronClaimUnstakedTrxButtonEnabled` is false, missing, or undefined in remote flags When user opens native TRX token details and the unstaked banner is shown Then the banner shows title and description but does not show the claim button ``` ## **Screenshots/Recordings** ### **Before** See prior screenshots on this PR (token details with banner). ### **After** Feature flag disabled / enabled — screenshots attached in thread (banner with and without claim CTA). ## **Pre-merge author checklist** - [ ] 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). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] 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. --- .../TronUnstakedBanner.test.tsx | 78 ++++++++++++++----- .../TronUnstakedBanner/TronUnstakedBanner.tsx | 25 +++--- app/components/UI/Perps/utils/wait.test.ts | 4 +- app/constants/featureFlags.ts | 2 + .../index.test.ts | 49 ++++++++++++ .../index.ts | 17 ++++ .../feature-flag-registry.test.ts | 1 + tests/feature-flags/feature-flag-registry.ts | 10 ++- 8 files changed, 153 insertions(+), 33 deletions(-) create mode 100644 app/selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled/index.test.ts create mode 100644 app/selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled/index.ts 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,