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.