fix: NativeAssetBalance uses typed subscribe DSL, not raw key subscription

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.
This commit is contained in:
2026-07-09 11:53:35 -07:00
parent 7a6b93a323
commit c5174d0ccf
4 changed files with 26 additions and 38 deletions
@@ -29,9 +29,9 @@ import kotlin.time.Duration.Companion.seconds
* Activity, so we collect it ourselves here via the same AssetsFeatureApi.updateSystem instance the real app
* uses, instead of relying on app UI lifecycle to start it.
*
* If this test fails, the failure message + logcat (tag "BalancesDiag", plus the standard per-updater error
* logs already wired into BalancesUpdateSystem/FullSyncPaymentUpdater) shows exactly which decision branch or
* exception is responsible - not another layer of inference from silence.
* If this test fails, the standard per-updater error logs already wired into BalancesUpdateSystem/
* FullSyncPaymentUpdater/NativeAssetBalance show exactly which decision branch or exception is responsible -
* not another layer of inference from silence.
*/
class PezkuwiFullArchitectureBalancesTest {
@@ -69,8 +69,8 @@ class PezkuwiFullArchitectureBalancesTest {
assertNotNull(
"No `assets` row was ever written for HEZ on Pezkuwi Asset Hub (metaId=$metaId) within 90s. " +
"The real BalancesUpdateSystem pipeline never completed a sync for this asset - check logcat " +
"tag 'BalancesDiag' and the standard FullSyncPaymentUpdater/StatemineAssetBalance error logs.",
"The real BalancesUpdateSystem pipeline never completed a sync for this asset - check the " +
"standard FullSyncPaymentUpdater/NativeAssetBalance error logs in logcat.",
assetRow
)
} finally {
@@ -45,11 +45,6 @@ class BalancesUpdateSystem(
}
private suspend fun balancesSync(chain: Chain, metaAccount: MetaAccount): Flow<Updater.SideEffect> {
Log.d(
"BalancesDiag",
"balancesSync(${chain.name}): hasAccountIn=${metaAccount.hasAccountIn(chain)} " +
"isDisabled=${chain.connectionState.isDisabled} canPerformFullSync=${chain.canPerformFullSync()}"
)
return when {
!metaAccount.hasAccountIn(chain) -> emptyFlow()
chain.connectionState.isDisabled -> emptyFlow()
@@ -97,7 +92,7 @@ class BalancesUpdateSystem(
// Was silently swallowed here with zero logging - listenForUpdates() itself is a suspend
// call that can throw synchronously (e.g. FullSyncPaymentUpdater.listenForUpdates() calling
// requireAccountIdIn(chain)), before ever returning a flow for the .catch{} above to guard.
Log.e("BalancesDiag", "listenForUpdates() threw synchronously for ${updater.javaClass.simpleName} in ${chain.name}", e)
Log.e(LOG_TAG, "listenForUpdates() threw synchronously for ${updater.javaClass.simpleName} in ${chain.name}", e)
emptyFlow()
}
}
@@ -1,6 +1,7 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.utility
import android.util.Log
import io.novafoundation.nova.common.data.network.runtime.binding.AccountInfo
import io.novafoundation.nova.common.data.network.runtime.binding.bindList
import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber
import io.novafoundation.nova.common.data.network.runtime.binding.castToDictEnum
@@ -17,7 +18,6 @@ import io.novafoundation.nova.core_db.dao.LockDao
import io.novafoundation.nova.core_db.model.BalanceHoldLocal
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache
import io.novafoundation.nova.feature_wallet_api.data.cache.bindAccountInfoOrDefault
import io.novafoundation.nova.feature_wallet_api.data.cache.updateAsset
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.AssetBalance
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.BalanceSyncUpdate
@@ -154,6 +154,14 @@ class NativeAssetBalance(
// Setup/subscription failures are allowed to propagate rather than being swallowed into emptyFlow()/NoCause:
// the caller, FullSyncPaymentUpdater.syncAsset(), wraps this whole call in a single retryWhen boundary meant
// to catch and retry exactly these failures. Swallowing here would make that retry boundary never trigger.
//
// Uses the typed remoteStorage.subscribe { metadata.system.account... } DSL (same as this class's own
// subscribeAccountBalanceUpdatePoint() and PooledBalanceUpdater/BalanceLocksUpdater) instead of a raw
// subscriptionBuilder.subscribe(key) call: on chains with several other assets/updaters already sharing
// the same SharedRequestsBuilder (e.g. Pezkuwi Asset Hub's 5 statemine assets + nomination-pools updater),
// the raw form's System.Account subscription was silently never reaching the wire - no exception, no data,
// ever - while every other asset on the same chain synced fine. The typed DSL is what every other caller
// on a busy shared connection already uses successfully.
override suspend fun startSyncingBalance(
chain: Chain,
chainAsset: Chain.Asset,
@@ -161,26 +169,18 @@ class NativeAssetBalance(
accountId: AccountId,
subscriptionBuilder: SharedRequestsBuilder
): Flow<BalanceSyncUpdate> {
Log.d("BalancesDiag", "NativeAssetBalance.startSyncingBalance() ENTERED for ${chainAsset.symbol} on ${chain.name}")
return remoteStorage.subscribe(chain.id, subscriptionBuilder) {
metadata.system.account.observeWithRaw(accountId)
}.map { change ->
val accountInfo = change.value ?: AccountInfo.empty()
val assetChanged = assetCache.updateAsset(metaAccount.id, chain.utilityAsset, accountInfo)
val runtime = chainRegistry.getRuntime(chain.id)
Log.d("BalancesDiag", "NativeAssetBalance: got runtime for ${chain.name}")
val key = runtime.metadata.system().storage("Account").storageKey(runtime, accountId)
Log.d("BalancesDiag", "NativeAssetBalance: computed key for ${chainAsset.symbol} on ${chain.name}: $key")
return subscriptionBuilder.subscribe(key)
.map { change ->
Log.d("BalancesDiag", "NativeAssetBalance: received change for ${chainAsset.symbol} on ${chain.name}")
val accountInfo = bindAccountInfoOrDefault(change.value, runtime)
val assetChanged = assetCache.updateAsset(metaAccount.id, chain.utilityAsset, accountInfo)
if (assetChanged) {
BalanceSyncUpdate.CauseFetchable(change.block)
} else {
BalanceSyncUpdate.NoCause
}
if (assetChanged) {
BalanceSyncUpdate.CauseFetchable(change.at!!)
} else {
BalanceSyncUpdate.NoCause
}
}
}
private fun bindBalanceHolds(dynamicInstance: Any?): List<BlockchainHold>? {
@@ -46,14 +46,7 @@ internal class FullSyncPaymentUpdater(
): Flow<Updater.SideEffect> {
val accountId = scopeValue.requireAccountIdIn(chain)
val enabled = chain.enabledAssets()
Log.d(
"BalancesDiag",
"FullSyncPaymentUpdater(${chain.name}): allAssets=${chain.assets.map { "${it.symbol}(id=${it.id},enabled=${it.enabled},type=${it.type})" }} " +
"enabledAssets=${enabled.map { it.symbol }}"
)
return enabled.map { chainAsset ->
return chain.enabledAssets().map { chainAsset ->
syncAsset(chainAsset, scopeValue, accountId, storageSubscriptionBuilder)
}
.mergeIfMultiple()