mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-android.git
synced 2026-07-22 09:05:48 +00:00
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:
+22
-1
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+5
-8
@@ -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)
|
||||
|
||||
|
||||
+23
-6
@@ -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(
|
||||
|
||||
+16
-6
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user