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
@@ -28,6 +28,9 @@ object MetaAccountSecrets : Schema<MetaAccountSecrets>() {
val TronKeypair by schema(KeyPairSchema).optional()
val TronDerivationPath by string().optional()
val BitcoinKeypair by schema(KeyPairSchema).optional()
val BitcoinDerivationPath by string().optional()
}
object ChainAccountSecrets : Schema<ChainAccountSecrets>() {
@@ -47,6 +50,8 @@ fun MetaAccountSecrets(
ethereumDerivationPath: String? = null,
tronKeypair: Keypair? = null,
tronDerivationPath: String? = null,
bitcoinKeypair: Keypair? = null,
bitcoinDerivationPath: String? = null,
): EncodableStruct<MetaAccountSecrets> = MetaAccountSecrets { secrets ->
secrets[Entropy] = entropy
secrets[SubstrateSeed] = substrateSeed
@@ -75,6 +80,15 @@ fun MetaAccountSecrets(
}
}
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(
@@ -103,6 +117,9 @@ val EncodableStruct<MetaAccountSecrets>.ethereumDerivationPath
val EncodableStruct<MetaAccountSecrets>.tronDerivationPath
get() = get(MetaAccountSecrets.TronDerivationPath)
val EncodableStruct<MetaAccountSecrets>.bitcoinDerivationPath
get() = get(MetaAccountSecrets.BitcoinDerivationPath)
val EncodableStruct<MetaAccountSecrets>.entropy
get() = get(MetaAccountSecrets.Entropy)
@@ -118,6 +135,9 @@ val EncodableStruct<MetaAccountSecrets>.ethereumKeypair
val EncodableStruct<MetaAccountSecrets>.tronKeypair
get() = get(MetaAccountSecrets.TronKeypair)
val EncodableStruct<MetaAccountSecrets>.bitcoinKeypair
get() = get(MetaAccountSecrets.BitcoinKeypair)
val EncodableStruct<ChainAccountSecrets>.derivationPath
get() = get(ChainAccountSecrets.DerivationPath)
@@ -58,6 +58,7 @@ fun chainOf(
isTestNet = false,
isEthereumBased = false,
isTronBased = false,
isBitcoinBased = false,
hasCrowdloans = false,
additional = "",
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.AddTransactionVersionToRuntime_50_51
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.AddTypeExtrasToMetaAccount_68_69
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
@Database(
version = 74,
version = 75,
entities = [
AccountLocal::class,
NodeLocal::class,
@@ -273,6 +274,7 @@ abstract class AppDatabase : RoomDatabase() {
.addMigrations(AddTypeExtrasToMetaAccount_68_69, AddMultisigCalls_69_70, AddMultisigSupportFlag_70_71)
.addMigrations(AddGifts_71_72, AddFieldsToContributions)
.addMigrations(AddTronSupport_73_74)
.addMigrations(AddBitcoinSupport_74_75)
.build()
}
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,
@ColumnInfo(defaultValue = "0")
val isTronBased: Boolean,
@ColumnInfo(defaultValue = "0")
val isBitcoinBased: Boolean,
val isTestNet: Boolean,
@ColumnInfo(defaultValue = "1")
val hasSubstrateRuntime: Boolean,
@@ -39,6 +39,8 @@ class MetaAccountLocal(
val typeExtras: SerializedJson?,
val tronPublicKey: ByteArray? = null,
val tronAddress: ByteArray? = null,
val bitcoinPublicKey: ByteArray? = null,
val bitcoinAddress: ByteArray? = null,
) {
enum class Status {
@@ -59,6 +61,9 @@ class MetaAccountLocal(
const val TRON_PUBKEY = "tronPublicKey"
const val TRON_ADDRESS = "tronAddress"
const val BITCOIN_PUBKEY = "bitcoinPublicKey"
const val BITCOIN_ADDRESS = "bitcoinAddress"
const val NAME = "name"
const val IS_SELECTED = "isSelected"
const val POSITION = "position"
@@ -90,7 +95,37 @@ class MetaAccountLocal(
globallyUniqueId = globallyUniqueId,
typeExtras = typeExtras,
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 {
it.id = id
}
@@ -109,6 +144,8 @@ class MetaAccountLocal(
if (!ethereumAddress.contentEquals(other.ethereumAddress)) return false
if (!tronPublicKey.contentEquals(other.tronPublicKey)) 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 (parentMetaId != other.parentMetaId) return false
if (isSelected != other.isSelected) return false
@@ -130,6 +167,8 @@ class MetaAccountLocal(
result = 31 * result + (ethereumAddress?.contentHashCode() ?: 0)
result = 31 * result + (tronPublicKey?.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 + (parentMetaId?.hashCode() ?: 0)
result = 31 * result + isSelected.hashCode()
@@ -52,6 +52,10 @@ interface LightMetaAccount {
*/
val tronAddress: 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 name: String
val type: Type
@@ -90,6 +94,8 @@ fun LightMetaAccount(
parentMetaId: Long?,
tronAddress: ByteArray? = null,
tronPublicKey: ByteArray? = null,
bitcoinAddress: ByteArray? = null,
bitcoinPublicKey: ByteArray? = null,
) = object : LightMetaAccount {
override val id: Long = id
override val globallyUniqueId: String = globallyUniqueId
@@ -100,6 +106,8 @@ fun LightMetaAccount(
override val ethereumPublicKey: ByteArray? = ethereumPublicKey
override val tronAddress: ByteArray? = tronAddress
override val tronPublicKey: ByteArray? = tronPublicKey
override val bitcoinAddress: ByteArray? = bitcoinAddress
override val bitcoinPublicKey: ByteArray? = bitcoinPublicKey
override val isSelected: Boolean = isSelected
override val name: String = name
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.SecretStoreV2
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.ethereumDerivationPath
import io.novafoundation.nova.common.data.secrets.v2.ethereumKeypair
@@ -90,6 +92,7 @@ class RealLocalAccountsCloudBackupFacade(
substrate = baseSecrets.getSubstrateBackupSecrets(),
ethereum = baseSecrets.getEthereumBackupSecrets(),
chainAccounts = emptyList(),
bitcoin = baseSecrets.getBitcoinBackupSecrets(),
)
return CloudBackup(
@@ -357,6 +360,7 @@ class RealLocalAccountsCloudBackupFacade(
substrate = prepareSubstrateBackupSecrets(baseSecrets, joinedMetaAccountInfo),
ethereum = baseSecrets.getEthereumBackupSecrets(),
chainAccounts = chainAccountsFromChainSecrets + chainAccountFromAdditionalSecrets,
bitcoin = baseSecrets.getBitcoinBackupSecrets(),
)
}
@@ -466,7 +470,9 @@ class RealLocalAccountsCloudBackupFacade(
substrateKeyPair = substrate?.keypair?.toLocalKeyPair() ?: return null,
substrateDerivationPath = substrate?.derivationPath,
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? {
if (this == null) return null
@@ -520,7 +535,9 @@ class RealLocalAccountsCloudBackupFacade(
ethereumPublicKey = metaAccount.ethereumPublicKey,
name = metaAccount.name,
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,
position = accountPosition,
status = MetaAccountLocal.Status.ACTIVE,
typeExtras = null
typeExtras = null,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey
).also {
if (localIdOverwrite != null) {
it.id = localIdOverwrite
@@ -65,6 +65,8 @@ class AccountMappers(
ethereumPublicKey = ethereumPublicKey,
tronAddress = tronAddress,
tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey,
isSelected = isSelected,
name = name,
status = mapMetaAccountStateFromLocal(status),
@@ -82,6 +84,8 @@ class AccountMappers(
ethereumPublicKey = ethereumPublicKey,
tronAddress = tronAddress,
tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey,
isSelected = isSelected,
name = name,
type = type,
@@ -101,6 +105,8 @@ class AccountMappers(
ethereumPublicKey = ethereumPublicKey,
tronAddress = tronAddress,
tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey,
isSelected = isSelected,
name = name,
type = type,
@@ -119,6 +125,8 @@ class AccountMappers(
ethereumPublicKey = ethereumPublicKey,
tronAddress = tronAddress,
tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey,
isSelected = isSelected,
name = name,
type = type,
@@ -138,6 +146,8 @@ class AccountMappers(
ethereumPublicKey = ethereumPublicKey,
tronAddress = tronAddress,
tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey,
isSelected = isSelected,
name = name,
type = type,
@@ -165,6 +175,8 @@ class AccountMappers(
ethereumPublicKey = ethereumPublicKey,
tronAddress = tronAddress,
tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey,
isSelected = isSelected,
name = name,
status = mapMetaAccountStateFromLocal(status),
@@ -183,6 +195,8 @@ class AccountMappers(
ethereumPublicKey = ethereumPublicKey,
tronAddress = tronAddress,
tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey,
chainAccounts = chainAccounts,
isSelected = isSelected,
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.mapMetaAccountWithBalanceFromLocal
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.MetaAccountInsertionData
import io.novafoundation.nova.runtime.ext.accountIdOf
@@ -64,10 +65,12 @@ class AccountDataSourceImpl(
private val secretsMetaAccountLocalFactory: SecretsMetaAccountLocalFactory,
secretStoreV1: SecretStoreV1,
accountDataMigration: AccountDataMigration,
private val bitcoinAddressBackfillMigration: BitcoinAddressBackfillMigration,
) : AccountDataSource, SecretStoreV1 by secretStoreV1 {
init {
migrateIfNeeded(accountDataMigration)
async { bitcoinAddressBackfillMigration.migrate() }
}
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.MetaAccountSecrets
import io.novafoundation.nova.common.utils.bitcoinPublicKeyToAccountId
import io.novafoundation.nova.common.utils.substrateAccountId
import io.novafoundation.nova.common.utils.tronPublicKeyToAccountId
import io.novafoundation.nova.core.model.CryptoType
@@ -31,6 +32,7 @@ class RealSecretsMetaAccountLocalFactory : SecretsMetaAccountLocalFactory {
val substratePublicKey = secrets[MetaAccountSecrets.SubstrateKeypair][KeyPairSchema.PublicKey]
val ethereumPublicKey = secrets[MetaAccountSecrets.EthereumKeypair]?.get(KeyPairSchema.PublicKey)
val tronPublicKey = secrets[MetaAccountSecrets.TronKeypair]?.get(KeyPairSchema.PublicKey)
val bitcoinPublicKey = secrets[MetaAccountSecrets.BitcoinKeypair]?.get(KeyPairSchema.PublicKey)
return MetaAccountLocal(
substratePublicKey = substratePublicKey,
@@ -47,7 +49,13 @@ class RealSecretsMetaAccountLocalFactory : SecretsMetaAccountLocalFactory {
globallyUniqueId = MetaAccountLocal.generateGloballyUniqueId(),
typeExtras = null,
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"
/**
* 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(
private val JsonDecoder: JsonDecoder
) {
@@ -131,6 +139,7 @@ class AccountSecretsFactory(
ethereumDerivationPath: String?,
accountSource: AccountSource,
tronDerivationPath: String? = TRON_DEFAULT_DERIVATION_PATH,
bitcoinDerivationPath: String? = BITCOIN_DEFAULT_DERIVATION_PATH,
): Result<MetaAccountSecrets> = withContext(Dispatchers.Default) {
val (substrateSecrets, substrateCryptoType) = chainAccountSecrets(
derivationPath = substrateDerivationPath,
@@ -157,6 +166,16 @@ class AccountSecretsFactory(
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(
entropy = substrateSecrets[ChainAccountSecrets.Entropy],
substrateSeed = substrateSecrets[ChainAccountSecrets.Seed],
@@ -165,7 +184,9 @@ class AccountSecretsFactory(
ethereumKeypair = ethereumKeypair,
ethereumDerivationPath = ethereumDerivationPath,
tronKeypair = tronKeypair,
tronDerivationPath = tronDerivationPath
tronDerivationPath = tronDerivationPath,
bitcoinKeypair = bitcoinKeypair,
bitcoinDerivationPath = bitcoinDerivationPath,
)
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.SecretsMetaAccountLocalFactory
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.signer.signingContext.SigningContextFactory
import io.novafoundation.nova.feature_account_impl.di.AccountFeatureModule.BindsModule
@@ -406,6 +407,7 @@ class AccountFeatureModule {
secretsMetaAccountLocalFactory: SecretsMetaAccountLocalFactory,
secretStoreV2: SecretStoreV2,
accountMappers: AccountMappers,
bitcoinAddressBackfillMigration: BitcoinAddressBackfillMigration,
): AccountDataSource {
return AccountDataSourceImpl(
preferences,
@@ -416,10 +418,21 @@ class AccountFeatureModule {
secretStoreV2,
secretsMetaAccountLocalFactory,
secretStoreV1,
accountDataMigration
accountDataMigration,
bitcoinAddressBackfillMigration
)
}
@Provides
@FeatureScope
fun provideBitcoinAddressBackfillMigration(
secretStoreV2: SecretStoreV2,
metaAccountDao: MetaAccountDao,
accountSecretsFactory: AccountSecretsFactory,
): BitcoinAddressBackfillMigration {
return BitcoinAddressBackfillMigration(secretStoreV2, metaAccountDao, accountSecretsFactory)
}
@Provides
fun provideNodeHostValidator() = NodeHostValidator()
@@ -24,6 +24,8 @@ open class DefaultMetaAccount(
override val parentMetaId: Long?,
override val tronAddress: ByteArray? = null,
override val tronPublicKey: ByteArray? = null,
override val bitcoinAddress: ByteArray? = null,
override val bitcoinPublicKey: ByteArray? = null,
) : MetaAccount {
override suspend fun supportsAddingChainAccount(chain: Chain): Boolean {
@@ -34,6 +36,7 @@ open class DefaultMetaAccount(
return when {
hasChainAccountIn(chain.id) -> true
chain.isTronBased -> tronAddress != null
chain.isBitcoinBased -> bitcoinAddress != null
chain.isEthereumBased -> ethereumAddress != null
else -> substrateAccountId != null
}
@@ -43,6 +46,7 @@ open class DefaultMetaAccount(
return when {
hasChainAccountIn(chain.id) -> chainAccounts.getValue(chain.id).accountId
chain.isTronBased -> tronAddress
chain.isBitcoinBased -> bitcoinAddress
chain.isEthereumBased -> ethereumAddress
else -> substrateAccountId
}
@@ -52,6 +56,7 @@ open class DefaultMetaAccount(
return when {
hasChainAccountIn(chain.id) -> chainAccounts.getValue(chain.id).publicKey
chain.isTronBased -> tronPublicKey
chain.isBitcoinBased -> bitcoinPublicKey
chain.isEthereumBased -> ethereumPublicKey
else -> substratePublicKey
}
@@ -24,6 +24,8 @@ class GenericLedgerMetaAccount(
private val supportedGenericLedgerChains: Set<ChainId>,
tronAddress: ByteArray? = null,
tronPublicKey: ByteArray? = null,
bitcoinAddress: ByteArray? = null,
bitcoinPublicKey: ByteArray? = null,
) : DefaultMetaAccount(
id = id,
globallyUniqueId = globallyUniqueId,
@@ -39,7 +41,9 @@ class GenericLedgerMetaAccount(
chainAccounts = chainAccounts,
parentMetaId = parentMetaId,
tronAddress = tronAddress,
tronPublicKey = tronPublicKey
tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey
) {
override suspend fun supportsAddingChainAccount(chain: Chain): Boolean {
@@ -24,6 +24,8 @@ class LegacyLedgerMetaAccount(
parentMetaId: Long?,
tronAddress: ByteArray? = null,
tronPublicKey: ByteArray? = null,
bitcoinAddress: ByteArray? = null,
bitcoinPublicKey: ByteArray? = null,
) : DefaultMetaAccount(
id = id,
globallyUniqueId = globallyUniqueId,
@@ -39,7 +41,9 @@ class LegacyLedgerMetaAccount(
chainAccounts = chainAccounts,
parentMetaId = parentMetaId,
tronAddress = tronAddress,
tronPublicKey = tronPublicKey
tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey
) {
override suspend fun supportsAddingChainAccount(chain: Chain): Boolean {
@@ -30,6 +30,8 @@ class RealMultisigMetaAccount(
parentMetaId: Long?,
tronAddress: ByteArray? = null,
tronPublicKey: ByteArray? = null,
bitcoinAddress: ByteArray? = null,
bitcoinPublicKey: ByteArray? = null,
) : DefaultMetaAccount(
id = id,
globallyUniqueId = globallyUniqueId,
@@ -45,7 +47,9 @@ class RealMultisigMetaAccount(
chainAccounts = chainAccounts,
parentMetaId = parentMetaId,
tronAddress = tronAddress,
tronPublicKey = tronPublicKey
tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey
),
MultisigMetaAccount {
@@ -41,6 +41,6 @@ class PolkadotVaultMetaAccount(
) {
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?,
tronAddress: ByteArray? = null,
tronPublicKey: ByteArray? = null,
bitcoinAddress: ByteArray? = null,
bitcoinPublicKey: ByteArray? = null,
) : DefaultMetaAccount(
id = id,
globallyUniqueId = globallyUniqueId,
@@ -39,7 +41,9 @@ internal class RealProxiedMetaAccount(
chainAccounts = chainAccounts,
parentMetaId = parentMetaId,
tronAddress = tronAddress,
tronPublicKey = tronPublicKey
tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey
),
ProxiedMetaAccount {
@@ -25,6 +25,8 @@ class RealSecretsMetaAccount(
parentMetaId: Long?,
tronAddress: ByteArray? = null,
tronPublicKey: ByteArray? = null,
bitcoinAddress: ByteArray? = null,
bitcoinPublicKey: ByteArray? = null,
) : DefaultMetaAccount(
id = id,
globallyUniqueId = globallyUniqueId,
@@ -40,7 +42,9 @@ class RealSecretsMetaAccount(
chainAccounts = chainAccounts,
parentMetaId = parentMetaId,
tronAddress = tronAddress,
tronPublicKey = tronPublicKey
tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey
),
SecretsMetaAccount {
@@ -23,7 +23,11 @@ data class CloudBackup(
val ethereumPublicKey: ByteArray?,
val name: String,
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 {
override val identifier: String = walletId
@@ -72,7 +76,9 @@ data class CloudBackup(
ethereumPublicKey.contentEquals(other.ethereumPublicKey) &&
name == other.name &&
type == other.type &&
chainAccounts == other.chainAccounts
chainAccounts == other.chainAccounts &&
bitcoinAddress.contentEquals(other.bitcoinAddress) &&
bitcoinPublicKey.contentEquals(other.bitcoinPublicKey)
}
override fun hashCode(): Int {
@@ -85,6 +91,8 @@ data class CloudBackup(
result = 31 * result + name.hashCode()
result = 31 * result + type.hashCode()
result = 31 * result + chainAccounts.hashCode()
result = 31 * result + (bitcoinAddress?.contentHashCode() ?: 0)
result = 31 * result + (bitcoinPublicKey?.contentHashCode() ?: 0)
result = 31 * result + identifier.hashCode()
return result
}
@@ -100,6 +108,7 @@ data class CloudBackup(
val substrate: SubstrateSecrets?,
val ethereum: EthereumSecrets?,
val chainAccounts: List<ChainAccountSecrets>,
val bitcoin: BitcoinSecrets? = null,
) : Identifiable {
override val identifier: String = walletId
@@ -118,6 +127,7 @@ data class CloudBackup(
if (substrate != other.substrate) return false
if (ethereum != other.ethereum) return false
if (chainAccounts != other.chainAccounts) return false
if (bitcoin != other.bitcoin) return false
return identifier == other.identifier
}
@@ -127,6 +137,7 @@ data class CloudBackup(
result = 31 * result + (substrate?.hashCode() ?: 0)
result = 31 * result + (ethereum?.hashCode() ?: 0)
result = 31 * result + chainAccounts.hashCode()
result = 31 * result + (bitcoin?.hashCode() ?: 0)
result = 31 * result + identifier.hashCode()
return result
}
@@ -201,6 +212,11 @@ data class CloudBackup(
val derivationPath: String?,
)
data class BitcoinSecrets(
val keypair: KeyPairSecrets,
val derivationPath: String?,
)
data class KeyPairSecrets(
val publicKey: ByteArray,
val privateKey: ByteArray,
@@ -234,5 +250,5 @@ data class CloudBackup(
}
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,
isEthereumBased = isEthereumBased,
isTronBased = false,
isBitcoinBased = false,
isTestNet = prefilledChain?.isTestNet.orFalse(),
source = Chain.Source.CUSTOM,
hasSubstrateRuntime = hasSubstrateRuntime,
@@ -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,