fix: Networks screen never showed live health/feedback for Tron's toggle

nodesHealthState() only ever looked at wssNodes(), so an HTTPS-only chain
(Tron - no wss endpoint at all) always got an empty node list here: the
enable/disable switch itself worked correctly (it reads/writes
chain.isEnabled directly, unrelated to this list), but with nothing ever
rendering underneath it, the screen looked frozen/unresponsive - this is
almost certainly why a single real toggle needed several taps to land
correctly, since there was no visible confirmation whichever tap actually
took effect.

Falls back to httpNodes() when a chain has no wss nodes, and adds a real
TronNodeHealthStateTester (GET /wallet/getchainparameters, needs no address)
instead of naively reusing EthereumNodeHealthStateTester's eth_getBalance
call, which TronGrid doesn't speak and would have always reported the node
as down regardless of its actual health.
This commit is contained in:
2026-07-11 06:50:27 -07:00
parent e055e1a84c
commit 141d2b1f42
4 changed files with 70 additions and 7 deletions
@@ -9,6 +9,7 @@ import io.novafoundation.nova.runtime.ext.isCustomNetwork
import io.novafoundation.nova.runtime.ext.isDisabled
import io.novafoundation.nova.runtime.ext.isEnabled
import io.novafoundation.nova.runtime.ext.selectedUnformattedWssNodeUrlOrNull
import io.novafoundation.nova.runtime.ext.httpNodes
import io.novafoundation.nova.runtime.ext.wssNodes
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
@@ -132,7 +133,13 @@ class RealNetworkManagementChainInteractor(
}
private fun nodesHealthState(chain: Chain, coroutineScope: CoroutineScope): Flow<List<NodeHealthState>> {
return chain.nodes.wssNodes().map {
// wssNodes() alone leaves an HTTPS-only chain (Tron today - no wss endpoint at all) with an empty list
// here, which silently renders as "nothing to show" rather than a real health state - fall back to the
// http nodes only when there are no wss ones, since a chain that genuinely has wss nodes should still
// prefer testing those.
val nodesToCheck = chain.nodes.wssNodes().ifEmpty { chain.nodes.httpNodes() }
return nodesToCheck.map {
nodeHealthState(chain, it, coroutineScope)
}.combine()
}
@@ -37,7 +37,9 @@ import io.novafoundation.nova.runtime.multiNetwork.runtime.types.BaseTypeSynchro
import io.novafoundation.nova.runtime.multiNetwork.runtime.types.TypesFetcher
import io.novasama.substrate_sdk_android.wsrpc.SocketService
import kotlinx.coroutines.flow.MutableStateFlow
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import java.util.concurrent.TimeUnit
import org.web3j.protocol.http.HttpService
import javax.inject.Provider
@@ -165,7 +167,13 @@ class ChainRegistryModule {
socketProvider,
connectionSecrets,
bulkRetriever,
web3ApiFactory
web3ApiFactory,
// A short-lived, minimally-configured client is enough for a health-check ping - unlike Web3ApiFactory's
// client, this never needs to survive/reuse connections across a long-lived RPC session.
OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build()
)
@Provides
@@ -5,6 +5,7 @@ import io.novafoundation.nova.runtime.ethereum.Web3ApiFactory
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.connection.ConnectionSecrets
import io.novasama.substrate_sdk_android.wsrpc.SocketService
import okhttp3.OkHttpClient
import javax.inject.Provider
import kotlinx.coroutines.CoroutineScope
@@ -12,15 +13,21 @@ class NodeHealthStateTesterFactory(
private val socketServiceProvider: Provider<SocketService>,
private val connectionSecrets: ConnectionSecrets,
private val bulkRetriever: BulkRetriever,
private val web3ApiFactory: Web3ApiFactory
private val web3ApiFactory: Web3ApiFactory,
private val httpClient: OkHttpClient,
) {
fun create(chain: Chain, node: Chain.Node, coroutineScope: CoroutineScope): NodeHealthStateTester {
val nodeIsSupported = chain.nodes.nodes.any { it.unformattedUrl == node.unformattedUrl }
require(nodeIsSupported)
return if (chain.hasSubstrateRuntime) {
SubstrateNodeHealthStateTester(
return when {
chain.isTronBased -> TronNodeHealthStateTester(
node = node,
httpClient = httpClient
)
chain.hasSubstrateRuntime -> SubstrateNodeHealthStateTester(
chain = chain,
socketService = socketServiceProvider.get(),
connectionSecrets = connectionSecrets,
@@ -28,8 +35,8 @@ class NodeHealthStateTesterFactory(
node = node,
coroutineScope = coroutineScope
)
} else {
EthereumNodeHealthStateTester(
else -> EthereumNodeHealthStateTester(
socketService = socketServiceProvider.get(),
connectionSecrets = connectionSecrets,
node = node,
@@ -0,0 +1,41 @@
package io.novafoundation.nova.runtime.multiNetwork.connection.node.healthState
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import kotlin.time.ExperimentalTime
import kotlin.time.measureTime
/**
* TronGrid speaks a plain REST API, not Ethereum JSON-RPC - reusing [EthereumNodeHealthStateTester] against it
* (as this codebase used to, before Tron nodes were included in health checks at all) would send an
* `eth_getBalance` call TronGrid doesn't understand, always reporting the node as unreachable regardless of its
* actual health. `GET /wallet/getchainparameters` needs no account/address context and is cheap on TronGrid's
* side, making it a good generic liveness ping - same endpoint this codebase already uses elsewhere
* (`TronGridApi.getChainParameters`), just called directly here since `runtime` cannot depend on
* `feature-wallet-impl` (wrong direction) to reuse that Retrofit interface.
*/
class TronNodeHealthStateTester(
private val node: Chain.Node,
private val httpClient: OkHttpClient,
) : NodeHealthStateTester {
@OptIn(ExperimentalTime::class)
override suspend fun testNodeHealthState(): Result<Long> = withContext(Dispatchers.IO) {
runCatching {
val request = Request.Builder()
.url("${node.unformattedUrl.trimEnd('/')}/wallet/getchainparameters")
.build()
val duration = measureTime {
httpClient.newCall(request).execute().use { response ->
check(response.isSuccessful) { "HTTP ${response.code}" }
}
}
duration.inWholeMilliseconds
}
}
}