mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-android.git
synced 2026-07-22 13:45: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
|
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.RealTronGridApi
|
||||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.RetrofitTronGridApi
|
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.RetrofitTronGridApi
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -29,6 +30,14 @@ class TronBalancesIntegrationTest {
|
|||||||
private val usdtContractAddress = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
|
private val usdtContractAddress = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
|
||||||
private val baseUrl = "https://api.trongrid.io"
|
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 maxAmount = BigInteger.valueOf(10).pow(30)
|
||||||
|
|
||||||
private val tronGridApi = run {
|
private val tronGridApi = run {
|
||||||
@@ -68,9 +77,21 @@ class TronBalancesIntegrationTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testTrc20UsdtBalanceLoading() = runBlocking {
|
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 less than $maxAmount", maxAmount > freeBalance)
|
||||||
assertTrue("USDT-TRC20 balance: $freeBalance is greater than 0", BigInteger.ZERO < 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_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.blockchain.assets.balances.tronNative.pollingBalanceFlow
|
||||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.TronGridApi
|
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.requireTronGridBaseUrl
|
||||||
import io.novafoundation.nova.runtime.ext.requireTrc20
|
import io.novafoundation.nova.runtime.ext.requireTrc20
|
||||||
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
|
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
|
||||||
@@ -21,9 +20,9 @@ import kotlinx.coroutines.flow.map
|
|||||||
import java.math.BigInteger
|
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
|
* TRC-20 token balance on a Tron-based chain. Read-only (Phase 1): fetches via an on-chain `balanceOf` contract
|
||||||
* `/v1/accounts/{address}` endpoint used for native TRX - TronGrid returns both in one response) and polls for
|
* call (see [TronGridApi.fetchTrc20Balance] for why this can't reuse the `/v1/accounts` endpoint that native
|
||||||
* updates. No transfer/history support here - see `TronAssetsModule`.
|
* TRX balance reads from) and polls for updates. No transfer/history support here - see `TronAssetsModule`.
|
||||||
*/
|
*/
|
||||||
class Trc20AssetBalance(
|
class Trc20AssetBalance(
|
||||||
private val assetCache: AssetCache,
|
private val assetCache: AssetCache,
|
||||||
@@ -52,9 +51,8 @@ class Trc20AssetBalance(
|
|||||||
|
|
||||||
override suspend fun queryAccountBalance(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId): ChainAssetBalance {
|
override suspend fun queryAccountBalance(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId): ChainAssetBalance {
|
||||||
val contractAddress = chainAsset.requireTrc20().contractAddress
|
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)
|
return ChainAssetBalance.fromFree(chainAsset, balance)
|
||||||
}
|
}
|
||||||
@@ -77,9 +75,8 @@ class Trc20AssetBalance(
|
|||||||
): Flow<BalanceSyncUpdate> {
|
): Flow<BalanceSyncUpdate> {
|
||||||
val contractAddress = chainAsset.requireTrc20().contractAddress
|
val contractAddress = chainAsset.requireTrc20().contractAddress
|
||||||
val baseUrl = chain.requireTronGridBaseUrl()
|
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 ->
|
.map { balance ->
|
||||||
assetCache.updateNonLockableAsset(metaAccount.id, chainAsset, balance)
|
assetCache.updateNonLockableAsset(metaAccount.id, chainAsset, balance)
|
||||||
|
|
||||||
|
|||||||
+23
-6
@@ -1,5 +1,7 @@
|
|||||||
package io.novafoundation.nova.feature_wallet_impl.data.network.tron
|
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_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.TronAccountResourceResponse
|
||||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAddressRequest
|
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.TronTriggerContractRequest
|
||||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronTriggerContractResponse
|
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.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.extensions.fromHex
|
||||||
|
import io.novasama.substrate_sdk_android.runtime.AccountId
|
||||||
import java.math.BigInteger
|
import java.math.BigInteger
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,7 +28,15 @@ interface TronGridApi {
|
|||||||
|
|
||||||
suspend fun fetchNativeBalance(baseUrl: String, address: String): Balance
|
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`.
|
* Builds an unsigned native TRX transfer via `POST /wallet/createtransaction`.
|
||||||
@@ -89,13 +101,18 @@ class RealTronGridApi(
|
|||||||
return accountData.balance?.toBigInteger() ?: BigInteger.ZERO
|
return accountData.balance?.toBigInteger() ?: BigInteger.ZERO
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun fetchTrc20Balance(baseUrl: String, address: String, contractAddress: String): Balance {
|
override suspend fun fetchTrc20Balance(baseUrl: String, holderAccountId: AccountId, contractAddress: String): Balance {
|
||||||
val accountData = fetchAccountData(baseUrl, address) ?: return BigInteger.ZERO
|
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()
|
val resultHex = response.constantResult?.firstOrNull() ?: return BigInteger.ZERO
|
||||||
.firstNotNullOfOrNull { entry -> entry[contractAddress] }
|
|
||||||
|
|
||||||
return rawBalance?.toBigIntegerOrNull() ?: BigInteger.ZERO
|
return runCatching { BigInteger(resultHex, 16) }.getOrDefault(BigInteger.ZERO)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun createNativeTransfer(
|
override suspend fun createNativeTransfer(
|
||||||
|
|||||||
+16
-6
@@ -5,12 +5,11 @@ import io.novasama.substrate_sdk_android.runtime.AccountId
|
|||||||
import java.math.BigInteger
|
import java.math.BigInteger
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal, hand-written Solidity ABI encoding for the single call this client ever makes to a TRC-20 contract:
|
* Minimal, hand-written Solidity ABI encoding for the two calls this client makes to a TRC-20 contract:
|
||||||
* `transfer(address,uint256)`.
|
* `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
|
||||||
* There is no pre-existing ABI-encoding utility reused here: Phase 1's TRC-20 balance reads
|
* storage, not the holder's Account object, so an address that has only ever received TRC-20 tokens has no
|
||||||
* (`Trc20AssetBalance`) go through TronGrid's `/v1/accounts` REST endpoint, not an on-chain `balanceOf` call -
|
* Account object at all and `/v1/accounts` silently returns empty for it regardless of its real balance).
|
||||||
* so no prior ABI-encoding code exists in this codebase.
|
|
||||||
*
|
*
|
||||||
* Both parameter types involved (`address`, `uint256`) are static (fixed-size), so encoding is just "left-pad
|
* 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
|
* 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 {
|
object Trc20TransferAbi {
|
||||||
|
|
||||||
const val TRANSFER_FUNCTION_SELECTOR = "transfer(address,uint256)"
|
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 -
|
* @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
|
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