Files
pezkuwi-wallet-android/app/src/androidTest/java/io/novafoundation/nova/balances/TronBalancesIntegrationTest.kt
T
pezkuwichain 0eab8a8ea2 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.
2026-07-11 15:21:24 -07:00

98 lines
4.7 KiB
Kotlin

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
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import org.junit.Assert.assertTrue
import org.junit.Test
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.converter.scalars.ScalarsConverterFactory
import java.math.BigInteger
/**
* Tron is a REST API (TronGrid), not a Substrate runtime - it has no ChainConnection/RuntimeProvider and isn't
* reachable through [BalancesIntegrationTest]'s chainRegistry-based mechanism at all. This exercises the exact
* same [io.novafoundation.nova.feature_wallet_impl.data.network.tron.TronGridApi] the production app uses for
* balance reads (see TronNativeAssetBalance/Trc20AssetBalance), just via a standalone Retrofit client instead of
* the full DI graph, since TronGridApi isn't exposed through a public feature API for tests to reach.
*
* Test account is the mainnet Founder's Tron address, verified live via TronGrid's public API on 2026-07-09 to
* hold a substantial non-zero balance of both native TRX and TRC-20 USDT - not a guessed or empty account.
*/
class TronBalancesIntegrationTest {
private val testAddress = "TDGZ4GfvCRe1d8oksj8fBD77ZHw4bkCPBA"
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 {
val retrofit = Retrofit.Builder()
.client(OkHttpClient.Builder().build())
.baseUrl(baseUrl)
.addConverterFactory(ScalarsConverterFactory.create())
.addConverterFactory(GsonConverterFactory.create())
.build()
RealTronGridApi(retrofit.create(RetrofitTronGridApi::class.java))
}
// TronGrid's public (no API key) endpoint rate-limits aggressively, and this test now runs on every PR
// (see balances_test.yml) in addition to its own 2 calls back-to-back - a bare 429 previously failed the
// whole run for a transient, infrastructure-level reason unrelated to whether the wallet's code is correct.
// Retry with backoff instead of just tolerating the flake.
private suspend fun <T> retryOn429(maxAttempts: Int = 4, block: suspend () -> T): T {
repeat(maxAttempts - 1) { attempt ->
try {
return block()
} catch (e: HttpException) {
if (e.code() != 429) throw e
delay(2_000L * (attempt + 1))
}
}
return block()
}
@Test
fun testNativeTrxBalanceLoading() = runBlocking {
val freeBalance = retryOn429 { tronGridApi.fetchNativeBalance(baseUrl, testAddress) }
assertTrue("TRX balance: $freeBalance is less than $maxAmount", maxAmount > freeBalance)
assertTrue("TRX balance: $freeBalance is greater than 0", BigInteger.ZERO < freeBalance)
}
@Test
fun testTrc20UsdtBalanceLoading() = runBlocking {
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
)
}
}