From 69b311cb4796b96c229c73cd10eaf0af8e2b9fea Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Sun, 12 Jul 2026 14:59:05 -0700 Subject: [PATCH] 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. --- .../data/network/bitcoin/BitcoinApi.kt | 42 ++++++++++ .../network/bitcoin/RetrofitBitcoinApi.kt | 14 ++++ .../bitcoin/model/BitcoinAddressResponse.kt | 22 +++++ .../assets/TypeBasedAssetSourceRegistry.kt | 4 + .../bitcoinNative/BitcoinBalancePolling.kt | 30 +++++++ .../BitcoinNativeAssetBalance.kt | 83 +++++++++++++++++++ .../di/modules/AssetsModule.kt | 3 + .../di/modules/BitcoinAssetsModule.kt | 57 +++++++++++++ .../nova/runtime/di/ChainRegistryModule.kt | 7 +- .../nova/runtime/di/RuntimeDependencies.kt | 3 + .../nova/runtime/ext/ChainExt.kt | 12 +++ .../chain/mappers/ChainMappersConstants.kt | 2 + .../chain/mappers/DomainToLocalChainMapper.kt | 2 + .../chain/mappers/LocalToDomainChainMapper.kt | 2 + .../runtime/multiNetwork/chain/model/Chain.kt | 6 ++ .../BitcoinNodeHealthStateTester.kt | 37 +++++++++ .../NodeHealthStateTesterFactory.kt | 17 ++-- 17 files changed, 336 insertions(+), 7 deletions(-) create mode 100644 feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/BitcoinApi.kt create mode 100644 feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/RetrofitBitcoinApi.kt create mode 100644 feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/model/BitcoinAddressResponse.kt create mode 100644 feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/bitcoinNative/BitcoinBalancePolling.kt create mode 100644 feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/bitcoinNative/BitcoinNativeAssetBalance.kt create mode 100644 feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/BitcoinAssetsModule.kt create mode 100644 runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/BitcoinNodeHealthStateTester.kt diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/BitcoinApi.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/BitcoinApi.kt new file mode 100644 index 00000000..8504babd --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/BitcoinApi.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import kotlinx.coroutines.delay +import retrofit2.HttpException +import java.math.BigInteger + +interface BitcoinApi { + + suspend fun fetchNativeBalance(baseUrl: String, address: String): Balance +} + +class RealBitcoinApi( + private val retrofitApi: RetrofitBitcoinApi +) : BitcoinApi { + + override suspend fun fetchNativeBalance(baseUrl: String, address: String): Balance { + val stats = retryOn429 { retrofitApi.getAddress(addressUrl(baseUrl, address)) }.chainStats + ?: return BigInteger.ZERO + + val funded = stats.fundedTxoSum ?: 0L + val spent = stats.spentTxoSum ?: 0L + + return (funded - spent).toBigInteger().coerceAtLeast(BigInteger.ZERO) + } + + private fun addressUrl(baseUrl: String, address: String): String { + return "${baseUrl.trimEnd('/')}/address/$address" + } + + private suspend fun retryOn429(maxAttempts: Int = 4, block: suspend () -> T): T { + repeat(maxAttempts - 1) { attempt -> + try { + return block() + } catch (e: HttpException) { + if (e.code() != 429) throw e + delay(1_000L * (attempt + 1)) + } + } + return block() + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/RetrofitBitcoinApi.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/RetrofitBitcoinApi.kt new file mode 100644 index 00000000..a0d6af41 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/RetrofitBitcoinApi.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin + +import io.novafoundation.nova.common.data.network.UserAgent +import io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.model.BitcoinAddressResponse +import retrofit2.http.GET +import retrofit2.http.Headers +import retrofit2.http.Url + +interface RetrofitBitcoinApi { + + @GET + @Headers(UserAgent.NOVA) + suspend fun getAddress(@Url url: String): BitcoinAddressResponse +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/model/BitcoinAddressResponse.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/model/BitcoinAddressResponse.kt new file mode 100644 index 00000000..4ced6159 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/model/BitcoinAddressResponse.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.model + +import com.google.gson.annotations.SerializedName + +/** + * Response shape of mempool.space's `GET /address/{address}`. + * + * `chainStats` reflects only confirmed on-chain activity; `mempoolStats` (unconfirmed) is deliberately not + * used for balance - matching the exchange's proven 2-confirmation-required posture for this same API. + */ +class BitcoinAddressResponse( + @SerializedName("chain_stats") + val chainStats: BitcoinAddressStats? = null, +) + +class BitcoinAddressStats( + @SerializedName("funded_txo_sum") + val fundedTxoSum: Long? = null, + + @SerializedName("spent_txo_sum") + val spentTxoSum: Long? = null, +) diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/TypeBasedAssetSourceRegistry.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/TypeBasedAssetSourceRegistry.kt index 9b505934..627aeafe 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/TypeBasedAssetSourceRegistry.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/TypeBasedAssetSourceRegistry.kt @@ -31,6 +31,7 @@ class TypeBasedAssetSourceRegistry( private val equilibriumAssetSource: Lazy, private val tronNativeSource: Lazy, private val trc20Source: Lazy, + private val bitcoinNativeSource: Lazy, private val unsupportedBalanceSource: AssetSource, private val nativeAssetEventDetector: NativeAssetEventDetector, @@ -49,6 +50,7 @@ class TypeBasedAssetSourceRegistry( is Chain.Asset.Type.Equilibrium -> equilibriumAssetSource.get() is Chain.Asset.Type.TronNative -> tronNativeSource.get() is Chain.Asset.Type.Trc20 -> trc20Source.get() + is Chain.Asset.Type.BitcoinNative -> bitcoinNativeSource.get() Chain.Asset.Type.Unsupported -> unsupportedBalanceSource } } @@ -63,6 +65,7 @@ class TypeBasedAssetSourceRegistry( add(equilibriumAssetSource.get()) add(tronNativeSource.get()) add(trc20Source.get()) + add(bitcoinNativeSource.get()) } } @@ -72,6 +75,7 @@ class TypeBasedAssetSourceRegistry( Chain.Asset.Type.EvmNative, is Chain.Asset.Type.TronNative, is Chain.Asset.Type.Trc20, + is Chain.Asset.Type.BitcoinNative, Chain.Asset.Type.Unsupported -> UnsupportedEventDetector() diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/bitcoinNative/BitcoinBalancePolling.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/bitcoinNative/BitcoinBalancePolling.kt new file mode 100644 index 00000000..3634d17b --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/bitcoinNative/BitcoinBalancePolling.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.bitcoinNative + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +private const val BITCOIN_BALANCE_POLLING_INTERVAL_MS = 30_000L + +/** + * mempool.space is a plain REST API with no push/subscription mechanism, same as TronGrid - see + * `TronBalancePolling.pollingBalanceFlow`'s doc for the full rationale this mirrors. + */ +internal fun pollingBalanceFlow( + intervalMs: Long = BITCOIN_BALANCE_POLLING_INTERVAL_MS, + fetch: suspend () -> Balance +): Flow = flow { + var lastEmitted: Balance? = null + + while (true) { + val latest = fetch() + + if (latest != lastEmitted) { + lastEmitted = latest + emit(latest) + } + + delay(intervalMs) + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/bitcoinNative/BitcoinNativeAssetBalance.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/bitcoinNative/BitcoinNativeAssetBalance.kt new file mode 100644 index 00000000..508e2968 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/bitcoinNative/BitcoinNativeAssetBalance.kt @@ -0,0 +1,83 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.bitcoinNative + +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache +import io.novafoundation.nova.feature_wallet_api.data.cache.updateNonLockableAsset +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.AssetBalance +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.BalanceSyncUpdate +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.ChainAssetBalance +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.TransferableBalanceUpdatePoint +import io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.BitcoinApi +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.ext.requireMempoolSpaceBaseUrl +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.map +import java.math.BigInteger + +/** + * Native BTC balance on a Bitcoin-based chain. Read-only (Phase 4): fetches via a mempool.space-style REST API + * and polls for updates, since that API has no push/subscription mechanism. No transfer/history support here - + * see `BitcoinAssetsModule` for how this is paired with `UnsupportedAssetTransfers`/`UnsupportedAssetHistory`. + */ +class BitcoinNativeAssetBalance( + private val assetCache: AssetCache, + private val bitcoinApi: BitcoinApi, +) : AssetBalance { + + override suspend fun startSyncingBalanceLocks( + metaAccount: MetaAccount, + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow<*> { + // Bitcoin native balance does not support locks + return emptyFlow() + } + + override fun isSelfSufficient(chainAsset: Chain.Asset): Boolean { + return true + } + + override suspend fun existentialDeposit(chainAsset: Chain.Asset): BigInteger { + // Bitcoin does not have an existential deposit concept (UTXO dust limit is enforced at the transfer level, not here) + return BigInteger.ZERO + } + + override suspend fun queryAccountBalance(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId): ChainAssetBalance { + val balance = bitcoinApi.fetchNativeBalance(chain.requireMempoolSpaceBaseUrl(), chain.addressOf(accountId)) + + return ChainAssetBalance.fromFree(chainAsset, balance) + } + + override suspend fun subscribeAccountBalanceUpdatePoint( + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId, + ): Flow { + // Not on the critical sync path (mirrors TronNativeAssetBalance/EvmNativeAssetBalance) - out of scope for Phase 4. + TODO("Not yet implemented") + } + + override suspend fun startSyncingBalance( + chain: Chain, + chainAsset: Chain.Asset, + metaAccount: MetaAccount, + accountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow { + val baseUrl = chain.requireMempoolSpaceBaseUrl() + val address = chain.addressOf(accountId) + + return pollingBalanceFlow { bitcoinApi.fetchNativeBalance(baseUrl, address) } + .map { balance -> + assetCache.updateNonLockableAsset(metaAccount.id, chainAsset, balance) + + BalanceSyncUpdate.NoCause + } + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/AssetsModule.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/AssetsModule.kt index ebe0597d..bbcf0da0 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/AssetsModule.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/AssetsModule.kt @@ -22,6 +22,7 @@ import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets EvmNativeAssetsModule::class, EquilibriumAssetsModule::class, TronAssetsModule::class, + BitcoinAssetsModule::class, UnsupportedAssetsModule::class ] ) @@ -38,6 +39,7 @@ class AssetsModule { @EquilibriumAsset equilibrium: Lazy, @TronNativeAssets tronNative: Lazy, @Trc20Assets trc20: Lazy, + @BitcoinNativeAssets bitcoinNative: Lazy, @UnsupportedAssets unsupported: AssetSource, nativeAssetEventDetector: NativeAssetEventDetector, @@ -53,6 +55,7 @@ class AssetsModule { equilibriumAssetSource = equilibrium, tronNativeSource = tronNative, trc20Source = trc20, + bitcoinNativeSource = bitcoinNative, unsupportedBalanceSource = unsupported, nativeAssetEventDetector = nativeAssetEventDetector, diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/BitcoinAssetsModule.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/BitcoinAssetsModule.kt new file mode 100644 index 00000000..9c1ff2bf --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/BitcoinAssetsModule.kt @@ -0,0 +1,57 @@ +package io.novafoundation.nova.feature_wallet_impl.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSource +import io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.BitcoinApi +import io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.RealBitcoinApi +import io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.RetrofitBitcoinApi +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.StaticAssetSource +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.bitcoinNative.BitcoinNativeAssetBalance +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.UnsupportedAssetHistory +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.UnsupportedAssetTransfers +import javax.inject.Qualifier + +@Qualifier +annotation class BitcoinNativeAssets + +/** + * Bitcoin support - Phase 4, read-only. + * + * Only `balance` is implemented for real; `transfers`/`history` reuse the same `Unsupported*` stubs the rest of + * the app uses for asset types with no send/history support yet (see `TronAssetsModule` for the identical + * Phase-1 precedent this mirrors). Send support is separate, later work. + */ +@Module +class BitcoinAssetsModule { + + @Provides + @FeatureScope + fun provideRetrofitBitcoinApi( + networkApiCreator: NetworkApiCreator + ): RetrofitBitcoinApi = networkApiCreator.create(RetrofitBitcoinApi::class.java) + + @Provides + @FeatureScope + fun provideBitcoinApi(retrofitBitcoinApi: RetrofitBitcoinApi): BitcoinApi = RealBitcoinApi(retrofitBitcoinApi) + + @Provides + @FeatureScope + fun provideBitcoinNativeBalance(assetCache: AssetCache, bitcoinApi: BitcoinApi) = BitcoinNativeAssetBalance(assetCache, bitcoinApi) + + @Provides + @BitcoinNativeAssets + @FeatureScope + fun provideBitcoinNativeAssetSource( + bitcoinNativeAssetBalance: BitcoinNativeAssetBalance, + unsupportedAssetTransfers: UnsupportedAssetTransfers, + unsupportedAssetHistory: UnsupportedAssetHistory, + ): AssetSource = StaticAssetSource( + transfers = unsupportedAssetTransfers, + balance = bitcoinNativeAssetBalance, + history = unsupportedAssetHistory + ) +} 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 97e74a26..8b674db2 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 @@ -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, bulkRetriever: BulkRetriever, connectionSecrets: ConnectionSecrets, - web3ApiFactory: Web3ApiFactory + web3ApiFactory: Web3ApiFactory, + httpClient: OkHttpClient, ) = NodeHealthStateTesterFactory( socketProvider, connectionSecrets, bulkRetriever, - web3ApiFactory + web3ApiFactory, + httpClient ) @Provides diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/di/RuntimeDependencies.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/di/RuntimeDependencies.kt index f56a081e..56efddc7 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/di/RuntimeDependencies.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/di/RuntimeDependencies.kt @@ -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 diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainExt.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainExt.kt index 475f26b4..909766a3 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainExt.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainExt.kt @@ -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) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/ChainMappersConstants.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/ChainMappersConstants.kt index f5925053..9430b07e 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/ChainMappersConstants.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/ChainMappersConstants.kt @@ -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" diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/DomainToLocalChainMapper.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/DomainToLocalChainMapper.kt index 56e2f197..ff2011c9 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/DomainToLocalChainMapper.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/DomainToLocalChainMapper.kt @@ -69,6 +69,8 @@ fun mapChainAssetTypeToRaw(type: Chain.Asset.Type): Pair ASSET_BITCOIN_NATIVE to null + Chain.Asset.Type.Unsupported -> ASSET_UNSUPPORTED to null } diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/LocalToDomainChainMapper.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/LocalToDomainChainMapper.kt index d2ea493d..27f9ffc3 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/LocalToDomainChainMapper.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/LocalToDomainChainMapper.kt @@ -95,6 +95,8 @@ private fun mapChainAssetTypeFromRaw(type: String?, typeExtras: Map 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 } } diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/model/Chain.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/model/Chain.kt index 41401b27..e4be32b0 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/model/Chain.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/model/Chain.kt @@ -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() } diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/BitcoinNodeHealthStateTester.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/BitcoinNodeHealthStateTester.kt new file mode 100644 index 00000000..a134f35a --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/BitcoinNodeHealthStateTester.kt @@ -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 = 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 + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/NodeHealthStateTesterFactory.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/NodeHealthStateTesterFactory.kt index b9986a1b..afac3d1d 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/NodeHealthStateTesterFactory.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/NodeHealthStateTesterFactory.kt @@ -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, 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,