Skip to content

Commit 5fd9eb9

Browse files
authored
chore: broadcast staking TRX transaction (MetaMask#22522)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** Staking and unstaking methods connected to the tron-snap: https://github.com/user-attachments/assets/a7f66644-1a44-4035-9e06-64c2ff71322f <img width="1323" height="45" alt="Screenshot 2025-11-19 at 17 54 46" src="https://github.com/user-attachments/assets/9ebe514b-2333-4b0a-ab12-969c48aa22bd" /> Example: https://tronscan.org/address/TGXFnQBLAdbdkupHUGSpeBfbxB72hkMsh2#/transaction/85bfd85b620e6a8fc5966458cbfcf3a9757454917f78c36b675e223b68564499 ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **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. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Integrates TRON staking and unstaking via Snap, updating Earn flows, lists, and selectors to support TRX with new validation, previews, and navigation. > > - **TRON staking/unstaking integration**: > - Add `useTronStake` and `useTronUnstake` hooks for validation/confirmation via `tron-staking-snap`. > - New utils: `tron-staking-snap` (Snap RPCs), `tron` (navigation/result handling, token builder, staked total). > - Add `TronStakePreview` UI for fee preview; reuse `ResourceToggle`. > - **Earn Input/Withdraw flows** (`EarnInputView.tsx`, `EarnWithdrawInputView.tsx`): > - Wire TRX validation on keypad input; confirm flows call Snap and navigate to success/error sheets. > - Show Tron-specific UI (resource toggle, fee preview) and simplified button labels; adjust disabled/loading states. > - Limit gas-cost warning to `isETH` only; debounce validation handler. > - **Token list and selection**: > - `EarnTokenList`: include TRX native in deposit list (even with zero balance when enabled); navigate without EVM network switch; sorting updated. > - `EarnTokenSelector`: render output token for withdraw; earn token for stake. > - **Selectors and data plumbing**: > - Support non-EVM (TRX) balances/tokens in earn selectors; default TRX pooled-staking APR to `0`. > - Add unified multichain token selector including non-EVM; filter Tron resource/testnet assets; use `getDecimalChainId`. > - **Utilities/constants/i18n**: > - Add `normalizeToDotDecimal`; `TronResourceType` constant; new TRON strings (fee, success/error copy). > - **Tests**: Extensive new/updated tests for views, hooks, lists, buttons, selectors, and utils; snapshot updates. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 0989d7f. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent e179270 commit 5fd9eb9

35 files changed

Lines changed: 2850 additions & 208 deletions

app/components/UI/Earn/Views/EarnInputView/EarnInputView.test.tsx

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -583,16 +583,33 @@ describe('EarnInputView', () => {
583583
(selectTrxStakingEnabled as unknown as jest.Mock).mockReturnValue(true);
584584

585585
(useEarnTokens as jest.Mock).mockReturnValue({
586-
getEarnToken: jest.fn(() => undefined),
586+
getEarnToken: jest.fn(() => ({
587+
name: 'TRON',
588+
symbol: 'TRX',
589+
ticker: 'TRX',
590+
chainId: 'tron:728126428',
591+
address: 'TEFik7dGm6r5Y1Af9mGwnELuJLa1jXDDUB',
592+
isNative: true,
593+
isETH: false,
594+
decimals: 6,
595+
balance: '0',
596+
balanceMinimalUnit: '0',
597+
balanceFormatted: '0 TRX',
598+
balanceFiat: '$0',
599+
tokenUsdExchangeRate: 0,
600+
experiences: [{ type: EARN_EXPERIENCES.POOLED_STAKING, apr: '0' }],
601+
experience: { type: EARN_EXPERIENCES.POOLED_STAKING, apr: '0' },
602+
})),
587603
getOutputToken: jest.fn(() => undefined),
588604
});
589605

590606
const TRX_TOKEN = {
591607
name: 'TRON',
592608
symbol: 'TRX',
593609
ticker: 'TRX',
594-
chainId: 'tron:main',
595-
address: 'T1111111111111111111111111111111111',
610+
chainId: 'tron:728126428',
611+
isNative: true,
612+
address: 'TEFik7dGm6r5Y1Af9mGwnELuJLa1jXDDUB',
596613
balance: '0',
597614
balanceFiat: '$0',
598615
isETH: false,
@@ -609,6 +626,52 @@ describe('EarnInputView', () => {
609626
expect(getByTestId('resource-toggle-energy')).toBeTruthy();
610627
expect(getByTestId('resource-toggle-bandwidth')).toBeTruthy();
611628
});
629+
630+
it('renders TRX earnToken with non-zero balance from selector', () => {
631+
(selectTrxStakingEnabled as unknown as jest.Mock).mockReturnValue(true);
632+
633+
const TRX_TOKEN = {
634+
name: 'TRON',
635+
symbol: 'TRX',
636+
ticker: 'TRX',
637+
chainId: 'tron:728126428',
638+
isNative: true,
639+
address: 'TEFik7dGm6r5Y1Af9mGwnELuJLa1jXDDUB',
640+
balance: '100',
641+
balanceFiat: '$100',
642+
decimals: 6,
643+
isETH: false,
644+
} as unknown as typeof MOCK_ETH_MAINNET_ASSET;
645+
646+
const mockGetEarnToken = jest.fn(() => ({
647+
...TRX_TOKEN,
648+
balanceMinimalUnit: '100000000',
649+
balanceFormatted: '100 TRX',
650+
balanceFiatNumber: 100,
651+
tokenUsdExchangeRate: 1,
652+
experiences: [{ type: EARN_EXPERIENCES.POOLED_STAKING, apr: '0' }],
653+
experience: { type: EARN_EXPERIENCES.POOLED_STAKING, apr: '0' },
654+
}));
655+
656+
(useEarnTokens as jest.Mock).mockReturnValue({
657+
getEarnToken: mockGetEarnToken,
658+
getOutputToken: jest.fn(() => undefined),
659+
});
660+
661+
const { getByTestId } = render(EarnInputView, {
662+
params: {
663+
token: TRX_TOKEN,
664+
},
665+
key: Routes.STAKING.STAKE,
666+
name: 'params',
667+
});
668+
669+
// Verify getEarnToken was called with the token
670+
expect(mockGetEarnToken).toHaveBeenCalledWith(TRX_TOKEN);
671+
// Verify TRX-specific UI elements are rendered
672+
expect(getByTestId('resource-toggle-energy')).toBeTruthy();
673+
expect(getByTestId('resource-toggle-bandwidth')).toBeTruthy();
674+
});
612675
});
613676

614677
describe('when values are entered in the keypad', () => {

app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx

Lines changed: 101 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,7 @@ import Button, {
2121
} from '../../../../../component-library/components/Buttons/Button';
2222
import { TextVariant } from '../../../../../component-library/components/Texts/Text';
2323
///: BEGIN:ONLY_INCLUDE_IF(tron)
24-
import ResourceToggle, {
25-
type ResourceType,
26-
} from '../../components/Tron/ResourceToggle';
24+
import ResourceToggle from '../../components/Tron/ResourceToggle';
2725
///: END:ONLY_INCLUDE_IF
2826
import Routes from '../../../../../constants/navigation/Routes';
2927
import Engine from '../../../../../core/Engine';
@@ -76,7 +74,12 @@ import { ScrollView } from 'react-native-gesture-handler';
7674
import { trace, TraceName } from '../../../../../util/trace';
7775
import { useEndTraceOnMount } from '../../../../hooks/useEndTraceOnMount';
7876
import { EVM_SCOPE } from '../../constants/networks';
79-
import { selectTrxStakingEnabled } from '../../../../../selectors/featureFlagController/trxStakingEnabled';
77+
///: BEGIN:ONLY_INCLUDE_IF(tron)
78+
import useTronStake from '../../hooks/useTronStake';
79+
import TronStakePreview from '../../components/Tron/StakePreview/TronStakePreview';
80+
import { ComputeFeeResult } from '../../utils/tron-staking-snap';
81+
import { handleTronStakingNavigationResult } from '../../utils/tron';
82+
///: END:ONLY_INCLUDE_IF
8083

8184
const EarnInputView = () => {
8285
// navigation hooks
@@ -113,8 +116,6 @@ const EarnInputView = () => {
113116
selectStablecoinLendingEnabledFlag,
114117
);
115118

116-
const isTrxStakingEnabled = useSelector(selectTrxStakingEnabled);
117-
118119
// if token is ETH, use 1 as the exchange rate
119120
// otherwise, use the contract exchange rate or 0 if undefined
120121
const exchangeRate = token.isETH
@@ -128,41 +129,25 @@ const EarnInputView = () => {
128129
const { getEarnToken } = useEarnTokens();
129130

130131
///: BEGIN:ONLY_INCLUDE_IF(tron)
131-
const [resourceType, setResourceType] = useState<ResourceType>('energy');
132-
const isTronNative =
133-
token.ticker === 'TRX' && String(token.chainId).startsWith('tron:');
132+
const {
133+
isTronNative,
134+
isTronEnabled,
135+
resourceType,
136+
setResourceType,
137+
validating: isTronStakeValidating,
138+
preview: tronPreview,
139+
validate: tronValidate,
140+
confirmStake: tronConfirmStake,
141+
} = useTronStake({ token });
134142
///: END:ONLY_INCLUDE_IF
135143

136-
const earnTokenFromMap = getEarnToken(token);
137-
138-
const earnToken = React.useMemo(() => {
139-
if (earnTokenFromMap) return earnTokenFromMap;
140-
141-
///: BEGIN:ONLY_INCLUDE_IF(tron)
142-
if (isTrxStakingEnabled && isTronNative) {
143-
const experiences = [{ type: EARN_EXPERIENCES.POOLED_STAKING, apr: '0' }];
144-
return {
145-
...token,
146-
isETH: false,
147-
balanceMinimalUnit: '0',
148-
balanceFormatted: token.balance ?? '0',
149-
balanceFiat: token.balanceFiat ?? '0',
150-
tokenUsdExchangeRate: 0,
151-
experiences,
152-
experience: experiences[0],
153-
} as EarnTokenDetails;
154-
}
155-
///: END:ONLY_INCLUDE_IF
144+
// Flag to conditionally show Tron-specific UI (false in non-Tron builds)
145+
let showTronStakingUI = false;
146+
///: BEGIN:ONLY_INCLUDE_IF(tron)
147+
showTronStakingUI = isTronEnabled;
148+
///: END:ONLY_INCLUDE_IF
156149

157-
return undefined;
158-
}, [
159-
earnTokenFromMap,
160-
///: BEGIN:ONLY_INCLUDE_IF(tron)
161-
isTrxStakingEnabled,
162-
isTronNative,
163-
token,
164-
///: END:ONLY_INCLUDE_IF
165-
]);
150+
const earnToken = getEarnToken(token);
166151

167152
const networkClientId = useSelector(selectNetworkClientId);
168153
const {
@@ -479,7 +464,7 @@ const EarnInputView = () => {
479464
]);
480465

481466
const handlePooledStakingFlow = useCallback(async () => {
482-
if (isHighGasCostImpact()) {
467+
if (isHighGasCostImpact() && earnToken?.isETH) {
483468
trackEvent(
484469
createEventBuilder(
485470
MetaMetricsEvents.STAKE_GAS_COST_IMPACT_WARNING_TRIGGERED,
@@ -589,6 +574,7 @@ const EarnInputView = () => {
589574
attemptDepositTransaction,
590575
createEventBuilder,
591576
earnToken?.chainId,
577+
earnToken?.isETH,
592578
estimatedGasFeeWei,
593579
getDepositTxGasPercentage,
594580
isHighGasCostImpact,
@@ -598,6 +584,14 @@ const EarnInputView = () => {
598584
]);
599585

600586
const handleEarnPress = useCallback(async () => {
587+
///: BEGIN:ONLY_INCLUDE_IF(tron)
588+
if (isTronEnabled) {
589+
const result = await tronConfirmStake?.(amountToken);
590+
handleTronStakingNavigationResult(navigation, result, 'stake');
591+
return;
592+
}
593+
///: END:ONLY_INCLUDE_IF
594+
601595
// Stablecoin Lending Flow
602596
if (
603597
earnToken?.experience?.type === EARN_EXPERIENCES.STABLECOIN_LENDING &&
@@ -612,6 +606,12 @@ const EarnInputView = () => {
612606
}, [
613607
earnToken?.experience?.type,
614608
isStablecoinLendingEnabled,
609+
amountToken,
610+
///: BEGIN:ONLY_INCLUDE_IF(tron)
611+
isTronEnabled,
612+
navigation,
613+
tronConfirmStake,
614+
///: END:ONLY_INCLUDE_IF
615615
handlePooledStakingFlow,
616616
handleLendingFlow,
617617
]);
@@ -700,7 +700,33 @@ const EarnInputView = () => {
700700
earnToken?.chainId,
701701
]);
702702

703+
const handleKeypadChangeWithValidation = useCallback(
704+
(data: { value: string; valueAsNumber: number; pressedKey: string }) => {
705+
handleKeypadChange(data);
706+
///: BEGIN:ONLY_INCLUDE_IF(tron)
707+
if (isTronEnabled && !isFiat) {
708+
tronValidate?.(data.value);
709+
}
710+
///: END:ONLY_INCLUDE_IF
711+
},
712+
[
713+
handleKeypadChange,
714+
///: BEGIN:ONLY_INCLUDE_IF(tron)
715+
isTronEnabled,
716+
isFiat,
717+
tronValidate,
718+
///: END:ONLY_INCLUDE_IF
719+
],
720+
);
721+
703722
const getButtonLabel = () => {
723+
///: BEGIN:ONLY_INCLUDE_IF(tron)
724+
// Tron staking has a simpler flow - just show "Stake"
725+
if (isTronEnabled) {
726+
return strings('stake.stake');
727+
}
728+
///: END:ONLY_INCLUDE_IF
729+
704730
if (!isNonZeroAmount) {
705731
return strings('stake.enter_amount');
706732
}
@@ -867,7 +893,7 @@ const EarnInputView = () => {
867893
<ScreenLayout style={styles.container}>
868894
{
869895
///: BEGIN:ONLY_INCLUDE_IF(tron)
870-
isTrxStakingEnabled && isTronNative && (
896+
isTronEnabled && (
871897
<ResourceToggle value={resourceType} onChange={setResourceType} />
872898
)
873899
///: END:ONLY_INCLUDE_IF
@@ -889,31 +915,42 @@ const EarnInputView = () => {
889915
currencyToggleValue={currencyToggleValue}
890916
/>
891917
<View style={styles.rewardsRateContainer}>
892-
{isStablecoinLendingEnabled && !isTrxStakingEnabled ? (
893-
<>
894-
<View style={styles.spacer} />
895-
<EarnTokenSelector
896-
token={token}
897-
action={EARN_INPUT_VIEW_ACTIONS.DEPOSIT}
918+
{!showTronStakingUI &&
919+
(isStablecoinLendingEnabled ? (
920+
<>
921+
<View style={styles.spacer} />
922+
<EarnTokenSelector
923+
token={token}
924+
action={EARN_INPUT_VIEW_ACTIONS.DEPOSIT}
925+
/>
926+
</>
927+
) : (
928+
<EstimatedAnnualRewardsCard
929+
estimatedAnnualRewards={estimatedAnnualRewards}
930+
onIconPress={withMetaMetrics(navigateToLearnMoreModal, {
931+
event: MetaMetricsEvents.TOOLTIP_OPENED,
932+
properties: {
933+
selected_provider: EVENT_PROVIDERS.CONSENSYS,
934+
text: 'Tooltip Opened',
935+
location: EVENT_LOCATIONS.EARN_INPUT_VIEW,
936+
tooltip_name: 'MetaMask Pool Estimated Rewards',
937+
},
938+
})}
939+
isLoading={isLoadingEarnMetadata}
898940
/>
899-
</>
900-
) : (
901-
<EstimatedAnnualRewardsCard
902-
estimatedAnnualRewards={estimatedAnnualRewards}
903-
onIconPress={withMetaMetrics(navigateToLearnMoreModal, {
904-
event: MetaMetricsEvents.TOOLTIP_OPENED,
905-
properties: {
906-
selected_provider: EVENT_PROVIDERS.CONSENSYS,
907-
text: 'Tooltip Opened',
908-
location: EVENT_LOCATIONS.EARN_INPUT_VIEW,
909-
tooltip_name: 'MetaMask Pool Estimated Rewards',
910-
},
911-
})}
912-
isLoading={isLoadingEarnMetadata}
913-
/>
914-
)}
941+
))}
915942
</View>
916943
</ScrollView>
944+
{
945+
///: BEGIN:ONLY_INCLUDE_IF(tron)
946+
isTronEnabled && isNonZeroAmount && (
947+
<TronStakePreview
948+
resourceType={resourceType}
949+
fee={tronPreview?.fee as ComputeFeeResult}
950+
/>
951+
)
952+
///: END:ONLY_INCLUDE_IF
953+
}
917954
<QuickAmounts
918955
amounts={percentageOptions}
919956
onAmountPress={handleQuickAmountPressWithTracking}
@@ -922,7 +959,7 @@ const EarnInputView = () => {
922959
<Keypad
923960
value={!isFiat ? amountToken : amountFiatNumber}
924961
// Debounce used to avoid error message flicker from recalculating gas fee estimate
925-
onChange={debounce(handleKeypadChange, 1)}
962+
onChange={debounce(handleKeypadChangeWithValidation, 1)}
926963
style={styles.keypad}
927964
currency={token.symbol}
928965
decimals={!isFiat ? 5 : 2}
@@ -938,7 +975,7 @@ const EarnInputView = () => {
938975
isOverMaximum.isOverMaximumToken ||
939976
isOverMaximum.isOverMaximumEth ||
940977
!isNonZeroAmount ||
941-
isLoadingEarnGasFee ||
978+
(isTronNative ? isTronStakeValidating : isLoadingEarnGasFee) ||
942979
isSubmittingStakeDepositTransaction
943980
}
944981
width={ButtonWidthTypes.Full}

app/components/UI/Earn/Views/EarnInputView/__snapshots__/EarnInputView.test.tsx.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1947,7 +1947,7 @@ exports[`EarnInputView when values are entered in the keypad updates ETH and fia
19471947
"flexGrow": 1,
19481948
}
19491949
}
1950-
handlerTag={8}
1950+
handlerTag={10}
19511951
handlerType="NativeViewGestureHandler"
19521952
onGestureHandlerEvent={[Function]}
19531953
onGestureHandlerStateChange={[Function]}

app/components/UI/Earn/Views/EarnWithdrawInputView/EarnWithdrawInputView.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -782,7 +782,7 @@ describe('EarnWithdrawInputView', () => {
782782
});
783783

784784
await waitFor(() => {
785-
expect(screen.getByText('Unstake')).toBeTruthy();
785+
expect(screen.getAllByText('Unstake')[0]).toBeTruthy();
786786
});
787787
});
788788
});

0 commit comments

Comments
 (0)