fix: TRC-20 balance always read 0 for never-activated holders

Trc20AssetBalance/RealTronGridApi.fetchTrc20Balance() read through
TronGrid's /v1/accounts/{address} REST endpoint - but a TRC-20 balance
lives entirely in the token contract's own storage, not the holder's
Account object. An address that has only ever received TRC-20 tokens
(never native TRX, never otherwise "activated" on-chain) has no Account
object at all, so /v1/accounts silently returns `data: []` for it
regardless of its real token balance, and the old code treated that the
same as a genuinely empty/zero account.

Found via a live test: a real wallet received 5 USDT-TRC20, the exchange
confirmed the transfer complete on-chain, but the app kept showing 0.
Independently verified live: /v1/accounts returned empty for the address,
while a direct balanceOf(address) contract call (triggerconstantcontract)
correctly returned 5000000.

Rewrote fetchTrc20Balance() to read via balanceOf(address) instead -
reuses the existing triggerConstantContract() call already used for TRC-20
transfer fee estimation, plus a new encodeBalanceOfParameters() ABI helper
alongside the existing transfer() one. Added a regression test pinned to
this exact real address/balance so this can't silently regress again.
This commit is contained in:
2026-07-11 15:21:24 -07:00
parent cf02896a58
commit 0eab8a8ea2
4 changed files with 66 additions and 21 deletions
@@ -10,7 +10,6 @@ import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.b
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.TransferableBalanceUpdatePoint
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.tronNative.pollingBalanceFlow
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.TronGridApi
import io.novafoundation.nova.runtime.ext.addressOf
import io.novafoundation.nova.runtime.ext.requireTronGridBaseUrl
import io.novafoundation.nova.runtime.ext.requireTrc20
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
@@ -21,9 +20,9 @@ import kotlinx.coroutines.flow.map
import java.math.BigInteger
/**
* TRC-20 token balance on a Tron-based chain. Read-only (Phase 1): fetches via TronGrid's REST API (the same
* `/v1/accounts/{address}` endpoint used for native TRX - TronGrid returns both in one response) and polls for
* updates. No transfer/history support here - see `TronAssetsModule`.
* TRC-20 token balance on a Tron-based chain. Read-only (Phase 1): fetches via an on-chain `balanceOf` contract
* call (see [TronGridApi.fetchTrc20Balance] for why this can't reuse the `/v1/accounts` endpoint that native
* TRX balance reads from) and polls for updates. No transfer/history support here - see `TronAssetsModule`.
*/
class Trc20AssetBalance(
private val assetCache: AssetCache,
@@ -52,9 +51,8 @@ class Trc20AssetBalance(
override suspend fun queryAccountBalance(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId): ChainAssetBalance {
val contractAddress = chainAsset.requireTrc20().contractAddress
val address = chain.addressOf(accountId)
val balance = tronGridApi.fetchTrc20Balance(chain.requireTronGridBaseUrl(), address, contractAddress)
val balance = tronGridApi.fetchTrc20Balance(chain.requireTronGridBaseUrl(), accountId, contractAddress)
return ChainAssetBalance.fromFree(chainAsset, balance)
}
@@ -77,9 +75,8 @@ class Trc20AssetBalance(
): Flow<BalanceSyncUpdate> {
val contractAddress = chainAsset.requireTrc20().contractAddress
val baseUrl = chain.requireTronGridBaseUrl()
val address = chain.addressOf(accountId)
return pollingBalanceFlow { tronGridApi.fetchTrc20Balance(baseUrl, address, contractAddress) }
return pollingBalanceFlow { tronGridApi.fetchTrc20Balance(baseUrl, accountId, contractAddress) }
.map { balance ->
assetCache.updateNonLockableAsset(metaAccount.id, chainAsset, balance)
@@ -1,5 +1,7 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.tron
import io.novafoundation.nova.common.utils.toTronHexAddress
import io.novafoundation.nova.common.utils.tronAddressToHexAddress
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAccountResourceResponse
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAddressRequest
@@ -9,7 +11,9 @@ import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronCr
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronTriggerContractRequest
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronTriggerContractResponse
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronUnsignedTransactionResponse
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction.Trc20TransferAbi
import io.novasama.substrate_sdk_android.extensions.fromHex
import io.novasama.substrate_sdk_android.runtime.AccountId
import java.math.BigInteger
/**
@@ -24,7 +28,15 @@ interface TronGridApi {
suspend fun fetchNativeBalance(baseUrl: String, address: String): Balance
suspend fun fetchTrc20Balance(baseUrl: String, address: String, contractAddress: String): Balance
/**
* Reads via an on-chain `balanceOf(address)` call (`triggerconstantcontract`), NOT `/v1/accounts` - a
* TRC-20 balance lives in the token contract's own storage, not in the holder's Account object, so an
* address that has only ever received TRC-20 tokens (never native TRX, never otherwise "activated") has no
* Account object at all and `/v1/accounts` returns empty for it regardless of its real token balance.
* Confirmed live: a wallet holding exactly 5 USDT-TRC20 and zero TRX/activation history returned `data: []`
* from `/v1/accounts` while `balanceOf` correctly returned 5000000.
*/
suspend fun fetchTrc20Balance(baseUrl: String, holderAccountId: AccountId, contractAddress: String): Balance
/**
* Builds an unsigned native TRX transfer via `POST /wallet/createtransaction`.
@@ -89,13 +101,18 @@ class RealTronGridApi(
return accountData.balance?.toBigInteger() ?: BigInteger.ZERO
}
override suspend fun fetchTrc20Balance(baseUrl: String, address: String, contractAddress: String): Balance {
val accountData = fetchAccountData(baseUrl, address) ?: return BigInteger.ZERO
override suspend fun fetchTrc20Balance(baseUrl: String, holderAccountId: AccountId, contractAddress: String): Balance {
val response = triggerConstantContract(
baseUrl = baseUrl,
ownerHexAddress = holderAccountId.toTronHexAddress(),
contractHexAddress = contractAddress.tronAddressToHexAddress(),
functionSelector = Trc20TransferAbi.BALANCE_OF_FUNCTION_SELECTOR,
parameterHex = Trc20TransferAbi.encodeBalanceOfParameters(holderAccountId)
)
val rawBalance = accountData.trc20.orEmpty()
.firstNotNullOfOrNull { entry -> entry[contractAddress] }
val resultHex = response.constantResult?.firstOrNull() ?: return BigInteger.ZERO
return rawBalance?.toBigIntegerOrNull() ?: BigInteger.ZERO
return runCatching { BigInteger(resultHex, 16) }.getOrDefault(BigInteger.ZERO)
}
override suspend fun createNativeTransfer(
@@ -5,12 +5,11 @@ import io.novasama.substrate_sdk_android.runtime.AccountId
import java.math.BigInteger
/**
* Minimal, hand-written Solidity ABI encoding for the single call this client ever makes to a TRC-20 contract:
* `transfer(address,uint256)`.
*
* There is no pre-existing ABI-encoding utility reused here: Phase 1's TRC-20 balance reads
* (`Trc20AssetBalance`) go through TronGrid's `/v1/accounts` REST endpoint, not an on-chain `balanceOf` call -
* so no prior ABI-encoding code exists in this codebase.
* Minimal, hand-written Solidity ABI encoding for the two calls this client makes to a TRC-20 contract:
* `transfer(address,uint256)` (sending) and `balanceOf(address)` (reading a balance - `Trc20AssetBalance` can't
* use TronGrid's `/v1/accounts` REST endpoint for this: a TRC-20 balance lives in the token contract's own
* storage, not the holder's Account object, so an address that has only ever received TRC-20 tokens has no
* Account object at all and `/v1/accounts` silently returns empty for it regardless of its real balance).
*
* Both parameter types involved (`address`, `uint256`) are static (fixed-size), so encoding is just "left-pad
* each to 32 bytes and concatenate" - no dynamic-type/offset table is needed. The 4-byte function selector is
@@ -22,6 +21,7 @@ import java.math.BigInteger
object Trc20TransferAbi {
const val TRANSFER_FUNCTION_SELECTOR = "transfer(address,uint256)"
const val BALANCE_OF_FUNCTION_SELECTOR = "balanceOf(address)"
/**
* @param recipient raw 20-byte Ethereum/Tron-style account id (NOT the `41`-prefixed Tron hex address -
@@ -36,4 +36,14 @@ object Trc20TransferAbi {
return addressParam + amountParam
}
/**
* @param holder raw 20-byte Ethereum/Tron-style account id - same encoding as [encodeTransferParameters]'s
* `recipient`.
*/
fun encodeBalanceOfParameters(holder: AccountId): String {
require(holder.size == 20) { "Tron/EVM-style account id must be 20 bytes, got ${holder.size}" }
return holder.toHexString(withPrefix = false).padStart(64, '0')
}
}