fix: retry balance sync on failure for ALL chains, not just Tron

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.
This commit is contained in:
2026-07-08 11:57:26 -07:00
parent 21d2896493
commit 4aebac305f
@@ -22,9 +22,14 @@ import io.novafoundation.nova.runtime.ext.enabledAssets
import io.novafoundation.nova.runtime.ext.localId import io.novafoundation.nova.runtime.ext.localId
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.runtime.AccountId import io.novasama.substrate_sdk_android.runtime.AccountId
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.retryWhen
private const val SYNC_RETRY_DELAY_MS = 30_000L
internal class FullSyncPaymentUpdater( internal class FullSyncPaymentUpdater(
private val operationDao: OperationDao, private val operationDao: OperationDao,
@@ -41,32 +46,43 @@ internal class FullSyncPaymentUpdater(
): Flow<Updater.SideEffect> { ): Flow<Updater.SideEffect> {
val accountId = scopeValue.requireAccountIdIn(chain) val accountId = scopeValue.requireAccountIdIn(chain)
return chain.enabledAssets().mapNotNull { chainAsset -> return chain.enabledAssets().map { chainAsset ->
syncAsset(chainAsset, scopeValue, accountId, storageSubscriptionBuilder) syncAsset(chainAsset, scopeValue, accountId, storageSubscriptionBuilder)
} }
.mergeIfMultiple() .mergeIfMultiple()
.noSideAffects() .noSideAffects()
} }
private suspend fun syncAsset( /**
* Wraps both the initial `startSyncingBalance()` call and the resulting flow in a single retry
* boundary. Without this, any transient failure - during initial subscription setup (a WSS
* hiccup, an orml currencyId-decode edge case, a node not supporting a specific RPC method
* during round-robin) or later in the flow (a dropped connection) - would permanently and
* silently kill sync for that one asset: no DB row ever gets created/updated for it, so it
* vanishes from every UI screen with nothing but a logcat line as evidence, until the app is
* restarted (and even then, with the same odds of failing again). Retrying indefinitely on a
* fixed interval matches the same fix already applied to Tron's balance polling.
*/
private fun syncAsset(
chainAsset: Chain.Asset, chainAsset: Chain.Asset,
metaAccount: MetaAccount, metaAccount: MetaAccount,
accountId: AccountId, accountId: AccountId,
storageSubscriptionBuilder: SharedRequestsBuilder storageSubscriptionBuilder: SharedRequestsBuilder
): Flow<BalanceSyncUpdate>? { ): Flow<BalanceSyncUpdate> {
val assetSource = assetSourceRegistry.sourceFor(chainAsset) val assetSource = assetSourceRegistry.sourceFor(chainAsset)
val assetUpdateFlow = runCatching { return flow {
assetSource.balance.startSyncingBalance(chain, chainAsset, metaAccount, accountId, storageSubscriptionBuilder) val assetUpdateFlow = assetSource.balance.startSyncingBalance(chain, chainAsset, metaAccount, accountId, storageSubscriptionBuilder)
emitAll(assetUpdateFlow)
} }
.onFailure { logSyncError(chain, chainAsset, error = it) } .onEach { balanceUpdate ->
.getOrNull() assetSource.history.syncOperationsForBalanceChange(chainAsset, balanceUpdate, accountId)
?: return null }
.retryWhen { cause, _ ->
return assetUpdateFlow.onEach { balanceUpdate -> logSyncError(chain, chainAsset, error = cause)
assetSource.history.syncOperationsForBalanceChange(chainAsset, balanceUpdate, accountId) delay(SYNC_RETRY_DELAY_MS)
} true
.catch { logSyncError(chain, chainAsset, error = it) } }
} }
private fun logSyncError(chain: Chain, chainAsset: Chain.Asset, error: Throwable) { private fun logSyncError(chain: Chain, chainAsset: Chain.Asset, error: Throwable) {