Complements the retryOn429 fix - having a key means requests are far less likely to hit
the rate limit at all, rather than just retrying transparently after they do. Wired the
same way as INFURA_API_KEY/DWELLIR_API_KEY: buildConfigField read from local.properties or
CI secret, added TRONGRID_API_KEY to android_build.yml's secret passthrough (every caller
already uses secrets: inherit, so no per-workflow changes needed beyond this).
TronGrid's public (no API key) endpoint rate-limits aggressively under normal, human-paced
usage - confirmed live during send-flow testing, where every Confirm tap (each re-running
fee estimation + broadcast) started hitting bare 429s after only a few attempts, with no
way through except retrying by hand until one happened to land outside the rate-limit window.
Added retryOn429 (exponential backoff, same shape as the existing test-only helper in
TronBalancesIntegrationTest) at the RealTronGridApi level so every call - fee estimation,
broadcast, balance reads - retries transparently. Broadcast is safe to retry on 429
specifically since it means TronGrid rejected the request before processing it, not that
the transaction may already be in flight.
TronGrid's createtransaction rejects amount=0 with a ContractValidateException.
estimateNativeFee called it unguarded (unlike the TRC-20 path, which already
wraps its dry run in runCatching), so the send screen's reactive fee loader
surfaced this as a generic "Network not responding" error whenever it ran
before the user typed an amount - found live testing the send flow end-to-end.
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.
Switching eq/any/argThat to the raw org.mockito.Mockito statics did not
avoid the 'eq(...) must not be null' crash (it moved from the @Test bodies
to the shared @Before setup(), since JUnit's @Before runs before every
test and failed first there) - Mockito.eq()/any() genuinely return null
regardless of which Kotlin entry point calls them, and that null still
gets checked once it flows into a Kotlin non-null-typed parameter
downstream. Local wrappers now guarantee a non-null return instead:
eq() falls back to the real passed-in value (harmless - the matcher is
already recorded on Mockito's thread-local stack by then), any()/argThat()
return an unchecked-cast dummy, mirroring mockito-kotlin's own internal
implementation of the same helpers.
Root cause of 'eq(...) must not be null' NPE: test_shared's eq()/any()/
argThat() are thin Kotlin wrappers with a declared non-null generic return
type T. Mockito.eq()/any() genuinely return null at runtime (that's how
their matcher-stack recording works) - fine when called directly from
Kotlin (a raw Java static call's return is a platform type, no null-check
inserted), but going through a Kotlin-declared wrapper whose T infers as
non-null at the call site (e.g. eq(baseUrl: String) here) gets a
compiler-inserted null-check on the wrapper's return, which then fires.
Scoped this fix to this test file only rather than touching test_shared's
shared implementation, which every other module's tests also depend on.
JUnit4's BlockJUnit4ClassRunner requires @Test methods to compile with a
void return type. 'fun test() = runBlocking { ... }' infers the function's
return type from the block's last expression - two of these tests ended
on a Mockito verify(...).someMethod(...) call, whose return type leaks
through as the inferred type (e.g. String, since TronGridApi.broadcastTransaction
returns String) instead of Unit, so the whole test class failed
ParentRunner validation before any test could even run. Declaring the
return type explicitly as Unit makes Kotlin discard the expression's
value (unit-coercion) rather than infer it - added to all three tests for
consistency, not just the two that were actually failing.
Default Gradle test logging only prints the exception class name + one
stack frame for a failing test, not its message or cause chain - that's
exactly what happened debugging RealTronTransactionServiceTest's
InvalidTestClassError just now (console showed nothing beyond the bare
exception type). Not useful for anyone hitting a real test failure in CI
without a local Gradle run to inspect the HTML report.
RealTronTransactionServiceTest needed test-shared's Mockito helpers
(whenever/eq/any/argThat), but feature-wallet-impl never declared
testImplementation project(':test-shared') - every other module that uses
these helpers (feature-account-impl, runtime, common, etc.) already does.
Also pin two ambiguous-without-it type inferences (argThat's lambda param,
emptyMap's type args) explicitly rather than relying on the dependency fix
alone to un-cascade them.
RealTronTransactionService's native-TRX branch (transact/calculateFee for
TronTransactionIntent.Native) had zero automated coverage - only the TRC20
ABI encoding (Trc20TransferAbiTest) and address derivation/formatting
(TronDerivationTest, TronAddressTest) were tested. Send/transfer code
moving real funds shouldn't ship untested just because it happens to share
its sign/broadcast plumbing with an already-tested asset type.
Covers: the raw_data is hashed and signed exactly as documented (sha256,
not the raw bytes or txID), the r+s+v signature is assembled correctly
before being sent to broadcastTransaction, and - the one true security
invariant here - a TronGrid response whose txID doesn't match
sha256(raw_data) is refused before ever reaching the signer, not just
before broadcast. Also covers the native fee estimator's bandwidth-shortfall
math against the documented fallback price.
Fixture note: the raw_data_hex/txID pair here is self-consistent (computed
locally) rather than live-captured against TronGrid, unlike
Trc20TransferAbiTest's ABI vector - this class never encodes a real
TransferContract protobuf itself (see its own class doc), so what matters
is that this service correctly hashes/signs/forwards whatever raw_data
TronGrid returns, which a self-consistent fixture exercises identically.
Diffed the whole branch against main (the live, working Play Store
source) instead of continuing to guess from logs. Found the actual
regression: syncAsset() was changed from a suspend fun that EAGERLY
calls startSyncingBalance() (registering each asset's storage key
synchronously, during listenForUpdates()) into a plain fun returning a
lazy flow { } that only calls startSyncingBalance() once collected -
to support a retryWhen wrapper added earlier this session.
BalancesUpdateSystem.launchChainUpdaters() calls
subscriptionBuilder.subscribe(coroutineContext) - which seals the
shared per-chain subscription multiplexer - immediately after
listenForUpdates() returns, then only starts collecting the merged
result flow (which is what triggers the lazy startSyncingBalance()
calls) afterward. So every asset's key registration now races against
the seal instead of reliably happening before it, matching main's
original guaranteed ordering. Some assets win the race often enough to
look like they work; this is why the full ecosystem test - which
creates one account and races ALL of it at once - saw every asset fail,
while the older single-asset test mostly saw only HEZ fail.
Reverted to main's version: suspend syncAsset(), eager
startSyncingBalance() call, no retryWhen. Loses automatic retry of
transient full-sync failures (which the retryWhen wrapper was meant to
add), but that capability isn't worth reintroducing an ordering bug
that can silently break sync for an unpredictable subset of assets on
every chain, not just Pezkuwi's.
CI proved the typed remoteStorage.subscribe DSL swap (previous commit)
was not a fix - it was a regression. All 8 assets across all 3 Pezkuwi
chains failed to sync in the follow-up test run (vs just HEZ-on-Asset-Hub
before), and the logs showed what looks like storage keys meant for
Pezkuwi Asset Hub's Assets pallet being sent to the Pezkuwi relay chain's
connection instead - some kind of cross-chain contamination introduced
by the DSL's interaction with the shared connection, not understood yet.
Reverting to the raw subscriptionBuilder.subscribe(key) form restores
the prior, narrower state: 5 of 6 assets on Pezkuwi Asset Hub sync fine,
HEZ specifically does not, and every other chain is unaffected. The
underlying HEZ bug is still open - this just stops the attempted fix
from actively making things worse while it's investigated further.
Root cause found via 4 rounds of CI diagnostics: HEZ (native asset) on
Pezkuwi Asset Hub was correctly present+enabled in the Chain domain model,
correctly dispatched to NativeAssetBalance, and its System.Account storage
key was correctly computed - but the raw subscriptionBuilder.subscribe(key)
call never actually reached the RPC connection, with zero exceptions and
zero data, while the chain's 5 other assets (all statemine-type, all using
the same SharedRequestsBuilder) synced fine. The same NativeAssetBalance
code worked correctly for every OTHER native asset on every OTHER chain
tested, ruling out a generic bug in the class.
Switched startSyncingBalance() to the typed remoteStorage.subscribe { }
DSL (metadata.system.account.observeWithRaw) - the same mechanism this
class's own subscribeAccountBalanceUpdatePoint() and PooledBalanceUpdater/
BalanceLocksUpdater already use successfully on the same busy chain
connection, instead of the raw subscriptionBuilder.subscribe(key) call
that appears to silently drop registration in that specific configuration.
Also removes the diagnostic logging added during the investigation
(BalancesUpdateSystem's per-chain balancesSync trace, FullSyncPaymentUpdater's
per-chain asset dump) - kept the one genuinely valuable permanent addition,
the "listenForUpdates() threw synchronously" error log for a previously
silent failure mode.
Confirmed via 3 CI runs: HEZ (native asset) is present+enabled in the runtime
Chain object for Pezkuwi Asset Hub and gets dispatched to NativeAssetBalance
without any exception, yet its System.Account subscribe request never reaches
that chain's RPC connection at all - no error logged either. This traces
exactly how far execution gets: does startSyncingBalance() even get entered,
does getRuntime()/storageKey() complete, does subscribe()'s flow ever emit.
HEZ (native type, assetId 0) on Pezkuwi Asset Hub never gets a System.Account
subscription sent to the RPC at all - no exception, no log, while the other 5
statemine-type assets on the same chain sync correctly. Ruled out via code
reading: enabledAssets() default, AssetSourceRegistry dispatch, type mapping,
Room composite PK. This logs the actual chain.assets contents at the exact
point enabledAssets() is consumed, to see directly whether HEZ is present/
enabled in the runtime Chain object or silently absent before this point.
StatemineAssetBalance and NativeAssetBalance's startSyncingBalance()
wrapped setup and subscription failures in runCatching{}/catch{} that
returned emptyFlow()/NoCause instead of propagating. This meant
FullSyncPaymentUpdater's retryWhen (added for the Tron fix) never
actually engaged for these asset types - a transient failure silently
and permanently stopped sync for that asset (HEZ, PEZ, USDT, DOT, ...)
with only a logcat line as evidence. Let the exceptions propagate so
the existing retry boundary can do its job.
ChainRegistry.currentChains ran registerChain()/unregisterChain() per
chain, unguarded, inside a plain (non-Supervisor) CoroutineScope and an
Eagerly-shared flow that never restarts once it dies. One bad chain row
could permanently kill sync for every chain. Isolated each chain's
register/unregister in its own runCatching, and switched the scope to
a SupervisorJob as defense in depth.
ChainUpdaterGroupUpdateSystem.runUpdaters() called getRuntime() and
built per-chain updater flows with no error boundary; callers merge
several chains' results together, so one chain's failure (e.g. a
disabled chain throwing DisabledChainException) could kill
governance/staking/crowdloan sync for every other chain in the group.
Wrapped the body in flow{} + catch to isolate failures per chain.
Generalizes today's Tron balance-polling fix - the same silent-death
bug exists in the shared Substrate sync path used by every chain
(Interlay, Kintsugi, Karura, Acala, Hydration, Polkadex, and any other
orml/statemine asset), not just Tron-specific code.
FullSyncPaymentUpdater.syncAsset() previously used runCatching around
the one-time startSyncingBalance() call and a separate .catch on the
resulting flow - either path failing (a transient WSS hiccup during
setup, an orml currencyId-decode edge case, a node not supporting a
specific RPC method during round-robin, a dropped connection later)
permanently ended that asset's sync for the Updater's lifetime: no
retry, only a Log.e line, and mapNotNull silently dropped the null
result. Since no balance update flow ever emits, AssetCache never
creates a DB row for that asset, and every UI surface (main list,
multi-chain picker) reads via an INNER JOIN that requires that row -
so the asset stays invisible with zero user-visible error until app
restart, which has the same odds of failing again.
Live-tested: Interlay/Kintsugi's node URLs are all reachable (manual
WSS handshake test, 101 Switching Protocols), and none of these chains
are blacklisted - so this wasn't a connectivity or config issue, it
was this retry gap.
Wraps both the initial subscription call and the ongoing flow in one
flow{} builder with retryWhen (30s fixed interval, matching Tron's
polling interval) instead of runCatching + .catch, so a transient
failure at either point just gets retried instead of permanently
killing sync for that asset.
Real user on a fresh install reported TRX and USDT-TRC20 never
appearing anywhere in the Tokens list (not "zero balance", genuinely
absent - including from the multi-chain USDT chain picker). Traced
the full pipeline; every layer up to and including
TypeBasedAssetSourceRegistry, ChainRegistry, address derivation, and
TronGridApi wiring was already correct.
Root cause: pollingBalanceFlow() had no retry around fetch() - any
single transient failure (DNS hiccup, timeout, momentary connectivity
loss during app cold start) threw out of the `while(true)` loop,
killing the flow permanently. The collector
(FullSyncPaymentUpdater.syncAsset()) only logs and gives up on
failure, it doesn't resubscribe. Since the asset's first balance
write never happens, AssetCache never inserts a DB row for it, and
every UI surface (main list, multi-chain picker, search) reads
exclusively via an INNER JOIN that requires that row to exist - so
the asset is invisible everywhere, permanently, with zero
user-visible error.
Swallow fetch() failures and retry on the next interval instead of
letting them escape the loop - one bad poll no longer blackholes the
asset for the rest of the app session.
Also fixes a related but separate gap found while tracing this:
RealSecretsMetaAccount.multiChainEncryptionIn() had no isTronBased
branch (only isEthereumBased), unlike DefaultMetaAccount's
hasAccountIn/accountIdIn which already handle Tron correctly. This
didn't cause the missing-tokens bug, but would have broken "export
account" (JSON key backup) for Tron - fixed by routing Tron through
MultiChainEncryption.Ethereum, matching what
RealTronTransactionService already does for actual transaction
signing (same secp256k1 keypair).
Doc comments describing "/wallet/*" endpoints were parsed as opening
a nested block comment (Kotlin block comments nest, unlike Java/C),
leaving the outer KDoc unterminated - not just a lint nit, ktlint
flagged these as invalid Kotlin files. Reworded to drop the trailing
"*". Also wraps two over-long function signatures/calls.
Reuses the existing generic fee-sufficiency validation
(sufficientTransferableBalanceToPayOriginFee) and send flow
unchanged. Transaction construction goes through TronGrid's own
createtransaction/triggersmartcontract endpoints (verified live
against Shasta testnet) rather than a hand-rolled protobuf
implementation; signing reuses the existing secp256k1 path
(SignerPayloadRaw.skipMessageHashing) already used for Ethereum,
no new crypto library. Also fixes a Phase 1 gap: Chain.isValidAddress()
had no isTronBased branch, so every Tron send would have failed
address validation.
No Energy staking/delegation/rental UI - only Tron's default
protocol behavior (burn TRX to cover Bandwidth/Energy shortfall).
* Add read-only Tron (TRC20) chain family support
Adds Tron as a third chain family alongside Substrate and EVM:
address derivation (BIP44 coin type 195, secp256k1 reused from the
existing Ethereum path, Base58Check encoding), TronGrid-backed TRX
and TRC20 USDT balance display, and a Room migration for the new
per-account Tron key/address columns. Read-only only — no send,
signing, or broadcast support yet.
* fix: exhaustive when branches for Trc20/TronNative asset types
ChainExt.kt's onChainAssetId and Common.kt's existentialDepositError
both switch exhaustively over Chain.Asset.Type and didn't have cases
for the new Trc20/TronNative variants, breaking compilation. Trc20
mirrors EvmErc20 (contract address as the on-chain id, dust burns
rather than transfers on removal); TronNative mirrors EvmNative.