Skip to content

Commit a559161

Browse files
feat(tron): display TRX ready for withdrawal (#27075)
## **Description** Display TRX that has completed the unstaking lock period and is ready for withdrawal on the token details view. Refactor Tron staking UI so `AssetOverviewContent` orchestrates all Tron staking components directly. ### Added - **`TronUnstakedBanner` component** — Info-only success banner displaying "You can claim X TRX..." when TRX has completed the 14-day unstaking lock period and is ready for withdrawal. No action button (claim button deferred to NEB-576). - **`TronStakingCta` component** — "Stake your TRX" promotional CTA with APR percentage and an "Earn" button, shown to eligible users who have no staked TRX positions. Gated behind `useStakingEligibility` so geo-blocked users never see it. - **`readyForWithdrawalBalance` derivation in `useTokenBalance`** — Parses `trxReadyForWithdrawal` from the Tron special assets selector and exposes it as a formatted string (only when > 0 and numeric). - **`stake.tron.has_claimable_trx` locale string** — New i18n key for the claimable TRX banner text. - **Unit tests** for `TronUnstakedBanner`, `TronStakingCta` (including eligibility guard), and `readyForWithdrawalBalance` edge cases in `useTokenBalance`. ### Changed - **`AssetOverviewContent` is now the orchestrator for all Tron staking UI.** It directly renders, in order: native Balance → staked Balance → `TronUnstakedBanner` → `TronUnstakingBanner` → `TronStakingButtons` (if staked) or `TronStakingCta` (if not staked). Previously, staking buttons/CTA were rendered inside `EarnBalance`. - **`EarnBalance` no longer renders any Tron staking UI.** When the Tron staking flag is enabled and the asset is a Tron chain asset, it returns `null` — all Tron staking rendering responsibility moved to `AssetOverviewContent`. - **`TronStakingButtons` simplified** — Removed `showUnstake`, `hasStakedPositions`, and `aprText` props. The CTA section was extracted into the new `TronStakingCta`. Now always renders "Unstake" and conditionally renders "Stake more" based on `useStakingEligibility`. Removed the `if (!isEligible && !hasStakedPositions) return null` early exit since the parent now controls when to render it. - **`useTokenBalance` guards `stakedTrxAsset` with `totalStaked > 0`** — Previously `createStakedTrxAsset` was always called for Tron native tokens, producing a truthy object even with zero balance. Now `stakedTrxAsset` is `undefined` when no TRX is actually staked, which correctly drives the conditional rendering in `AssetOverviewContent`. **Note:** This PR intentionally does not include a claim button. The button and snap interaction are in NEB-576. ## **Changelog** CHANGELOG entry: Added a banner to display TRX that is ready for withdrawal on the token details view ## **Related issues** Refs: NEB-582 ## **Manual testing steps** ```gherkin Feature: TRX ready for withdrawal display Scenario: user views TRX token details with TRX ready for withdrawal Given user has TRX that has completed the 14-day unstaking lock period When user navigates to the TRX token details view Then a success banner is displayed showing "You can claim X TRX. Once claimed you'll get TRX back in your wallet." And no action button is displayed in the banner Scenario: user views TRX token details without TRX ready for withdrawal Given user has no TRX ready for withdrawal When user navigates to the TRX token details view Then no claim banner is displayed ``` ## **Screenshots/Recordings** ### **Before** N/A - new feature ### **After** <img width="507" height="950" alt="Screenshot 2026-03-09 at 22 42 19" src="https://github.com/user-attachments/assets/31892885-023f-4a62-be3e-c04978b05348" /> ## **Pre-merge author checklist** - [x] 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). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] 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. Made with [Cursor](https://cursor.com) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Updates Tron token details balance derivation and reorganizes staking/unstaking UI rendering paths, which could affect when/where staking CTAs and banners appear for TRX users; changes are UI/formatting focused with test coverage. > > **Overview** > Adds a Tron-only “claimable TRX” success banner on the token details view by deriving a new `readyForWithdrawalBalance` from `trxReadyForWithdrawal` in `useTokenBalance` (with numeric/zero guards) and wiring it through `TokenDetails` into `AssetOverviewContent`. > > Refactors Tron staking UI ownership: `AssetOverviewContent` now directly renders Tron staking/unstaking components (including a new `TronStakingCta` for eligible users with no staked TRX), while `EarnBalance` no longer renders any Tron staking UI and returns `null` when Tron staking is enabled. > > Simplifies `TronStakingButtons` by removing CTA-related props/markup, always showing Unstake and conditionally showing “Stake more” based on eligibility, and introduces shared test IDs; adds/updates unit tests and introduces the new `stake.tron.has_claimable_trx` locale string. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 4fbff82. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 33262f4 commit a559161

18 files changed

Lines changed: 563 additions & 283 deletions

app/components/UI/Earn/components/EarnBalance/EarnBalance.test.tsx

Lines changed: 14 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@ import StakingBalance from '../../../Stake/components/StakingBalance/StakingBala
55
import { TokenI } from '../../../Tokens/types';
66
import EarnLendingBalance from '../EarnLendingBalance';
77
import { selectTrxStakingEnabled } from '../../../../../selectors/featureFlagController/trxStakingEnabled';
8-
import { selectTronSpecialAssetsBySelectedAccountGroup } from '../../../../../selectors/assets/assets-list';
9-
import TronStakingButtons from '../Tron/TronStakingButtons';
108
import { selectIsMusdConversionFlowEnabledFlag } from '../../selectors/featureFlags';
119

1210
/**
@@ -25,16 +23,6 @@ jest.mock(
2523
}),
2624
);
2725

28-
jest.mock('../../../../../selectors/assets/assets-list', () => ({
29-
...jest.requireActual('../../../../../selectors/assets/assets-list'),
30-
selectTronSpecialAssetsBySelectedAccountGroup: jest.fn(),
31-
}));
32-
33-
jest.mock('../Tron/TronStakingButtons', () => ({
34-
__esModule: true,
35-
default: jest.fn(() => null),
36-
}));
37-
3826
jest.mock('../../../../../selectors/earnController', () => ({
3927
...jest.requireActual('../../../../../selectors/earnController'),
4028
earnSelectors: {
@@ -128,35 +116,10 @@ const mockUseMusdConversionEligibility =
128116
typeof useMusdConversionEligibility
129117
>;
130118

131-
jest.mock('../../hooks/useTronStakeApy', () => ({
132-
__esModule: true,
133-
default: jest.fn().mockReturnValue({
134-
apyPercent: '4.5%',
135-
isLoading: false,
136-
error: null,
137-
}),
138-
}));
139-
140-
const createEmptySpecialAssetsMap = () => ({
141-
energy: undefined,
142-
bandwidth: undefined,
143-
maxEnergy: undefined,
144-
maxBandwidth: undefined,
145-
stakedTrxForEnergy: undefined,
146-
stakedTrxForBandwidth: undefined,
147-
totalStakedTrx: 0,
148-
trxReadyForWithdrawal: undefined,
149-
trxStakingRewards: undefined,
150-
trxInLockPeriod: undefined,
151-
});
152-
153119
describe('EarnBalance', () => {
154120
beforeEach(() => {
155121
jest.clearAllMocks();
156122
(jest.mocked(selectTrxStakingEnabled) as jest.Mock).mockReturnValue(false);
157-
(
158-
jest.mocked(selectTronSpecialAssetsBySelectedAccountGroup) as jest.Mock
159-
).mockReturnValue(createEmptySpecialAssetsMap());
160123
});
161124

162125
describe('Ethereum Mainnet', () => {
@@ -253,30 +216,26 @@ describe('EarnBalance', () => {
253216

254217
describe('TRON', () => {
255218
const mockFlag = selectTrxStakingEnabled as unknown as jest.Mock;
256-
const mockTronResources =
257-
selectTronSpecialAssetsBySelectedAccountGroup as unknown as jest.Mock;
258219

259-
it('renders TRON stake button with aprText for TRX without staked positions', () => {
220+
it('renders nothing for TRX when Tron staking is enabled', () => {
260221
const trx: Partial<TokenI> = {
261222
chainId: 'tron:728126428',
262223
ticker: 'TRX',
263224
symbol: 'TRX',
264225
};
265226

266227
mockFlag.mockReturnValue(true);
267-
mockTronResources.mockReturnValue(createEmptySpecialAssetsMap());
268228

269-
renderWithProvider(<EarnBalance asset={trx as TokenI} />);
229+
const { toJSON } = renderWithProvider(
230+
<EarnBalance asset={trx as TokenI} />,
231+
);
270232

271-
expect(TronStakingButtons).toHaveBeenCalled();
272-
const props = (TronStakingButtons as jest.Mock).mock.calls[0][0];
273-
expect(props.asset).toBe(trx);
274-
expect(props.aprText).toBe('4.5%');
275-
expect(props.showUnstake).toBeUndefined();
276-
expect(props.hasStakedPositions).toBeUndefined();
233+
expect(toJSON()).toBeNull();
234+
expect(StakingBalance).not.toHaveBeenCalled();
235+
expect(EarnLendingBalance).not.toHaveBeenCalled();
277236
});
278237

279-
it('renders TRON stake more and unstake for sTRX with staked positions', () => {
238+
it('renders nothing for sTRX when Tron staking is enabled', () => {
280239
const strx: Partial<TokenI> = {
281240
chainId: 'tron:728126428',
282241
ticker: 'sTRX',
@@ -285,20 +244,14 @@ describe('EarnBalance', () => {
285244
};
286245

287246
mockFlag.mockReturnValue(true);
288-
mockTronResources.mockReturnValue({
289-
...createEmptySpecialAssetsMap(),
290-
stakedTrxForEnergy: { symbol: 'strx-energy', balance: '1' },
291-
stakedTrxForBandwidth: { symbol: 'strx-bandwidth', balance: '2' },
292-
totalStakedTrx: 3,
293-
});
294247

295-
renderWithProvider(<EarnBalance asset={strx as TokenI} />);
248+
const { toJSON } = renderWithProvider(
249+
<EarnBalance asset={strx as TokenI} />,
250+
);
296251

297-
expect(TronStakingButtons).toHaveBeenCalled();
298-
const props = (TronStakingButtons as jest.Mock).mock.calls[0][0];
299-
expect(props.asset).toBe(strx);
300-
expect(props.showUnstake).toBe(true);
301-
expect(props.hasStakedPositions).toBe(true);
252+
expect(toJSON()).toBeNull();
253+
expect(StakingBalance).not.toHaveBeenCalled();
254+
expect(EarnLendingBalance).not.toHaveBeenCalled();
302255
});
303256
});
304257

app/components/UI/Earn/components/EarnBalance/index.tsx

Lines changed: 1 addition & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,7 @@ import { TokenI } from '../../../Tokens/types';
77
import EarnLendingBalance from '../EarnLendingBalance';
88
import { selectIsStakeableToken } from '../../../Stake/selectors/stakeableTokens';
99
///: BEGIN:ONLY_INCLUDE_IF(tron)
10-
import TronStakingButtons from '../Tron/TronStakingButtons';
11-
import { selectTronSpecialAssetsBySelectedAccountGroup } from '../../../../../selectors/assets/assets-list';
1210
import { selectTrxStakingEnabled } from '../../../../../selectors/featureFlagController/trxStakingEnabled';
13-
import { hasStakedTrxPositions as hasStakedTrxPositionsUtil } from '../../utils/tron';
14-
import useTronStakeApy from '../../hooks/useTronStakeApy';
1511
///: END:ONLY_INCLUDE_IF
1612
import { useMusdConversionTokens } from '../../hooks/useMusdConversionTokens';
1713
import { useMusdConversionEligibility } from '../../hooks/useMusdConversionEligibility';
@@ -38,43 +34,12 @@ const EarnBalance = ({ asset }: EarnBalanceProps) => {
3834

3935
const { isConversionToken } = useMusdConversionTokens();
4036
const { isEligible: isGeoEligible } = useMusdConversionEligibility();
37+
4138
///: BEGIN:ONLY_INCLUDE_IF(tron)
4239
const isTrxStakingEnabled = useSelector(selectTrxStakingEnabled);
43-
4440
const isTron = asset?.chainId?.startsWith('tron:');
45-
const isNativeTrx =
46-
isTron && (asset?.ticker === 'TRX' || asset?.symbol === 'TRX');
47-
const isStakedTrxAsset =
48-
isTron && (asset?.ticker === 'sTRX' || asset?.symbol === 'sTRX');
49-
50-
const tronSpecialAssets = useSelector(
51-
selectTronSpecialAssetsBySelectedAccountGroup,
52-
);
53-
const hasStakedTrxPositions = React.useMemo(
54-
() => hasStakedTrxPositionsUtil(tronSpecialAssets),
55-
[tronSpecialAssets],
56-
);
57-
58-
const { apyPercent: tronApyPercent } = useTronStakeApy();
5941

6042
if (isTron && isTrxStakingEnabled) {
61-
if (hasStakedTrxPositions && isStakedTrxAsset) {
62-
// sTRX row: show Unstake + Stake more
63-
return (
64-
<TronStakingButtons asset={asset} showUnstake hasStakedPositions />
65-
);
66-
}
67-
68-
if (!hasStakedTrxPositions && isNativeTrx) {
69-
// TRX native row: show CTA + single Stake button
70-
return (
71-
<TronStakingButtons
72-
asset={asset}
73-
aprText={tronApyPercent ?? undefined}
74-
/>
75-
);
76-
}
77-
7843
return null;
7944
}
8045
///: END:ONLY_INCLUDE_IF

app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.styles.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,6 @@ const styleSheet = (params: { theme: Theme }) =>
99
borderRadius: 12,
1010
backgroundColor: params.theme.colors.background.section,
1111
},
12-
ctaContent: {
13-
alignItems: 'center',
14-
marginBottom: 16,
15-
gap: 4,
16-
},
17-
ctaTitle: {
18-
textAlign: 'center',
19-
},
20-
ctaText: {
21-
textAlign: 'center',
22-
},
2312
buttonsRow: {
2413
flexDirection: 'row',
2514
justifyContent: 'space-between',

0 commit comments

Comments
 (0)