From f0c4085ccd8e6d0df6f3e9f9e6ce1d5a5c942d42 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:02:39 +0100 Subject: [PATCH 1/4] fix: tron trc20 balances for non-activated accounts + send warning - TronApi: fallback for non-activated accounts - uses hardcoded top tokens (USDT, USDC) + received TRC20 tx history discovery, then balanceOf() via triggerconstantcontract to fetch balances that TronGrid /v1/accounts skips - portfolioSlice utils: checkAccountHasActivity now checks token balances so non-activated accounts with TRC20 tokens aren't skipped - useIsTronAddressActivated: new hook to check if a Tron recipient address has been activated on-chain (checks for address property in response) - SendAmountDetails: warning shown when sending TRC20 to non-activated recipient (suppressed for native TRX sends which activate the account) Closes #12190 Co-Authored-By: Claude Sonnet 4.6 --- packages/unchained-client/src/tron/api.ts | 45 +++++++++++++++++++ src/assets/translations/en/main.json | 5 +++ .../Modals/Send/views/SendAmountDetails.tsx | 12 +++++ .../useIsTronAddressActivated.ts | 44 ++++++++++++++++++ .../slices/portfolioSlice/utils/index.ts | 6 ++- 5 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 src/hooks/useIsTronAddressActivated/useIsTronAddressActivated.ts diff --git a/packages/unchained-client/src/tron/api.ts b/packages/unchained-client/src/tron/api.ts index 79e0f59d5ae..477d94ae796 100644 --- a/packages/unchained-client/src/tron/api.ts +++ b/packages/unchained-client/src/tron/api.ts @@ -81,6 +81,51 @@ 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 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) + }) + } + } + + const balanceResults = await Promise.all( + Array.from(discoveredContracts).map(contractAddress => + this.getTRC20Balance({ address: params.pubkey, contractAddress }).then(balance => ({ + contractAddress, + balance, + })), + ), + ) + + balanceResults.forEach(({ contractAddress, balance }) => { + if (balance !== '0') { + tokens.push({ contractAddress, balance }) + } + }) + } catch (_fallbackErr) { + // Fallback also failed - continue with what we have + } } } 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: { From d849fc020587920d7889dad6f8810fce32b6ac74 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:45:07 +0100 Subject: [PATCH 2/4] chore: add qabot fixture for tron non-activated balances Co-Authored-By: Claude Sonnet 4.6 --- e2e/fixtures/tron-non-activated-balances.yaml | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 e2e/fixtures/tron-non-activated-balances.yaml diff --git a/e2e/fixtures/tron-non-activated-balances.yaml b/e2e/fixtures/tron-non-activated-balances.yaml new file mode 100644 index 00000000000..fce85eb930c --- /dev/null +++ b/e2e/fixtures/tron-non-activated-balances.yaml @@ -0,0 +1,39 @@ +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/eip155:195/slip44:195 or search for USDT on Tron in the asset list + 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. The Continue/Preview button is still enabled. + screenshot: true From e16846fa72d8efc8e6bd31bc5cf325fa0b08bef5 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:59:37 +0100 Subject: [PATCH 3/4] fix: address coderabbit review comments - throttle balanceOf calls sequentially instead of Promise.all fanout - fix fixture: use correct tron:0x2b6653dc/trc20:... USDT asset route - fix fixture: separate warning assertion from preview-enabled assertion Co-Authored-By: Claude Sonnet 4.6 --- e2e/fixtures/tron-non-activated-balances.yaml | 9 +++++++-- packages/unchained-client/src/tron/api.ts | 19 +++++-------------- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/e2e/fixtures/tron-non-activated-balances.yaml b/e2e/fixtures/tron-non-activated-balances.yaml index fce85eb930c..63aeda9ae76 100644 --- a/e2e/fixtures/tron-non-activated-balances.yaml +++ b/e2e/fixtures/tron-non-activated-balances.yaml @@ -7,7 +7,7 @@ depends_on: - wallet-health.yaml steps: - name: Navigate to Tron USDT asset page - instruction: Navigate to the Tron USDT asset page at /assets/eip155:195/slip44:195 or search for USDT on Tron in the asset list + instruction: Navigate to the Tron USDT asset page at /assets/tron:0x2b6653dc/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t expected: The USDT (Tron) asset page is visible screenshot: true @@ -35,5 +35,10 @@ steps: 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. The Continue/Preview button is still enabled. + 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 477d94ae796..4f493066274 100644 --- a/packages/unchained-client/src/tron/api.ts +++ b/packages/unchained-client/src/tron/api.ts @@ -109,20 +109,11 @@ export class TronApi { } } - const balanceResults = await Promise.all( - Array.from(discoveredContracts).map(contractAddress => - this.getTRC20Balance({ address: params.pubkey, contractAddress }).then(balance => ({ - contractAddress, - balance, - })), - ), - ) - - balanceResults.forEach(({ contractAddress, balance }) => { - if (balance !== '0') { - tokens.push({ contractAddress, balance }) - } - }) + for (const contractAddress of discoveredContracts) { + await this.throttle() + const balance = await this.getTRC20Balance({ address: params.pubkey, contractAddress }) + if (balance !== '0') tokens.push({ contractAddress, balance }) + } } catch (_fallbackErr) { // Fallback also failed - continue with what we have } From 7b97bf91664ea3bfb2827d03a9cb56928b14989e Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:11:56 +0100 Subject: [PATCH 4/4] fix: cap fallback contract probes + log fallback errors - add MAX_FALLBACK_CONTRACTS = 20 ceiling to prevent stalling shared requestQueue - log fallback discovery errors instead of silently swallowing them Co-Authored-By: Claude Sonnet 4.6 --- packages/unchained-client/src/tron/api.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/unchained-client/src/tron/api.ts b/packages/unchained-client/src/tron/api.ts index 4f493066274..9c7c150d6f3 100644 --- a/packages/unchained-client/src/tron/api.ts +++ b/packages/unchained-client/src/tron/api.ts @@ -92,6 +92,7 @@ export class TronApi { 'TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8', // USDC ] + const MAX_FALLBACK_CONTRACTS = 20 const discoveredContracts = new Set(HARDCODED_TOP_TOKENS) const trc20TxResponse = await fetch( @@ -109,13 +110,19 @@ export class TronApi { } } - for (const contractAddress of discoveredContracts) { + 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) { - // Fallback also failed - continue with what we have + } 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) {