feat: Bitcoin key derivation, secrets storage, and chain-family plumbing

Derives a BIP84 (native SegWit) keypair alongside the existing substrate/
ethereum/tron ones, stores it in MetaAccountSecrets/CloudBackup, persists
bitcoinPublicKey/bitcoinAddress on meta_accounts (DB migration 74->75), and
threads isBitcoinBased through Chain/ChainLocal/mappers/ChainExt/ChainRegistry
the same way isTronBased was wired. Adds BitcoinAddressBackfillMigration,
mirroring TronAddressBackfillMigration, so existing wallets get a Bitcoin
address without re-import.
This commit is contained in:
2026-07-12 14:47:07 -07:00
parent 2a0ccffbe4
commit 36b5a17644
29 changed files with 365 additions and 29 deletions
@@ -272,6 +272,7 @@ fun Chain.requireGenesisHash() = requireNotNull(genesisHash)
fun Chain.addressOf(accountId: ByteArray): String {
return when {
isTronBased -> accountId.toTronAddress()
isBitcoinBased -> accountId.toBitcoinAddress()
isEthereumBased -> accountId.toEthereumAddress()
else -> accountId.toAddress(addressPrefix.toShort())
}
@@ -282,7 +283,7 @@ fun Chain.addressOf(accountId: AccountIdKey): String {
}
fun Chain.legacyAddressOfOrNull(accountId: ByteArray): String? {
return if (isEthereumBased || isTronBased) {
return if (isEthereumBased || isTronBased || isBitcoinBased) {
null
} else {
legacyAddressPrefix?.let { accountId.toAddress(it.toShort()) }
@@ -296,6 +297,7 @@ fun ByteArray.toEthereumAddress(): String {
fun Chain.accountIdOf(address: String): ByteArray {
return when {
isTronBased -> address.tronAddressToAccountId()
isBitcoinBased -> address.bitcoinAddressToAccountId()
isEthereumBased -> address.asEthereumAddress().toAccountId().value
else -> address.toAccountId()
}
@@ -329,6 +331,7 @@ fun Chain.accountIdOrNull(address: String): ByteArray? {
fun Chain.emptyAccountId() = when {
isTronBased -> emptyTronAccountId()
isBitcoinBased -> emptyBitcoinAccountId()
isEthereumBased -> emptyEthereumAccountId()
else -> emptySubstrateAccountId()
}
@@ -342,6 +345,7 @@ fun Chain.accountIdOrDefault(maybeAddress: String): ByteArray {
fun Chain.accountIdOf(publicKey: ByteArray): ByteArray {
return when {
isTronBased -> publicKey.tronPublicKeyToAccountId()
isBitcoinBased -> publicKey.bitcoinPublicKeyToAccountId()
isEthereumBased -> publicKey.asEthereumPublicKey().toAccountId().value
else -> publicKey.substrateAccountId()
}
@@ -361,13 +365,15 @@ fun Chain.multiAddressOf(accountId: ByteArray): MultiAddress {
fun Chain.isValidAddress(address: String): Boolean {
return runCatching {
if (isEthereumBased) {
address.asEthereumAddress().isValid()
} else {
address.toAccountId() // verify supplied address can be converted to account id
when {
isBitcoinBased -> address.isValidBitcoinAddress()
isEthereumBased -> address.asEthereumAddress().isValid()
else -> {
address.toAccountId() // verify supplied address can be converted to account id
addressPrefix.toShort() == address.addressPrefix() ||
legacyAddressPrefix?.toShort() == address.addressPrefix()
addressPrefix.toShort() == address.addressPrefix() ||
legacyAddressPrefix?.toShort() == address.addressPrefix()
}
}
}.getOrDefault(false)
}
@@ -221,11 +221,11 @@ class ChainRegistry(
}
private suspend fun registerConnection(chain: Chain): ChainConnection? {
// Tron nodes are plain REST APIs (TronGrid), not WSS JSON-RPC endpoints - ChainConnection's
// SocketService can only speak the latter, so attempting to set one up here would hang
// indefinitely instead of failing fast. Tron balance/transfer operations already go through
// their own dedicated TronGridApi client, independent of this connection pool.
if (chain.isTronBased) return null
// Tron/Bitcoin nodes are plain REST APIs (TronGrid / mempool.space), not WSS JSON-RPC endpoints -
// ChainConnection's SocketService can only speak the latter, so attempting to set one up here would
// hang indefinitely instead of failing fast. Balance/transfer operations for both go through their
// own dedicated REST API clients, independent of this connection pool.
if (chain.isTronBased || chain.isBitcoinBased) return null
val connection = connectionPool.setupConnection(chain)
@@ -128,6 +128,7 @@ fun mapChainToLocal(chain: Chain, gson: Gson): ChainLocal {
legacyPrefix = chain.legacyAddressPrefix,
isEthereumBased = chain.isEthereumBased,
isTronBased = chain.isTronBased,
isBitcoinBased = chain.isBitcoinBased,
isTestNet = chain.isTestNet,
hasSubstrateRuntime = chain.hasSubstrateRuntime,
pushSupport = chain.pushSupport,
@@ -276,6 +276,7 @@ fun mapChainLocalToChain(
legacyAddressPrefix = legacyPrefix,
isEthereumBased = isEthereumBased,
isTronBased = isTronBased,
isBitcoinBased = isBitcoinBased,
isTestNet = isTestNet,
hasCrowdloans = hasCrowdloans,
pushSupport = pushSupport,
@@ -21,6 +21,7 @@ import io.novafoundation.nova.runtime.multiNetwork.chain.remote.model.ChainRemot
private const val ETHEREUM_OPTION = "ethereumBased"
private const val TRON_OPTION = "tronBased"
private const val BITCOIN_OPTION = "bitcoinBased"
private const val CROWDLOAN_OPTION = "crowdloans"
private const val TESTNET_OPTION = "testnet"
private const val PROXY_OPTION = "proxy"
@@ -92,6 +93,7 @@ fun mapRemoteChainToLocal(
legacyPrefix = legacyAddressPrefix,
isEthereumBased = ETHEREUM_OPTION in optionsOrEmpty,
isTronBased = TRON_OPTION in optionsOrEmpty,
isBitcoinBased = BITCOIN_OPTION in optionsOrEmpty,
isTestNet = TESTNET_OPTION in optionsOrEmpty,
hasCrowdloans = CROWDLOAN_OPTION in optionsOrEmpty,
supportProxy = PROXY_OPTION in optionsOrEmpty,
@@ -33,6 +33,7 @@ data class Chain(
val types: Types?,
val isEthereumBased: Boolean,
val isTronBased: Boolean,
val isBitcoinBased: Boolean,
val isTestNet: Boolean,
val source: Source,
val hasSubstrateRuntime: Boolean,