Skip to content

Commit f482b1a

Browse files
feat(tron): display TRX in unstaking lock period (NEB-577)
Add TronUnstakingBanner component to show TRX that is currently in the 14-day unstaking lock period. Also display staking rewards as a Balance row. Both are derived from the Tron special assets selector and rendered conditionally on the token details view. Made-with: Cursor
1 parent c3d9fe3 commit f482b1a

10 files changed

Lines changed: 244 additions & 10 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import React from 'react';
2+
import { render } from '@testing-library/react-native';
3+
import TronUnstakingBanner from './TronUnstakingBanner';
4+
import { TronUnstakingBannerTestIds } from './TronUnstakingBanner.testIds';
5+
import { strings } from '../../../../../../../locales/i18n';
6+
7+
describe('TronUnstakingBanner', () => {
8+
it('renders the unstaking text with the given amount', () => {
9+
const { getByTestId } = render(<TronUnstakingBanner amount="500" />);
10+
11+
const expected = strings('stake.tron.trx_unstaking_in_progress', {
12+
amount: '500',
13+
});
14+
expect(
15+
getByTestId(TronUnstakingBannerTestIds.BANNER_TEXT),
16+
).toHaveTextContent(expected);
17+
});
18+
19+
it('renders with a different amount', () => {
20+
const { getByTestId } = render(<TronUnstakingBanner amount="1,234.5" />);
21+
22+
const expected = strings('stake.tron.trx_unstaking_in_progress', {
23+
amount: '1,234.5',
24+
});
25+
expect(
26+
getByTestId(TronUnstakingBannerTestIds.BANNER_TEXT),
27+
).toHaveTextContent(expected);
28+
});
29+
});
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export enum TronUnstakingBannerTestIds {
2+
BANNER_TEXT = 'tron-unstaking-banner',
3+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import React from 'react';
2+
import { strings } from '../../../../../../../locales/i18n';
3+
import Banner, {
4+
BannerAlertSeverity,
5+
BannerVariant,
6+
} from '../../../../../../component-library/components/Banners/Banner';
7+
import { Text } from '@metamask/design-system-react-native';
8+
import { TronUnstakingBannerTestIds } from './TronUnstakingBanner.testIds';
9+
10+
interface TronUnstakingBannerProps {
11+
amount: string;
12+
}
13+
14+
const TronUnstakingBanner = ({ amount }: TronUnstakingBannerProps) => (
15+
<Banner
16+
severity={BannerAlertSeverity.Info}
17+
variant={BannerVariant.Alert}
18+
description={
19+
<Text testID={TronUnstakingBannerTestIds.BANNER_TEXT}>
20+
{strings('stake.tron.trx_unstaking_in_progress', { amount })}
21+
</Text>
22+
}
23+
/>
24+
);
25+
26+
export default TronUnstakingBanner;

app/components/UI/TokenDetails/Views/TokenDetails.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ const TokenDetails: React.FC<{
138138
///: BEGIN:ONLY_INCLUDE_IF(tron)
139139
isTronNative,
140140
stakedTrxAsset,
141+
inLockPeriodBalance,
141142
///: END:ONLY_INCLUDE_IF
142143
} = useTokenBalance(token);
143144

@@ -210,6 +211,7 @@ const TokenDetails: React.FC<{
210211
///: BEGIN:ONLY_INCLUDE_IF(tron)
211212
isTronNative={isTronNative}
212213
stakedTrxAsset={stakedTrxAsset}
214+
inLockPeriodBalance={inLockPeriodBalance}
213215
///: END:ONLY_INCLUDE_IF
214216
/>
215217
<ActivityHeader

app/components/UI/TokenDetails/components/AssetOverviewContent.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import { isCaipAssetType } from '@metamask/utils';
6262
import { formatAddressToAssetId } from '@metamask/bridge-controller';
6363
///: BEGIN:ONLY_INCLUDE_IF(tron)
6464
import TronEnergyBandwidthDetail from '../../AssetOverview/TronEnergyBandwidthDetail/TronEnergyBandwidthDetail';
65+
import TronUnstakingBanner from '../../Earn/components/Tron/TronUnstakingBanner/TronUnstakingBanner';
6566
///: END:ONLY_INCLUDE_IF
6667
import MarketClosedActionButton from '../../AssetOverview/MarketClosedActionButton';
6768
import { IconName } from '../../../../component-library/components/Icons/Icon';
@@ -113,6 +114,10 @@ const styleSheet = (params: { theme: Theme }) => {
113114
perpsPositionTitle: {
114115
marginBottom: 8,
115116
} as TextStyle,
117+
bannerWrapper: {
118+
paddingHorizontal: 16,
119+
marginTop: 8,
120+
} as ViewStyle,
116121
});
117122
};
118123

@@ -156,6 +161,7 @@ export interface AssetOverviewContentProps {
156161
// Tron-specific
157162
isTronNative?: boolean;
158163
stakedTrxAsset?: TokenI;
164+
inLockPeriodBalance?: string;
159165
onMarketInsightsDisplayResolved?: (isDisplayed: boolean) => void;
160166
}
161167

@@ -193,6 +199,7 @@ const AssetOverviewContent: React.FC<AssetOverviewContentProps> = ({
193199
goToSwaps,
194200
isTronNative,
195201
stakedTrxAsset,
202+
inLockPeriodBalance,
196203
onMarketInsightsDisplayResolved,
197204
}) => {
198205
const { styles } = useStyles(styleSheet, {});
@@ -524,6 +531,15 @@ const AssetOverviewContent: React.FC<AssetOverviewContentProps> = ({
524531
)
525532
///: END:ONLY_INCLUDE_IF
526533
}
534+
{
535+
///: BEGIN:ONLY_INCLUDE_IF(tron)
536+
isTronNative && inLockPeriodBalance && (
537+
<View style={styles.bannerWrapper}>
538+
<TronUnstakingBanner amount={inLockPeriodBalance} />
539+
</View>
540+
)
541+
///: END:ONLY_INCLUDE_IF
542+
}
527543
{isMarketInsightsEnabled &&
528544
marketInsightsReport &&
529545
marketInsightsCaip19Id ? (

app/components/UI/TokenDetails/hooks/useTokenBalance.test.ts

Lines changed: 144 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { Asset } from '@metamask/assets-controllers';
2+
import { Hex } from '@metamask/utils';
13
import { renderHookWithProvider } from '../../../../util/test/renderWithProvider';
24
import { useTokenBalance } from './useTokenBalance';
35
import { TokenI } from '../../Tokens/types';
@@ -8,6 +10,24 @@ import {
810
} from '../../../../selectors/assets/assets-list';
911
import { createStakedTrxAsset } from '../../AssetOverview/utils/createStakedTrxAsset';
1012

13+
const TRON_MAINNET_CHAIN_ID = 'tron:728126428';
14+
15+
const createMockTronAsset = (symbol: string, balance: string): Asset =>
16+
({
17+
accountType: 'tron:eoa',
18+
assetId: `${TRON_MAINNET_CHAIN_ID}/slip44:${symbol}`,
19+
chainId: TRON_MAINNET_CHAIN_ID,
20+
accountId: 'mock-account-id',
21+
image: '',
22+
name: symbol,
23+
symbol,
24+
decimals: 6,
25+
isNative: false,
26+
rawBalance: '0x0' as Hex,
27+
balance,
28+
fiat: { balance: 0, currency: 'usd', conversionRate: 1 },
29+
}) as Asset;
30+
1131
const createEmptySpecialAssetsMap = (): TronSpecialAssetsMap => ({
1232
energy: undefined,
1333
bandwidth: undefined,
@@ -74,7 +94,6 @@ describe('useTokenBalance', () => {
7494

7595
const { result } = renderHookWithProvider(() => useTokenBalance(token));
7696

77-
// Address is normalized to checksum format for consistent lookup
7897
expect(mockSelectAsset).toHaveBeenCalledWith(expect.any(Object), {
7998
address: '0x6B175474E89094C44Da98b954EedeAC495271d0F',
8099
chainId: token.chainId,
@@ -111,7 +130,7 @@ describe('useTokenBalance', () => {
111130
it('returns staked TRX asset for Tron native token', () => {
112131
const tronToken = {
113132
address: '',
114-
chainId: 'tron:0x2b6653dc',
133+
chainId: TRON_MAINNET_CHAIN_ID,
115134
ticker: 'TRX',
116135
symbol: 'TRX',
117136
} as TokenI;
@@ -126,9 +145,9 @@ describe('useTokenBalance', () => {
126145

127146
mockSelectTronResources.mockReturnValue({
128147
...createEmptySpecialAssetsMap(),
129-
stakedTrxForEnergy: { symbol: 'strx-energy', balance: '100' },
130-
stakedTrxForBandwidth: { symbol: 'strx-bandwidth', balance: '200' },
131-
} as TronSpecialAssetsMap);
148+
stakedTrxForEnergy: createMockTronAsset('strx-energy', '100'),
149+
stakedTrxForBandwidth: createMockTronAsset('strx-bandwidth', '200'),
150+
});
132151

133152
mockCreateStakedTrxAsset.mockReturnValue(mockStakedAsset);
134153

@@ -150,4 +169,124 @@ describe('useTokenBalance', () => {
150169
'200',
151170
);
152171
});
172+
173+
it('returns in-lock-period balance for Tron native token', () => {
174+
const tronToken = {
175+
address: '',
176+
chainId: TRON_MAINNET_CHAIN_ID,
177+
ticker: 'TRX',
178+
symbol: 'TRX',
179+
} as TokenI;
180+
181+
mockSelectAsset.mockReturnValue({
182+
balance: '1000',
183+
balanceFiat: '$100.00',
184+
symbol: 'TRX',
185+
} as TokenI);
186+
187+
mockSelectTronResources.mockReturnValue({
188+
...createEmptySpecialAssetsMap(),
189+
trxInLockPeriod: createMockTronAsset('trx-in-lock-period', '20'),
190+
});
191+
192+
const { result } = renderHookWithProvider(() => useTokenBalance(tronToken));
193+
194+
expect(result.current.inLockPeriodBalance).toBe('20');
195+
});
196+
197+
it('returns undefined for in-lock-period when balance is zero', () => {
198+
const tronToken = {
199+
address: '',
200+
chainId: TRON_MAINNET_CHAIN_ID,
201+
ticker: 'TRX',
202+
symbol: 'TRX',
203+
} as TokenI;
204+
205+
mockSelectAsset.mockReturnValue({
206+
balance: '1000',
207+
balanceFiat: '$100.00',
208+
symbol: 'TRX',
209+
} as TokenI);
210+
211+
mockSelectTronResources.mockReturnValue({
212+
...createEmptySpecialAssetsMap(),
213+
trxInLockPeriod: createMockTronAsset('trx-in-lock-period', '0'),
214+
});
215+
216+
const { result } = renderHookWithProvider(() => useTokenBalance(tronToken));
217+
218+
expect(result.current.inLockPeriodBalance).toBeUndefined();
219+
});
220+
221+
it('returns undefined for in-lock-period when balance is non-numeric', () => {
222+
const tronToken = {
223+
address: '',
224+
chainId: TRON_MAINNET_CHAIN_ID,
225+
ticker: 'TRX',
226+
symbol: 'TRX',
227+
} as TokenI;
228+
229+
mockSelectAsset.mockReturnValue({
230+
balance: '1000',
231+
balanceFiat: '$100.00',
232+
symbol: 'TRX',
233+
} as TokenI);
234+
235+
mockSelectTronResources.mockReturnValue({
236+
...createEmptySpecialAssetsMap(),
237+
trxInLockPeriod: createMockTronAsset(
238+
'trx-in-lock-period',
239+
'not-a-number',
240+
),
241+
});
242+
243+
const { result } = renderHookWithProvider(() => useTokenBalance(tronToken));
244+
245+
expect(result.current.inLockPeriodBalance).toBeUndefined();
246+
});
247+
248+
it('returns undefined for in-lock-period when balance is empty string', () => {
249+
const tronToken = {
250+
address: '',
251+
chainId: TRON_MAINNET_CHAIN_ID,
252+
ticker: 'TRX',
253+
symbol: 'TRX',
254+
} as TokenI;
255+
256+
mockSelectAsset.mockReturnValue({
257+
balance: '1000',
258+
balanceFiat: '$100.00',
259+
symbol: 'TRX',
260+
} as TokenI);
261+
262+
mockSelectTronResources.mockReturnValue({
263+
...createEmptySpecialAssetsMap(),
264+
trxInLockPeriod: createMockTronAsset('trx-in-lock-period', ''),
265+
});
266+
267+
const { result } = renderHookWithProvider(() => useTokenBalance(tronToken));
268+
269+
expect(result.current.inLockPeriodBalance).toBeUndefined();
270+
});
271+
272+
it('returns undefined for in-lock-period when resources are not available', () => {
273+
const tronToken = {
274+
address: '',
275+
chainId: TRON_MAINNET_CHAIN_ID,
276+
ticker: 'TRX',
277+
symbol: 'TRX',
278+
} as TokenI;
279+
280+
mockSelectAsset.mockReturnValue({
281+
balance: '1000',
282+
balanceFiat: '$100.00',
283+
symbol: 'TRX',
284+
} as TokenI);
285+
286+
mockSelectTronResources.mockReturnValue(createEmptySpecialAssetsMap());
287+
288+
const { result } = renderHookWithProvider(() => useTokenBalance(tronToken));
289+
290+
expect(result.current.inLockPeriodBalance).toBeUndefined();
291+
});
153292
});

app/components/UI/TokenDetails/hooks/useTokenBalance.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
} from '../../../../selectors/assets/assets-list';
1111
import { toFormattedAddress } from '../../../../util/address';
1212
///: BEGIN:ONLY_INCLUDE_IF(tron)
13+
import I18n from '../../../../../locales/i18n';
14+
import { formatWithThreshold } from '../../../../util/assets';
1315
import { createStakedTrxAsset } from '../../AssetOverview/utils/createStakedTrxAsset';
1416
///: END:ONLY_INCLUDE_IF
1517

@@ -20,6 +22,7 @@ export interface UseTokenBalanceResult {
2022
///: BEGIN:ONLY_INCLUDE_IF(tron)
2123
isTronNative: boolean;
2224
stakedTrxAsset: TokenI | undefined;
25+
inLockPeriodBalance: string | undefined;
2326
///: END:ONLY_INCLUDE_IF
2427
}
2528

@@ -33,9 +36,8 @@ export const useTokenBalance = (token: TokenI): UseTokenBalanceResult => {
3336
);
3437

3538
///: BEGIN:ONLY_INCLUDE_IF(tron)
36-
const { stakedTrxForEnergy, stakedTrxForBandwidth } = useSelector(
37-
selectTronSpecialAssetsBySelectedAccountGroup,
38-
);
39+
const { stakedTrxForEnergy, stakedTrxForBandwidth, trxInLockPeriod } =
40+
useSelector(selectTronSpecialAssetsBySelectedAccountGroup);
3941

4042
const isTronNative =
4143
token.ticker === 'TRX' && String(token.chainId).startsWith('tron:');
@@ -47,6 +49,17 @@ export const useTokenBalance = (token: TokenI): UseTokenBalanceResult => {
4749
stakedTrxForBandwidth?.balance,
4850
)
4951
: undefined;
52+
53+
const parsedInLockPeriod = trxInLockPeriod
54+
? parseFloat(trxInLockPeriod.balance)
55+
: 0;
56+
const inLockPeriodBalance =
57+
isTronNative && parsedInLockPeriod > 0
58+
? formatWithThreshold(parsedInLockPeriod, 0.00001, I18n.locale, {
59+
minimumFractionDigits: 0,
60+
maximumFractionDigits: 5,
61+
}) || undefined
62+
: undefined;
5063
///: END:ONLY_INCLUDE_IF
5164

5265
const balance = processedAsset?.balance;
@@ -60,6 +73,7 @@ export const useTokenBalance = (token: TokenI): UseTokenBalanceResult => {
6073
///: BEGIN:ONLY_INCLUDE_IF(tron)
6174
isTronNative,
6275
stakedTrxAsset,
76+
inLockPeriodBalance,
6377
///: END:ONLY_INCLUDE_IF
6478
};
6579
};

app/util/assets/index.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ describe('formatWithThreshold', () => {
2323
);
2424
});
2525

26+
test('returns an empty string when amount is NaN', () => {
27+
expect(formatWithThreshold(NaN, 10, 'en-US', enUSCurrencyOptions)).toBe('');
28+
});
29+
2630
test('formats zero correctly in en-US currency format', () => {
2731
expect(formatWithThreshold(0, 10, 'en-US', enUSCurrencyOptions)).toBe(
2832
'$0.00',

app/util/assets/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export const formatWithThreshold = (
99
locale: string,
1010
options: Intl.NumberFormatOptions,
1111
): string => {
12-
if (amount === null) {
12+
if (amount === null || isNaN(amount)) {
1313
return '';
1414
}
1515

locales/languages/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6093,7 +6093,8 @@
60936093
"fee": "Fee",
60946094
"errors": {
60956095
"insufficient_balance": "You don't have enough resource balance to do this action."
6096-
}
6096+
},
6097+
"trx_unstaking_in_progress": "Unstaking {{amount}} TRX in progress. It takes 14 days for unstaking."
60976098
},
60986099
"stake_eth": "Stake ETH",
60996100
"unstake_eth": "Unstake ETH",

0 commit comments

Comments
 (0)