diff --git a/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/MetaAccountSecrets.kt b/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/MetaAccountSecrets.kt index c0dd818e..6bc801db 100644 --- a/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/MetaAccountSecrets.kt +++ b/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/MetaAccountSecrets.kt @@ -28,6 +28,9 @@ object MetaAccountSecrets : Schema() { 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() { @@ -47,6 +50,8 @@ fun MetaAccountSecrets( ethereumDerivationPath: String? = null, tronKeypair: Keypair? = null, tronDerivationPath: String? = null, + bitcoinKeypair: Keypair? = null, + bitcoinDerivationPath: String? = null, ): EncodableStruct = 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.ethereumDerivationPath val EncodableStruct.tronDerivationPath get() = get(MetaAccountSecrets.TronDerivationPath) +val EncodableStruct.bitcoinDerivationPath + get() = get(MetaAccountSecrets.BitcoinDerivationPath) + val EncodableStruct.entropy get() = get(MetaAccountSecrets.Entropy) @@ -118,6 +135,9 @@ val EncodableStruct.ethereumKeypair val EncodableStruct.tronKeypair get() = get(MetaAccountSecrets.TronKeypair) +val EncodableStruct.bitcoinKeypair + get() = get(MetaAccountSecrets.BitcoinKeypair) + val EncodableStruct.derivationPath get() = get(ChainAccountSecrets.DerivationPath) diff --git a/core-db/src/androidTest/java/io/novafoundation/nova/core_db/dao/Helpers.kt b/core-db/src/androidTest/java/io/novafoundation/nova/core_db/dao/Helpers.kt index 5ea76180..1cd5f359 100644 --- a/core-db/src/androidTest/java/io/novafoundation/nova/core_db/dao/Helpers.kt +++ b/core-db/src/androidTest/java/io/novafoundation/nova/core_db/dao/Helpers.kt @@ -58,6 +58,7 @@ fun chainOf( isTestNet = false, isEthereumBased = false, isTronBased = false, + isBitcoinBased = false, hasCrowdloans = false, additional = "", governance = "governance", diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/AppDatabase.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/AppDatabase.kt index 014f6b02..d14c9542 100644 --- a/core-db/src/main/java/io/novafoundation/nova/core_db/AppDatabase.kt +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/AppDatabase.kt @@ -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!! diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/74_75_AddBitcoinSupport.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/74_75_AddBitcoinSupport.kt new file mode 100644 index 00000000..24fd633c --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/74_75_AddBitcoinSupport.kt @@ -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") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/ChainLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/ChainLocal.kt index 77216596..faf32f39 100644 --- a/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/ChainLocal.kt +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/ChainLocal.kt @@ -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, diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/MetaAccountLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/MetaAccountLocal.kt index 4886b762..c4c3983e 100644 --- a/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/MetaAccountLocal.kt +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/MetaAccountLocal.kt @@ -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() diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/MetaAccount.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/MetaAccount.kt index 1741cb26..b328a252 100644 --- a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/MetaAccount.kt +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/MetaAccount.kt @@ -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 diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/cloudBackup/RealLocalAccountsCloudBackupFacade.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/cloudBackup/RealLocalAccountsCloudBackupFacade.kt index 3717e617..a446508b 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/cloudBackup/RealLocalAccountsCloudBackupFacade.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/cloudBackup/RealLocalAccountsCloudBackupFacade.kt @@ -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?.getBitcoinBackupSecrets(): CloudBackup.WalletPrivateInfo.BitcoinSecrets? { + if (this == null) return null + + return CloudBackup.WalletPrivateInfo.BitcoinSecrets( + keypair = bitcoinKeypair?.toBackupKeypairSecrets() ?: return null, + derivationPath = bitcoinDerivationPath + ) + } + private fun EncodableStruct?.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 diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/mappers/AccountMappers.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/mappers/AccountMappers.kt index ae8d3b8b..fdff1bcd 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/mappers/AccountMappers.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/mappers/AccountMappers.kt @@ -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, diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/AccountDataSourceImpl.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/AccountDataSourceImpl.kt index b4021b68..65ca3145 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/AccountDataSourceImpl.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/AccountDataSourceImpl.kt @@ -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 { diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/SecretsMetaAccountLocalFactory.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/SecretsMetaAccountLocalFactory.kt index 891d0d17..765558a6 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/SecretsMetaAccountLocalFactory.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/SecretsMetaAccountLocalFactory.kt @@ -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() ) } } diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/BitcoinAddressBackfillMigration.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/BitcoinAddressBackfillMigration.kt new file mode 100644 index 00000000..8a2e8d3e --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/BitcoinAddressBackfillMigration.kt @@ -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)") + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/secrets/AccountSecretsFactory.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/secrets/AccountSecretsFactory.kt index 5cdff614..0939771a 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/secrets/AccountSecretsFactory.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/secrets/AccountSecretsFactory.kt @@ -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 = 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()?.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) diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureModule.kt index 42a1baec..ee7b594e 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureModule.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureModule.kt @@ -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() diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/DefaultMetaAccount.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/DefaultMetaAccount.kt index 13ba653a..98661b2f 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/DefaultMetaAccount.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/DefaultMetaAccount.kt @@ -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 } diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/GenericLedgerMetaAccount.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/GenericLedgerMetaAccount.kt index 6e0c5164..7ac80199 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/GenericLedgerMetaAccount.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/GenericLedgerMetaAccount.kt @@ -24,6 +24,8 @@ class GenericLedgerMetaAccount( private val supportedGenericLedgerChains: Set, 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 { diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/LegacyLedgerMetaAccount.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/LegacyLedgerMetaAccount.kt index 9eae9bc5..0f5d5f11 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/LegacyLedgerMetaAccount.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/LegacyLedgerMetaAccount.kt @@ -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 { diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/MultisigMetaAccount.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/MultisigMetaAccount.kt index 2f16fe46..146c4fa7 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/MultisigMetaAccount.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/MultisigMetaAccount.kt @@ -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 { diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/PolkadotVaultMetaAccount.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/PolkadotVaultMetaAccount.kt index bba2effc..670d2cac 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/PolkadotVaultMetaAccount.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/PolkadotVaultMetaAccount.kt @@ -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 } } diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealProxiedMetaAccount.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealProxiedMetaAccount.kt index 46c2bfe7..51cb92cf 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealProxiedMetaAccount.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealProxiedMetaAccount.kt @@ -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 { diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealSecretsMetaAccount.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealSecretsMetaAccount.kt index 7b88a202..8b597107 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealSecretsMetaAccount.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealSecretsMetaAccount.kt @@ -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 { diff --git a/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/CloudBackup.kt b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/CloudBackup.kt index 579a6bfc..db178c87 100644 --- a/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/CloudBackup.kt +++ b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/CloudBackup.kt @@ -23,7 +23,11 @@ data class CloudBackup( val ethereumPublicKey: ByteArray?, val name: String, val type: Type, - val chainAccounts: Set + val chainAccounts: Set, + // 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, + 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() } diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/utils/CustomChainFactory.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/utils/CustomChainFactory.kt index 2b99a9c1..f74d9314 100644 --- a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/utils/CustomChainFactory.kt +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/utils/CustomChainFactory.kt @@ -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, diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainExt.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainExt.kt index 2f7afc1f..2bc09898 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainExt.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainExt.kt @@ -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) } diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/ChainRegistry.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/ChainRegistry.kt index 1ae735b7..a82ff975 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/ChainRegistry.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/ChainRegistry.kt @@ -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) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/DomainToLocalChainMapper.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/DomainToLocalChainMapper.kt index 0a61c94e..56e2f197 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/DomainToLocalChainMapper.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/DomainToLocalChainMapper.kt @@ -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, diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/LocalToDomainChainMapper.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/LocalToDomainChainMapper.kt index 8b85a865..d2ea493d 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/LocalToDomainChainMapper.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/LocalToDomainChainMapper.kt @@ -276,6 +276,7 @@ fun mapChainLocalToChain( legacyAddressPrefix = legacyPrefix, isEthereumBased = isEthereumBased, isTronBased = isTronBased, + isBitcoinBased = isBitcoinBased, isTestNet = isTestNet, hasCrowdloans = hasCrowdloans, pushSupport = pushSupport, diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/RemoteToLocalChainMappers.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/RemoteToLocalChainMappers.kt index 8f1b36b1..bfa3e342 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/RemoteToLocalChainMappers.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/RemoteToLocalChainMappers.kt @@ -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, diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/model/Chain.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/model/Chain.kt index 03801dec..41401b27 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/model/Chain.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/model/Chain.kt @@ -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,