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.
This commit is contained in:
Satoshi Qazi Muhammed committed 2026-10-31 00:39:50 +04:00
1 parent 0b1319c866
commit ec9a2cf307
5 files changed
+167 -10

No files matched your search

+45 -3
View File
@@ -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
}
}
// 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)
@@ -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
@@ -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<ChainRemote>?
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<ChainRemote>? = runCatching {
context.assets.open(ASSET_PATH).bufferedReader().use { reader ->
gson.fromJson<List<ChainRemote>>(reader, object : TypeToken<List<ChainRemote>>() {}.type)
}
}.onFailure {
Log.e(LOG_TAG, "No usable bundled chain list", it)
}.getOrNull()
}
@@ -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<ChainRemote>, localChainsJoinedInfo: List<JoinedChainInfo>) {
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
@@ -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 {