Skip to content

Commit ef5e684

Browse files
feat(earn): gate Tron unstaked claim button behind remote flag (MetaMask#27908)
## **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.
1 parent 5e444bd commit ef5e684

8 files changed

Lines changed: 153 additions & 33 deletions

File tree

app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.test.tsx

Lines changed: 58 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
11
import React from 'react';
2-
import { render, fireEvent } from '@testing-library/react-native';
2+
import { fireEvent } from '@testing-library/react-native';
3+
import type { CaipChainId } from '@metamask/utils';
34
import TronUnstakedBanner from './TronUnstakedBanner';
45
import { strings } from '../../../../../../../locales/i18n';
56
import useTronClaimUnstakedTrx from '../../../hooks/useTronClaimUnstakedTrx';
67
import useEarnToasts from '../../../hooks/useEarnToasts';
8+
import renderWithProvider from '../../../../../../util/test/renderWithProvider';
9+
import { selectTronClaimUnstakedTrxButtonEnabled } from '../../../../../../selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled';
710
import { TronUnstakedBannerTestIds } from './TronUnstakedBanner.testIds';
811

12+
jest.mock(
13+
'../../../../../../selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled',
14+
() => ({
15+
selectTronClaimUnstakedTrxButtonEnabled: jest.fn(),
16+
}),
17+
);
18+
919
jest.mock('../../../hooks/useTronClaimUnstakedTrx');
1020
const mockUseTronClaimUnstakedTrx =
1121
useTronClaimUnstakedTrx as jest.MockedFunction<
@@ -23,11 +33,18 @@ jest.mock('../../../hooks/useEarnToasts');
2333
},
2434
});
2535

36+
const mockSelectTronClaimUnstakedTrxButtonEnabled =
37+
selectTronClaimUnstakedTrxButtonEnabled as unknown as jest.Mock;
38+
39+
const renderBanner = (props: { amount: string; chainId: CaipChainId }) =>
40+
renderWithProvider(<TronUnstakedBanner {...props} />, undefined, false);
41+
2642
describe('TronUnstakedBanner', () => {
2743
const mockHandleClaimUnstakedTrx = jest.fn();
2844

2945
beforeEach(() => {
3046
jest.clearAllMocks();
47+
mockSelectTronClaimUnstakedTrxButtonEnabled.mockReturnValue(true);
3148
mockUseTronClaimUnstakedTrx.mockReturnValue({
3249
handleClaimUnstakedTrx: mockHandleClaimUnstakedTrx,
3350
isSubmitting: false,
@@ -42,9 +59,10 @@ describe('TronUnstakedBanner', () => {
4259
});
4360

4461
it('renders the title with the given amount', () => {
45-
const { getByText } = render(
46-
<TronUnstakedBanner amount="100" chainId="tron:728126428" />,
47-
);
62+
const { getByText } = renderBanner({
63+
amount: '100',
64+
chainId: 'tron:728126428',
65+
});
4866

4967
const expectedTitle = strings('stake.tron.unstaked_banner.title', {
5068
amount: '100',
@@ -53,30 +71,49 @@ describe('TronUnstakedBanner', () => {
5371
});
5472

5573
it('renders the description', () => {
56-
const { getByText } = render(
57-
<TronUnstakedBanner amount="100" chainId="tron:728126428" />,
58-
);
74+
const { getByText } = renderBanner({
75+
amount: '100',
76+
chainId: 'tron:728126428',
77+
});
5978

6079
const expectedDescription = strings(
6180
'stake.tron.unstaked_banner.description',
6281
);
6382
expect(getByText(expectedDescription)).toBeOnTheScreen();
6483
});
6584

66-
it('renders the Withdraw button', () => {
67-
const { getByTestId } = render(
68-
<TronUnstakedBanner amount="100" chainId="tron:728126428" />,
69-
);
85+
it('renders the claim button when tronClaimUnstakedTrxButtonEnabled is true', () => {
86+
const { getByTestId } = renderBanner({
87+
amount: '100',
88+
chainId: 'tron:728126428',
89+
});
7090

7191
expect(
7292
getByTestId(TronUnstakedBannerTestIds.CLAIM_BUTTON),
7393
).toBeOnTheScreen();
7494
});
7595

96+
it('does not render the claim button when tronClaimUnstakedTrxButtonEnabled is false', () => {
97+
mockSelectTronClaimUnstakedTrxButtonEnabled.mockReturnValue(false);
98+
99+
const { getByText, queryByTestId } = renderBanner({
100+
amount: '100',
101+
chainId: 'tron:728126428',
102+
});
103+
104+
expect(
105+
queryByTestId(TronUnstakedBannerTestIds.CLAIM_BUTTON),
106+
).not.toBeOnTheScreen();
107+
expect(
108+
getByText(strings('stake.tron.unstaked_banner.description')),
109+
).toBeOnTheScreen();
110+
});
111+
76112
it('calls handleClaimUnstakedTrx when button is pressed', () => {
77-
const { getByTestId } = render(
78-
<TronUnstakedBanner amount="100" chainId="tron:728126428" />,
79-
);
113+
const { getByTestId } = renderBanner({
114+
amount: '100',
115+
chainId: 'tron:728126428',
116+
});
80117

81118
fireEvent.press(getByTestId(TronUnstakedBannerTestIds.CLAIM_BUTTON));
82119
expect(mockHandleClaimUnstakedTrx).toHaveBeenCalledTimes(1);
@@ -89,9 +126,10 @@ describe('TronUnstakedBanner', () => {
89126
errors: undefined,
90127
});
91128

92-
const { getByTestId } = render(
93-
<TronUnstakedBanner amount="100" chainId="tron:728126428" />,
94-
);
129+
const { getByTestId } = renderBanner({
130+
amount: '100',
131+
chainId: 'tron:728126428',
132+
});
95133

96134
const button = getByTestId(TronUnstakedBannerTestIds.CLAIM_BUTTON);
97135
expect(button.props.accessibilityState?.disabled).toBe(true);
@@ -104,7 +142,7 @@ describe('TronUnstakedBanner', () => {
104142
errors: ['InsufficientBalance'],
105143
});
106144

107-
render(<TronUnstakedBanner amount="100" chainId="tron:728126428" />);
145+
renderBanner({ amount: '100', chainId: 'tron:728126428' });
108146

109147
expect(mockFailedToastFn).toHaveBeenCalledWith(['InsufficientBalance']);
110148
expect(mockShowToast).toHaveBeenCalledWith(mockFailedToastResult);
@@ -117,14 +155,14 @@ describe('TronUnstakedBanner', () => {
117155
errors: [],
118156
});
119157

120-
render(<TronUnstakedBanner amount="100" chainId="tron:728126428" />);
158+
renderBanner({ amount: '100', chainId: 'tron:728126428' });
121159

122160
expect(mockFailedToastFn).toHaveBeenCalledWith([]);
123161
expect(mockShowToast).toHaveBeenCalledWith(mockFailedToastResult);
124162
});
125163

126164
it('does not show error toast when there are no errors', () => {
127-
render(<TronUnstakedBanner amount="100" chainId="tron:728126428" />);
165+
renderBanner({ amount: '100', chainId: 'tron:728126428' });
128166

129167
expect(mockShowToast).not.toHaveBeenCalled();
130168
});

app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.tsx

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React, { useEffect } from 'react';
2+
import { useSelector } from 'react-redux';
23
import type { CaipChainId } from '@metamask/utils';
34
import { strings } from '../../../../../../../locales/i18n';
45
import Banner, {
@@ -13,6 +14,7 @@ import {
1314
} from '@metamask/design-system-react-native';
1415
import useTronClaimUnstakedTrx from '../../../hooks/useTronClaimUnstakedTrx';
1516
import useEarnToasts from '../../../hooks/useEarnToasts';
17+
import { selectTronClaimUnstakedTrxButtonEnabled } from '../../../../../../selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled';
1618
import { TronUnstakedBannerTestIds } from './TronUnstakedBanner.testIds';
1719

1820
interface TronUnstakedBannerProps {
@@ -21,6 +23,7 @@ interface TronUnstakedBannerProps {
2123
}
2224

2325
const TronUnstakedBanner = ({ amount, chainId }: TronUnstakedBannerProps) => {
26+
const showClaimButton = useSelector(selectTronClaimUnstakedTrxButtonEnabled);
2427
const { handleClaimUnstakedTrx, isSubmitting, errors } =
2528
useTronClaimUnstakedTrx({ chainId });
2629
const { showToast, EarnToastOptions } = useEarnToasts();
@@ -41,19 +44,21 @@ const TronUnstakedBanner = ({ amount, chainId }: TronUnstakedBannerProps) => {
4144
<>
4245
<Text
4346
testID={TronUnstakedBannerTestIds.BANNER_DESCRIPTION}
44-
twClassName="pt-1 pb-4"
47+
twClassName={showClaimButton ? 'pt-1 pb-4' : 'pt-1'}
4548
>
4649
{strings('stake.tron.unstaked_banner.description')}
4750
</Text>
48-
<Button
49-
testID={TronUnstakedBannerTestIds.CLAIM_BUTTON}
50-
variant={ButtonVariant.Primary}
51-
size={ButtonSize.Md}
52-
onPress={handleClaimUnstakedTrx}
53-
isDisabled={isSubmitting}
54-
>
55-
{strings('stake.tron.unstaked_banner.button')}
56-
</Button>
51+
{showClaimButton ? (
52+
<Button
53+
testID={TronUnstakedBannerTestIds.CLAIM_BUTTON}
54+
variant={ButtonVariant.Primary}
55+
size={ButtonSize.Md}
56+
onPress={handleClaimUnstakedTrx}
57+
isDisabled={isSubmitting}
58+
>
59+
{strings('stake.tron.unstaked_banner.button')}
60+
</Button>
61+
) : null}
5762
</>
5863
}
5964
/>

app/components/UI/Perps/utils/wait.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,14 @@ describe('wait', () => {
1313
const promise = wait(100);
1414
jest.advanceTimersByTime(100);
1515
await promise;
16-
expect(promise).resolves.toBeUndefined();
16+
await expect(promise).resolves.toBeUndefined();
1717
});
1818

1919
it('should handle zero duration', async () => {
2020
const promise = wait(0);
2121
jest.advanceTimersByTime(0);
2222
await promise;
23-
expect(promise).resolves.toBeUndefined();
23+
await expect(promise).resolves.toBeUndefined();
2424
});
2525

2626
it('should return a Promise that resolves to undefined', async () => {

app/constants/featureFlags.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export enum FeatureFlagNames {
1616
tokenDetailsV2ButtonLayout = 'tokenDetailsV2ButtonLayout',
1717
complianceEnabled = 'complianceEnabled',
1818
legacyIosGoogleConfigEnabled = 'legacyIosGoogleConfigEnabled',
19+
tronClaimUnstakedTrxButtonEnabled = 'tronClaimUnstakedTrxButtonEnabled',
1920
}
2021

2122
export const DEFAULT_FEATURE_FLAG_VALUES: Partial<
@@ -24,4 +25,5 @@ export const DEFAULT_FEATURE_FLAG_VALUES: Partial<
2425
[FeatureFlagNames.assetsDefiPositionsEnabled]: true,
2526
[FeatureFlagNames.tokenDetailsV2Buttons]: false,
2627
[FeatureFlagNames.tokenDetailsV2ButtonLayout]: false,
28+
[FeatureFlagNames.tronClaimUnstakedTrxButtonEnabled]: false,
2729
};
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { Json } from '@metamask/utils';
2+
import { selectTronClaimUnstakedTrxButtonEnabled } from '.';
3+
import {
4+
DEFAULT_FEATURE_FLAG_VALUES,
5+
FeatureFlagNames,
6+
} from '../../../constants/featureFlags';
7+
8+
describe('Tron claim unstaked TRX button enabled feature flag selector', () => {
9+
describe('selectTronClaimUnstakedTrxButtonEnabled', () => {
10+
it('returns true when remote flag is explicitly true', () => {
11+
const result = selectTronClaimUnstakedTrxButtonEnabled.resultFunc({
12+
[FeatureFlagNames.tronClaimUnstakedTrxButtonEnabled]: true,
13+
});
14+
15+
expect(result).toBe(true);
16+
});
17+
18+
it('returns false when remote flag is explicitly false', () => {
19+
const result = selectTronClaimUnstakedTrxButtonEnabled.resultFunc({
20+
[FeatureFlagNames.tronClaimUnstakedTrxButtonEnabled]: false,
21+
});
22+
23+
expect(result).toBe(false);
24+
});
25+
26+
it('returns default value when remote flag is not set', () => {
27+
const result = selectTronClaimUnstakedTrxButtonEnabled.resultFunc({});
28+
29+
expect(result).toBe(
30+
DEFAULT_FEATURE_FLAG_VALUES[
31+
FeatureFlagNames.tronClaimUnstakedTrxButtonEnabled
32+
],
33+
);
34+
});
35+
36+
it('returns default value when remote flag is undefined', () => {
37+
const result = selectTronClaimUnstakedTrxButtonEnabled.resultFunc({
38+
[FeatureFlagNames.tronClaimUnstakedTrxButtonEnabled]:
39+
undefined as unknown as Json,
40+
});
41+
42+
expect(result).toBe(
43+
DEFAULT_FEATURE_FLAG_VALUES[
44+
FeatureFlagNames.tronClaimUnstakedTrxButtonEnabled
45+
],
46+
);
47+
});
48+
});
49+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { createSelector } from 'reselect';
2+
import { selectRemoteFeatureFlags } from '..';
3+
import {
4+
DEFAULT_FEATURE_FLAG_VALUES,
5+
FeatureFlagNames,
6+
} from '../../../constants/featureFlags';
7+
8+
export const selectTronClaimUnstakedTrxButtonEnabled = createSelector(
9+
selectRemoteFeatureFlags,
10+
(remoteFeatureFlags) =>
11+
Boolean(
12+
remoteFeatureFlags[FeatureFlagNames.tronClaimUnstakedTrxButtonEnabled] ??
13+
DEFAULT_FEATURE_FLAG_VALUES[
14+
FeatureFlagNames.tronClaimUnstakedTrxButtonEnabled
15+
],
16+
),
17+
);

tests/feature-flags/feature-flag-registry.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ describe('Feature Flag Registry', () => {
8585
expect(flagNames).toContain('bridgeConfigV2');
8686
expect(flagNames).toContain('bitcoinAccounts');
8787
expect(flagNames).toContain('tronAccounts');
88+
expect(flagNames).toContain('tronClaimUnstakedTrxButtonEnabled');
8889
});
8990
});
9091

tests/feature-flags/feature-flag-registry.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export interface FeatureFlagRegistryEntry {
6262
* Remote flag values are stored in the exact format returned by the production
6363
* client-config API, so they can be served directly by the E2E mock server.
6464
*
65-
* Production defaults last synced: 2026-03-02
65+
* Production defaults last synced: 2026-03-25
6666
* Source: https://client-config.api.cx.metamask.io/v1/flags?client=mobile&distribution=main&environment=prod
6767
*/
6868
export const FEATURE_FLAG_REGISTRY: Record<string, FeatureFlagRegistryEntry> = {
@@ -3618,6 +3618,14 @@ export const FEATURE_FLAG_REGISTRY: Record<string, FeatureFlagRegistryEntry> = {
36183618
status: FeatureFlagStatus.Active,
36193619
},
36203620

3621+
tronClaimUnstakedTrxButtonEnabled: {
3622+
name: 'tronClaimUnstakedTrxButtonEnabled',
3623+
type: FeatureFlagType.Remote,
3624+
inProd: true,
3625+
productionDefault: false,
3626+
status: FeatureFlagStatus.Active,
3627+
},
3628+
36213629
tronStaking: {
36223630
name: 'tronStaking',
36233631
type: FeatureFlagType.Remote,

0 commit comments

Comments
 (0)