Skip to content

Commit 5edb90b

Browse files
feat(tron): display new staking information
Made-with: Cursor
1 parent 893e98a commit 5edb90b

18 files changed

Lines changed: 640 additions & 18 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import I18n from '../../../../../locales/i18n';
2+
import { formatWithThreshold } from '../../../../util/assets';
3+
import { TokenI } from '../../Tokens/types';
4+
5+
const MINIMUM_DISPLAY_THRESHOLD = 0.00001;
6+
7+
function formatTrxBalance(amount: number | string | undefined): string {
8+
const value = Number(amount) || 0;
9+
return formatWithThreshold(value, MINIMUM_DISPLAY_THRESHOLD, I18n.locale, {
10+
minimumFractionDigits: 0,
11+
maximumFractionDigits: 5,
12+
});
13+
}
14+
15+
export function createReadyForWithdrawalTrxAsset(
16+
base: TokenI,
17+
amount?: number | string,
18+
): TokenI {
19+
return {
20+
...base,
21+
name: 'Ready for Withdrawal',
22+
symbol: 'rfwTRX',
23+
ticker: 'rfwTRX',
24+
isStaked: false,
25+
balance: formatTrxBalance(amount),
26+
};
27+
}
28+
29+
export function createStakingRewardsTrxAsset(
30+
base: TokenI,
31+
amount?: number | string,
32+
): TokenI {
33+
return {
34+
...base,
35+
name: 'Staking Rewards',
36+
symbol: 'srTRX',
37+
ticker: 'srTRX',
38+
isStaked: false,
39+
balance: formatTrxBalance(amount),
40+
};
41+
}
42+
43+
export function createInLockPeriodTrxAsset(
44+
base: TokenI,
45+
amount?: number | string,
46+
): TokenI {
47+
return {
48+
...base,
49+
name: 'In Lock Period',
50+
symbol: 'ilpTRX',
51+
ticker: 'ilpTRX',
52+
isStaked: false,
53+
balance: formatTrxBalance(amount),
54+
};
55+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import React from 'react';
2+
import { render, fireEvent } from '@testing-library/react-native';
3+
import TronClaimBanner from './TronClaimBanner';
4+
import { strings } from '../../../../../../../locales/i18n';
5+
import useTronClaim from '../../../hooks/useTronClaim';
6+
7+
jest.mock('../../../hooks/useTronClaim');
8+
const mockUseTronClaim = useTronClaim as jest.MockedFunction<
9+
typeof useTronClaim
10+
>;
11+
12+
describe('TronClaimBanner', () => {
13+
const mockHandleClaim = jest.fn();
14+
15+
beforeEach(() => {
16+
jest.clearAllMocks();
17+
mockUseTronClaim.mockReturnValue({
18+
handleClaim: mockHandleClaim,
19+
isSubmitting: false,
20+
errors: undefined,
21+
});
22+
});
23+
24+
it('renders the claim text with the given amount', () => {
25+
const { getByText } = render(
26+
<TronClaimBanner amount="100" chainId="tron:0x2b6653dc" />,
27+
);
28+
29+
const expected = strings('stake.tron.has_claimable_trx', {
30+
amount: '100',
31+
});
32+
expect(getByText(expected)).toBeDefined();
33+
});
34+
35+
it('renders the Claim TRX button', () => {
36+
const { getByTestId } = render(
37+
<TronClaimBanner amount="100" chainId="tron:0x2b6653dc" />,
38+
);
39+
40+
expect(getByTestId('tron-claim-banner-button')).toBeDefined();
41+
});
42+
43+
it('calls handleClaim when button is pressed', () => {
44+
const { getByTestId } = render(
45+
<TronClaimBanner amount="100" chainId="tron:0x2b6653dc" />,
46+
);
47+
48+
fireEvent.press(getByTestId('tron-claim-banner-button'));
49+
expect(mockHandleClaim).toHaveBeenCalledTimes(1);
50+
});
51+
52+
it('disables the button when isSubmitting is true', () => {
53+
mockUseTronClaim.mockReturnValue({
54+
handleClaim: mockHandleClaim,
55+
isSubmitting: true,
56+
errors: undefined,
57+
});
58+
59+
const { getByTestId } = render(
60+
<TronClaimBanner amount="100" chainId="tron:0x2b6653dc" />,
61+
);
62+
63+
const button = getByTestId('tron-claim-banner-button');
64+
expect(button.props.accessibilityState.disabled).toBe(true);
65+
});
66+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import React from 'react';
2+
import { ViewStyle } from 'react-native';
3+
import type { CaipChainId } from '@metamask/utils';
4+
import { strings } from '../../../../../../../locales/i18n';
5+
import Banner, {
6+
BannerAlertSeverity,
7+
BannerVariant,
8+
} from '../../../../../../component-library/components/Banners/Banner';
9+
import { Text, TextButton } from '@metamask/design-system-react-native';
10+
import useTronClaim from '../../../hooks/useTronClaim';
11+
12+
interface TronClaimBannerProps {
13+
amount: string;
14+
chainId: CaipChainId;
15+
style?: ViewStyle;
16+
}
17+
18+
const TronClaimBanner = ({ amount, chainId, style }: TronClaimBannerProps) => {
19+
const { handleClaim, isSubmitting } = useTronClaim({ chainId });
20+
21+
return (
22+
<Banner
23+
severity={BannerAlertSeverity.Success}
24+
variant={BannerVariant.Alert}
25+
style={style}
26+
description={
27+
<>
28+
<Text testID="tron-claim-banner">
29+
{strings('stake.tron.has_claimable_trx', { amount })}
30+
</Text>
31+
<TextButton
32+
testID="tron-claim-banner-button"
33+
twClassName="self-start"
34+
onPress={handleClaim}
35+
isDisabled={isSubmitting}
36+
>
37+
{strings('stake.tron.claim_trx')}
38+
</TextButton>
39+
</>
40+
}
41+
/>
42+
);
43+
};
44+
45+
export default TronClaimBanner;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from './TronClaimBanner';
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import React from 'react';
2+
import { render } from '@testing-library/react-native';
3+
import TronUnstakingBanner from './TronUnstakingBanner';
4+
import { strings } from '../../../../../../../locales/i18n';
5+
6+
describe('TronUnstakingBanner', () => {
7+
it('renders the unstaking text with the given amount', () => {
8+
const { getByText } = render(<TronUnstakingBanner amount="500" />);
9+
10+
const expected = strings('stake.tron.trx_unstaking_in_progress', {
11+
amount: '500',
12+
});
13+
expect(getByText(expected)).toBeDefined();
14+
});
15+
16+
it('renders with a different amount', () => {
17+
const { getByText } = render(<TronUnstakingBanner amount="1,234.5" />);
18+
19+
const expected = strings('stake.tron.trx_unstaking_in_progress', {
20+
amount: '1,234.5',
21+
});
22+
expect(getByText(expected)).toBeDefined();
23+
});
24+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import React from 'react';
2+
import { ViewStyle } from 'react-native';
3+
import { strings } from '../../../../../../../locales/i18n';
4+
import Banner, {
5+
BannerAlertSeverity,
6+
BannerVariant,
7+
} from '../../../../../../component-library/components/Banners/Banner';
8+
import Text from '../../../../../../component-library/components/Texts/Text';
9+
10+
interface TronUnstakingBannerProps {
11+
amount: string;
12+
style?: ViewStyle;
13+
}
14+
15+
const TronUnstakingBanner = ({ amount, style }: TronUnstakingBannerProps) => (
16+
<Banner
17+
severity={BannerAlertSeverity.Info}
18+
variant={BannerVariant.Alert}
19+
style={style}
20+
description={
21+
<Text testID="tron-unstaking-banner">
22+
{strings('stake.tron.trx_unstaking_in_progress', { amount })}
23+
</Text>
24+
}
25+
/>
26+
);
27+
28+
export default TronUnstakingBanner;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from './TronUnstakingBanner';
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { act, renderHook } from '@testing-library/react-hooks';
2+
import type { InternalAccount } from '@metamask/keyring-internal-api';
3+
import useTronClaim from './useTronClaim';
4+
import { claimUnstakedTrx } from '../utils/tron-staking-snap';
5+
6+
const mockSelectSelectedInternalAccountByScope = jest.fn();
7+
8+
jest.mock('react-redux', () => ({
9+
useSelector: jest.fn((selector) => selector()),
10+
}));
11+
12+
jest.mock('../../../../selectors/multichainAccounts/accounts', () => ({
13+
selectSelectedInternalAccountByScope: () =>
14+
mockSelectSelectedInternalAccountByScope,
15+
}));
16+
17+
jest.mock('../utils/tron-staking-snap', () => ({
18+
claimUnstakedTrx: jest.fn(),
19+
}));
20+
21+
describe('useTronClaim', () => {
22+
const mockClaimUnstakedTrx =
23+
claimUnstakedTrx as jest.MockedFunction<
24+
typeof claimUnstakedTrx
25+
>;
26+
27+
const mockAccount: Partial<InternalAccount> = {
28+
id: 'tron-account-1',
29+
metadata: {
30+
name: 'Tron Account',
31+
snap: { id: 'npm:@metamask/tron-wallet-snap', name: 'Tron Wallet Snap', enabled: true },
32+
importTime: 0,
33+
keyring: { type: 'snap' },
34+
lastSelected: 0,
35+
},
36+
};
37+
38+
beforeEach(() => {
39+
jest.clearAllMocks();
40+
mockSelectSelectedInternalAccountByScope.mockReturnValue(mockAccount);
41+
});
42+
43+
it('returns initial state', () => {
44+
const { result } = renderHook(() =>
45+
useTronClaim({ chainId: 'tron:728126428' }),
46+
);
47+
48+
expect(result.current.isSubmitting).toBe(false);
49+
expect(result.current.errors).toBeUndefined();
50+
expect(typeof result.current.handleClaim).toBe('function');
51+
});
52+
53+
it('calls claimUnstakedTrx with correct params on handleClaim', async () => {
54+
mockClaimUnstakedTrx.mockResolvedValue({ valid: true });
55+
56+
const { result } = renderHook(() =>
57+
useTronClaim({ chainId: 'tron:728126428' }),
58+
);
59+
60+
await act(async () => {
61+
await result.current.handleClaim();
62+
});
63+
64+
expect(mockClaimUnstakedTrx).toHaveBeenCalledWith(mockAccount, {
65+
fromAccountId: 'tron-account-1',
66+
assetId: 'tron:728126428/slip44:195',
67+
});
68+
expect(result.current.isSubmitting).toBe(false);
69+
expect(result.current.errors).toBeUndefined();
70+
});
71+
72+
it('sets errors when claim returns errors', async () => {
73+
mockClaimUnstakedTrx.mockResolvedValue({
74+
valid: false,
75+
errors: ['Insufficient energy'],
76+
});
77+
78+
const { result } = renderHook(() =>
79+
useTronClaim({ chainId: 'tron:728126428' }),
80+
);
81+
82+
await act(async () => {
83+
await result.current.handleClaim();
84+
});
85+
86+
expect(result.current.errors).toEqual(['Insufficient energy']);
87+
});
88+
89+
it('sets errors when claim throws', async () => {
90+
mockClaimUnstakedTrx.mockRejectedValue(new Error('Network error'));
91+
92+
const { result } = renderHook(() =>
93+
useTronClaim({ chainId: 'tron:728126428' }),
94+
);
95+
96+
await act(async () => {
97+
await result.current.handleClaim();
98+
});
99+
100+
expect(result.current.errors).toEqual(['Network error']);
101+
expect(result.current.isSubmitting).toBe(false);
102+
});
103+
104+
it('does nothing when no account is selected', async () => {
105+
mockSelectSelectedInternalAccountByScope.mockReturnValue(null);
106+
107+
const { result } = renderHook(() =>
108+
useTronClaim({ chainId: 'tron:728126428' }),
109+
);
110+
111+
await act(async () => {
112+
await result.current.handleClaim();
113+
});
114+
115+
expect(mockClaimUnstakedTrx).not.toHaveBeenCalled();
116+
});
117+
});

0 commit comments

Comments
 (0)