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:
Satoshi Qazi Muhammed committed 2026-07-12 14:47:07 -07:00
1 parent 2a0ccffbe4
commit 36b5a17644
29 files changed
+365 -29

No files matched your search

@@ -28,6 +28,9 @@ object MetaAccountSecrets : Schema<MetaAccountSecrets>() {
val TronKeypair by schema(KeyPairSchema).optional() val TronKeypair by schema(KeyPairSchema).optional()
val TronDerivationPath by string().optional() val TronDerivationPath by string().optional()
val BitcoinKeypair by schema(KeyPairSchema).optional()
val BitcoinDerivationPath by string().optional()
} }
object ChainAccountSecrets : Schema<ChainAccountSecrets>() { object ChainAccountSecrets : Schema<ChainAccountSecrets>() {
@@ -47,6 +50,8 @@ fun MetaAccountSecrets(
ethereumDerivationPath: String? = null, ethereumDerivationPath: String? = null,
tronKeypair: Keypair? = null, tronKeypair: Keypair? = null,
tronDerivationPath: String? = null, tronDerivationPath: String? = null,
bitcoinKeypair: Keypair? = null,
bitcoinDerivationPath: String? = null,
): EncodableStruct<MetaAccountSecrets> = MetaAccountSecrets { secrets -> ): EncodableStruct<MetaAccountSecrets> = MetaAccountSecrets { secrets ->
secrets[Entropy] = entropy secrets[Entropy] = entropy
secrets[SubstrateSeed] = substrateSeed secrets[SubstrateSeed] = substrateSeed
@@ -75,6 +80,15 @@ fun MetaAccountSecrets(
} }
} }
secrets[TronDerivationPath] = tronDerivationPath secrets[TronDerivationPath] = tronDerivationPath
secrets[BitcoinKeypair] = bitcoinKeypair?.let {
KeyPairSchema { keypair ->
keypair[PublicKey] = it.publicKey
keypair[PrivateKey] = it.privateKey
keypair[Nonce] = null // bitcoin uses secp256k1 (like ethereum/tron), so nonce is always null
}
}
secrets[BitcoinDerivationPath] = bitcoinDerivationPath
} }
fun ChainAccountSecrets( fun ChainAccountSecrets(
@@ -103,6 +117,9 @@ val EncodableStruct<MetaAccountSecrets>.ethereumDerivationPath
val EncodableStruct<MetaAccountSecrets>.tronDerivationPath val EncodableStruct<MetaAccountSecrets>.tronDerivationPath
get() = get(MetaAccountSecrets.TronDerivationPath) get() = get(MetaAccountSecrets.TronDerivationPath)
val EncodableStruct<MetaAccountSecrets>.bitcoinDerivationPath
get() = get(MetaAccountSecrets.BitcoinDerivationPath)
val EncodableStruct<MetaAccountSecrets>.entropy val EncodableStruct<MetaAccountSecrets>.entropy
get() = get(MetaAccountSecrets.Entropy) get() = get(MetaAccountSecrets.Entropy)
@@ -118,6 +135,9 @@ val EncodableStruct<MetaAccountSecrets>.ethereumKeypair
val EncodableStruct<MetaAccountSecrets>.tronKeypair val EncodableStruct<MetaAccountSecrets>.tronKeypair
get() = get(MetaAccountSecrets.TronKeypair) get() = get(MetaAccountSecrets.TronKeypair)
val EncodableStruct<MetaAccountSecrets>.bitcoinKeypair
get() = get(MetaAccountSecrets.BitcoinKeypair)
val EncodableStruct<ChainAccountSecrets>.derivationPath val EncodableStruct<ChainAccountSecrets>.derivationPath
get() = get(ChainAccountSecrets.DerivationPath) get() = get(ChainAccountSecrets.DerivationPath)
@@ -58,6 +58,7 @@ fun chainOf(
isTestNet = false, isTestNet = false,
isEthereumBased = false, isEthereumBased = false,
isTronBased = false, isTronBased = false,
isBitcoinBased = false,
hasCrowdloans = false, hasCrowdloans = false,
additional = "", additional = "",
governance = "governance", governance = "governance",
@@ -94,6 +94,7 @@ import io.novafoundation.nova.core_db.migrations.AddStakingTypeToTotalRewards_44
import io.novafoundation.nova.core_db.migrations.AddSwapOption_48_49 import io.novafoundation.nova.core_db.migrations.AddSwapOption_48_49
import io.novafoundation.nova.core_db.migrations.AddTransactionVersionToRuntime_50_51 import io.novafoundation.nova.core_db.migrations.AddTransactionVersionToRuntime_50_51
import io.novafoundation.nova.core_db.migrations.AddTransferApisTable_29_30 import io.novafoundation.nova.core_db.migrations.AddTransferApisTable_29_30
import io.novafoundation.nova.core_db.migrations.AddBitcoinSupport_74_75
import io.novafoundation.nova.core_db.migrations.AddTronSupport_73_74 import io.novafoundation.nova.core_db.migrations.AddTronSupport_73_74
import io.novafoundation.nova.core_db.migrations.AddTypeExtrasToMetaAccount_68_69 import io.novafoundation.nova.core_db.migrations.AddTypeExtrasToMetaAccount_68_69
import io.novafoundation.nova.core_db.migrations.AddVersioningToGovernanceDapps_32_33 import io.novafoundation.nova.core_db.migrations.AddVersioningToGovernanceDapps_32_33
@@ -167,7 +168,7 @@ import io.novafoundation.nova.core_db.model.operation.SwapTypeLocal
import io.novafoundation.nova.core_db.model.operation.TransferTypeLocal import io.novafoundation.nova.core_db.model.operation.TransferTypeLocal
@Database( @Database(
version = 74, version = 75,
entities = [ entities = [
AccountLocal::class, AccountLocal::class,
NodeLocal::class, NodeLocal::class,
@@ -273,6 +274,7 @@ abstract class AppDatabase : RoomDatabase() {
.addMigrations(AddTypeExtrasToMetaAccount_68_69, AddMultisigCalls_69_70, AddMultisigSupportFlag_70_71) .addMigrations(AddTypeExtrasToMetaAccount_68_69, AddMultisigCalls_69_70, AddMultisigSupportFlag_70_71)
.addMigrations(AddGifts_71_72, AddFieldsToContributions) .addMigrations(AddGifts_71_72, AddFieldsToContributions)
.addMigrations(AddTronSupport_73_74) .addMigrations(AddTronSupport_73_74)
.addMigrations(AddBitcoinSupport_74_75)
.build() .build()
} }
return instance!! return instance!!
@@ -0,0 +1,14 @@
package io.novafoundation.nova.core_db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
val AddBitcoinSupport_74_75 = object : Migration(74, 75) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE chains ADD COLUMN isBitcoinBased INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE meta_accounts ADD COLUMN bitcoinPublicKey BLOB")
db.execSQL("ALTER TABLE meta_accounts ADD COLUMN bitcoinAddress BLOB")
}
}
@@ -23,6 +23,8 @@ data class ChainLocal(
val isEthereumBased: Boolean, val isEthereumBased: Boolean,
@ColumnInfo(defaultValue = "0") @ColumnInfo(defaultValue = "0")
val isTronBased: Boolean, val isTronBased: Boolean,
@ColumnInfo(defaultValue = "0")
val isBitcoinBased: Boolean,
val isTestNet: Boolean, val isTestNet: Boolean,
@ColumnInfo(defaultValue = "1") @ColumnInfo(defaultValue = "1")
val hasSubstrateRuntime: Boolean, val hasSubstrateRuntime: Boolean,
@@ -39,6 +39,8 @@ class MetaAccountLocal(
val typeExtras: SerializedJson?, val typeExtras: SerializedJson?,
val tronPublicKey: ByteArray? = null, val tronPublicKey: ByteArray? = null,
val tronAddress: ByteArray? = null, val tronAddress: ByteArray? = null,
val bitcoinPublicKey: ByteArray? = null,
val bitcoinAddress: ByteArray? = null,
) { ) {
enum class Status { enum class Status {
@@ -59,6 +61,9 @@ class MetaAccountLocal(
const val TRON_PUBKEY = "tronPublicKey" const val TRON_PUBKEY = "tronPublicKey"
const val TRON_ADDRESS = "tronAddress" const val TRON_ADDRESS = "tronAddress"
const val BITCOIN_PUBKEY = "bitcoinPublicKey"
const val BITCOIN_ADDRESS = "bitcoinAddress"
const val NAME = "name" const val NAME = "name"
const val IS_SELECTED = "isSelected" const val IS_SELECTED = "isSelected"
const val POSITION = "position" const val POSITION = "position"
@@ -90,7 +95,37 @@ class MetaAccountLocal(
globallyUniqueId = globallyUniqueId, globallyUniqueId = globallyUniqueId,
typeExtras = typeExtras, typeExtras = typeExtras,
tronPublicKey = tronPublicKey, tronPublicKey = tronPublicKey,
tronAddress = tronAddress tronAddress = tronAddress,
bitcoinPublicKey = bitcoinPublicKey,
bitcoinAddress = bitcoinAddress,
).also {
it.id = id
}
}
// We do not use copy as we need explicitly set id
fun addBitcoinAccount(
bitcoinPublicKey: ByteArray,
bitcoinAddress: ByteArray,
): MetaAccountLocal {
return MetaAccountLocal(
substratePublicKey = substratePublicKey,
substrateCryptoType = substrateCryptoType,
substrateAccountId = substrateAccountId,
ethereumPublicKey = ethereumPublicKey,
ethereumAddress = ethereumAddress,
name = name,
parentMetaId = parentMetaId,
isSelected = isSelected,
position = position,
type = type,
status = status,
globallyUniqueId = globallyUniqueId,
typeExtras = typeExtras,
tronPublicKey = tronPublicKey,
tronAddress = tronAddress,
bitcoinPublicKey = bitcoinPublicKey,
bitcoinAddress = bitcoinAddress,
).also { ).also {
it.id = id it.id = id
} }
@@ -109,6 +144,8 @@ class MetaAccountLocal(
if (!ethereumAddress.contentEquals(other.ethereumAddress)) return false if (!ethereumAddress.contentEquals(other.ethereumAddress)) return false
if (!tronPublicKey.contentEquals(other.tronPublicKey)) return false if (!tronPublicKey.contentEquals(other.tronPublicKey)) return false
if (!tronAddress.contentEquals(other.tronAddress)) return false if (!tronAddress.contentEquals(other.tronAddress)) return false
if (!bitcoinPublicKey.contentEquals(other.bitcoinPublicKey)) return false
if (!bitcoinAddress.contentEquals(other.bitcoinAddress)) return false
if (name != other.name) return false if (name != other.name) return false
if (parentMetaId != other.parentMetaId) return false if (parentMetaId != other.parentMetaId) return false
if (isSelected != other.isSelected) return false if (isSelected != other.isSelected) return false
@@ -130,6 +167,8 @@ class MetaAccountLocal(
result = 31 * result + (ethereumAddress?.contentHashCode() ?: 0) result = 31 * result + (ethereumAddress?.contentHashCode() ?: 0)
result = 31 * result + (tronPublicKey?.contentHashCode() ?: 0) result = 31 * result + (tronPublicKey?.contentHashCode() ?: 0)
result = 31 * result + (tronAddress?.contentHashCode() ?: 0) result = 31 * result + (tronAddress?.contentHashCode() ?: 0)
result = 31 * result + (bitcoinPublicKey?.contentHashCode() ?: 0)
result = 31 * result + (bitcoinAddress?.contentHashCode() ?: 0)
result = 31 * result + name.hashCode() result = 31 * result + name.hashCode()
result = 31 * result + (parentMetaId?.hashCode() ?: 0) result = 31 * result + (parentMetaId?.hashCode() ?: 0)
result = 31 * result + isSelected.hashCode() result = 31 * result + isSelected.hashCode()
@@ -52,6 +52,10 @@ interface LightMetaAccount {
*/ */
val tronAddress: ByteArray? val tronAddress: ByteArray?
val tronPublicKey: ByteArray? val tronPublicKey: ByteArray?
/** Bitcoin account id (HASH160 of the compressed pubkey - see [io.novafoundation.nova.common.utils.hash160]). */
val bitcoinAddress: ByteArray?
val bitcoinPublicKey: ByteArray?
val isSelected: Boolean val isSelected: Boolean
val name: String val name: String
val type: Type val type: Type
@@ -90,6 +94,8 @@ fun LightMetaAccount(
parentMetaId: Long?, parentMetaId: Long?,
tronAddress: ByteArray? = null, tronAddress: ByteArray? = null,
tronPublicKey: ByteArray? = null, tronPublicKey: ByteArray? = null,
bitcoinAddress: ByteArray? = null,
bitcoinPublicKey: ByteArray? = null,
) = object : LightMetaAccount { ) = object : LightMetaAccount {
override val id: Long = id override val id: Long = id
override val globallyUniqueId: String = globallyUniqueId override val globallyUniqueId: String = globallyUniqueId
@@ -100,6 +106,8 @@ fun LightMetaAccount(
override val ethereumPublicKey: ByteArray? = ethereumPublicKey override val ethereumPublicKey: ByteArray? = ethereumPublicKey
override val tronAddress: ByteArray? = tronAddress override val tronAddress: ByteArray? = tronAddress
override val tronPublicKey: ByteArray? = tronPublicKey override val tronPublicKey: ByteArray? = tronPublicKey
override val bitcoinAddress: ByteArray? = bitcoinAddress
override val bitcoinPublicKey: ByteArray? = bitcoinPublicKey
override val isSelected: Boolean = isSelected override val isSelected: Boolean = isSelected
override val name: String = name override val name: String = name
override val type: LightMetaAccount.Type = type override val type: LightMetaAccount.Type = type
@@ -7,6 +7,8 @@ import io.novafoundation.nova.common.data.secrets.v2.KeyPairSchema
import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets
import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2
import io.novafoundation.nova.common.data.secrets.v2.derivationPath import io.novafoundation.nova.common.data.secrets.v2.derivationPath
import io.novafoundation.nova.common.data.secrets.v2.bitcoinDerivationPath
import io.novafoundation.nova.common.data.secrets.v2.bitcoinKeypair
import io.novafoundation.nova.common.data.secrets.v2.entropy import io.novafoundation.nova.common.data.secrets.v2.entropy
import io.novafoundation.nova.common.data.secrets.v2.ethereumDerivationPath import io.novafoundation.nova.common.data.secrets.v2.ethereumDerivationPath
import io.novafoundation.nova.common.data.secrets.v2.ethereumKeypair import io.novafoundation.nova.common.data.secrets.v2.ethereumKeypair
@@ -90,6 +92,7 @@ class RealLocalAccountsCloudBackupFacade(
substrate = baseSecrets.getSubstrateBackupSecrets(), substrate = baseSecrets.getSubstrateBackupSecrets(),
ethereum = baseSecrets.getEthereumBackupSecrets(), ethereum = baseSecrets.getEthereumBackupSecrets(),
chainAccounts = emptyList(), chainAccounts = emptyList(),
bitcoin = baseSecrets.getBitcoinBackupSecrets(),
) )
return CloudBackup( return CloudBackup(
@@ -357,6 +360,7 @@ class RealLocalAccountsCloudBackupFacade(
substrate = prepareSubstrateBackupSecrets(baseSecrets, joinedMetaAccountInfo), substrate = prepareSubstrateBackupSecrets(baseSecrets, joinedMetaAccountInfo),
ethereum = baseSecrets.getEthereumBackupSecrets(), ethereum = baseSecrets.getEthereumBackupSecrets(),
chainAccounts = chainAccountsFromChainSecrets + chainAccountFromAdditionalSecrets, chainAccounts = chainAccountsFromChainSecrets + chainAccountFromAdditionalSecrets,
bitcoin = baseSecrets.getBitcoinBackupSecrets(),
) )
} }
@@ -466,7 +470,9 @@ class RealLocalAccountsCloudBackupFacade(
substrateKeyPair = substrate?.keypair?.toLocalKeyPair() ?: return null, substrateKeyPair = substrate?.keypair?.toLocalKeyPair() ?: return null,
substrateDerivationPath = substrate?.derivationPath, substrateDerivationPath = substrate?.derivationPath,
ethereumKeypair = ethereum?.keypair?.toLocalKeyPair(), ethereumKeypair = ethereum?.keypair?.toLocalKeyPair(),
ethereumDerivationPath = ethereum?.derivationPath ethereumDerivationPath = ethereum?.derivationPath,
bitcoinKeypair = bitcoin?.keypair?.toLocalKeyPair(),
bitcoinDerivationPath = bitcoin?.derivationPath
) )
} }
@@ -479,6 +485,15 @@ class RealLocalAccountsCloudBackupFacade(
) )
} }
private fun EncodableStruct<MetaAccountSecrets>?.getBitcoinBackupSecrets(): CloudBackup.WalletPrivateInfo.BitcoinSecrets? {
if (this == null) return null
return CloudBackup.WalletPrivateInfo.BitcoinSecrets(
keypair = bitcoinKeypair?.toBackupKeypairSecrets() ?: return null,
derivationPath = bitcoinDerivationPath
)
}
private fun EncodableStruct<MetaAccountSecrets>?.getSubstrateBackupSecrets(): CloudBackup.WalletPrivateInfo.SubstrateSecrets? { private fun EncodableStruct<MetaAccountSecrets>?.getSubstrateBackupSecrets(): CloudBackup.WalletPrivateInfo.SubstrateSecrets? {
if (this == null) return null if (this == null) return null
@@ -520,7 +535,9 @@ class RealLocalAccountsCloudBackupFacade(
ethereumPublicKey = metaAccount.ethereumPublicKey, ethereumPublicKey = metaAccount.ethereumPublicKey,
name = metaAccount.name, name = metaAccount.name,
type = metaAccount.type.toBackupWalletType() ?: return null, type = metaAccount.type.toBackupWalletType() ?: return null,
chainAccounts = chainAccounts.mapToSet { chainAccount -> chainAccount.toBackupPublicChainAccount(chainsById) } chainAccounts = chainAccounts.mapToSet { chainAccount -> chainAccount.toBackupPublicChainAccount(chainsById) },
bitcoinAddress = metaAccount.bitcoinAddress,
bitcoinPublicKey = metaAccount.bitcoinPublicKey
) )
} }
@@ -542,7 +559,9 @@ class RealLocalAccountsCloudBackupFacade(
isSelected = isSelected, isSelected = isSelected,
position = accountPosition, position = accountPosition,
status = MetaAccountLocal.Status.ACTIVE, status = MetaAccountLocal.Status.ACTIVE,
typeExtras = null typeExtras = null,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey
).also { ).also {
if (localIdOverwrite != null) { if (localIdOverwrite != null) {
it.id = localIdOverwrite it.id = localIdOverwrite
@@ -65,6 +65,8 @@ class AccountMappers(
ethereumPublicKey = ethereumPublicKey, ethereumPublicKey = ethereumPublicKey,
tronAddress = tronAddress, tronAddress = tronAddress,
tronPublicKey = tronPublicKey, tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey,
isSelected = isSelected, isSelected = isSelected,
name = name, name = name,
status = mapMetaAccountStateFromLocal(status), status = mapMetaAccountStateFromLocal(status),
@@ -82,6 +84,8 @@ class AccountMappers(
ethereumPublicKey = ethereumPublicKey, ethereumPublicKey = ethereumPublicKey,
tronAddress = tronAddress, tronAddress = tronAddress,
tronPublicKey = tronPublicKey, tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey,
isSelected = isSelected, isSelected = isSelected,
name = name, name = name,
type = type, type = type,
@@ -101,6 +105,8 @@ class AccountMappers(
ethereumPublicKey = ethereumPublicKey, ethereumPublicKey = ethereumPublicKey,
tronAddress = tronAddress, tronAddress = tronAddress,
tronPublicKey = tronPublicKey, tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey,
isSelected = isSelected, isSelected = isSelected,
name = name, name = name,
type = type, type = type,
@@ -119,6 +125,8 @@ class AccountMappers(
ethereumPublicKey = ethereumPublicKey, ethereumPublicKey = ethereumPublicKey,
tronAddress = tronAddress, tronAddress = tronAddress,
tronPublicKey = tronPublicKey, tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey,
isSelected = isSelected, isSelected = isSelected,
name = name, name = name,
type = type, type = type,
@@ -138,6 +146,8 @@ class AccountMappers(
ethereumPublicKey = ethereumPublicKey, ethereumPublicKey = ethereumPublicKey,
tronAddress = tronAddress, tronAddress = tronAddress,
tronPublicKey = tronPublicKey, tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey,
isSelected = isSelected, isSelected = isSelected,
name = name, name = name,
type = type, type = type,
@@ -165,6 +175,8 @@ class AccountMappers(
ethereumPublicKey = ethereumPublicKey, ethereumPublicKey = ethereumPublicKey,
tronAddress = tronAddress, tronAddress = tronAddress,
tronPublicKey = tronPublicKey, tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey,
isSelected = isSelected, isSelected = isSelected,
name = name, name = name,
status = mapMetaAccountStateFromLocal(status), status = mapMetaAccountStateFromLocal(status),
@@ -183,6 +195,8 @@ class AccountMappers(
ethereumPublicKey = ethereumPublicKey, ethereumPublicKey = ethereumPublicKey,
tronAddress = tronAddress, tronAddress = tronAddress,
tronPublicKey = tronPublicKey, tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey,
chainAccounts = chainAccounts, chainAccounts = chainAccounts,
isSelected = isSelected, isSelected = isSelected,
name = name, name = name,
@@ -30,6 +30,7 @@ import io.novafoundation.nova.feature_account_impl.data.mappers.AccountMappers
import io.novafoundation.nova.feature_account_impl.data.mappers.mapMetaAccountTypeToLocal import io.novafoundation.nova.feature_account_impl.data.mappers.mapMetaAccountTypeToLocal
import io.novafoundation.nova.feature_account_impl.data.mappers.mapMetaAccountWithBalanceFromLocal import io.novafoundation.nova.feature_account_impl.data.mappers.mapMetaAccountWithBalanceFromLocal
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.AccountDataMigration import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.AccountDataMigration
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.BitcoinAddressBackfillMigration
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.model.ChainAccountInsertionData import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.model.ChainAccountInsertionData
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.model.MetaAccountInsertionData import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.model.MetaAccountInsertionData
import io.novafoundation.nova.runtime.ext.accountIdOf import io.novafoundation.nova.runtime.ext.accountIdOf
@@ -64,10 +65,12 @@ class AccountDataSourceImpl(
private val secretsMetaAccountLocalFactory: SecretsMetaAccountLocalFactory, private val secretsMetaAccountLocalFactory: SecretsMetaAccountLocalFactory,
secretStoreV1: SecretStoreV1, secretStoreV1: SecretStoreV1,
accountDataMigration: AccountDataMigration, accountDataMigration: AccountDataMigration,
private val bitcoinAddressBackfillMigration: BitcoinAddressBackfillMigration,
) : AccountDataSource, SecretStoreV1 by secretStoreV1 { ) : AccountDataSource, SecretStoreV1 by secretStoreV1 {
init { init {
migrateIfNeeded(accountDataMigration) migrateIfNeeded(accountDataMigration)
async { bitcoinAddressBackfillMigration.migrate() }
} }
private fun migrateIfNeeded(migration: AccountDataMigration) = async { private fun migrateIfNeeded(migration: AccountDataMigration) = async {
@@ -2,6 +2,7 @@ package io.novafoundation.nova.feature_account_impl.data.repository.datasource
import io.novafoundation.nova.common.data.secrets.v2.KeyPairSchema import io.novafoundation.nova.common.data.secrets.v2.KeyPairSchema
import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets
import io.novafoundation.nova.common.utils.bitcoinPublicKeyToAccountId
import io.novafoundation.nova.common.utils.substrateAccountId import io.novafoundation.nova.common.utils.substrateAccountId
import io.novafoundation.nova.common.utils.tronPublicKeyToAccountId import io.novafoundation.nova.common.utils.tronPublicKeyToAccountId
import io.novafoundation.nova.core.model.CryptoType import io.novafoundation.nova.core.model.CryptoType
@@ -31,6 +32,7 @@ class RealSecretsMetaAccountLocalFactory : SecretsMetaAccountLocalFactory {
val substratePublicKey = secrets[MetaAccountSecrets.SubstrateKeypair][KeyPairSchema.PublicKey] val substratePublicKey = secrets[MetaAccountSecrets.SubstrateKeypair][KeyPairSchema.PublicKey]
val ethereumPublicKey = secrets[MetaAccountSecrets.EthereumKeypair]?.get(KeyPairSchema.PublicKey) val ethereumPublicKey = secrets[MetaAccountSecrets.EthereumKeypair]?.get(KeyPairSchema.PublicKey)
val tronPublicKey = secrets[MetaAccountSecrets.TronKeypair]?.get(KeyPairSchema.PublicKey) val tronPublicKey = secrets[MetaAccountSecrets.TronKeypair]?.get(KeyPairSchema.PublicKey)
val bitcoinPublicKey = secrets[MetaAccountSecrets.BitcoinKeypair]?.get(KeyPairSchema.PublicKey)
return MetaAccountLocal( return MetaAccountLocal(
substratePublicKey = substratePublicKey, substratePublicKey = substratePublicKey,
@@ -47,7 +49,13 @@ class RealSecretsMetaAccountLocalFactory : SecretsMetaAccountLocalFactory {
globallyUniqueId = MetaAccountLocal.generateGloballyUniqueId(), globallyUniqueId = MetaAccountLocal.generateGloballyUniqueId(),
typeExtras = null, typeExtras = null,
tronPublicKey = tronPublicKey, tronPublicKey = tronPublicKey,
tronAddress = tronPublicKey?.tronPublicKeyToAccountId() tronAddress = tronPublicKey?.tronPublicKeyToAccountId(),
bitcoinPublicKey = bitcoinPublicKey,
// Bip32EcdsaKeypairFactory already yields a compressed (33-byte) public key (confirmed against
// substrate-sdk-android's own source: ECDSAUtils.derivePublicKey -> compressedPublicKeyFromPrivate),
// which is exactly the format both BIP143 (P2WPKH) and bitcoinPublicKeyToAccountId() require - no
// extra compression/decompression step needed here, unlike Ethereum's uncompressed-key derivation.
bitcoinAddress = bitcoinPublicKey?.bitcoinPublicKeyToAccountId()
) )
} }
} }
@@ -0,0 +1,119 @@
package io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration
import android.util.Log
import io.novafoundation.nova.common.data.secrets.v2.ChainAccountSecrets
import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets
import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2
import io.novafoundation.nova.common.data.secrets.v2.bitcoinDerivationPath
import io.novafoundation.nova.common.data.secrets.v2.bitcoinKeypair
import io.novafoundation.nova.common.data.secrets.v2.entropy
import io.novafoundation.nova.common.data.secrets.v2.ethereumDerivationPath
import io.novafoundation.nova.common.data.secrets.v2.ethereumKeypair
import io.novafoundation.nova.common.data.secrets.v2.mapKeypairStructToKeypair
import io.novafoundation.nova.common.data.secrets.v2.seed
import io.novafoundation.nova.common.data.secrets.v2.substrateDerivationPath
import io.novafoundation.nova.common.data.secrets.v2.substrateKeypair
import io.novafoundation.nova.common.data.secrets.v2.tronDerivationPath
import io.novafoundation.nova.common.data.secrets.v2.tronKeypair
import io.novafoundation.nova.common.utils.bitcoinPublicKeyToAccountId
import io.novafoundation.nova.core_db.dao.MetaAccountDao
import io.novafoundation.nova.core_db.dao.updateMetaAccount
import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal
import io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory
import io.novafoundation.nova.feature_account_impl.data.secrets.BITCOIN_DEFAULT_DERIVATION_PATH
import io.novasama.substrate_sdk_android.encrypt.mnemonic.MnemonicCreator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
private const val TAG = "BitcoinAddressBackfill"
/**
* Idempotent, per-account backfill for accounts that don't yet have a Bitcoin keypair - mirrors
* [TronAddressBackfillMigration]'s exact design (see that class for the full rationale): any account created
* before Bitcoin support existed has no `MetaAccountSecrets.BitcoinKeypair`/`meta_accounts.bitcoinAddress` yet,
* and this fills it in from the account's own mnemonic without requiring re-import.
*
* Deliberately has NO "have I already run once" flag, for the same reason as the Tron migration: a one-shot
* flag that gets set even when backfill legitimately still didn't produce a key (e.g. a since-fixed bug kept
* re-losing it) would permanently strand that account. This is cheap to call for an account that doesn't need
* it, so it just runs unconditionally on every app start.
*
* Only touches accounts that are `Type.SECRETS` (mnemonic-derived) and still hold their `Entropy` in
* [SecretStoreV2] - same restriction as the Tron migration, for the same reason (watch-only/Ledger/Json/
* multisig/proxied accounts and raw-seed imports never had a Bitcoin-capable mnemonic to derive from).
*/
class BitcoinAddressBackfillMigration(
private val secretStoreV2: SecretStoreV2,
private val metaAccountDao: MetaAccountDao,
private val accountSecretsFactory: AccountSecretsFactory,
) {
suspend fun migrate() = withContext(Dispatchers.Default) {
val secretsAccounts = metaAccountDao.getMetaAccounts().filter { it.type == MetaAccountLocal.Type.SECRETS }
Log.d(TAG, "migrate() starting - ${secretsAccounts.size} SECRETS-type account(s): ${secretsAccounts.map { it.id }}")
secretsAccounts.forEach { account ->
try {
backfillIfNeeded(account)
} catch (e: Throwable) {
Log.e(TAG, "backfill failed for metaId=${account.id}, continuing with remaining accounts", e)
}
}
Log.d(TAG, "migrate() done")
}
private suspend fun backfillIfNeeded(account: MetaAccountLocal) {
val secrets = secretStoreV2.getMetaAccountSecrets(account.id)
if (secrets == null) {
Log.d(TAG, "metaId=${account.id}: no stored secrets at all (watch-only/Ledger/etc) - skipping")
return
}
val entropy = secrets.entropy
if (entropy == null) {
Log.d(TAG, "metaId=${account.id}: no entropy (raw-seed import, not a mnemonic) - skipping")
return
}
if (secrets.bitcoinKeypair != null) {
Log.d(TAG, "metaId=${account.id}: already has a BitcoinKeypair - skipping")
return
}
val substrateCryptoType = account.substrateCryptoType
if (substrateCryptoType == null) {
Log.d(TAG, "metaId=${account.id}: substrateCryptoType is null - skipping")
return
}
Log.d(TAG, "metaId=${account.id}: deriving Bitcoin keypair")
val mnemonic = MnemonicCreator.fromEntropy(entropy).words
val bitcoinChainSecrets = accountSecretsFactory.chainAccountSecrets(
derivationPath = BITCOIN_DEFAULT_DERIVATION_PATH,
accountSource = AccountSecretsFactory.AccountSource.Mnemonic(substrateCryptoType, mnemonic),
isEthereum = true
).secrets
val bitcoinKeypair = mapKeypairStructToKeypair(bitcoinChainSecrets[ChainAccountSecrets.Keypair])
val updatedSecrets = MetaAccountSecrets(
substrateKeyPair = mapKeypairStructToKeypair(secrets.substrateKeypair),
entropy = secrets.entropy,
substrateSeed = secrets.seed,
substrateDerivationPath = secrets.substrateDerivationPath,
ethereumKeypair = secrets.ethereumKeypair?.let(::mapKeypairStructToKeypair),
ethereumDerivationPath = secrets.ethereumDerivationPath,
tronKeypair = secrets.tronKeypair?.let(::mapKeypairStructToKeypair),
tronDerivationPath = secrets.tronDerivationPath,
bitcoinKeypair = bitcoinKeypair,
bitcoinDerivationPath = BITCOIN_DEFAULT_DERIVATION_PATH
)
secretStoreV2.putMetaAccountSecrets(account.id, updatedSecrets)
val bitcoinAccountId = bitcoinKeypair.publicKey.bitcoinPublicKeyToAccountId()
metaAccountDao.updateMetaAccount(account.id) { it.addBitcoinAccount(bitcoinKeypair.publicKey, bitcoinAccountId) }
Log.d(TAG, "metaId=${account.id}: backfilled successfully, bitcoinAddress set (${bitcoinAccountId.size} bytes)")
}
}
@@ -34,6 +34,14 @@ import kotlinx.coroutines.withContext
*/ */
const val TRON_DEFAULT_DERIVATION_PATH = "//44//195//0/0/0" const val TRON_DEFAULT_DERIVATION_PATH = "//44//195//0/0/0"
/**
* BIP84 purpose (native SegWit), SLIP-44 coin type 0 (Bitcoin). Deliberately `//84//...`, not `//44//...` -
* this app only supports native SegWit (bech32 `bc1q...`) addresses, not legacy/P2SH-SegWit, so the derivation
* path signals that choice the same way real Bitcoin wallets do. Not user-configurable yet - always derived at
* this fixed path (single address, no HD address-index rotation).
*/
const val BITCOIN_DEFAULT_DERIVATION_PATH = "//84//0//0/0/0"
class AccountSecretsFactory( class AccountSecretsFactory(
private val JsonDecoder: JsonDecoder private val JsonDecoder: JsonDecoder
) { ) {
@@ -131,6 +139,7 @@ class AccountSecretsFactory(
ethereumDerivationPath: String?, ethereumDerivationPath: String?,
accountSource: AccountSource, accountSource: AccountSource,
tronDerivationPath: String? = TRON_DEFAULT_DERIVATION_PATH, tronDerivationPath: String? = TRON_DEFAULT_DERIVATION_PATH,
bitcoinDerivationPath: String? = BITCOIN_DEFAULT_DERIVATION_PATH,
): Result<MetaAccountSecrets> = withContext(Dispatchers.Default) { ): Result<MetaAccountSecrets> = withContext(Dispatchers.Default) {
val (substrateSecrets, substrateCryptoType) = chainAccountSecrets( val (substrateSecrets, substrateCryptoType) = chainAccountSecrets(
derivationPath = substrateDerivationPath, derivationPath = substrateDerivationPath,
@@ -157,6 +166,16 @@ class AccountSecretsFactory(
Bip32EcdsaKeypairFactory.generate(seed = seed, junctions = decodedTronDerivationPath?.junctions.orEmpty()) Bip32EcdsaKeypairFactory.generate(seed = seed, junctions = decodedTronDerivationPath?.junctions.orEmpty())
} }
// Bitcoin (native SegWit) also reuses the exact same secp256k1/BIP32 keypair generation as Ethereum/Tron -
// only the SLIP-44 coin type (0) and BIP84 purpose differ. See BITCOIN_DEFAULT_DERIVATION_PATH's doc.
val bitcoinKeypair = accountSource.castOrNull<AccountSource.Mnemonic>()?.let {
val decodedBitcoinDerivationPath = decodeDerivationPath(bitcoinDerivationPath, ethereum = true)
val seed = deriveSeed(it.mnemonic, password = decodedBitcoinDerivationPath?.password, ethereum = true).seed
Bip32EcdsaKeypairFactory.generate(seed = seed, junctions = decodedBitcoinDerivationPath?.junctions.orEmpty())
}
val secrets = MetaAccountSecrets( val secrets = MetaAccountSecrets(
entropy = substrateSecrets[ChainAccountSecrets.Entropy], entropy = substrateSecrets[ChainAccountSecrets.Entropy],
substrateSeed = substrateSecrets[ChainAccountSecrets.Seed], substrateSeed = substrateSecrets[ChainAccountSecrets.Seed],
@@ -165,7 +184,9 @@ class AccountSecretsFactory(
ethereumKeypair = ethereumKeypair, ethereumKeypair = ethereumKeypair,
ethereumDerivationPath = ethereumDerivationPath, ethereumDerivationPath = ethereumDerivationPath,
tronKeypair = tronKeypair, tronKeypair = tronKeypair,
tronDerivationPath = tronDerivationPath tronDerivationPath = tronDerivationPath,
bitcoinKeypair = bitcoinKeypair,
bitcoinDerivationPath = bitcoinDerivationPath,
) )
Result(secrets = secrets, cryptoType = substrateCryptoType) Result(secrets = secrets, cryptoType = substrateCryptoType)
@@ -104,6 +104,7 @@ import io.novafoundation.nova.feature_account_impl.data.repository.datasource.Ac
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.RealSecretsMetaAccountLocalFactory import io.novafoundation.nova.feature_account_impl.data.repository.datasource.RealSecretsMetaAccountLocalFactory
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.SecretsMetaAccountLocalFactory import io.novafoundation.nova.feature_account_impl.data.repository.datasource.SecretsMetaAccountLocalFactory
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.AccountDataMigration import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.AccountDataMigration
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.BitcoinAddressBackfillMigration
import io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory import io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory
import io.novafoundation.nova.feature_account_impl.data.signer.signingContext.SigningContextFactory import io.novafoundation.nova.feature_account_impl.data.signer.signingContext.SigningContextFactory
import io.novafoundation.nova.feature_account_impl.di.AccountFeatureModule.BindsModule import io.novafoundation.nova.feature_account_impl.di.AccountFeatureModule.BindsModule
@@ -406,6 +407,7 @@ class AccountFeatureModule {
secretsMetaAccountLocalFactory: SecretsMetaAccountLocalFactory, secretsMetaAccountLocalFactory: SecretsMetaAccountLocalFactory,
secretStoreV2: SecretStoreV2, secretStoreV2: SecretStoreV2,
accountMappers: AccountMappers, accountMappers: AccountMappers,
bitcoinAddressBackfillMigration: BitcoinAddressBackfillMigration,
): AccountDataSource { ): AccountDataSource {
return AccountDataSourceImpl( return AccountDataSourceImpl(
preferences, preferences,
@@ -416,10 +418,21 @@ class AccountFeatureModule {
secretStoreV2, secretStoreV2,
secretsMetaAccountLocalFactory, secretsMetaAccountLocalFactory,
secretStoreV1, secretStoreV1,
accountDataMigration accountDataMigration,
bitcoinAddressBackfillMigration
) )
} }
@Provides
@FeatureScope
fun provideBitcoinAddressBackfillMigration(
secretStoreV2: SecretStoreV2,
metaAccountDao: MetaAccountDao,
accountSecretsFactory: AccountSecretsFactory,
): BitcoinAddressBackfillMigration {
return BitcoinAddressBackfillMigration(secretStoreV2, metaAccountDao, accountSecretsFactory)
}
@Provides @Provides
fun provideNodeHostValidator() = NodeHostValidator() fun provideNodeHostValidator() = NodeHostValidator()
@@ -24,6 +24,8 @@ open class DefaultMetaAccount(
override val parentMetaId: Long?, override val parentMetaId: Long?,
override val tronAddress: ByteArray? = null, override val tronAddress: ByteArray? = null,
override val tronPublicKey: ByteArray? = null, override val tronPublicKey: ByteArray? = null,
override val bitcoinAddress: ByteArray? = null,
override val bitcoinPublicKey: ByteArray? = null,
) : MetaAccount { ) : MetaAccount {
override suspend fun supportsAddingChainAccount(chain: Chain): Boolean { override suspend fun supportsAddingChainAccount(chain: Chain): Boolean {
@@ -34,6 +36,7 @@ open class DefaultMetaAccount(
return when { return when {
hasChainAccountIn(chain.id) -> true hasChainAccountIn(chain.id) -> true
chain.isTronBased -> tronAddress != null chain.isTronBased -> tronAddress != null
chain.isBitcoinBased -> bitcoinAddress != null
chain.isEthereumBased -> ethereumAddress != null chain.isEthereumBased -> ethereumAddress != null
else -> substrateAccountId != null else -> substrateAccountId != null
} }
@@ -43,6 +46,7 @@ open class DefaultMetaAccount(
return when { return when {
hasChainAccountIn(chain.id) -> chainAccounts.getValue(chain.id).accountId hasChainAccountIn(chain.id) -> chainAccounts.getValue(chain.id).accountId
chain.isTronBased -> tronAddress chain.isTronBased -> tronAddress
chain.isBitcoinBased -> bitcoinAddress
chain.isEthereumBased -> ethereumAddress chain.isEthereumBased -> ethereumAddress
else -> substrateAccountId else -> substrateAccountId
} }
@@ -52,6 +56,7 @@ open class DefaultMetaAccount(
return when { return when {
hasChainAccountIn(chain.id) -> chainAccounts.getValue(chain.id).publicKey hasChainAccountIn(chain.id) -> chainAccounts.getValue(chain.id).publicKey
chain.isTronBased -> tronPublicKey chain.isTronBased -> tronPublicKey
chain.isBitcoinBased -> bitcoinPublicKey
chain.isEthereumBased -> ethereumPublicKey chain.isEthereumBased -> ethereumPublicKey
else -> substratePublicKey else -> substratePublicKey
} }
@@ -24,6 +24,8 @@ class GenericLedgerMetaAccount(
private val supportedGenericLedgerChains: Set<ChainId>, private val supportedGenericLedgerChains: Set<ChainId>,
tronAddress: ByteArray? = null, tronAddress: ByteArray? = null,
tronPublicKey: ByteArray? = null, tronPublicKey: ByteArray? = null,
bitcoinAddress: ByteArray? = null,
bitcoinPublicKey: ByteArray? = null,
) : DefaultMetaAccount( ) : DefaultMetaAccount(
id = id, id = id,
globallyUniqueId = globallyUniqueId, globallyUniqueId = globallyUniqueId,
@@ -39,7 +41,9 @@ class GenericLedgerMetaAccount(
chainAccounts = chainAccounts, chainAccounts = chainAccounts,
parentMetaId = parentMetaId, parentMetaId = parentMetaId,
tronAddress = tronAddress, tronAddress = tronAddress,
tronPublicKey = tronPublicKey tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey
) { ) {
override suspend fun supportsAddingChainAccount(chain: Chain): Boolean { override suspend fun supportsAddingChainAccount(chain: Chain): Boolean {
@@ -24,6 +24,8 @@ class LegacyLedgerMetaAccount(
parentMetaId: Long?, parentMetaId: Long?,
tronAddress: ByteArray? = null, tronAddress: ByteArray? = null,
tronPublicKey: ByteArray? = null, tronPublicKey: ByteArray? = null,
bitcoinAddress: ByteArray? = null,
bitcoinPublicKey: ByteArray? = null,
) : DefaultMetaAccount( ) : DefaultMetaAccount(
id = id, id = id,
globallyUniqueId = globallyUniqueId, globallyUniqueId = globallyUniqueId,
@@ -39,7 +41,9 @@ class LegacyLedgerMetaAccount(
chainAccounts = chainAccounts, chainAccounts = chainAccounts,
parentMetaId = parentMetaId, parentMetaId = parentMetaId,
tronAddress = tronAddress, tronAddress = tronAddress,
tronPublicKey = tronPublicKey tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey
) { ) {
override suspend fun supportsAddingChainAccount(chain: Chain): Boolean { override suspend fun supportsAddingChainAccount(chain: Chain): Boolean {
@@ -30,6 +30,8 @@ class RealMultisigMetaAccount(
parentMetaId: Long?, parentMetaId: Long?,
tronAddress: ByteArray? = null, tronAddress: ByteArray? = null,
tronPublicKey: ByteArray? = null, tronPublicKey: ByteArray? = null,
bitcoinAddress: ByteArray? = null,
bitcoinPublicKey: ByteArray? = null,
) : DefaultMetaAccount( ) : DefaultMetaAccount(
id = id, id = id,
globallyUniqueId = globallyUniqueId, globallyUniqueId = globallyUniqueId,
@@ -45,7 +47,9 @@ class RealMultisigMetaAccount(
chainAccounts = chainAccounts, chainAccounts = chainAccounts,
parentMetaId = parentMetaId, parentMetaId = parentMetaId,
tronAddress = tronAddress, tronAddress = tronAddress,
tronPublicKey = tronPublicKey tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey
), ),
MultisigMetaAccount { MultisigMetaAccount {
@@ -41,6 +41,6 @@ class PolkadotVaultMetaAccount(
) { ) {
override suspend fun supportsAddingChainAccount(chain: Chain): Boolean { override suspend fun supportsAddingChainAccount(chain: Chain): Boolean {
return !chain.isEthereumBased && !chain.isTronBased return !chain.isEthereumBased && !chain.isTronBased && !chain.isBitcoinBased
} }
} }
@@ -24,6 +24,8 @@ internal class RealProxiedMetaAccount(
parentMetaId: Long?, parentMetaId: Long?,
tronAddress: ByteArray? = null, tronAddress: ByteArray? = null,
tronPublicKey: ByteArray? = null, tronPublicKey: ByteArray? = null,
bitcoinAddress: ByteArray? = null,
bitcoinPublicKey: ByteArray? = null,
) : DefaultMetaAccount( ) : DefaultMetaAccount(
id = id, id = id,
globallyUniqueId = globallyUniqueId, globallyUniqueId = globallyUniqueId,
@@ -39,7 +41,9 @@ internal class RealProxiedMetaAccount(
chainAccounts = chainAccounts, chainAccounts = chainAccounts,
parentMetaId = parentMetaId, parentMetaId = parentMetaId,
tronAddress = tronAddress, tronAddress = tronAddress,
tronPublicKey = tronPublicKey tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey
), ),
ProxiedMetaAccount { ProxiedMetaAccount {
@@ -25,6 +25,8 @@ class RealSecretsMetaAccount(
parentMetaId: Long?, parentMetaId: Long?,
tronAddress: ByteArray? = null, tronAddress: ByteArray? = null,
tronPublicKey: ByteArray? = null, tronPublicKey: ByteArray? = null,
bitcoinAddress: ByteArray? = null,
bitcoinPublicKey: ByteArray? = null,
) : DefaultMetaAccount( ) : DefaultMetaAccount(
id = id, id = id,
globallyUniqueId = globallyUniqueId, globallyUniqueId = globallyUniqueId,
@@ -40,7 +42,9 @@ class RealSecretsMetaAccount(
chainAccounts = chainAccounts, chainAccounts = chainAccounts,
parentMetaId = parentMetaId, parentMetaId = parentMetaId,
tronAddress = tronAddress, tronAddress = tronAddress,
tronPublicKey = tronPublicKey tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey
), ),
SecretsMetaAccount { SecretsMetaAccount {
@@ -23,7 +23,11 @@ data class CloudBackup(
val ethereumPublicKey: ByteArray?, val ethereumPublicKey: ByteArray?,
val name: String, val name: String,
val type: Type, val type: Type,
val chainAccounts: Set<ChainAccountInfo> val chainAccounts: Set<ChainAccountInfo>,
// Nullable and defaulted so that Gson deserializing a backup written before Bitcoin support existed -
// which has no such field in its JSON at all - lands on null here rather than failing.
val bitcoinAddress: ByteArray? = null,
val bitcoinPublicKey: ByteArray? = null,
) : Identifiable { ) : Identifiable {
override val identifier: String = walletId override val identifier: String = walletId
@@ -72,7 +76,9 @@ data class CloudBackup(
ethereumPublicKey.contentEquals(other.ethereumPublicKey) && ethereumPublicKey.contentEquals(other.ethereumPublicKey) &&
name == other.name && name == other.name &&
type == other.type && type == other.type &&
chainAccounts == other.chainAccounts chainAccounts == other.chainAccounts &&
bitcoinAddress.contentEquals(other.bitcoinAddress) &&
bitcoinPublicKey.contentEquals(other.bitcoinPublicKey)
} }
override fun hashCode(): Int { override fun hashCode(): Int {
@@ -85,6 +91,8 @@ data class CloudBackup(
result = 31 * result + name.hashCode() result = 31 * result + name.hashCode()
result = 31 * result + type.hashCode() result = 31 * result + type.hashCode()
result = 31 * result + chainAccounts.hashCode() result = 31 * result + chainAccounts.hashCode()
result = 31 * result + (bitcoinAddress?.contentHashCode() ?: 0)
result = 31 * result + (bitcoinPublicKey?.contentHashCode() ?: 0)
result = 31 * result + identifier.hashCode() result = 31 * result + identifier.hashCode()
return result return result
} }
@@ -100,6 +108,7 @@ data class CloudBackup(
val substrate: SubstrateSecrets?, val substrate: SubstrateSecrets?,
val ethereum: EthereumSecrets?, val ethereum: EthereumSecrets?,
val chainAccounts: List<ChainAccountSecrets>, val chainAccounts: List<ChainAccountSecrets>,
val bitcoin: BitcoinSecrets? = null,
) : Identifiable { ) : Identifiable {
override val identifier: String = walletId override val identifier: String = walletId
@@ -118,6 +127,7 @@ data class CloudBackup(
if (substrate != other.substrate) return false if (substrate != other.substrate) return false
if (ethereum != other.ethereum) return false if (ethereum != other.ethereum) return false
if (chainAccounts != other.chainAccounts) return false if (chainAccounts != other.chainAccounts) return false
if (bitcoin != other.bitcoin) return false
return identifier == other.identifier return identifier == other.identifier
} }
@@ -127,6 +137,7 @@ data class CloudBackup(
result = 31 * result + (substrate?.hashCode() ?: 0) result = 31 * result + (substrate?.hashCode() ?: 0)
result = 31 * result + (ethereum?.hashCode() ?: 0) result = 31 * result + (ethereum?.hashCode() ?: 0)
result = 31 * result + chainAccounts.hashCode() result = 31 * result + chainAccounts.hashCode()
result = 31 * result + (bitcoin?.hashCode() ?: 0)
result = 31 * result + identifier.hashCode() result = 31 * result + identifier.hashCode()
return result return result
} }
@@ -201,6 +212,11 @@ data class CloudBackup(
val derivationPath: String?, val derivationPath: String?,
) )
data class BitcoinSecrets(
val keypair: KeyPairSecrets,
val derivationPath: String?,
)
data class KeyPairSecrets( data class KeyPairSecrets(
val publicKey: ByteArray, val publicKey: ByteArray,
val privateKey: ByteArray, val privateKey: ByteArray,
@@ -234,5 +250,5 @@ data class CloudBackup(
} }
fun CloudBackup.WalletPrivateInfo.isCompletelyEmpty(): Boolean { fun CloudBackup.WalletPrivateInfo.isCompletelyEmpty(): Boolean {
return entropy == null && substrate == null && ethereum == null && chainAccounts.isEmpty() return entropy == null && substrate == null && ethereum == null && bitcoin == null && chainAccounts.isEmpty()
} }
@@ -125,6 +125,7 @@ class CustomChainFactory(
types = prefilledChain?.types, types = prefilledChain?.types,
isEthereumBased = isEthereumBased, isEthereumBased = isEthereumBased,
isTronBased = false, isTronBased = false,
isBitcoinBased = false,
isTestNet = prefilledChain?.isTestNet.orFalse(), isTestNet = prefilledChain?.isTestNet.orFalse(),
source = Chain.Source.CUSTOM, source = Chain.Source.CUSTOM,
hasSubstrateRuntime = hasSubstrateRuntime, hasSubstrateRuntime = hasSubstrateRuntime,
@@ -272,6 +272,7 @@ fun Chain.requireGenesisHash() = requireNotNull(genesisHash)
fun Chain.addressOf(accountId: ByteArray): String { fun Chain.addressOf(accountId: ByteArray): String {
return when { return when {
isTronBased -> accountId.toTronAddress() isTronBased -> accountId.toTronAddress()
isBitcoinBased -> accountId.toBitcoinAddress()
isEthereumBased -> accountId.toEthereumAddress() isEthereumBased -> accountId.toEthereumAddress()
else -> accountId.toAddress(addressPrefix.toShort()) else -> accountId.toAddress(addressPrefix.toShort())
} }
@@ -282,7 +283,7 @@ fun Chain.addressOf(accountId: AccountIdKey): String {
} }
fun Chain.legacyAddressOfOrNull(accountId: ByteArray): String? { fun Chain.legacyAddressOfOrNull(accountId: ByteArray): String? {
return if (isEthereumBased || isTronBased) { return if (isEthereumBased || isTronBased || isBitcoinBased) {
null null
} else { } else {
legacyAddressPrefix?.let { accountId.toAddress(it.toShort()) } legacyAddressPrefix?.let { accountId.toAddress(it.toShort()) }
@@ -296,6 +297,7 @@ fun ByteArray.toEthereumAddress(): String {
fun Chain.accountIdOf(address: String): ByteArray { fun Chain.accountIdOf(address: String): ByteArray {
return when { return when {
isTronBased -> address.tronAddressToAccountId() isTronBased -> address.tronAddressToAccountId()
isBitcoinBased -> address.bitcoinAddressToAccountId()
isEthereumBased -> address.asEthereumAddress().toAccountId().value isEthereumBased -> address.asEthereumAddress().toAccountId().value
else -> address.toAccountId() else -> address.toAccountId()
} }
@@ -329,6 +331,7 @@ fun Chain.accountIdOrNull(address: String): ByteArray? {
fun Chain.emptyAccountId() = when { fun Chain.emptyAccountId() = when {
isTronBased -> emptyTronAccountId() isTronBased -> emptyTronAccountId()
isBitcoinBased -> emptyBitcoinAccountId()
isEthereumBased -> emptyEthereumAccountId() isEthereumBased -> emptyEthereumAccountId()
else -> emptySubstrateAccountId() else -> emptySubstrateAccountId()
} }
@@ -342,6 +345,7 @@ fun Chain.accountIdOrDefault(maybeAddress: String): ByteArray {
fun Chain.accountIdOf(publicKey: ByteArray): ByteArray { fun Chain.accountIdOf(publicKey: ByteArray): ByteArray {
return when { return when {
isTronBased -> publicKey.tronPublicKeyToAccountId() isTronBased -> publicKey.tronPublicKeyToAccountId()
isBitcoinBased -> publicKey.bitcoinPublicKeyToAccountId()
isEthereumBased -> publicKey.asEthereumPublicKey().toAccountId().value isEthereumBased -> publicKey.asEthereumPublicKey().toAccountId().value
else -> publicKey.substrateAccountId() else -> publicKey.substrateAccountId()
} }
@@ -361,13 +365,15 @@ fun Chain.multiAddressOf(accountId: ByteArray): MultiAddress {
fun Chain.isValidAddress(address: String): Boolean { fun Chain.isValidAddress(address: String): Boolean {
return runCatching { return runCatching {
if (isEthereumBased) { when {
address.asEthereumAddress().isValid() isBitcoinBased -> address.isValidBitcoinAddress()
} else { isEthereumBased -> address.asEthereumAddress().isValid()
address.toAccountId() // verify supplied address can be converted to account id else -> {
address.toAccountId() // verify supplied address can be converted to account id
addressPrefix.toShort() == address.addressPrefix() || addressPrefix.toShort() == address.addressPrefix() ||
legacyAddressPrefix?.toShort() == address.addressPrefix() legacyAddressPrefix?.toShort() == address.addressPrefix()
}
} }
}.getOrDefault(false) }.getOrDefault(false)
} }
@@ -221,11 +221,11 @@ class ChainRegistry(
} }
private suspend fun registerConnection(chain: Chain): ChainConnection? { private suspend fun registerConnection(chain: Chain): ChainConnection? {
// Tron nodes are plain REST APIs (TronGrid), not WSS JSON-RPC endpoints - ChainConnection's // Tron/Bitcoin nodes are plain REST APIs (TronGrid / mempool.space), not WSS JSON-RPC endpoints -
// SocketService can only speak the latter, so attempting to set one up here would hang // ChainConnection's SocketService can only speak the latter, so attempting to set one up here would
// indefinitely instead of failing fast. Tron balance/transfer operations already go through // hang indefinitely instead of failing fast. Balance/transfer operations for both go through their
// their own dedicated TronGridApi client, independent of this connection pool. // own dedicated REST API clients, independent of this connection pool.
if (chain.isTronBased) return null if (chain.isTronBased || chain.isBitcoinBased) return null
val connection = connectionPool.setupConnection(chain) val connection = connectionPool.setupConnection(chain)
@@ -128,6 +128,7 @@ fun mapChainToLocal(chain: Chain, gson: Gson): ChainLocal {
legacyPrefix = chain.legacyAddressPrefix, legacyPrefix = chain.legacyAddressPrefix,
isEthereumBased = chain.isEthereumBased, isEthereumBased = chain.isEthereumBased,
isTronBased = chain.isTronBased, isTronBased = chain.isTronBased,
isBitcoinBased = chain.isBitcoinBased,
isTestNet = chain.isTestNet, isTestNet = chain.isTestNet,
hasSubstrateRuntime = chain.hasSubstrateRuntime, hasSubstrateRuntime = chain.hasSubstrateRuntime,
pushSupport = chain.pushSupport, pushSupport = chain.pushSupport,
@@ -276,6 +276,7 @@ fun mapChainLocalToChain(
legacyAddressPrefix = legacyPrefix, legacyAddressPrefix = legacyPrefix,
isEthereumBased = isEthereumBased, isEthereumBased = isEthereumBased,
isTronBased = isTronBased, isTronBased = isTronBased,
isBitcoinBased = isBitcoinBased,
isTestNet = isTestNet, isTestNet = isTestNet,
hasCrowdloans = hasCrowdloans, hasCrowdloans = hasCrowdloans,
pushSupport = pushSupport, 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 ETHEREUM_OPTION = "ethereumBased"
private const val TRON_OPTION = "tronBased" private const val TRON_OPTION = "tronBased"
private const val BITCOIN_OPTION = "bitcoinBased"
private const val CROWDLOAN_OPTION = "crowdloans" private const val CROWDLOAN_OPTION = "crowdloans"
private const val TESTNET_OPTION = "testnet" private const val TESTNET_OPTION = "testnet"
private const val PROXY_OPTION = "proxy" private const val PROXY_OPTION = "proxy"
@@ -92,6 +93,7 @@ fun mapRemoteChainToLocal(
legacyPrefix = legacyAddressPrefix, legacyPrefix = legacyAddressPrefix,
isEthereumBased = ETHEREUM_OPTION in optionsOrEmpty, isEthereumBased = ETHEREUM_OPTION in optionsOrEmpty,
isTronBased = TRON_OPTION in optionsOrEmpty, isTronBased = TRON_OPTION in optionsOrEmpty,
isBitcoinBased = BITCOIN_OPTION in optionsOrEmpty,
isTestNet = TESTNET_OPTION in optionsOrEmpty, isTestNet = TESTNET_OPTION in optionsOrEmpty,
hasCrowdloans = CROWDLOAN_OPTION in optionsOrEmpty, hasCrowdloans = CROWDLOAN_OPTION in optionsOrEmpty,
supportProxy = PROXY_OPTION in optionsOrEmpty, supportProxy = PROXY_OPTION in optionsOrEmpty,
@@ -33,6 +33,7 @@ data class Chain(
val types: Types?, val types: Types?,
val isEthereumBased: Boolean, val isEthereumBased: Boolean,
val isTronBased: Boolean, val isTronBased: Boolean,
val isBitcoinBased: Boolean,
val isTestNet: Boolean, val isTestNet: Boolean,
val source: Source, val source: Source,
val hasSubstrateRuntime: Boolean, val hasSubstrateRuntime: Boolean,