feat: Bitcoin API client, balance reading, and node health checks

Adds a mempool.space-style REST client (RealBitcoinApi, with the same
retry-on-429 pattern as TronGridApi), a polling AssetBalance implementation,
and BitcoinNodeHealthStateTester so the Networks screen can show live
health for Bitcoin nodes (mirrors TronNodeHealthStateTester's rationale:
mempool.space speaks plain REST, not JSON-RPC, so the existing Ethereum/
Substrate testers can't be reused).

Wires Chain.Asset.Type.BitcoinNative through the asset-type mappers and
TypeBasedAssetSourceRegistry, and BitcoinAssetsModule into AssetsModule -
transfers/history stay on the Unsupported stubs until send support lands.
This commit is contained in:
2026-07-12 14:59:05 -07:00
parent db1476f119
commit 69b311cb47
17 changed files with 336 additions and 7 deletions
@@ -37,6 +37,7 @@ 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 org.web3j.protocol.http.HttpService
import javax.inject.Provider
@@ -160,12 +161,14 @@ class ChainRegistryModule {
socketProvider: Provider<SocketService>,
bulkRetriever: BulkRetriever,
connectionSecrets: ConnectionSecrets,
web3ApiFactory: Web3ApiFactory
web3ApiFactory: Web3ApiFactory,
httpClient: OkHttpClient,
) = NodeHealthStateTesterFactory(
socketProvider,
connectionSecrets,
bulkRetriever,
web3ApiFactory
web3ApiFactory,
httpClient
)
@Provides
@@ -10,11 +10,14 @@ import io.novafoundation.nova.core_db.dao.ChainAssetDao
import io.novafoundation.nova.core_db.dao.ChainDao
import io.novafoundation.nova.core_db.dao.StorageDao
import io.novasama.substrate_sdk_android.wsrpc.SocketService
import okhttp3.OkHttpClient
interface RuntimeDependencies {
fun networkApiCreator(): NetworkApiCreator
fun okHttpClient(): OkHttpClient
fun socketServiceCreator(): SocketService
fun gson(): Gson
@@ -560,6 +560,18 @@ fun Chain.requireTronGridBaseUrl(): String {
}
}
/**
* The mempool.space-style REST API base url for a Bitcoin-based chain - same rationale as [requireTronGridBaseUrl]:
* Bitcoin has no JSON-RPC/WS node concept at all, so the configured `nodes` entry directly *is* the REST API base url.
*/
fun Chain.requireMempoolSpaceBaseUrl(): String {
require(isBitcoinBased) { "Chain $id is not Bitcoin-based" }
return requireNotNull(nodes.nodes.minByOrNull { it.orderId }?.unformattedUrl) {
"No mempool.space-style node configured for chain $id"
}
}
fun Chain.Asset.requireEquilibrium(): Type.Equilibrium {
require(type is Type.Equilibrium)
@@ -12,6 +12,8 @@ const val ASSET_EVM_NATIVE = "evmNative"
const val ASSET_TRON_NATIVE = "tronNative"
const val ASSET_TRC20 = "trc20"
const val ASSET_BITCOIN_NATIVE = "bitcoinNative"
const val ASSET_EQUILIBRIUM = "equilibrium"
const val ASSET_EQUILIBRIUM_ON_CHAIN_ID = "assetId"
@@ -69,6 +69,8 @@ fun mapChainAssetTypeToRaw(type: Chain.Asset.Type): Pair<String, Map<String, Any
ASSET_EQUILIBRIUM_ON_CHAIN_ID to type.id.toString()
)
Chain.Asset.Type.BitcoinNative -> ASSET_BITCOIN_NATIVE to null
Chain.Asset.Type.Unsupported -> ASSET_UNSUPPORTED to null
}
@@ -95,6 +95,8 @@ private fun mapChainAssetTypeFromRaw(type: String?, typeExtras: Map<String, Any?
ASSET_EQUILIBRIUM -> Chain.Asset.Type.Equilibrium((typeExtras!![ASSET_EQUILIBRIUM_ON_CHAIN_ID] as String).toBigInteger())
ASSET_BITCOIN_NATIVE -> Chain.Asset.Type.BitcoinNative
else -> Chain.Asset.Type.Unsupported
}
}
@@ -139,6 +139,12 @@ data class Chain(
val contractAddress: String
) : Type()
/**
* Native BTC balance on a Bitcoin-based chain.
* Balance is fetched from a mempool.space-style REST API rather than JSON-RPC.
*/
object BitcoinNative : Type()
object Unsupported : Type()
}
@@ -0,0 +1,37 @@
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
/**
* mempool.space speaks a plain REST API, not Ethereum JSON-RPC - see [TronNodeHealthStateTester]'s doc for the
* identical rationale this mirrors. `GET /blocks/tip/height` needs no account/address context and is cheap on
* mempool.space's side, making it a good generic liveness ping.
*/
class BitcoinNodeHealthStateTester(
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('/')}/blocks/tip/height")
.build()
val duration = measureTime {
httpClient.newCall(request).execute().use { response ->
check(response.isSuccessful) { "HTTP ${response.code}" }
}
}
duration.inWholeMilliseconds
}
}
}
@@ -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.isBitcoinBased -> BitcoinNodeHealthStateTester(
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,