From ec9a2cf307f75c4899830a8aef1d7cafa0882e11 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Sat, 31 Oct 2026 00:39:50 +0400 Subject: [PATCH] feat(runtime): start a first install from the chain list the APK was built with A first install has no chains until chains.json is fetched, and the fetch retries forever: with GitHub and its mirrors unreachable the wallet stays empty. The runtime module now bundles the live chains.json at build time (bundleChainsConfig, run before preBuild, falling back to jsDelivr and refusing anything that is not a non-empty JSON array). When the local database is empty, ChainSyncService tries the network once and, if that fails, applies the bundled list so the wallet opens; the fetched list then replaces it through the usual diff as soon as it arrives. --- runtime/build.gradle | 48 +++++++++++++++++-- .../nova/runtime/di/ChainRegistryModule.kt | 7 ++- .../multiNetwork/chain/BundledChains.kt | 37 ++++++++++++++ .../multiNetwork/chain/ChainSyncService.kt | 40 ++++++++++++++-- .../chain/ChainSyncServiceTest.kt | 45 +++++++++++++++++ 5 files changed, 167 insertions(+), 10 deletions(-) create mode 100644 runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/BundledChains.kt diff --git a/runtime/build.gradle b/runtime/build.gradle index 436183ea..4fd74285 100644 --- a/runtime/build.gradle +++ b/runtime/build.gradle @@ -1,11 +1,14 @@ apply from: '../scripts/secrets.gradle' apply plugin: 'kotlin-parcelize' +def chainsUrl = "https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/android/chains.json" +def bundledConfigDir = "$buildDir/generated/bundledConfig" + android { defaultConfig { - buildConfigField "String", "CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/android/chains.json\"" + buildConfigField "String", "CHAINS_URL", "\"$chainsUrl\"" buildConfigField "String", "EVM_ASSETS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/assets/evm/v3/assets.json\"" buildConfigField "String", "PRE_CONFIGURED_CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/preConfigured/chains.json\"" buildConfigField "String", "PRE_CONFIGURED_CHAIN_DETAILS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/preConfigured/details\"" @@ -27,7 +30,7 @@ android { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' - buildConfigField "String", "CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/android/chains.json\"" + buildConfigField "String", "CHAINS_URL", "\"$chainsUrl\"" buildConfigField "String", "EVM_ASSETS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/assets/evm/v3/assets.json\"" buildConfigField "String", "PRE_CONFIGURED_CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/preConfigured/chains.json\"" buildConfigField "String", "PRE_CONFIGURED_CHAIN_DETAILS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/preConfigured/details\"" @@ -66,4 +69,43 @@ dependencies { androidTestImplementation androidTestRunnerDep androidTestImplementation androidTestRulesDep androidTestImplementation androidJunitDep -} \ No newline at end of file +} + +// The chain list a first install starts from when none can be fetched (AssetBundledChains). It is the live +// list at build time, so every build carries the chains that were current when it was made. +def bundleChainsConfig = tasks.register("bundleChainsConfig") { + def target = file("$bundledConfigDir/bundled-config/chains.json") + outputs.file(target) + outputs.upToDateWhen { false } + + doLast { + def sources = [ + chainsUrl, + chainsUrl.replace("https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/", + "https://cdn.jsdelivr.net/gh/pezkuwichain/pezkuwi-wallet-utils@master/") + ] + def bytes = null + for (source in sources) { + try { + bytes = new URL(source).getBytes(connectTimeout: 20_000, readTimeout: 60_000) + break + } catch (IOException e) { + logger.warn("bundleChainsConfig: $source failed: $e") + } + } + if (bytes == null) throw new GradleException("bundleChainsConfig: no source answered") + + def chains = new groovy.json.JsonSlurper().parse(bytes) + if (!(chains instanceof List) || chains.isEmpty()) { + throw new GradleException("bundleChainsConfig: the chain list is not a non-empty JSON array") + } + + target.parentFile.mkdirs() + target.bytes = bytes + def sha256 = java.security.MessageDigest.getInstance("SHA-256").digest(bytes).encodeHex().toString() + logger.lifecycle("bundleChainsConfig: ${chains.size()} chains, sha256 $sha256") + } +} + +android.sourceSets.main.assets.srcDir(bundledConfigDir) +preBuild.dependsOn(bundleChainsConfig) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/di/ChainRegistryModule.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/di/ChainRegistryModule.kt index eff94b38..29ff4187 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/di/ChainRegistryModule.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/di/ChainRegistryModule.kt @@ -1,5 +1,6 @@ package io.novafoundation.nova.runtime.di +import android.content.Context import com.google.gson.Gson import dagger.Module import dagger.Provides @@ -15,6 +16,7 @@ import io.novafoundation.nova.runtime.ethereum.Web3ApiFactory import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry import io.novafoundation.nova.runtime.multiNetwork.asset.EvmAssetsSyncService import io.novafoundation.nova.runtime.multiNetwork.asset.remote.AssetFetcher +import io.novafoundation.nova.runtime.multiNetwork.chain.AssetBundledChains import io.novafoundation.nova.runtime.multiNetwork.chain.ChainSyncService import io.novafoundation.nova.runtime.multiNetwork.chain.remote.ChainFetcher import io.novafoundation.nova.runtime.multiNetwork.connection.ChainConnection @@ -56,8 +58,9 @@ class ChainRegistryModule { dao: ChainDao, chainAssetDao: ChainAssetDao, chainFetcher: ChainFetcher, - gson: Gson - ) = ChainSyncService(dao, chainFetcher, gson) + gson: Gson, + context: Context + ) = ChainSyncService(dao, chainFetcher, gson, AssetBundledChains(context, gson)) @Provides @ApplicationScope diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/BundledChains.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/BundledChains.kt new file mode 100644 index 00000000..476ed64c --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/BundledChains.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.runtime.multiNetwork.chain + +import android.content.Context +import android.util.Log +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.runtime.multiNetwork.chain.remote.model.ChainRemote + +/** The chain list the APK was built with: what a first install starts from when no chain list can be fetched. */ +fun interface BundledChains { + + fun load(): List? + + companion object { + val None = BundledChains { null } + } +} + +class AssetBundledChains( + private val context: Context, + private val gson: Gson, +) : BundledChains { + + companion object { + // Written at build time by the `bundleChainsConfig` task in runtime/build.gradle. + const val ASSET_PATH = "bundled-config/chains.json" + } + + override fun load(): List? = runCatching { + context.assets.open(ASSET_PATH).bufferedReader().use { reader -> + gson.fromJson>(reader, object : TypeToken>() {}.type) + } + }.onFailure { + Log.e(LOG_TAG, "No usable bundled chain list", it) + }.getOrNull() +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/ChainSyncService.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/ChainSyncService.kt index 5e571e0b..bab41c46 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/ChainSyncService.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/ChainSyncService.kt @@ -12,6 +12,7 @@ import io.novafoundation.nova.core_db.model.chain.AssetSourceLocal import io.novafoundation.nova.core_db.model.chain.ChainAssetLocal.Companion.ENABLED_DEFAULT_BOOL import io.novafoundation.nova.core_db.model.chain.ChainLocal import io.novafoundation.nova.core_db.model.chain.ChainNodeLocal +import io.novafoundation.nova.core_db.model.chain.JoinedChainInfo import io.novafoundation.nova.core_db.model.chain.NodeSelectionPreferencesLocal import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapExternalApisToLocal import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapRemoteAssetToLocal @@ -19,17 +20,48 @@ import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapRemoteChainT import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapRemoteExplorersToLocal import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapRemoteNodesToLocal import io.novafoundation.nova.runtime.multiNetwork.chain.remote.ChainFetcher +import io.novafoundation.nova.runtime.multiNetwork.chain.remote.model.ChainRemote +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class ChainSyncService( private val chainDao: ChainDao, private val chainFetcher: ChainFetcher, - private val gson: Gson + private val gson: Gson, + private val bundledChains: BundledChains = BundledChains.None, ) { suspend fun syncUp() = withContext(Dispatchers.Default) { - val localChainsJoinedInfo = chainDao.getJoinChainInfo().filter { it.chain.source != ChainLocal.Source.CUSTOM } + if (loadLocal().isEmpty()) { + // A first install has nothing to show until a chain list arrives. Try the network once (the + // config mirrors are behind it), and if that fails, start from the list the APK was built with + // so the wallet opens; the remote list replaces it below as soon as it can be fetched. + val firstTry = try { + chainFetcher.getChains() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(LOG_TAG, "First chain fetch failed, starting from the bundled chain list", e) + null + } + + if (firstTry != null) { + applyRemoteChains(firstTry, loadLocal()) + return@withContext + } + + bundledChains.load()?.let { applyRemoteChains(it, loadLocal()) } + } + + val remoteChains = retryUntilDone { chainFetcher.getChains() } + + applyRemoteChains(remoteChains, loadLocal()) + } + + private suspend fun loadLocal() = chainDao.getJoinChainInfo().filter { it.chain.source != ChainLocal.Source.CUSTOM } + + private suspend fun applyRemoteChains(remoteChains: List, localChainsJoinedInfo: List) { val oldChains = localChainsJoinedInfo.map { it.chain } val oldAssets = localChainsJoinedInfo.flatMap { it.assets }.filter { it.source == AssetSourceLocal.DEFAULT } val oldNodes = localChainsJoinedInfo.flatMap { it.nodes }.filter { it.source != ChainNodeLocal.Source.CUSTOM } @@ -40,8 +72,6 @@ class ChainSyncService( val oldChainsById = oldChains.associateBy { it.id } val associatedOldAssets = oldAssets.associateBy { it.fullId() } - val remoteChains = retryUntilDone { chainFetcher.getChains() } - // A transient upstream issue (CDN hiccup, regional network filtering, a bad publish) can make // chainFetcher.getChains() return successfully with a suspiciously small/empty list instead of // throwing. Applying that as a diff against a populated local DB would delete most or all of the @@ -54,7 +84,7 @@ class ChainSyncService( "Refusing to apply chain sync: remote returned ${remoteChains.size} chains vs ${oldChains.size} currently stored " + "(would remove more than half). Likely a transient fetch issue - skipping this sync cycle." ) - return@withContext + return } // One malformed/incompatible chain (a new field the app's mapper doesn't understand yet, a bad diff --git a/runtime/src/test/java/io/novafoundation/nova/runtime/multiNetwork/chain/ChainSyncServiceTest.kt b/runtime/src/test/java/io/novafoundation/nova/runtime/multiNetwork/chain/ChainSyncServiceTest.kt index dc880dd6..bd4451cc 100644 --- a/runtime/src/test/java/io/novafoundation/nova/runtime/multiNetwork/chain/ChainSyncServiceTest.kt +++ b/runtime/src/test/java/io/novafoundation/nova/runtime/multiNetwork/chain/ChainSyncServiceTest.kt @@ -25,6 +25,7 @@ import io.novafoundation.nova.test_shared.emptyDiff import io.novafoundation.nova.test_shared.insertsElement import io.novafoundation.nova.test_shared.removesElement import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -131,6 +132,50 @@ class ChainSyncServiceTest { } } + @Test + fun `a first install starts from the bundled list when the fetch fails`() { + runBlocking { + val bundled = REMOTE_CHAIN.copy(chainId = "0xbb") + chainSyncService = ChainSyncService(dao, chainFetcher, gson) { listOf(bundled) } + localReturns(emptyList()) + `when`(chainFetcher.getChains()).thenThrow(RuntimeException("unreachable")).thenReturn(listOf(REMOTE_CHAIN)) + + chainSyncService.syncUp() + + verify(dao).applyDiff( + chainDiff = insertsChainWithId(bundled.chainId), + assetsDiff = insertsAssetWithId(assetId), + nodesDiff = insertsNodeWithUrl(nodeUrl), + explorersDiff = insertsExplorerByName(explorerName), + externalApisDiff = insertsTransferApiByUrl(transferApiUrl), + nodeSelectionPreferencesDiff = insertsNodeSelectionPreferences(bundled.chainId) + ) + // ... and the fetched list still follows it. + verify(dao).applyDiff( + chainDiff = insertsChainWithId(REMOTE_CHAIN.chainId), + assetsDiff = insertsAssetWithId(assetId), + nodesDiff = insertsNodeWithUrl(nodeUrl), + explorersDiff = insertsExplorerByName(explorerName), + externalApisDiff = insertsTransferApiByUrl(transferApiUrl), + nodeSelectionPreferencesDiff = insertsNodeSelectionPreferences(REMOTE_CHAIN.chainId) + ) + } + } + + @Test + fun `the bundled list is not read when the fetch works`() { + runBlocking { + var bundledReads = 0 + chainSyncService = ChainSyncService(dao, chainFetcher, gson) { bundledReads++; emptyList() } + localReturns(emptyList()) + remoteReturns(listOf(REMOTE_CHAIN)) + + chainSyncService.syncUp() + + assertEquals(0, bundledReads) + } + } + @Test fun `should not insert the same chain`() { runBlocking {