From 0eab8a8ea2150f9e01a599e5c008e8f986031c3f Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Sat, 11 Jul 2026 15:21:24 -0700 Subject: [PATCH] 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. --- .../balances/TronBalancesIntegrationTest.kt | 23 ++++++++++++++- .../balances/trc20/Trc20AssetBalance.kt | 13 ++++----- .../data/network/tron/TronGridApi.kt | 29 +++++++++++++++---- .../tron/transaction/Trc20TransferAbi.kt | 22 ++++++++++---- 4 files changed, 66 insertions(+), 21 deletions(-) diff --git a/app/src/androidTest/java/io/novafoundation/nova/balances/TronBalancesIntegrationTest.kt b/app/src/androidTest/java/io/novafoundation/nova/balances/TronBalancesIntegrationTest.kt index a9baeac1..395a3065 100644 --- a/app/src/androidTest/java/io/novafoundation/nova/balances/TronBalancesIntegrationTest.kt +++ b/app/src/androidTest/java/io/novafoundation/nova/balances/TronBalancesIntegrationTest.kt @@ -1,5 +1,6 @@ package io.novafoundation.nova.balances +import io.novafoundation.nova.common.utils.tronAddressToAccountId import io.novafoundation.nova.feature_wallet_impl.data.network.tron.RealTronGridApi import io.novafoundation.nova.feature_wallet_impl.data.network.tron.RetrofitTronGridApi import kotlinx.coroutines.delay @@ -29,6 +30,14 @@ class TronBalancesIntegrationTest { private val usdtContractAddress = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" private val baseUrl = "https://api.trongrid.io" + // A real wallet that received exactly 5 USDT-TRC20 (2026-07-11) and has NEVER had any native TRX/other + // on-chain activity - confirmed live to have no Account object at all (`/v1/accounts` returns `data: []`) + // despite genuinely holding the token (`balanceOf` correctly returns 5000000). Regression coverage for the + // exact bug this uncovered: fetchTrc20Balance used to read through `/v1/accounts` and silently returned 0 + // for any address in this state, well after it was live and had already deceived a real user mid-transfer. + private val unactivatedHolderAddress = "TUdvwdGeqcag51XkhgRK21KmhH2qw37LZG" + private val unactivatedHolderExpectedUsdtBalance = BigInteger.valueOf(5_000_000L) + private val maxAmount = BigInteger.valueOf(10).pow(30) private val tronGridApi = run { @@ -68,9 +77,21 @@ class TronBalancesIntegrationTest { @Test fun testTrc20UsdtBalanceLoading() = runBlocking { - val freeBalance = retryOn429 { tronGridApi.fetchTrc20Balance(baseUrl, testAddress, usdtContractAddress) } + val freeBalance = retryOn429 { tronGridApi.fetchTrc20Balance(baseUrl, testAddress.tronAddressToAccountId(), usdtContractAddress) } assertTrue("USDT-TRC20 balance: $freeBalance is less than $maxAmount", maxAmount > freeBalance) assertTrue("USDT-TRC20 balance: $freeBalance is greater than 0", BigInteger.ZERO < freeBalance) } + + @Test + fun testTrc20BalanceLoadingForNeverActivatedHolder() = runBlocking { + val freeBalance = retryOn429 { + tronGridApi.fetchTrc20Balance(baseUrl, unactivatedHolderAddress.tronAddressToAccountId(), usdtContractAddress) + } + + assertTrue( + "USDT-TRC20 balance for a never-activated holder: expected $unactivatedHolderExpectedUsdtBalance, got $freeBalance", + freeBalance == unactivatedHolderExpectedUsdtBalance + ) + } } diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/trc20/Trc20AssetBalance.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/trc20/Trc20AssetBalance.kt index d3120b3b..2826a3d1 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/trc20/Trc20AssetBalance.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/trc20/Trc20AssetBalance.kt @@ -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 { 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) diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/TronGridApi.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/TronGridApi.kt index 8fa93b1b..afb2b032 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/TronGridApi.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/TronGridApi.kt @@ -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( diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/Trc20TransferAbi.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/Trc20TransferAbi.kt index 90da3f41..2f411740 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/Trc20TransferAbi.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/Trc20TransferAbi.kt @@ -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') + } }