mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-android.git
synced 2026-07-22 18:25:50 +00:00
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:
+20
-1
@@ -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
|
||||
|
||||
+14
@@ -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,
|
||||
|
||||
+8
-2
@@ -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 {
|
||||
|
||||
+5
-1
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -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)
|
||||
|
||||
+115
@@ -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)")
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -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)
|
||||
|
||||
+35
@@ -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)
|
||||
|
||||
+10
-2
@@ -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
|
||||
|
||||
+13
-1
@@ -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()
|
||||
|
||||
|
||||
+5
@@ -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
|
||||
}
|
||||
|
||||
+5
-1
@@ -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 {
|
||||
|
||||
+5
-1
@@ -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 {
|
||||
|
||||
+5
-1
@@ -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 {
|
||||
|
||||
|
||||
+5
-1
@@ -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 {
|
||||
|
||||
+5
-1
@@ -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 {
|
||||
|
||||
|
||||
+15
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+183
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user