Add native Solana support: address derivation, accounts, signing, backfill migration, CloudBackup wiring

Same read/send-capable chain-family pattern as Tron/Bitcoin, but for
Ed25519/SLIP-0010 instead of secp256k1/BIP32 (Solana's account id IS its
raw public key, so no separate address-hash derivation step is needed).

- SLIP-0010 Ed25519 keypair derivation (Bip32Ed25519KeypairFactory),
  cross-validated against an independent slip10 reference implementation
  and pinned to a known-good test vector.
- Chain.isSolanaBased / Chain.Asset.Type.SolanaNative wired through every
  chain-mapper, account-model, and signer dispatch site (mirrors the
  existing Tron/Bitcoin wiring exactly).
- SolanaAddressBackfillMigration derives Solana keys for wallets created
  before this support existed, same self-healing no-stuck-flag design as
  the Tron/Bitcoin backfills - also fixed a latent bug in those two
  migrations where MetaAccountSecrets/MetaAccountLocal.addXAccount()
  dropped sibling chain-family fields on a re-run.
- Room migration 75->76 adds isSolanaBased/solanaPublicKey/solanaAddress
  columns.
- CloudBackup's already-reserved solanaAddress/SolanaSecrets fields are
  now actually read/written by RealLocalAccountsCloudBackupFacade; also
  fixed isCompletelyEmpty() to check solana, so a Solana-only wallet
  doesn't get silently dropped from backup.
This commit is contained in:
2026-07-16 18:49:09 -07:00
parent 6fd7a56382
commit d35687f2ce
40 changed files with 861 additions and 24 deletions
@@ -59,6 +59,7 @@ fun chainOf(
isEthereumBased = false,
isTronBased = false,
isBitcoinBased = false,
isSolanaBased = false,
hasCrowdloans = false,
additional = "",
governance = "governance",
@@ -95,6 +95,7 @@ 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.AddSolanaSupport_75_76
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
@@ -168,7 +169,7 @@ import io.novafoundation.nova.core_db.model.operation.SwapTypeLocal
import io.novafoundation.nova.core_db.model.operation.TransferTypeLocal
@Database(
version = 75,
version = 76,
entities = [
AccountLocal::class,
NodeLocal::class,
@@ -275,6 +276,7 @@ abstract class AppDatabase : RoomDatabase() {
.addMigrations(AddGifts_71_72, AddFieldsToContributions)
.addMigrations(AddTronSupport_73_74)
.addMigrations(AddBitcoinSupport_74_75)
.addMigrations(AddSolanaSupport_75_76)
.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 AddSolanaSupport_75_76 = object : Migration(75, 76) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE chains ADD COLUMN isSolanaBased INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE meta_accounts ADD COLUMN solanaPublicKey BLOB")
db.execSQL("ALTER TABLE meta_accounts ADD COLUMN solanaAddress BLOB")
}
}
@@ -25,6 +25,8 @@ data class ChainLocal(
val isTronBased: Boolean,
@ColumnInfo(defaultValue = "0")
val isBitcoinBased: Boolean,
@ColumnInfo(defaultValue = "0")
val isSolanaBased: Boolean,
val isTestNet: Boolean,
@ColumnInfo(defaultValue = "1")
val hasSubstrateRuntime: Boolean,
@@ -41,6 +41,8 @@ class MetaAccountLocal(
val tronAddress: ByteArray? = null,
val bitcoinPublicKey: ByteArray? = null,
val bitcoinAddress: ByteArray? = null,
val solanaPublicKey: ByteArray? = null,
val solanaAddress: ByteArray? = null,
) {
enum class Status {
@@ -64,6 +66,9 @@ class MetaAccountLocal(
const val BITCOIN_PUBKEY = "bitcoinPublicKey"
const val BITCOIN_ADDRESS = "bitcoinAddress"
const val SOLANA_PUBKEY = "solanaPublicKey"
const val SOLANA_ADDRESS = "solanaAddress"
const val NAME = "name"
const val IS_SELECTED = "isSelected"
const val POSITION = "position"
@@ -98,6 +103,8 @@ class MetaAccountLocal(
tronAddress = tronAddress,
bitcoinPublicKey = bitcoinPublicKey,
bitcoinAddress = bitcoinAddress,
solanaPublicKey = solanaPublicKey,
solanaAddress = solanaAddress,
).also {
it.id = id
}
@@ -126,6 +133,8 @@ class MetaAccountLocal(
tronAddress = tronAddress,
bitcoinPublicKey = bitcoinPublicKey,
bitcoinAddress = bitcoinAddress,
solanaPublicKey = solanaPublicKey,
solanaAddress = solanaAddress,
).also {
it.id = id
}
@@ -151,7 +160,46 @@ class MetaAccountLocal(
globallyUniqueId = globallyUniqueId,
typeExtras = typeExtras,
tronPublicKey = tronPublicKey,
tronAddress = tronAddress
tronAddress = tronAddress,
// Previously missing - dropped bitcoin/solana on any account already carrying them. Since
// this migration is deliberately unconditional/self-healing (re-runs every app start to
// recover from a since-fixed bug re-losing a keypair), a re-run after Bitcoin/Solana were
// already backfilled would silently wipe them back to null. See addBitcoinAccount/
// addSolanaAccount - every addXAccount must carry every sibling chain-family field forward.
bitcoinPublicKey = bitcoinPublicKey,
bitcoinAddress = bitcoinAddress,
solanaPublicKey = solanaPublicKey,
solanaAddress = solanaAddress,
).also {
it.id = id
}
}
// We do not use copy as we need explicitly set id
fun addSolanaAccount(
solanaPublicKey: ByteArray,
solanaAddress: 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,
solanaPublicKey = solanaPublicKey,
solanaAddress = solanaAddress,
).also {
it.id = id
}
@@ -172,6 +220,8 @@ class MetaAccountLocal(
if (!tronAddress.contentEquals(other.tronAddress)) return false
if (!bitcoinPublicKey.contentEquals(other.bitcoinPublicKey)) return false
if (!bitcoinAddress.contentEquals(other.bitcoinAddress)) return false
if (!solanaPublicKey.contentEquals(other.solanaPublicKey)) return false
if (!solanaAddress.contentEquals(other.solanaAddress)) return false
if (name != other.name) return false
if (parentMetaId != other.parentMetaId) return false
if (isSelected != other.isSelected) return false
@@ -195,6 +245,8 @@ class MetaAccountLocal(
result = 31 * result + (tronAddress?.contentHashCode() ?: 0)
result = 31 * result + (bitcoinPublicKey?.contentHashCode() ?: 0)
result = 31 * result + (bitcoinAddress?.contentHashCode() ?: 0)
result = 31 * result + (solanaPublicKey?.contentHashCode() ?: 0)
result = 31 * result + (solanaAddress?.contentHashCode() ?: 0)
result = 31 * result + name.hashCode()
result = 31 * result + (parentMetaId?.hashCode() ?: 0)
result = 31 * result + isSelected.hashCode()