Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions e2e/fixtures/tron-non-activated-balances.yaml
Original file line number Diff line number Diff line change
@@ -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
43 changes: 43 additions & 0 deletions packages/unchained-client/src/tron/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(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
Expand Down
5 changes: 5 additions & 0 deletions src/assets/translations/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions src/components/Modals/Send/views/SendAmountDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
fromAccountId,
fromAssetId,
starknetChainId,
tronAssetId,
} from '@shapeshiftoss/caip'
import { useMutation } from '@tanstack/react-query'
import get from 'lodash/get'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -404,6 +408,14 @@ export const SendAmountDetails = () => {
</AlertDescription>
</Alert>
)}
{isTronRecipientActivated === false && asset?.assetId !== tronAssetId && (
<Alert status='warning' borderRadius='lg' mb={3}>
<AlertIcon />
<AlertDescription>
<Text translation='tron.recipientNotActivated.sendWarning' />
</AlertDescription>
</Alert>
)}
{!hasNoAccountForAsset && (
<Flex alignItems='center' justifyContent='space-between' mb={4}>
<Flex alignItems='center'>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<boolean | undefined> => {
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,
})
}
6 changes: 4 additions & 2 deletions src/state/slices/portfolioSlice/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,8 +487,10 @@ export const checkAccountHasActivity = (account: Account<ChainId>) => {
return hasActivity
}
case CHAIN_NAMESPACE.Tron: {
const hasActivity = bnOrZero(account.balance).gt(0)

const tronAccount = account as Account<KnownChainIds.TronMainnet>
const hasActivity =
bnOrZero(tronAccount.balance).gt(0) ||
(tronAccount.chainSpecific.tokens ?? []).some(token => bnOrZero(token.balance).gt(0))
return hasActivity
}
case CHAIN_NAMESPACE.Sui: {
Expand Down
Loading