diff --git a/e2e/fixtures/tron-non-activated-balances.yaml b/e2e/fixtures/tron-non-activated-balances.yaml new file mode 100644 index 00000000000..63aeda9ae76 --- /dev/null +++ b/e2e/fixtures/tron-non-activated-balances.yaml @@ -0,0 +1,44 @@ +name: Tron Non-Activated Account Balances +description: > + Verifies fix for #12190 - USDT/TRC20 tokens show in portfolio for non-activated Tron accounts, + and send flow shows a warning when sending TRC20 to a non-activated recipient address. +route: / +depends_on: + - wallet-health.yaml +steps: + - name: Navigate to Tron USDT asset page + instruction: Navigate to the Tron USDT asset page at /assets/tron:0x2b6653dc/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t + expected: The USDT (Tron) asset page is visible + screenshot: true + + - name: Verify USDT balance visible for Tron account + instruction: Check the account balances shown for USDT on Tron. Look for any TRX/Tron wallet accounts listed. + expected: At least one account shows a USDT balance (even if zero TRX balance) + screenshot: true + + - name: Open send modal for Tron USDT + instruction: Click the Send button on the USDT Tron asset page to open the send flow + expected: The send modal opens showing the address input screen + screenshot: true + + - name: Enter non-activated Tron recipient address + instruction: > + In the address input field, type the known non-activated Tron address: TNJ1pzzz8Tzip6Nid6DxMVso3zM4bLFDxu + Wait for address validation to complete. + expected: The address is accepted and validated (no red error), navigation to amount screen happens + screenshot: true + + - name: Verify non-activated recipient warning shows + instruction: > + On the send amount screen, look for a yellow/orange warning alert about the recipient address + not being activated on Tron. The warning should mention that tokens will arrive but the + recipient needs TRX to move them. + expected: > + A warning alert is visible saying the recipient address hasn't been activated and they'll + need TRX to use the tokens. + screenshot: true + + - name: Verify send is not blocked + instruction: Enter a valid non-zero USDT amount, for example 1. + expected: The Preview button is enabled - the warning is informational only and does not block the send flow. + screenshot: true diff --git a/packages/unchained-client/src/tron/api.ts b/packages/unchained-client/src/tron/api.ts index 79e0f59d5ae..9c7c150d6f3 100644 --- a/packages/unchained-client/src/tron/api.ts +++ b/packages/unchained-client/src/tron/api.ts @@ -81,6 +81,49 @@ export class TronApi { }) } }) + } else if (!trc20Data.data || trc20Data.data.length === 0) { + // Non-activated account: /v1/accounts returns data:[] for addresses that have never sent TRX. + // Fall back to discovering TRC20 balances via received transactions + hardcoded top tokens. + try { + await this.throttle() + + const HARDCODED_TOP_TOKENS = [ + 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', // USDT + 'TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8', // USDC + ] + + const MAX_FALLBACK_CONTRACTS = 20 + const discoveredContracts = new Set(HARDCODED_TOP_TOKENS) + + const trc20TxResponse = await fetch( + `${this.rpcUrl}/v1/accounts/${params.pubkey}/transactions/trc20?limit=200&only_to=true`, + ) + + if (trc20TxResponse.ok) { + const trc20TxData = await trc20TxResponse.json() + + if (trc20TxData.data && Array.isArray(trc20TxData.data)) { + trc20TxData.data.forEach((tx: { token_info?: { address?: string } }) => { + const contractAddress = tx.token_info?.address + if (contractAddress) discoveredContracts.add(contractAddress) + }) + } + } + + for (const contractAddress of Array.from(discoveredContracts).slice( + 0, + MAX_FALLBACK_CONTRACTS, + )) { + await this.throttle() + const balance = await this.getTRC20Balance({ address: params.pubkey, contractAddress }) + if (balance !== '0') tokens.push({ contractAddress, balance }) + } + } catch (fallbackErr) { + console.error('Failed TRC20 fallback discovery for non-activated TRON account', { + address: `${params.pubkey.slice(0, 6)}...${params.pubkey.slice(-4)}`, + error: fallbackErr, + }) + } } } catch (err) { // TRC20 fetch failed, continue with just TRC10 tokens diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 4aab16c207f..45bd0ec9ad3 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -2182,6 +2182,11 @@ "deployFailed": "Deployment Failed" } }, + "tron": { + "recipientNotActivated": { + "sendWarning": "This address hasn't been activated on Tron yet. Your tokens will arrive, but the recipient won't be able to move them until they also receive some TRX." + } + }, "transactionHistory": { "transactionHistory": "Transaction History", "recentTransactions": "Recent Transactions", diff --git a/src/components/Modals/Send/views/SendAmountDetails.tsx b/src/components/Modals/Send/views/SendAmountDetails.tsx index 4df234897a4..d16d22f384f 100644 --- a/src/components/Modals/Send/views/SendAmountDetails.tsx +++ b/src/components/Modals/Send/views/SendAmountDetails.tsx @@ -20,6 +20,7 @@ import { fromAccountId, fromAssetId, starknetChainId, + tronAssetId, } from '@shapeshiftoss/caip' import { useMutation } from '@tanstack/react-query' import get from 'lodash/get' @@ -47,6 +48,7 @@ import { SlideTransition } from '@/components/SlideTransition' import { Text } from '@/components/Text/Text' import { getChainAdapterManager } from '@/context/PluginProvider/chainAdapterSingleton' import { useIsStarknetAccountDeployed } from '@/hooks/useIsStarknetAccountDeployed/useIsStarknetAccountDeployed' +import { useIsTronAddressActivated } from '@/hooks/useIsTronAddressActivated/useIsTronAddressActivated' import { useNotificationToast } from '@/hooks/useNotificationToast' import { useWallet } from '@/hooks/useWallet/useWallet' import { parseAddressInputWithChainId } from '@/lib/address/address' @@ -114,6 +116,8 @@ export const SendAmountDetails = () => { refetch: refetchDeploymentStatus, } = useIsStarknetAccountDeployed(accountId) + const { data: isTronRecipientActivated } = useIsTronAddressActivated(to, asset?.chainId) + const deployAccountMutation = useMutation({ mutationFn: async () => { if (!accountId || !wallet) throw new Error('Missing account or wallet') @@ -404,6 +408,14 @@ export const SendAmountDetails = () => { )} + {isTronRecipientActivated === false && asset?.assetId !== tronAssetId && ( + + + + + + + )} {!hasNoAccountForAsset && ( diff --git a/src/hooks/useIsTronAddressActivated/useIsTronAddressActivated.ts b/src/hooks/useIsTronAddressActivated/useIsTronAddressActivated.ts new file mode 100644 index 00000000000..56a491c1e98 --- /dev/null +++ b/src/hooks/useIsTronAddressActivated/useIsTronAddressActivated.ts @@ -0,0 +1,44 @@ +import type { ChainId } from '@shapeshiftoss/caip' +import { tronChainId } from '@shapeshiftoss/caip' +import { useQuery } from '@tanstack/react-query' + +import { assertGetTronChainAdapter } from '@/lib/utils/tron' + +const checkTronAddressActivated = async ( + to: string | undefined, + chainId: ChainId | undefined, +): Promise => { + if (!to || !chainId || chainId !== tronChainId) return undefined + if (!to.startsWith('T')) return undefined + + try { + const adapter = assertGetTronChainAdapter(chainId) + const rpcUrl = adapter.httpProvider.getRpcUrl() + + const response = await fetch(`${rpcUrl}/wallet/getaccount`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ address: to, visible: true }), + }) + + if (!response.ok) return undefined + + const data = await response.json() + + // Activated accounts have an 'address' field; non-activated accounts return {} + return !!data?.address + } catch (error) { + console.error('Failed to check Tron address activation status:', error) + return undefined + } +} + +export const useIsTronAddressActivated = (to: string | undefined, chainId: ChainId | undefined) => { + return useQuery({ + queryKey: ['isTronAddressActivated', to, chainId], + queryFn: () => checkTronAddressActivated(to, chainId), + enabled: Boolean(to) && to?.startsWith('T') && chainId === tronChainId, + staleTime: 30_000, + refetchInterval: false, + }) +} diff --git a/src/state/slices/portfolioSlice/utils/index.ts b/src/state/slices/portfolioSlice/utils/index.ts index 53e448a9754..e909b7cbc30 100644 --- a/src/state/slices/portfolioSlice/utils/index.ts +++ b/src/state/slices/portfolioSlice/utils/index.ts @@ -487,8 +487,10 @@ export const checkAccountHasActivity = (account: Account) => { return hasActivity } case CHAIN_NAMESPACE.Tron: { - const hasActivity = bnOrZero(account.balance).gt(0) - + const tronAccount = account as Account + const hasActivity = + bnOrZero(tronAccount.balance).gt(0) || + (tronAccount.chainSpecific.tokens ?? []).some(token => bnOrZero(token.balance).gt(0)) return hasActivity } case CHAIN_NAMESPACE.Sui: {