fix: log and stop swallowing synchronous listenForUpdates() failures; retry NDK download in CI

BalancesUpdateSystem.launchChainUpdaters() wrapped updater.listenForUpdates() in a
try/catch that discarded synchronous exceptions with zero logging - a silent failure
point that could explain a chain's balances never syncing with no trace in logcat.

Also add a full-architecture instrumented test that persists a real watch-only
MetaAccount and polls AssetDao through the actual BalancesUpdateSystem pipeline,
instead of bypassing it like the existing BalancesIntegrationTest does.
This commit is contained in:
2026-07-09 07:52:15 -07:00
parent b3714d3b79
commit 688a23ba6b
3 changed files with 120 additions and 1 deletions
+21 -1
View File
@@ -18,7 +18,27 @@ runs:
- name: Install NDK
run: |
SDKMANAGER=$(find ${ANDROID_SDK_ROOT}/cmdline-tools -name sdkmanager -type f 2>/dev/null | head -1)
echo "y" | sudo ${SDKMANAGER} --install "ndk;26.1.10909125" --sdk_root=${ANDROID_SDK_ROOT}
NDK_PACKAGE="ndk;26.1.10909125"
# sdkmanager's download of the ~1GB NDK zip from Google's CDN occasionally comes back truncated/corrupted
# ("Error on ZipFile unknown archive") with no built-in retry, taking down the whole build for a purely
# transient network blip. Retry with cleanup between attempts: sdkmanager can otherwise resume from the
# same corrupted partial file instead of re-fetching, making a naive retry fail identically every time.
for attempt in 1 2 3; do
echo "NDK install attempt $attempt/3"
if echo "y" | sudo ${SDKMANAGER} --install "$NDK_PACKAGE" --sdk_root=${ANDROID_SDK_ROOT}; then
echo "NDK installed successfully"
exit 0
fi
echo "Attempt $attempt failed - clearing any partial/corrupted download before retrying"
sudo rm -rf "${ANDROID_SDK_ROOT}/ndk/26.1.10909125"
sudo find "${ANDROID_SDK_ROOT}" -maxdepth 1 -name "tmp*" -exec rm -rf {} +
sleep 10
done
echo "NDK install failed after 3 attempts"
exit 1
shell: bash
- name: Set ndk.dir in local.properties
@@ -0,0 +1,90 @@
package io.novafoundation.nova.balances
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.core.model.CryptoType
import io.novafoundation.nova.core_db.dao.AssetDao
import io.novafoundation.nova.core_db.dao.MetaAccountDao
import io.novafoundation.nova.core_db.di.DbApi
import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal
import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import org.junit.Assert.assertNotNull
import org.junit.Test
import kotlin.time.Duration.Companion.seconds
/**
* Exercises the ACTUAL production balance-sync pipeline (BalancesUpdateSystem -> AssetCache/AssetDao) end to
* end, unlike [BalancesIntegrationTest] which bypasses it entirely via a direct low-level storage query. This
* is meant to answer one question with hard evidence, not speculation: does the app's real, running background
* sync ever write an `assets` cache row for HEZ on the Pezkuwi Asset Hub chain, for a real, well-funded account?
*
* 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.
*/
class PezkuwiFullArchitectureBalancesTest {
// Mainnet Founder account (SS58, generic substrate prefix) - verified live via @pezkuwi/api on 2026-07-09
// to hold a substantial non-zero, non-frozen free HEZ balance on Pezkuwi Asset Hub (180,297.80 HEZ).
private val founderSubstrateAddress = "5CyuFfbF95rzBxru7c9yEsX4XmQXUxpLUcbj9RLg9K1cGiiF"
private val pezkuwiAssetHubChainId = "e7c15092dcbe3f320260ddbbc685bfceed9125a3b3d8436db2766201dec3b949"
private val hezAssetId = 0
private val context = ApplicationProvider.getApplicationContext<Context>()
private val dbApi = FeatureUtils.getFeature<DbApi>(context, DbApi::class.java)
private val metaAccountDao = dbApi.metaAccountDao()
private val assetDao: AssetDao = dbApi.provideAssetDao()
@Test
fun testPezkuwiAssetHubHezBalanceActuallySyncs() = runBlocking {
val metaId = insertAndSelectFounderWatchAccount(metaAccountDao)
val assetRow = withTimeoutOrNull(90.seconds) {
while (true) {
val asset = assetDao.getAsset(metaId, pezkuwiAssetHubChainId, hezAssetId)
if (asset != null) return@withTimeoutOrNull asset
delay(2.seconds)
}
@Suppress("UNREACHABLE_CODE")
null
}
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.",
assetRow
)
}
private suspend fun insertAndSelectFounderWatchAccount(dao: MetaAccountDao): Long {
val accountId = founderSubstrateAddress.toAccountId()
val metaAccount = MetaAccountLocal(
substratePublicKey = accountId,
substrateCryptoType = CryptoType.SR25519,
substrateAccountId = accountId,
ethereumPublicKey = null,
ethereumAddress = null,
name = "PezkuwiFullArchitectureBalancesTest",
parentMetaId = null,
isSelected = false,
position = 0,
type = MetaAccountLocal.Type.WATCH_ONLY,
status = MetaAccountLocal.Status.ACTIVE,
globallyUniqueId = MetaAccountLocal.generateGloballyUniqueId(),
typeExtras = null
)
val metaId = dao.insertMetaAccount(metaAccount)
dao.selectMetaAccount(metaId)
return metaId
}
}
@@ -45,6 +45,11 @@ 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()
@@ -89,6 +94,10 @@ class BalancesUpdateSystem(
try {
updater.listenForUpdates(subscriptionBuilder, metaAccount).catch { logError(chain, it) }
} catch (e: Exception) {
// 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)
emptyFlow()
}
}