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
@@ -17,6 +17,8 @@ import io.novafoundation.nova.common.data.secrets.v2.nonce
import io.novafoundation.nova.common.data.secrets.v2.privateKey
import io.novafoundation.nova.common.data.secrets.v2.publicKey
import io.novafoundation.nova.common.data.secrets.v2.seed
import io.novafoundation.nova.common.data.secrets.v2.solanaDerivationPath
import io.novafoundation.nova.common.data.secrets.v2.solanaKeypair
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
@@ -96,6 +98,7 @@ class RealLocalAccountsCloudBackupFacade(
chainAccounts = emptyList(),
bitcoin = baseSecrets.getBitcoinBackupSecrets(),
tron = baseSecrets.getTronBackupSecrets(),
solana = baseSecrets.getSolanaBackupSecrets(),
)
return CloudBackup(
@@ -365,6 +368,7 @@ class RealLocalAccountsCloudBackupFacade(
chainAccounts = chainAccountsFromChainSecrets + chainAccountFromAdditionalSecrets,
bitcoin = baseSecrets.getBitcoinBackupSecrets(),
tron = baseSecrets.getTronBackupSecrets(),
solana = baseSecrets.getSolanaBackupSecrets(),
)
}
@@ -478,7 +482,9 @@ class RealLocalAccountsCloudBackupFacade(
bitcoinKeypair = bitcoin?.keypair?.toLocalKeyPair(),
bitcoinDerivationPath = bitcoin?.derivationPath,
tronKeypair = tron?.keypair?.toLocalKeyPair(),
tronDerivationPath = tron?.derivationPath
tronDerivationPath = tron?.derivationPath,
solanaKeypair = solana?.keypair?.toLocalKeyPair(),
solanaDerivationPath = solana?.derivationPath
)
}
@@ -509,6 +515,15 @@ class RealLocalAccountsCloudBackupFacade(
)
}
private fun EncodableStruct<MetaAccountSecrets>?.getSolanaBackupSecrets(): CloudBackup.WalletPrivateInfo.SolanaSecrets? {
if (this == null) return null
return CloudBackup.WalletPrivateInfo.SolanaSecrets(
keypair = solanaKeypair?.toBackupKeypairSecrets() ?: return null,
derivationPath = solanaDerivationPath
)
}
private fun EncodableStruct<MetaAccountSecrets>?.getSubstrateBackupSecrets(): CloudBackup.WalletPrivateInfo.SubstrateSecrets? {
if (this == null) return null
@@ -555,6 +570,8 @@ class RealLocalAccountsCloudBackupFacade(
bitcoinPublicKey = metaAccount.bitcoinPublicKey,
tronAddress = metaAccount.tronAddress,
tronPublicKey = metaAccount.tronPublicKey,
solanaAddress = metaAccount.solanaAddress,
solanaPublicKey = metaAccount.solanaPublicKey,
)
}
@@ -581,6 +598,8 @@ class RealLocalAccountsCloudBackupFacade(
bitcoinPublicKey = bitcoinPublicKey,
tronAddress = tronAddress,
tronPublicKey = tronPublicKey,
solanaAddress = solanaAddress,
solanaPublicKey = solanaPublicKey,
).also {
if (localIdOverwrite != null) {
it.id = localIdOverwrite
@@ -67,6 +67,8 @@ class AccountMappers(
tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey,
solanaAddress = solanaAddress,
solanaPublicKey = solanaPublicKey,
isSelected = isSelected,
name = name,
status = mapMetaAccountStateFromLocal(status),
@@ -86,6 +88,8 @@ class AccountMappers(
tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey,
solanaAddress = solanaAddress,
solanaPublicKey = solanaPublicKey,
isSelected = isSelected,
name = name,
type = type,
@@ -107,6 +111,8 @@ class AccountMappers(
tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey,
solanaAddress = solanaAddress,
solanaPublicKey = solanaPublicKey,
isSelected = isSelected,
name = name,
type = type,
@@ -127,6 +133,8 @@ class AccountMappers(
tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey,
solanaAddress = solanaAddress,
solanaPublicKey = solanaPublicKey,
isSelected = isSelected,
name = name,
type = type,
@@ -148,6 +156,8 @@ class AccountMappers(
tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey,
solanaAddress = solanaAddress,
solanaPublicKey = solanaPublicKey,
isSelected = isSelected,
name = name,
type = type,
@@ -177,6 +187,8 @@ class AccountMappers(
tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey,
solanaAddress = solanaAddress,
solanaPublicKey = solanaPublicKey,
isSelected = isSelected,
name = name,
status = mapMetaAccountStateFromLocal(status),
@@ -197,6 +209,8 @@ class AccountMappers(
tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey,
solanaAddress = solanaAddress,
solanaPublicKey = solanaPublicKey,
chainAccounts = chainAccounts,
isSelected = isSelected,
name = name,
@@ -31,6 +31,7 @@ import io.novafoundation.nova.feature_account_impl.data.mappers.mapMetaAccountTy
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.SolanaAddressBackfillMigration
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.TronAddressBackfillMigration
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
@@ -68,11 +69,12 @@ class AccountDataSourceImpl(
accountDataMigration: AccountDataMigration,
tronAddressBackfillMigration: TronAddressBackfillMigration,
bitcoinAddressBackfillMigration: BitcoinAddressBackfillMigration,
solanaAddressBackfillMigration: SolanaAddressBackfillMigration,
) : AccountDataSource, SecretStoreV1 by secretStoreV1 {
init {
// Run sequentially in one coroutine, not as independent launches - the Tron/Bitcoin backfills read
// accounts/secrets that the legacy migration may still be in the middle of writing for very old
// Run sequentially in one coroutine, not as independent launches - the Tron/Bitcoin/Solana backfills
// read accounts/secrets that the legacy migration may still be in the middle of writing for very old
// (pre-MetaAccount) installs, and separate GlobalScope.launch calls give no ordering guarantee
// relative to each other.
async {
@@ -90,6 +92,10 @@ class AccountDataSourceImpl(
bitcoinAddressBackfillMigration.migrate()
Log.d("AccountDataSourceImpl", "about to run solanaAddressBackfillMigration")
solanaAddressBackfillMigration.migrate()
Log.d("AccountDataSourceImpl", "migrations block done")
metaAccountDao.getMetaAccounts().forEach {
@@ -33,6 +33,7 @@ class RealSecretsMetaAccountLocalFactory : SecretsMetaAccountLocalFactory {
val ethereumPublicKey = secrets[MetaAccountSecrets.EthereumKeypair]?.get(KeyPairSchema.PublicKey)
val tronPublicKey = secrets[MetaAccountSecrets.TronKeypair]?.get(KeyPairSchema.PublicKey)
val bitcoinPublicKey = secrets[MetaAccountSecrets.BitcoinKeypair]?.get(KeyPairSchema.PublicKey)
val solanaPublicKey = secrets[MetaAccountSecrets.SolanaKeypair]?.get(KeyPairSchema.PublicKey)
return MetaAccountLocal(
substratePublicKey = substratePublicKey,
@@ -55,7 +56,10 @@ class RealSecretsMetaAccountLocalFactory : SecretsMetaAccountLocalFactory {
// 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()
bitcoinAddress = bitcoinPublicKey?.bitcoinPublicKeyToAccountId(),
// Solana's accountId IS the public key itself, no hash/transform step - see SolanaAddress.kt's doc.
solanaPublicKey = solanaPublicKey,
solanaAddress = solanaPublicKey
)
}
}
@@ -11,6 +11,8 @@ 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.solanaDerivationPath
import io.novafoundation.nova.common.data.secrets.v2.solanaKeypair
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
@@ -106,7 +108,11 @@ class BitcoinAddressBackfillMigration(
tronKeypair = secrets.tronKeypair?.let(::mapKeypairStructToKeypair),
tronDerivationPath = secrets.tronDerivationPath,
bitcoinKeypair = bitcoinKeypair,
bitcoinDerivationPath = BITCOIN_DEFAULT_DERIVATION_PATH
bitcoinDerivationPath = BITCOIN_DEFAULT_DERIVATION_PATH,
// Must carry this forward unchanged - dropping it would silently wipe an already-backfilled
// Solana keypair, since this migration re-runs unconditionally on every app start (see class doc).
solanaKeypair = secrets.solanaKeypair?.let(::mapKeypairStructToKeypair),
solanaDerivationPath = secrets.solanaDerivationPath,
)
secretStoreV2.putMetaAccountSecrets(account.id, updatedSecrets)
@@ -0,0 +1,115 @@
package io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration
import android.util.Log
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.solanaKeypair
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.Bip32Ed25519KeypairFactory
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.SOLANA_DEFAULT_DERIVATION_PATH
import io.novafoundation.nova.feature_account_impl.data.secrets.SOLANA_DEFAULT_DERIVATION_PATH_SEGMENTS
import io.novasama.substrate_sdk_android.encrypt.mnemonic.MnemonicCreator
import io.novasama.substrate_sdk_android.encrypt.seed.bip39.Bip39SeedFactory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
private const val TAG = "SolanaAddressBackfill"
/**
* Idempotent, per-account backfill for accounts that don't yet have a Solana keypair - mirrors
* [TronAddressBackfillMigration]/[BitcoinAddressBackfillMigration]'s exact design (see those classes for the
* full rationale). Deliberately has NO "have I already run once" flag, for the same stuck-flag reason.
*
* Only touches accounts that are `Type.SECRETS` and still hold their `Entropy` in [SecretStoreV2] - same
* restriction as the Tron/Bitcoin migrations.
*
* Unlike Tron/Bitcoin (which reuse [io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory.chainAccountSecrets]
* with `isEthereum = true`, since both are secp256k1/BIP32), Solana is Ed25519/SLIP-0010 - a different curve and
* derivation scheme entirely - so this derives the seed directly via [Bip39SeedFactory.deriveSeed] (the exact
* same chain-agnostic BIP39 seed step Tron/Bitcoin/Ethereum all use) and feeds it to [Bip32Ed25519KeypairFactory]
* at [SOLANA_DEFAULT_DERIVATION_PATH_SEGMENTS], the same call `AccountSecretsFactory.metaAccountSecrets()` makes
* for a fresh account - so a backfilled account ends up with byte-for-byte the same Solana address it would
* have gotten had it been created today.
*
* Carries every other chain-family field (substrate/ethereum/tron/bitcoin) forward unchanged when rewriting
* secrets - dropping any of them here would silently wipe an already-backfilled sibling key on retry.
*/
class SolanaAddressBackfillMigration(
private val secretStoreV2: SecretStoreV2,
private val metaAccountDao: MetaAccountDao,
) {
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.solanaKeypair != null) {
Log.d(TAG, "metaId=${account.id}: already has a SolanaKeypair - skipping")
return
}
Log.d(TAG, "metaId=${account.id}: deriving Solana keypair")
val mnemonic = MnemonicCreator.fromEntropy(entropy).words
val bip39Seed = Bip39SeedFactory.deriveSeed(mnemonic, null).seed
val solanaKeypair = Bip32Ed25519KeypairFactory.generate(bip39Seed, SOLANA_DEFAULT_DERIVATION_PATH_SEGMENTS)
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 = secrets.bitcoinKeypair?.let(::mapKeypairStructToKeypair),
bitcoinDerivationPath = secrets.bitcoinDerivationPath,
solanaKeypair = solanaKeypair,
solanaDerivationPath = SOLANA_DEFAULT_DERIVATION_PATH
)
secretStoreV2.putMetaAccountSecrets(account.id, updatedSecrets)
// Solana's accountId IS the public key itself - see SolanaAddress.kt's doc.
metaAccountDao.updateMetaAccount(account.id) { it.addSolanaAccount(solanaKeypair.publicKey, solanaKeypair.publicKey) }
Log.d(TAG, "metaId=${account.id}: backfilled successfully, solanaAddress set (${solanaKeypair.publicKey.size} bytes)")
}
}
@@ -4,11 +4,15 @@ 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.solanaDerivationPath
import io.novafoundation.nova.common.data.secrets.v2.solanaKeypair
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.tronKeypair
@@ -115,7 +119,14 @@ class TronAddressBackfillMigration(
ethereumKeypair = secrets.ethereumKeypair?.let(::mapKeypairStructToKeypair),
ethereumDerivationPath = secrets.ethereumDerivationPath,
tronKeypair = tronKeypair,
tronDerivationPath = TRON_DEFAULT_DERIVATION_PATH
tronDerivationPath = TRON_DEFAULT_DERIVATION_PATH,
// Must carry these forward unchanged - dropping them would silently wipe an
// already-backfilled sibling keypair, since this migration re-runs unconditionally
// on every app start (see class doc).
bitcoinKeypair = secrets.bitcoinKeypair?.let(::mapKeypairStructToKeypair),
bitcoinDerivationPath = secrets.bitcoinDerivationPath,
solanaKeypair = secrets.solanaKeypair?.let(::mapKeypairStructToKeypair),
solanaDerivationPath = secrets.solanaDerivationPath,
)
secretStoreV2.putMetaAccountSecrets(account.id, updatedSecrets)
@@ -6,6 +6,7 @@ import io.novafoundation.nova.common.data.network.runtime.binding.cast
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.mapKeypairStructToKeypair
import io.novafoundation.nova.common.utils.Bip32Ed25519KeypairFactory
import io.novafoundation.nova.common.utils.castOrNull
import io.novafoundation.nova.common.utils.deriveSeed32
import io.novafoundation.nova.core.model.CryptoType
@@ -42,6 +43,24 @@ const val TRON_DEFAULT_DERIVATION_PATH = "//44//195//0/0/0"
*/
const val BITCOIN_DEFAULT_DERIVATION_PATH = "//84//0//0/0/0"
/**
* SLIP-44 coin type 501 is Solana's registered BIP44 coin type. Path is m/44'/501'/0'/0' (all
* hardened, SLIP-0010 Ed25519) - the Phantom/Solflare/Solana CLI default, NOT the older
* Sollet-style m/44'/501'/0' (no final change level). Not user-configurable via the "Advanced
* Encryption" UI yet, same as Tron/Bitcoin - always derived at this fixed path.
*
* This is deliberately a plain "44/501/0/0" segment list, not the junction-string format
* ("//44//501//0/0") Tron/Bitcoin use - unlike those (which reuse [Bip32EcdsaKeypairFactory]/
* [DerivationPathDecoder]'s junction decoding, built for secp256k1), Solana's Ed25519 derivation
* has no such decoder in this codebase and is handled directly by [Bip32Ed25519KeypairFactory].
*/
val SOLANA_DEFAULT_DERIVATION_PATH_SEGMENTS = listOf(44, 501, 0, 0)
/** Human-readable form of [SOLANA_DEFAULT_DERIVATION_PATH_SEGMENTS], stored/displayed the same way
* [TRON_DEFAULT_DERIVATION_PATH]/[BITCOIN_DEFAULT_DERIVATION_PATH] are - not itself parsed back
* into segments anywhere, purely for display/backup/consistency with the other two. */
const val SOLANA_DEFAULT_DERIVATION_PATH = "m/44'/501'/0'/0'"
class AccountSecretsFactory(
private val JsonDecoder: JsonDecoder
) {
@@ -140,6 +159,7 @@ class AccountSecretsFactory(
accountSource: AccountSource,
tronDerivationPath: String? = TRON_DEFAULT_DERIVATION_PATH,
bitcoinDerivationPath: String? = BITCOIN_DEFAULT_DERIVATION_PATH,
solanaDerivationPath: String? = SOLANA_DEFAULT_DERIVATION_PATH,
): Result<MetaAccountSecrets> = withContext(Dispatchers.Default) {
val (substrateSecrets, substrateCryptoType) = chainAccountSecrets(
derivationPath = substrateDerivationPath,
@@ -176,6 +196,19 @@ class AccountSecretsFactory(
Bip32EcdsaKeypairFactory.generate(seed = seed, junctions = decodedBitcoinDerivationPath?.junctions.orEmpty())
}
// Solana's Ed25519 SLIP-0010 derivation is a fundamentally different scheme from
// Tron/Bitcoin's secp256k1/BIP32 (see Bip32Ed25519KeypairFactory's doc) - it still starts
// from the exact same standard BIP39 seed (seed generation itself is chain-agnostic), just
// via fixed segments rather than junction-string decoding (no ed25519 path decoder exists
// in this codebase, so this doesn't reuse decodeDerivationPath/DerivationPathDecoder).
val solanaKeypair = accountSource.castOrNull<AccountSource.Mnemonic>()?.let {
// No BIP39 passphrase support yet (Phase 1, same as Tron/Bitcoin) - there is no
// junction-string decoder for ed25519 paths to extract one from even if there were.
val seed = deriveSeed(it.mnemonic, password = null, ethereum = true).seed
Bip32Ed25519KeypairFactory.generate(seed, SOLANA_DEFAULT_DERIVATION_PATH_SEGMENTS)
}
val secrets = MetaAccountSecrets(
entropy = substrateSecrets[ChainAccountSecrets.Entropy],
substrateSeed = substrateSecrets[ChainAccountSecrets.Seed],
@@ -187,6 +220,8 @@ class AccountSecretsFactory(
tronDerivationPath = tronDerivationPath,
bitcoinKeypair = bitcoinKeypair,
bitcoinDerivationPath = bitcoinDerivationPath,
solanaKeypair = solanaKeypair,
solanaDerivationPath = solanaDerivationPath,
)
Result(secrets = secrets, cryptoType = substrateCryptoType)
@@ -7,6 +7,7 @@ import io.novafoundation.nova.common.data.secrets.v2.getChainAccountKeypair
import io.novafoundation.nova.common.data.secrets.v2.getMetaAccountKeypair
import io.novafoundation.nova.common.data.secrets.v2.seed
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.core.model.CryptoType
import io.novafoundation.nova.common.sequrity.TwoFactorVerificationResult
import io.novafoundation.nova.common.sequrity.TwoFactorVerificationService
import io.novafoundation.nova.feature_account_api.data.signer.SigningContext
@@ -144,13 +145,15 @@ class SecretsSigner(
val multiChainEncryption = metaAccount.multiChainEncryptionFor(accountId, chainsById)!!
val isTronBased = metaAccount.tronAddress?.contentEquals(accountId) == true
val isBitcoinBased = metaAccount.bitcoinAddress?.contentEquals(accountId) == true
val isSolanaBased = metaAccount.solanaAddress?.contentEquals(accountId) == true
return secretStoreV2.getKeypair(
metaAccount = metaAccount,
accountId = accountId,
isEthereumBased = multiChainEncryption is MultiChainEncryption.Ethereum,
isTronBased = isTronBased,
isBitcoinBased = isBitcoinBased
isBitcoinBased = isBitcoinBased,
isSolanaBased = isSolanaBased
)
}
@@ -166,10 +169,11 @@ class SecretsSigner(
isEthereumBased: Boolean,
isTronBased: Boolean = false,
isBitcoinBased: Boolean = false,
isSolanaBased: Boolean = false,
) = if (hasChainSecrets(metaAccount.id, accountId)) {
getChainAccountKeypair(metaAccount.id, accountId)
} else {
getMetaAccountKeypair(metaAccount.id, isEthereumBased, isTronBased, isBitcoinBased)
getMetaAccountKeypair(metaAccount.id, isEthereumBased, isTronBased, isBitcoinBased, isSolanaBased)
}
/**
@@ -185,6 +189,10 @@ class SecretsSigner(
// null -> NPE on `!!`.
tronAddress?.contentEquals(accountId) == true -> MultiChainEncryption.Ethereum
bitcoinAddress?.contentEquals(accountId) == true -> MultiChainEncryption.Ethereum
// Solana is Ed25519, not secp256k1, so it doesn't join the Tron/Bitcoin group above -
// it reuses the same MultiChainEncryption.Substrate(ED25519) path Substrate's own
// ed25519 accounts use (see RealSecretsMetaAccount.multiChainEncryptionIn).
solanaAddress?.contentEquals(accountId) == true -> MultiChainEncryption.substrateFrom(CryptoType.ED25519)
else -> {
val chainAccount = chainAccounts.values.firstOrNull { it.accountId.contentEquals(accountId) } ?: return null
val cryptoType = chainAccount.cryptoType ?: return null
@@ -105,6 +105,7 @@ import io.novafoundation.nova.feature_account_impl.data.repository.datasource.Re
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.repository.datasource.migration.SolanaAddressBackfillMigration
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.TronAddressBackfillMigration
import io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory
import io.novafoundation.nova.feature_account_impl.data.signer.signingContext.SigningContextFactory
@@ -410,6 +411,7 @@ class AccountFeatureModule {
secretStoreV2: SecretStoreV2,
accountMappers: AccountMappers,
bitcoinAddressBackfillMigration: BitcoinAddressBackfillMigration,
solanaAddressBackfillMigration: SolanaAddressBackfillMigration,
): AccountDataSource {
return AccountDataSourceImpl(
preferences,
@@ -422,7 +424,8 @@ class AccountFeatureModule {
secretStoreV1,
accountDataMigration,
tronAddressBackfillMigration,
bitcoinAddressBackfillMigration
bitcoinAddressBackfillMigration,
solanaAddressBackfillMigration
)
}
@@ -436,6 +439,15 @@ class AccountFeatureModule {
return BitcoinAddressBackfillMigration(secretStoreV2, metaAccountDao, accountSecretsFactory)
}
@Provides
@FeatureScope
fun provideSolanaAddressBackfillMigration(
secretStoreV2: SecretStoreV2,
metaAccountDao: MetaAccountDao,
): SolanaAddressBackfillMigration {
return SolanaAddressBackfillMigration(secretStoreV2, metaAccountDao)
}
@Provides
fun provideNodeHostValidator() = NodeHostValidator()
@@ -26,6 +26,8 @@ open class DefaultMetaAccount(
override val tronPublicKey: ByteArray? = null,
override val bitcoinAddress: ByteArray? = null,
override val bitcoinPublicKey: ByteArray? = null,
override val solanaAddress: ByteArray? = null,
override val solanaPublicKey: ByteArray? = null,
) : MetaAccount {
override suspend fun supportsAddingChainAccount(chain: Chain): Boolean {
@@ -37,6 +39,7 @@ open class DefaultMetaAccount(
hasChainAccountIn(chain.id) -> true
chain.isTronBased -> tronAddress != null
chain.isBitcoinBased -> bitcoinAddress != null
chain.isSolanaBased -> solanaAddress != null
chain.isEthereumBased -> ethereumAddress != null
else -> substrateAccountId != null
}
@@ -47,6 +50,7 @@ open class DefaultMetaAccount(
hasChainAccountIn(chain.id) -> chainAccounts.getValue(chain.id).accountId
chain.isTronBased -> tronAddress
chain.isBitcoinBased -> bitcoinAddress
chain.isSolanaBased -> solanaAddress
chain.isEthereumBased -> ethereumAddress
else -> substrateAccountId
}
@@ -57,6 +61,7 @@ open class DefaultMetaAccount(
hasChainAccountIn(chain.id) -> chainAccounts.getValue(chain.id).publicKey
chain.isTronBased -> tronPublicKey
chain.isBitcoinBased -> bitcoinPublicKey
chain.isSolanaBased -> solanaPublicKey
chain.isEthereumBased -> ethereumPublicKey
else -> substratePublicKey
}
@@ -26,6 +26,8 @@ class GenericLedgerMetaAccount(
tronPublicKey: ByteArray? = null,
bitcoinAddress: ByteArray? = null,
bitcoinPublicKey: ByteArray? = null,
solanaAddress: ByteArray? = null,
solanaPublicKey: ByteArray? = null,
) : DefaultMetaAccount(
id = id,
globallyUniqueId = globallyUniqueId,
@@ -43,7 +45,9 @@ class GenericLedgerMetaAccount(
tronAddress = tronAddress,
tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey
bitcoinPublicKey = bitcoinPublicKey,
solanaAddress = solanaAddress,
solanaPublicKey = solanaPublicKey
) {
override suspend fun supportsAddingChainAccount(chain: Chain): Boolean {
@@ -26,6 +26,8 @@ class LegacyLedgerMetaAccount(
tronPublicKey: ByteArray? = null,
bitcoinAddress: ByteArray? = null,
bitcoinPublicKey: ByteArray? = null,
solanaAddress: ByteArray? = null,
solanaPublicKey: ByteArray? = null,
) : DefaultMetaAccount(
id = id,
globallyUniqueId = globallyUniqueId,
@@ -43,7 +45,9 @@ class LegacyLedgerMetaAccount(
tronAddress = tronAddress,
tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey
bitcoinPublicKey = bitcoinPublicKey,
solanaAddress = solanaAddress,
solanaPublicKey = solanaPublicKey
) {
override suspend fun supportsAddingChainAccount(chain: Chain): Boolean {
@@ -32,6 +32,8 @@ class RealMultisigMetaAccount(
tronPublicKey: ByteArray? = null,
bitcoinAddress: ByteArray? = null,
bitcoinPublicKey: ByteArray? = null,
solanaAddress: ByteArray? = null,
solanaPublicKey: ByteArray? = null,
) : DefaultMetaAccount(
id = id,
globallyUniqueId = globallyUniqueId,
@@ -49,7 +51,9 @@ class RealMultisigMetaAccount(
tronAddress = tronAddress,
tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey
bitcoinPublicKey = bitcoinPublicKey,
solanaAddress = solanaAddress,
solanaPublicKey = solanaPublicKey
),
MultisigMetaAccount {
@@ -24,6 +24,8 @@ class PolkadotVaultMetaAccount(
tronPublicKey: ByteArray? = null,
bitcoinAddress: ByteArray? = null,
bitcoinPublicKey: ByteArray? = null,
solanaAddress: ByteArray? = null,
solanaPublicKey: ByteArray? = null,
) : DefaultMetaAccount(
id = id,
globallyUniqueId = globallyUniqueId,
@@ -41,7 +43,9 @@ class PolkadotVaultMetaAccount(
tronAddress = tronAddress,
tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey
bitcoinPublicKey = bitcoinPublicKey,
solanaAddress = solanaAddress,
solanaPublicKey = solanaPublicKey
) {
override suspend fun supportsAddingChainAccount(chain: Chain): Boolean {
@@ -26,6 +26,8 @@ internal class RealProxiedMetaAccount(
tronPublicKey: ByteArray? = null,
bitcoinAddress: ByteArray? = null,
bitcoinPublicKey: ByteArray? = null,
solanaAddress: ByteArray? = null,
solanaPublicKey: ByteArray? = null,
) : DefaultMetaAccount(
id = id,
globallyUniqueId = globallyUniqueId,
@@ -43,7 +45,9 @@ internal class RealProxiedMetaAccount(
tronAddress = tronAddress,
tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey
bitcoinPublicKey = bitcoinPublicKey,
solanaAddress = solanaAddress,
solanaPublicKey = solanaPublicKey
),
ProxiedMetaAccount {
@@ -27,6 +27,8 @@ class RealSecretsMetaAccount(
tronPublicKey: ByteArray? = null,
bitcoinAddress: ByteArray? = null,
bitcoinPublicKey: ByteArray? = null,
solanaAddress: ByteArray? = null,
solanaPublicKey: ByteArray? = null,
) : DefaultMetaAccount(
id = id,
globallyUniqueId = globallyUniqueId,
@@ -44,7 +46,9 @@ class RealSecretsMetaAccount(
tronAddress = tronAddress,
tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey
bitcoinPublicKey = bitcoinPublicKey,
solanaAddress = solanaAddress,
solanaPublicKey = solanaPublicKey
),
SecretsMetaAccount {
@@ -55,9 +59,16 @@ class RealSecretsMetaAccount(
// Tron and Bitcoin both reuse the same secp256k1 keypair/signing as Ethereum - see
// RealTronTransactionService/RealBitcoinTransactionService's use of
// Signer.sign(MultiChainEncryption.Ethereum, ...).
// Signer.sign(MultiChainEncryption.Ethereum, ...). Solana is Ed25519, not
// secp256k1, so it can't join that group - it reuses MultiChainEncryption.Substrate
// (EncryptionType.ED25519) instead, the exact same code path Substrate's own
// ed25519 accounts already use (Signer.signEd25519 doesn't care which chain family
// the keypair belongs to, only that it's a raw Ed25519 keypair - confirmed by
// reading substrate-sdk-android's Signer.kt directly).
if (chain.isEthereumBased || chain.isTronBased || chain.isBitcoinBased) {
MultiChainEncryption.Ethereum
} else if (chain.isSolanaBased) {
MultiChainEncryption.substrateFrom(CryptoType.ED25519)
} else {
MultiChainEncryption.substrateFrom(cryptoType)
}
@@ -69,6 +80,8 @@ class RealSecretsMetaAccount(
chain.isBitcoinBased -> MultiChainEncryption.Ethereum
chain.isSolanaBased -> MultiChainEncryption.substrateFrom(CryptoType.ED25519)
else -> substrateCryptoType?.let(MultiChainEncryption.Companion::substrateFrom)
}
}
@@ -0,0 +1,183 @@
package io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration
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.mapKeypairStructToKeypair
import io.novafoundation.nova.common.data.secrets.v2.solanaKeypair
import io.novafoundation.nova.common.utils.invoke
import io.novafoundation.nova.common.utils.solanaAddressToAccountId
import io.novafoundation.nova.common.utils.toSolanaAddress
import io.novafoundation.nova.core.model.CryptoType
import io.novafoundation.nova.core_db.dao.MetaAccountDao
import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal
import io.novasama.substrate_sdk_android.encrypt.mnemonic.MnemonicCreator
import io.novasama.substrate_sdk_android.scale.EncodableStruct
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatcher
import org.mockito.Mock
import org.mockito.Mockito
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
import org.mockito.junit.MockitoJUnitRunner
// Same guaranteed-non-null-return wrappers as TronAddressBackfillMigrationTest, for the same reason - see
// that test's class doc for the full writeup.
private fun <T> eq(value: T): T = Mockito.eq(value) ?: value
@Suppress("UNCHECKED_CAST")
private fun <T> any(): T {
Mockito.any<T>()
return null as T
}
@Suppress("UNCHECKED_CAST")
private fun <T> argThat(matcher: (T) -> Boolean): T {
Mockito.argThat(ArgumentMatcher<T> { matcher(it) })
return null as T
}
private fun <T> whenever(methodCall: T?) = Mockito.`when`(methodCall)
/**
* Mirrors [TronAddressBackfillMigrationTest]'s design (see that class's doc for the full context on why this
* matters). Unlike Tron/Bitcoin's migration test, [SolanaAddressBackfillMigration] doesn't go through
* [io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory] (Solana's Ed25519
* derivation is a different code path entirely - see that class's constructor), so there's no factory to mock
* or use for real - the derivation math (SLIP-0010 via [io.novafoundation.nova.common.utils.Bip32Ed25519KeypairFactory])
* runs directly. Asserts against the same live-cross-validated reference address
* [io.novafoundation.nova.common.utils.SolanaAddressTest] already established for the standard BIP39 test
* mnemonic, so this test and that one are pinned to the same known-good vector.
*/
@RunWith(MockitoJUnitRunner::class)
class SolanaAddressBackfillMigrationTest {
@Mock
lateinit var secretStoreV2: SecretStoreV2
@Mock
lateinit var metaAccountDao: MetaAccountDao
private lateinit var subject: SolanaAddressBackfillMigration
private val testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
private val expectedSolanaAccountId = "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk".solanaAddressToAccountId()
@Before
fun setup() {
subject = SolanaAddressBackfillMigration(secretStoreV2, metaAccountDao)
}
@Test
fun `migrate should derive and persist the well-known reference Solana address for a pre-existing mnemonic account`(): Unit = runBlocking {
val account = secretsAccount(id = 42)
val secrets = accountSecrets(entropy = MnemonicCreator.fromWords(testMnemonic).entropy, solanaKeypair = null)
whenever(metaAccountDao.getMetaAccounts()).thenReturn(listOf(account))
whenever(metaAccountDao.getMetaAccount(eq(42L))).thenReturn(account)
whenever(secretStoreV2.getMetaAccountSecrets(eq(42L))).thenReturn(secrets)
subject.migrate()
verify(metaAccountDao).updateMetaAccount(
argThat<MetaAccountLocal> { updated ->
updated.id == 42L && updated.solanaAddress.contentEquals(expectedSolanaAccountId)
}
)
verify(secretStoreV2).putMetaAccountSecrets(
eq(42L),
argThat<EncodableStruct<MetaAccountSecrets>> { updatedSecrets ->
val solanaKeypairStruct = updatedSecrets.solanaKeypair
solanaKeypairStruct != null &&
mapKeypairStructToKeypair(solanaKeypairStruct).publicKey.toSolanaAddress() == "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk"
}
)
}
@Test
fun `migrate should skip an account that already has a Solana keypair`(): Unit = runBlocking {
val account = secretsAccount(id = 7)
val existingSolanaKeypair = KeyPairSchema { keypair ->
keypair[KeyPairSchema.PublicKey] = ByteArray(32) { 9 }
keypair[KeyPairSchema.PrivateKey] = ByteArray(32) { 8 }
keypair[KeyPairSchema.Nonce] = null
}
val secrets = accountSecrets(entropy = MnemonicCreator.fromWords(testMnemonic).entropy, solanaKeypair = existingSolanaKeypair)
whenever(metaAccountDao.getMetaAccounts()).thenReturn(listOf(account))
whenever(secretStoreV2.getMetaAccountSecrets(eq(7L))).thenReturn(secrets)
subject.migrate()
verify(metaAccountDao, never()).updateMetaAccount(any())
verify(secretStoreV2, never()).putMetaAccountSecrets(Mockito.anyLong(), any())
}
@Test
fun `migrate should skip an account with no entropy (raw-seed import, not a mnemonic)`(): Unit = runBlocking {
val account = secretsAccount(id = 11)
val secrets = accountSecrets(entropy = null, solanaKeypair = null)
whenever(metaAccountDao.getMetaAccounts()).thenReturn(listOf(account))
whenever(secretStoreV2.getMetaAccountSecrets(eq(11L))).thenReturn(secrets)
subject.migrate()
verify(metaAccountDao, never()).updateMetaAccount(any())
verify(secretStoreV2, never()).putMetaAccountSecrets(Mockito.anyLong(), any())
}
@Test
fun `migrate should skip an account with no stored secrets at all (watch-only, Ledger, etc)`(): Unit = runBlocking {
val account = secretsAccount(id = 13)
whenever(metaAccountDao.getMetaAccounts()).thenReturn(listOf(account))
whenever(secretStoreV2.getMetaAccountSecrets(eq(13L))).thenReturn(null)
subject.migrate()
verify(metaAccountDao, never()).updateMetaAccount(any())
verify(secretStoreV2, never()).putMetaAccountSecrets(Mockito.anyLong(), any())
}
private fun secretsAccount(id: Long): MetaAccountLocal {
return MetaAccountLocal(
substratePublicKey = ByteArray(32) { 1 },
substrateCryptoType = CryptoType.SR25519,
substrateAccountId = ByteArray(32) { 2 },
ethereumPublicKey = null,
ethereumAddress = null,
name = "Test account",
parentMetaId = null,
isSelected = true,
position = 0,
type = MetaAccountLocal.Type.SECRETS,
status = MetaAccountLocal.Status.ACTIVE,
globallyUniqueId = "test-guid-$id",
typeExtras = null
).also { it.id = id }
}
private fun accountSecrets(entropy: ByteArray?, solanaKeypair: EncodableStruct<KeyPairSchema>?): EncodableStruct<MetaAccountSecrets> {
val dummySubstrateKeypair = KeyPairSchema { keypair ->
keypair[KeyPairSchema.PublicKey] = ByteArray(32) { 5 }
keypair[KeyPairSchema.PrivateKey] = ByteArray(32) { 6 }
keypair[KeyPairSchema.Nonce] = ByteArray(8) { 7 }
}
return MetaAccountSecrets(
substrateKeyPair = mapKeypairStructToKeypair(dummySubstrateKeypair),
entropy = entropy,
substrateSeed = null,
substrateDerivationPath = null,
ethereumKeypair = null,
ethereumDerivationPath = null,
solanaKeypair = solanaKeypair?.let(::mapKeypairStructToKeypair),
solanaDerivationPath = null
)
}
}