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.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.runtime.AccountId
import kotlinx.coroutines.delay
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.retryWhen
private const val SYNC_RETRY_DELAY_MS = 30_000L
internal class FullSyncPaymentUpdater(
private val operationDao: OperationDao,
@@ -41,32 +46,43 @@ internal class FullSyncPaymentUpdater(
): Flow<Updater.SideEffect> {
val accountId = scopeValue.requireAccountIdIn(chain)
return chain.enabledAssets().mapNotNull { chainAsset ->
return chain.enabledAssets().map { chainAsset ->
syncAsset(chainAsset, scopeValue, accountId, storageSubscriptionBuilder)
}
.mergeIfMultiple()
.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,
metaAccount: MetaAccount,
accountId: AccountId,
storageSubscriptionBuilder: SharedRequestsBuilder
): Flow<BalanceSyncUpdate>? {
): Flow<BalanceSyncUpdate> {
val assetSource = assetSourceRegistry.sourceFor(chainAsset)
val assetUpdateFlow = runCatching {
assetSource.balance.startSyncingBalance(chain, chainAsset, metaAccount, accountId, storageSubscriptionBuilder)
return flow {
val assetUpdateFlow = assetSource.balance.startSyncingBalance(chain, chainAsset, metaAccount, accountId, storageSubscriptionBuilder)
emitAll(assetUpdateFlow)
}
.onFailure { logSyncError(chain, chainAsset, error = it) }
.getOrNull()
?: return null
return assetUpdateFlow.onEach { balanceUpdate ->
assetSource.history.syncOperationsForBalanceChange(chainAsset, balanceUpdate, accountId)
}
.catch { logSyncError(chain, chainAsset, error = it) }
.onEach { balanceUpdate ->
assetSource.history.syncOperationsForBalanceChange(chainAsset, balanceUpdate, accountId)
}
.retryWhen { cause, _ ->
logSyncError(chain, chainAsset, error = cause)
delay(SYNC_RETRY_DELAY_MS)
true
}
}
private fun logSyncError(chain: Chain, chainAsset: Chain.Asset, error: Throwable) {