Config keeps working when GitHub is unreachable: mirrors and a bundled chain list (#28)

* feat(network): fetch config from jsDelivr when raw.githubusercontent.com fails

Every config file the app reads -- chains, assets, DApps, XCM, banners, token
icons -- comes from raw.githubusercontent.com with no other source, so when that
host cannot be reached a fresh install has no chains at all.

ConfigMirrorInterceptor retries the same file on cdn.jsdelivr.net and then
fastly.jsdelivr.net when the primary fails to connect or answers 403, 429, 451
or 5xx. A 404 is an answer and is returned as is. A host that failed is skipped
for ten minutes so later requests do not each wait for its timeout. Mirror
requests revalidate, so a mirror's long max-age cannot pin a copy in the HTTP
cache after the primary is back.

The shared OkHttp client and Coil's image client both carry it.

* 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.

* feat(dapps): show the bundled DApps catalog until one can be fetched

The DApps catalog lived only in memory and every read waited on first(), so
with the catalog URL unreachable the DApps screen waited forever.

feature-dapp-impl now bundles dapps_full.json at build time. On the first
sync the repository tries the network once and, if that fails, publishes the
bundled catalog so every read returns; the fetched catalog replaces it as
soon as it arrives.

The download task moves to scripts/bundled-config.gradle and serves both
the chain list and the DApps catalog. runtime and feature-dapp-impl unit
tests return defaults for android.util.Log, which the fallback calls.

* feat(network): resolve names over DNS-over-HTTPS first, system DNS as fallback

A network that answers plain DNS with a wrong address decides where the app
connects. The shared OkHttp client and Coil's client now resolve through
EncryptedFirstDns: Google, Cloudflare and Quad9 over HTTPS, each reached by
IP, then the system resolver if all of them fail, so no network is worse off.

- A resolver whose request failed is skipped for ten minutes, so a network
  that blocks DoH pays the four-second timeout once. NXDOMAIN and SERVFAIL do
  not count as failures.
- IPv4 addresses are returned first: DoH answers IPv6 whether or not the
  network has it, and OkHttp tries addresses in order.
- IP literals and localhost never leave the device.

WebSocket RPC (nv-websocket) still resolves through the system.

* feat(config): verify signed config metadata, TUF style

The rules the app will apply to config it fetches, not wired yet:

- A root, signed by a threshold of root keys, names the keys that may sign
  the targets list and how many must. The APK carries the first root.
- A new root is accepted one version at a time, signed by a threshold of the
  old root's keys and of its own, so keys can be replaced without a release
  and a lost targets key can be revoked.
- The targets list gives the sha256 of each config file; it cannot go back a
  version, cannot jump more than 10,000 at once, and is refused once it or
  its root has expired.
- Ed25519 over the payload bytes exactly as published (BouncyCastle, already
  a dependency); a key signing twice counts once; a key must match its id.

* fix(config): parse metadata with the Gson API the app resolves

JsonParser.parseString is not in the Gson version on the app's classpath;
the instance parse method is, and behaves the same here.

* test(runtime): add the Log.e overload with a throwable to the test shim

The first-install fallback logs the failed fetch with its exception, and the
shim only had the two-argument form, so the test hit NoSuchMethodError.

* feat(config): refuse config files the signed list does not give

SignedConfigInterceptor checks every JSON file from pezkuwi-wallet-utils
against signed/targets.json, whichever host the mirrors got it from. A
file the list does not give, or one without a verified list behind it,
comes back as a 502, so callers keep the stored chains and bundled
lists as they do when the source is unreachable. Newer roots are
followed from signed/root.N.json; the targets version and the latest
time seen are stored, so an older list cannot be replayed.

Root keys may now be sr25519 wallet accounts: they sign through the
wallet's message signing, which wraps the bytes in <Bytes>, and the
SDK's schnorrkel verifies them. The APK carries root 1 as an asset,
trusted as the APK is.
This commit is contained in:
SatoshiQaziMuhammed authored and GitHub committed 2026-11-01 10:10:50 +04:00
1 parent 2d5d8bec49
commit 781b713257
27 files changed
+1681 -16

No files matched your search

@@ -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
@@ -22,5 +22,10 @@ public class Log {
return 0;
}
public static int e(String tag, String msg, Throwable tr) {
System.out.println("ERROR: " + tag + ": " + msg + ": " + tr);
return 0;
}
// add other methods if required...
}
@@ -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 {