Skip to content

Commit ff386ec

Browse files
feat(tron): display TRX ready for withdrawal (NEB-582)
Add info-only TronClaimBanner component to show TRX that has completed the unstaking lock period and is ready for withdrawal. The banner displays the claimable amount without any action button. The claim action will be added in a follow-up PR. Made-with: Cursor
1 parent 5cb745e commit ff386ec

17 files changed

Lines changed: 517 additions & 280 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',

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

Lines changed: 27 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,6 @@ jest.mock('../../../../../../component-library/hooks', () => ({
3131
styles: {
3232
balanceButtonsContainer: {},
3333
balanceActionButton: {},
34-
ctaContent: {},
35-
ctaTitle: {},
36-
ctaText: {},
3734
buttonsRow: {},
3835
},
3936
}),
@@ -69,10 +66,6 @@ jest.mock('../../../../Stake/hooks/useStakingEligibility', () => ({
6966
jest.mock('../../../../../../../locales/i18n', () => ({
7067
strings: (key: string) => {
7168
const map: Record<string, string> = {
72-
'stake.stake_your_trx_cta.title': 'Stake your TRX',
73-
'stake.stake_your_trx_cta.description_start': 'Earn up to ',
74-
'stake.stake_your_trx_cta.description_end': ' annually',
75-
'stake.stake_your_trx_cta.earn_button': 'Stake',
7669
'stake.stake_more': 'Stake more',
7770
'stake.unstake': 'Unstake',
7871
};
@@ -108,12 +101,15 @@ describe('TronStakingButtons', () => {
108101
isStaked: false,
109102
} as TokenI;
110103

111-
it('navigates to stake screen with base asset TRX when not staked and uses default hasStakedPositions', () => {
112-
const { getByTestId, getByText } = render(
113-
<TronStakingButtons asset={baseAsset} showUnstake={false} />,
114-
);
104+
it('renders both Unstake and Stake more buttons', () => {
105+
const { getByText } = render(<TronStakingButtons asset={baseAsset} />);
106+
107+
expect(getByText('Unstake')).toBeOnTheScreen();
108+
expect(getByText('Stake more')).toBeOnTheScreen();
109+
});
115110

116-
expect(getByText('Stake')).toBeOnTheScreen();
111+
it('navigates to stake screen on Stake more press', () => {
112+
const { getByTestId } = render(<TronStakingButtons asset={baseAsset} />);
117113

118114
fireEvent.press(getByTestId('stake-more-button'));
119115

@@ -131,7 +127,18 @@ describe('TronStakingButtons', () => {
131127
});
132128
});
133129

134-
it('navigates to stake with synthesized TRX when asset is staked TRX without nativeAsset', () => {
130+
it('navigates to unstake screen on Unstake press', () => {
131+
const { getByTestId } = render(<TronStakingButtons asset={baseAsset} />);
132+
133+
fireEvent.press(getByTestId('unstake-button'));
134+
135+
expect(mockNavigate).toHaveBeenCalledWith('StakeScreens', {
136+
screen: Routes.STAKING.UNSTAKE,
137+
params: { token: baseAsset },
138+
});
139+
});
140+
141+
it('resolves base asset from selector when asset is staked TRX', () => {
135142
const stakedTrx = {
136143
...baseAsset,
137144
symbol: 'sTRX',
@@ -149,125 +156,29 @@ describe('TronStakingButtons', () => {
149156
selector({} as unknown as ReturnType<typeof Object>),
150157
);
151158

152-
const { getByTestId } = render(
153-
<TronStakingButtons asset={stakedTrx} hasStakedPositions />,
154-
);
159+
const { getByTestId } = render(<TronStakingButtons asset={stakedTrx} />);
160+
155161
fireEvent.press(getByTestId('stake-more-button'));
156162

157-
expect(mockNavigate).toHaveBeenCalled();
158163
const call = mockNavigate.mock.calls.find((c) => c[0] === 'StakeScreens');
159-
expect(call?.[1]?.screen).toBe(Routes.STAKING.STAKE);
160164
const tokenArg = call?.[1]?.params?.token;
161165
expect(tokenArg.symbol).toBe('TRX');
162-
expect(tokenArg.ticker).toBe('TRX');
163166
expect(tokenArg.isStaked).toBe(false);
164167
});
165168

166-
it('shows Unstake button when showUnstake is true and navigates on press', () => {
167-
const { getByTestId } = render(
168-
<TronStakingButtons asset={baseAsset} showUnstake hasStakedPositions />,
169-
);
170-
171-
fireEvent.press(getByTestId('unstake-button'));
172-
173-
expect(mockNavigate).toHaveBeenCalledWith('StakeScreens', {
174-
screen: Routes.STAKING.UNSTAKE,
175-
params: { token: baseAsset },
176-
});
177-
});
178-
179-
it('does not render stake button when user is not eligible', () => {
180-
mockUseStakingEligibility.mockReturnValue({
181-
isEligible: false,
182-
isLoadingEligibility: false,
183-
error: null,
184-
refreshPooledStakingEligibility: jest.fn(),
185-
});
186-
187-
const { queryByTestId } = render(<TronStakingButtons asset={baseAsset} />);
188-
189-
expect(queryByTestId('stake-more-button')).toBeNull();
190-
});
191-
192-
it('renders unstake button when user is not eligible and has active staked position', () => {
193-
mockUseStakingEligibility.mockReturnValue({
194-
isEligible: false,
195-
isLoadingEligibility: false,
196-
error: null,
197-
refreshPooledStakingEligibility: jest.fn(),
198-
});
199-
200-
const { queryByTestId } = render(
201-
<TronStakingButtons asset={baseAsset} showUnstake hasStakedPositions />,
202-
);
203-
204-
expect(queryByTestId('unstake-button')).toBeOnTheScreen();
205-
});
206-
207-
describe('CTA section', () => {
208-
it('renders CTA title and description without aprText when hasStakedPositions is false', () => {
209-
const { getByText } = render(
210-
<TronStakingButtons asset={baseAsset} hasStakedPositions={false} />,
211-
);
212-
213-
expect(getByText('Stake your TRX')).toBeOnTheScreen();
214-
expect(getByText(/Earn up to/)).toBeOnTheScreen();
215-
expect(getByText(/annually/)).toBeOnTheScreen();
216-
});
217-
218-
it('renders CTA with APR value when aprText is provided', () => {
219-
const { getByText } = render(
220-
<TronStakingButtons
221-
asset={baseAsset}
222-
hasStakedPositions={false}
223-
aprText="4.5%"
224-
/>,
225-
);
226-
227-
expect(getByText('Stake your TRX')).toBeOnTheScreen();
228-
expect(getByText('4.5%')).toBeOnTheScreen();
229-
});
230-
231-
it('does not render CTA section when hasStakedPositions is true', () => {
232-
const { queryByText } = render(
233-
<TronStakingButtons
234-
asset={baseAsset}
235-
hasStakedPositions
236-
aprText="4.5%"
237-
/>,
238-
);
239-
240-
expect(queryByText('Stake your TRX')).toBeNull();
241-
});
242-
243-
it('does not render CTA section when user is not eligible', () => {
244-
mockUseStakingEligibility.mockReturnValue({
245-
isEligible: false,
246-
isLoadingEligibility: false,
247-
error: null,
248-
refreshPooledStakingEligibility: jest.fn(),
249-
});
250-
251-
const { queryByText } = render(
252-
<TronStakingButtons asset={baseAsset} hasStakedPositions={false} />,
253-
);
254-
255-
expect(queryByText('Stake your TRX')).toBeNull();
256-
});
257-
});
258-
259-
it('renders nothing when user is not eligible and has no active positions', () => {
169+
it('hides Stake more button when user is not eligible', () => {
260170
mockUseStakingEligibility.mockReturnValue({
261171
isEligible: false,
262172
isLoadingEligibility: false,
263173
error: null,
264174
refreshPooledStakingEligibility: jest.fn(),
265175
});
266176

267-
const { toJSON } = render(
268-
<TronStakingButtons asset={baseAsset} hasStakedPositions={false} />,
177+
const { queryByTestId, getByTestId } = render(
178+
<TronStakingButtons asset={baseAsset} />,
269179
);
270180

271-
expect(toJSON()).toBeNull();
181+
expect(queryByTestId('stake-more-button')).toBeNull();
182+
expect(getByTestId('unstake-button')).toBeOnTheScreen();
272183
});
273184
});

0 commit comments

Comments
 (0)