package io.novafoundation.nova.balances import android.content.Context import androidx.test.core.app.ApplicationProvider import com.google.gson.Gson import io.novafoundation.nova.common.data.network.runtime.binding.AccountInfo import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountInfo import io.novafoundation.nova.common.di.FeatureUtils import io.novafoundation.nova.common.utils.fromJson import io.novafoundation.nova.common.utils.hasModule import io.novafoundation.nova.common.utils.system import io.novafoundation.nova.core.model.CryptoType import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent import io.novafoundation.nova.feature_account_impl.domain.account.model.DefaultMetaAccount import io.novafoundation.nova.runtime.BuildConfig.TEST_CHAINS_URL import io.novafoundation.nova.runtime.di.RuntimeApi import io.novafoundation.nova.runtime.di.RuntimeComponent import io.novafoundation.nova.runtime.extrinsic.systemRemark import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain import io.novafoundation.nova.runtime.multiNetwork.connection.ChainConnection import io.novafoundation.nova.runtime.multiNetwork.getSocket import io.novasama.substrate_sdk_android.extensions.fromHex import io.novasama.substrate_sdk_android.runtime.metadata.storage import io.novasama.substrate_sdk_android.runtime.metadata.storageKey import io.novasama.substrate_sdk_android.wsrpc.networkStateFlow import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import org.junit.Assert.assertTrue import org.junit.Assume.assumeNoException import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.Parameterized import java.math.BigInteger import java.math.BigInteger.ZERO import java.net.URL import kotlin.time.Duration.Companion.seconds @RunWith(Parameterized::class) class BalancesIntegrationTest( private val testChainId: String, private val testChainName: String, private val testAccount: String ) { companion object { @JvmStatic @Parameterized.Parameters(name = "{1}") fun data(): List> { val arrayOfNetworks: Array = Gson().fromJson(URL(TEST_CHAINS_URL).readText()) return arrayOfNetworks.map { arrayOf(it.chainId, it.name, it.account) } } class TestData( val chainId: String, val name: String, val account: String? ) } private val maxAmount = BigInteger.valueOf(10).pow(30) private val runtimeApi = FeatureUtils.getFeature( ApplicationProvider.getApplicationContext(), RuntimeApi::class.java ) private val accountApi = FeatureUtils.getFeature( ApplicationProvider.getApplicationContext(), AccountFeatureApi::class.java ) private val chainRegistry = runtimeApi.chainRegistry() private val externalRequirementFlow = runtimeApi.externalRequirementFlow() private val remoteStorage = runtimeApi.remoteStorageSource() private val extrinsicService = accountApi.extrinsicService() @Before fun before() = runBlocking { externalRequirementFlow.emit(ChainConnection.ExternalRequirement.ALLOWED) } @Test fun testBalancesLoading() = runBlocking(Dispatchers.Default) { val chains = chainRegistry.getChain(testChainId) try { val freeBalance = testBalancesInChainAsync(chains, testAccount)?.data?.free ?: error("Balance was null") assertTrue("Free balance: $freeBalance is less than $maxAmount", maxAmount > freeBalance) assertTrue("Free balance: $freeBalance is greater than 0", ZERO < freeBalance) } catch (e: Exception) { skipIfChainUnreachable(e) } } @Test fun testFeeLoading() = runBlocking(Dispatchers.Default) { val chains = chainRegistry.getChain(testChainId) try { testFeeLoadingAsync(chains) } catch (e: Exception) { skipIfChainUnreachable(e) } Unit } /** * A chain's public RPC being temporarily unreachable is an external-infra flake, not a * failure of our balance-reading code - skip (assumption failure) rather than fail the build, * mirroring how production code already isolates/tolerates per-chain RPC outages elsewhere * (BalancesUpdateSystem, ChainSyncService). Any other exception still fails the test as before. */ private fun skipIfChainUnreachable(e: Exception) { val isConnectivityFailure = e is TimeoutCancellationException || e.cause is TimeoutCancellationException if (!isConnectivityFailure) throw e assumeNoException("$testChainName RPC unreachable, treating as environment flake: ${e.message}", e) } private suspend fun testBalancesInChainAsync(chain: Chain, currentAccount: String): AccountInfo? { return coroutineScope { try { withTimeout(80.seconds) { remoteStorage.query( chainId = chain.id, keyBuilder = { it.metadata.system().storage("Account").storageKey(it, currentAccount.fromHex()) }, binding = { scale, runtime -> scale?.let { bindAccountInfo(scale, runtime) } } ) } } catch (e: Exception) { throw Exception("Socket state: ${chainRegistry.getSocket(chain.id).networkStateFlow().first()}, error: ${e.message}", e) } } } private suspend fun testFeeLoadingAsync(chain: Chain) { return coroutineScope { withTimeout(80.seconds) { extrinsicService.estimateFee(chain, testTransactionOrigin()) { systemRemark(byteArrayOf(0)) val haveBatch = runtime.metadata.hasModule("Utility") if (haveBatch) { systemRemark(byteArrayOf(0)) } } } } } private fun testTransactionOrigin(): TransactionOrigin = TransactionOrigin.Wallet( createTestMetaAccount() ) private fun createTestMetaAccount(): MetaAccount { val metaAccount = DefaultMetaAccount( id = 0, globallyUniqueId = MetaAccountLocal.generateGloballyUniqueId(), substratePublicKey = testAccount.fromHex(), substrateCryptoType = CryptoType.SR25519, substrateAccountId = testAccount.fromHex(), ethereumAddress = testAccount.fromHex(), ethereumPublicKey = testAccount.fromHex(), isSelected = true, name = "Test", type = LightMetaAccount.Type.WATCH_ONLY, status = LightMetaAccount.Status.ACTIVE, chainAccounts = emptyMap(), parentMetaId = null ) return metaAccount } }