diff --git a/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/MetaAccountSecrets.kt b/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/MetaAccountSecrets.kt index 6bc801db..3acc1eaf 100644 --- a/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/MetaAccountSecrets.kt +++ b/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/MetaAccountSecrets.kt @@ -31,6 +31,9 @@ object MetaAccountSecrets : Schema() { val BitcoinKeypair by schema(KeyPairSchema).optional() val BitcoinDerivationPath by string().optional() + + val SolanaKeypair by schema(KeyPairSchema).optional() + val SolanaDerivationPath by string().optional() } object ChainAccountSecrets : Schema() { @@ -52,6 +55,8 @@ fun MetaAccountSecrets( tronDerivationPath: String? = null, bitcoinKeypair: Keypair? = null, bitcoinDerivationPath: String? = null, + solanaKeypair: Keypair? = null, + solanaDerivationPath: String? = null, ): EncodableStruct = MetaAccountSecrets { secrets -> secrets[Entropy] = entropy secrets[SubstrateSeed] = substrateSeed @@ -89,6 +94,15 @@ fun MetaAccountSecrets( } } secrets[BitcoinDerivationPath] = bitcoinDerivationPath + + secrets[SolanaKeypair] = solanaKeypair?.let { + KeyPairSchema { keypair -> + keypair[PublicKey] = it.publicKey + keypair[PrivateKey] = it.privateKey + keypair[Nonce] = null // Solana uses Ed25519, not Sr25519, so nonce is always null + } + } + secrets[SolanaDerivationPath] = solanaDerivationPath } fun ChainAccountSecrets( @@ -120,6 +134,9 @@ val EncodableStruct.tronDerivationPath val EncodableStruct.bitcoinDerivationPath get() = get(MetaAccountSecrets.BitcoinDerivationPath) +val EncodableStruct.solanaDerivationPath + get() = get(MetaAccountSecrets.SolanaDerivationPath) + val EncodableStruct.entropy get() = get(MetaAccountSecrets.Entropy) @@ -138,6 +155,9 @@ val EncodableStruct.tronKeypair val EncodableStruct.bitcoinKeypair get() = get(MetaAccountSecrets.BitcoinKeypair) +val EncodableStruct.solanaKeypair + get() = get(MetaAccountSecrets.SolanaKeypair) + val EncodableStruct.derivationPath get() = get(ChainAccountSecrets.DerivationPath) diff --git a/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/SecretStoreV2.kt b/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/SecretStoreV2.kt index 1b44c7d0..4e0a2117 100644 --- a/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/SecretStoreV2.kt +++ b/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/SecretStoreV2.kt @@ -158,10 +158,11 @@ suspend fun SecretStoreV2.getMetaAccountKeypair( isEthereum: Boolean, isTron: Boolean = false, isBitcoin: Boolean = false, + isSolana: Boolean = false, ): Keypair = withContext(Dispatchers.Default) { val secrets = getMetaAccountSecrets(metaId) ?: noMetaSecrets(metaId) - mapMetaAccountSecretsToKeypair(secrets, isEthereum, isTron, isBitcoin) + mapMetaAccountSecretsToKeypair(secrets, isEthereum, isTron, isBitcoin, isSolana) } fun mapMetaAccountSecretsToKeypair( @@ -169,13 +170,16 @@ fun mapMetaAccountSecretsToKeypair( ethereum: Boolean, tron: Boolean = false, bitcoin: Boolean = false, + solana: Boolean = false, ): Keypair { // Tron and Bitcoin both reuse Ethereum's secp256k1 curve but derive their own keypair under a different // SLIP-44 path - each must be checked before `ethereum`, not folded into it, or that account would get - // signed with the wrong (Ethereum) private key. + // signed with the wrong (Ethereum) private key. Solana is Ed25519 (not secp256k1 at all) but the same + // "check before ethereum" reasoning applies - it's still its own distinct keypair. val keypairStruct = when { tron -> secrets[MetaAccountSecrets.TronKeypair] ?: noTronSecret() bitcoin -> secrets[MetaAccountSecrets.BitcoinKeypair] ?: noBitcoinSecret() + solana -> secrets[MetaAccountSecrets.SolanaKeypair] ?: noSolanaSecret() ethereum -> secrets[MetaAccountSecrets.EthereumKeypair] ?: noEthereumSecret() else -> secrets[MetaAccountSecrets.SubstrateKeypair] } @@ -213,6 +217,8 @@ private fun noBitcoinSecret(): Nothing = error("No bitcoin keypair found") private fun noTronSecret(): Nothing = error("No tron keypair found") +private fun noSolanaSecret(): Nothing = error("No solana keypair found") + fun mapKeypairStructToKeypair(struct: EncodableStruct): Keypair { return Keypair( publicKey = struct[KeyPairSchema.PublicKey], diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/SolanaAddress.kt b/common/src/main/java/io/novafoundation/nova/common/utils/SolanaAddress.kt new file mode 100644 index 00000000..a3ab42fc --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/SolanaAddress.kt @@ -0,0 +1,97 @@ +package io.novafoundation.nova.common.utils + +import io.novasama.substrate_sdk_android.encrypt.keypair.BaseKeypair +import io.novasama.substrate_sdk_android.encrypt.keypair.Keypair +import io.novasama.substrate_sdk_android.runtime.AccountId +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +/** + * Solana address format: the raw 32-byte Ed25519 public key, Base58-encoded directly - no + * checksum, no prefix byte (unlike Tron's Base58Check, see [TronAddress.kt]'s [Base58Check]). + * [Base58] (defined there) is reused as-is since it's exactly this: a plain, non-checksummed + * Base58 codec. + * + * Solana's account id IS the public key itself (no separate keccak/hash-based derivation the way + * Ethereum/Tron/Bitcoin have) - `AccountId` here is always exactly 32 bytes. + * + * Derivation: SLIP-0010 (Ed25519 hierarchical, hardened-only - Ed25519 has no public-key-only + * derivation the way secp256k1/BIP32 does, so every path segment is implicitly hardened) at path + * m/44'/501'/0'/0' (SLIP-44 coin type 501, the modern Phantom/Solflare/Solana CLI default - NOT + * the older Sollet-style m/44'/501'/0' with no final change level). Cross-validated against an + * independent implementation (the `slip10` PyPI package) for the standard BIP39 test mnemonic + * ("abandon x11 about", empty passphrase): both agree on + * HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk - see [SolanaAddressTest]. + */ +private const val SOLANA_SEED_HMAC_KEY = "ed25519 seed" + +object Bip32Ed25519KeypairFactory { + + /** + * `seed` is the standard 64-byte BIP39 seed (the exact same one Ethereum/Tron/Bitcoin derive + * via `Bip39SeedFactory.deriveSeed` - BIP39 seed generation itself is chain-agnostic, only + * what happens to it afterward differs). `path` is a list of child indices - all implicitly + * hardened, so callers pass plain ints (44, 501, 0, 0), not pre-marked/offset values. + */ + fun generate(seed: ByteArray, path: List): Keypair { + var (key, chainCode) = masterKeyFromSeed(seed) + + for (index in path) { + val (childKey, childChainCode) = deriveHardenedChild(key, chainCode, index) + key = childKey + chainCode = childChainCode + } + + val privateKeyParams = Ed25519PrivateKeyParameters(key, 0) + val publicKey = privateKeyParams.generatePublicKey().encoded + + return BaseKeypair(privateKey = key, publicKey = publicKey) + } + + private fun masterKeyFromSeed(seed: ByteArray): Pair { + val digest = hmacSha512(SOLANA_SEED_HMAC_KEY.toByteArray(Charsets.UTF_8), seed) + return digest.copyOfRange(0, 32) to digest.copyOfRange(32, 64) + } + + /** SLIP-0010's ed25519 child derivation is always hardened: `HMAC-SHA512(chainCode, 0x00 ++ + * parentKey ++ ser32(index | 0x80000000))`, unlike BIP32 secp256k1 (which can also derive + * non-hardened children from a public key alone) - there is no non-hardened case to handle. */ + private fun deriveHardenedChild(key: ByteArray, chainCode: ByteArray, index: Int): Pair { + val hardenedIndex = index or (1 shl 31) + val data = byteArrayOf(0) + key + ser32(hardenedIndex) + + val digest = hmacSha512(chainCode, data) + return digest.copyOfRange(0, 32) to digest.copyOfRange(32, 64) + } + + private fun ser32(value: Int): ByteArray = byteArrayOf( + (value ushr 24).toByte(), + (value ushr 16).toByte(), + (value ushr 8).toByte(), + value.toByte(), + ) + + private fun hmacSha512(key: ByteArray, data: ByteArray): ByteArray { + val mac = Mac.getInstance("HmacSHA512") + mac.init(SecretKeySpec(key, "HmacSHA512")) + return mac.doFinal(data) + } +} + +fun AccountId.toSolanaAddress(): String { + require(size == 32) { "Solana account id (public key) must be 32 bytes, got $size" } + + return Base58.encode(this) +} + +fun String.solanaAddressToAccountId(): AccountId { + val decoded = Base58.decode(this) + require(decoded.size == 32) { "Not a valid Solana address: $this" } + + return decoded +} + +fun String.isValidSolanaAddress(): Boolean = runCatching { solanaAddressToAccountId() }.isSuccess + +fun emptySolanaAccountId() = ByteArray(32) { 1 } diff --git a/common/src/test/java/io/novafoundation/nova/common/utils/SolanaAddressTest.kt b/common/src/test/java/io/novafoundation/nova/common/utils/SolanaAddressTest.kt new file mode 100644 index 00000000..5747fe18 --- /dev/null +++ b/common/src/test/java/io/novafoundation/nova/common/utils/SolanaAddressTest.kt @@ -0,0 +1,81 @@ +package io.novafoundation.nova.common.utils + +import io.novasama.substrate_sdk_android.extensions.fromHex +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SolanaAddressTest { + + /** + * The standard BIP39 test mnemonic ("abandon" x11 + "about", empty passphrase) - its 64-byte + * seed is a well-known, independently-verifiable industry reference value (used identically + * across Bitcoin/Ethereum/etc test vectors). Derived here at m/44'/501'/0'/0' (SLIP-0010 + * Ed25519, the Phantom/Solflare/Solana CLI default path) and cross-checked against an + * independent second implementation (the `slip10` PyPI package, run standalone in Python + * outside this codebase) - both agree exactly on this address, so this is a real, + * cross-validated vector, not one invented for this test. + */ + private val knownSeedHex = + "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4" + private val knownPath = listOf(44, 501, 0, 0) + private val knownAddress = "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk" + + @Test + fun `should derive the known reference Solana address from the standard test seed`() { + val keypair = Bip32Ed25519KeypairFactory.generate(knownSeedHex.fromHex(), knownPath) + + assertEquals(knownAddress, keypair.publicKey.toSolanaAddress()) + } + + @Test + fun `different derivation path should yield a different address`() { + val keypair = Bip32Ed25519KeypairFactory.generate(knownSeedHex.fromHex(), listOf(44, 501, 0)) + + assertFalse(keypair.publicKey.toSolanaAddress() == knownAddress) + } + + @Test + fun `address encode-decode should round trip`() { + val keypair = Bip32Ed25519KeypairFactory.generate(knownSeedHex.fromHex(), knownPath) + val address = keypair.publicKey.toSolanaAddress() + + val decoded = address.solanaAddressToAccountId() + assertTrue(decoded.contentEquals(keypair.publicKey)) + } + + @Test + fun `isValidSolanaAddress should accept known good address`() { + assertTrue(knownAddress.isValidSolanaAddress()) + } + + @Test + fun `isValidSolanaAddress should reject a Tron address`() { + assertFalse("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t".isValidSolanaAddress()) + } + + @Test + fun `isValidSolanaAddress should reject too-short input`() { + assertFalse("1111".isValidSolanaAddress()) + } + + @Test + fun `toSolanaAddress should reject a non-32-byte input`() { + val notThirtyTwoBytes = ByteArray(20) + + assertThrows(IllegalArgumentException::class.java) { + notThirtyTwoBytes.toSolanaAddress() + } + } + + private fun assertThrows(expected: Class, block: () -> Unit) { + try { + block() + } catch (e: Throwable) { + assertTrue("Expected ${expected.name} but got ${e::class.java.name}", expected.isInstance(e)) + return + } + throw AssertionError("Expected ${expected.name} to be thrown, but nothing was thrown") + } +} diff --git a/core-db/src/androidTest/java/io/novafoundation/nova/core_db/dao/Helpers.kt b/core-db/src/androidTest/java/io/novafoundation/nova/core_db/dao/Helpers.kt index 1cd5f359..b1abb3e7 100644 --- a/core-db/src/androidTest/java/io/novafoundation/nova/core_db/dao/Helpers.kt +++ b/core-db/src/androidTest/java/io/novafoundation/nova/core_db/dao/Helpers.kt @@ -59,6 +59,7 @@ fun chainOf( isEthereumBased = false, isTronBased = false, isBitcoinBased = false, + isSolanaBased = false, hasCrowdloans = false, additional = "", governance = "governance", diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/AppDatabase.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/AppDatabase.kt index d14c9542..2b852135 100644 --- a/core-db/src/main/java/io/novafoundation/nova/core_db/AppDatabase.kt +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/AppDatabase.kt @@ -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!! diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/75_76_AddSolanaSupport.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/75_76_AddSolanaSupport.kt new file mode 100644 index 00000000..8a57055f --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/75_76_AddSolanaSupport.kt @@ -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") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/ChainLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/ChainLocal.kt index faf32f39..a94d094f 100644 --- a/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/ChainLocal.kt +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/ChainLocal.kt @@ -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, diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/MetaAccountLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/MetaAccountLocal.kt index ed001602..5598e15b 100644 --- a/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/MetaAccountLocal.kt +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/MetaAccountLocal.kt @@ -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() diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/secrets/SecretStoreExt.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/secrets/SecretStoreExt.kt index c132e8b0..8af61677 100644 --- a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/secrets/SecretStoreExt.kt +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/secrets/SecretStoreExt.kt @@ -25,7 +25,18 @@ suspend fun SecretStoreV2.getAccountSecrets( fun AccountSecrets.keypair(chain: Chain): Keypair { return fold( - left = { mapMetaAccountSecretsToKeypair(it, ethereum = chain.isEthereumBased, bitcoin = chain.isBitcoinBased) }, + // `tron` was missing here already (found while adding `solana`) - without it, exporting a + // Tron account's private key from this call site would have silently returned the wrong + // (Ethereum) keypair instead of the Tron-specific one. Fixed alongside, not a separate change. + left = { + mapMetaAccountSecretsToKeypair( + it, + ethereum = chain.isEthereumBased, + tron = chain.isTronBased, + bitcoin = chain.isBitcoinBased, + solana = chain.isSolanaBased, + ) + }, right = { mapChainAccountSecretsToKeypair(it) } ) } diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/MetaAccount.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/MetaAccount.kt index b328a252..679010b1 100644 --- a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/MetaAccount.kt +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/MetaAccount.kt @@ -56,6 +56,10 @@ interface LightMetaAccount { /** Bitcoin account id (HASH160 of the compressed pubkey - see [io.novafoundation.nova.common.utils.hash160]). */ val bitcoinAddress: ByteArray? val bitcoinPublicKey: ByteArray? + + /** Solana account id, which IS the raw Ed25519 public key itself - see SolanaAddress.kt's doc. */ + val solanaAddress: ByteArray? + val solanaPublicKey: ByteArray? val isSelected: Boolean val name: String val type: Type @@ -96,6 +100,8 @@ fun LightMetaAccount( tronPublicKey: ByteArray? = null, bitcoinAddress: ByteArray? = null, bitcoinPublicKey: ByteArray? = null, + solanaAddress: ByteArray? = null, + solanaPublicKey: ByteArray? = null, ) = object : LightMetaAccount { override val id: Long = id override val globallyUniqueId: String = globallyUniqueId @@ -108,6 +114,8 @@ fun LightMetaAccount( override val tronPublicKey: ByteArray? = tronPublicKey override val bitcoinAddress: ByteArray? = bitcoinAddress override val bitcoinPublicKey: ByteArray? = bitcoinPublicKey + override val solanaAddress: ByteArray? = solanaAddress + override val solanaPublicKey: ByteArray? = solanaPublicKey override val isSelected: Boolean = isSelected override val name: String = name override val type: LightMetaAccount.Type = type diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/cloudBackup/RealLocalAccountsCloudBackupFacade.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/cloudBackup/RealLocalAccountsCloudBackupFacade.kt index 2e066d22..87826d82 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/cloudBackup/RealLocalAccountsCloudBackupFacade.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/cloudBackup/RealLocalAccountsCloudBackupFacade.kt @@ -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?.getSolanaBackupSecrets(): CloudBackup.WalletPrivateInfo.SolanaSecrets? { + if (this == null) return null + + return CloudBackup.WalletPrivateInfo.SolanaSecrets( + keypair = solanaKeypair?.toBackupKeypairSecrets() ?: return null, + derivationPath = solanaDerivationPath + ) + } + private fun EncodableStruct?.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 diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/mappers/AccountMappers.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/mappers/AccountMappers.kt index fdff1bcd..95c2412b 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/mappers/AccountMappers.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/mappers/AccountMappers.kt @@ -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, diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/AccountDataSourceImpl.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/AccountDataSourceImpl.kt index 6d997afd..2c088352 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/AccountDataSourceImpl.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/AccountDataSourceImpl.kt @@ -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 { diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/SecretsMetaAccountLocalFactory.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/SecretsMetaAccountLocalFactory.kt index 765558a6..ec12d597 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/SecretsMetaAccountLocalFactory.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/SecretsMetaAccountLocalFactory.kt @@ -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 ) } } diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/BitcoinAddressBackfillMigration.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/BitcoinAddressBackfillMigration.kt index 8a2e8d3e..b0307b75 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/BitcoinAddressBackfillMigration.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/BitcoinAddressBackfillMigration.kt @@ -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) diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/SolanaAddressBackfillMigration.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/SolanaAddressBackfillMigration.kt new file mode 100644 index 00000000..b311cb87 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/SolanaAddressBackfillMigration.kt @@ -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)") + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigration.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigration.kt index 58b3f07a..ac9b93a8 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigration.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigration.kt @@ -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) diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/secrets/AccountSecretsFactory.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/secrets/AccountSecretsFactory.kt index 0939771a..70d81e18 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/secrets/AccountSecretsFactory.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/secrets/AccountSecretsFactory.kt @@ -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 = 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()?.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) diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/secrets/SecretsSigner.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/secrets/SecretsSigner.kt index 1d0cd56f..4a45ff83 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/secrets/SecretsSigner.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/secrets/SecretsSigner.kt @@ -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 diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureModule.kt index 404edfcb..353b27ac 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureModule.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureModule.kt @@ -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() diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/DefaultMetaAccount.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/DefaultMetaAccount.kt index 98661b2f..49783142 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/DefaultMetaAccount.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/DefaultMetaAccount.kt @@ -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 } diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/GenericLedgerMetaAccount.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/GenericLedgerMetaAccount.kt index 7ac80199..d9197246 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/GenericLedgerMetaAccount.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/GenericLedgerMetaAccount.kt @@ -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 { diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/LegacyLedgerMetaAccount.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/LegacyLedgerMetaAccount.kt index 0f5d5f11..bc46f67b 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/LegacyLedgerMetaAccount.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/LegacyLedgerMetaAccount.kt @@ -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 { diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/MultisigMetaAccount.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/MultisigMetaAccount.kt index 146c4fa7..b8bb399b 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/MultisigMetaAccount.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/MultisigMetaAccount.kt @@ -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 { diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/PolkadotVaultMetaAccount.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/PolkadotVaultMetaAccount.kt index a1ace6ad..568b81da 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/PolkadotVaultMetaAccount.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/PolkadotVaultMetaAccount.kt @@ -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 { diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealProxiedMetaAccount.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealProxiedMetaAccount.kt index 51cb92cf..594ede3c 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealProxiedMetaAccount.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealProxiedMetaAccount.kt @@ -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 { diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealSecretsMetaAccount.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealSecretsMetaAccount.kt index f29bc849..4136fccc 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealSecretsMetaAccount.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealSecretsMetaAccount.kt @@ -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) } } diff --git a/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/SolanaAddressBackfillMigrationTest.kt b/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/SolanaAddressBackfillMigrationTest.kt new file mode 100644 index 00000000..61d5bb0a --- /dev/null +++ b/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/SolanaAddressBackfillMigrationTest.kt @@ -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 eq(value: T): T = Mockito.eq(value) ?: value + +@Suppress("UNCHECKED_CAST") +private fun any(): T { + Mockito.any() + return null as T +} + +@Suppress("UNCHECKED_CAST") +private fun argThat(matcher: (T) -> Boolean): T { + Mockito.argThat(ArgumentMatcher { matcher(it) }) + return null as T +} + +private fun 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 { updated -> + updated.id == 42L && updated.solanaAddress.contentEquals(expectedSolanaAccountId) + } + ) + + verify(secretStoreV2).putMetaAccountSecrets( + eq(42L), + argThat> { 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?): EncodableStruct { + 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 + ) + } +} diff --git a/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/CloudBackup.kt b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/CloudBackup.kt index 3a4a1aa0..20892545 100644 --- a/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/CloudBackup.kt +++ b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/CloudBackup.kt @@ -285,5 +285,8 @@ data class CloudBackup( } fun CloudBackup.WalletPrivateInfo.isCompletelyEmpty(): Boolean { - return entropy == null && substrate == null && ethereum == null && tron == null && bitcoin == null && chainAccounts.isEmpty() + // `solana` was missing here already (found while wiring Solana into RealLocalAccountsCloudBackupFacade) - + // without it, a wallet whose only secrets were Solana would have been wrongly treated as "completely empty" + // and silently dropped from the backup. + return entropy == null && substrate == null && ethereum == null && tron == null && bitcoin == null && solana == null && chainAccounts.isEmpty() } diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/utils/CustomChainFactory.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/utils/CustomChainFactory.kt index f74d9314..0a200494 100644 --- a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/utils/CustomChainFactory.kt +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/utils/CustomChainFactory.kt @@ -126,6 +126,7 @@ class CustomChainFactory( isEthereumBased = isEthereumBased, isTronBased = false, isBitcoinBased = false, + isSolanaBased = false, isTestNet = prefilledChain?.isTestNet.orFalse(), source = Chain.Source.CUSTOM, hasSubstrateRuntime = hasSubstrateRuntime, diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainExt.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainExt.kt index 67fa6ae1..54e21ad2 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainExt.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainExt.kt @@ -281,6 +281,7 @@ fun Chain.addressOf(accountId: ByteArray): String { return when { isTronBased -> accountId.toTronAddress() isBitcoinBased -> accountId.toBitcoinAddress() + isSolanaBased -> accountId.toSolanaAddress() isEthereumBased -> accountId.toEthereumAddress() else -> accountId.toAddress(addressPrefix.toShort()) } @@ -291,7 +292,7 @@ fun Chain.addressOf(accountId: AccountIdKey): String { } fun Chain.legacyAddressOfOrNull(accountId: ByteArray): String? { - return if (isEthereumBased || isTronBased || isBitcoinBased) { + return if (isEthereumBased || isTronBased || isBitcoinBased || isSolanaBased) { null } else { legacyAddressPrefix?.let { accountId.toAddress(it.toShort()) } @@ -311,6 +312,7 @@ fun Chain.accountIdOf(address: String): ByteArray { // opaque purposes (identicon generation, presence checks) elsewhere, never fed back into building a // scriptPubKey - see BitcoinDestinationAddress.kt's [hash] doc. isBitcoinBased -> runCatching { address.bitcoinAddressToAccountId() }.getOrElse { address.decodeBitcoinDestination().hash } + isSolanaBased -> address.solanaAddressToAccountId() isEthereumBased -> address.asEthereumAddress().toAccountId().value else -> address.toAccountId() } @@ -345,6 +347,7 @@ fun Chain.accountIdOrNull(address: String): ByteArray? { fun Chain.emptyAccountId() = when { isTronBased -> emptyTronAccountId() isBitcoinBased -> emptyBitcoinAccountId() + isSolanaBased -> emptySolanaAccountId() isEthereumBased -> emptyEthereumAccountId() else -> emptySubstrateAccountId() } @@ -359,6 +362,9 @@ fun Chain.accountIdOf(publicKey: ByteArray): ByteArray { return when { isTronBased -> publicKey.tronPublicKeyToAccountId() isBitcoinBased -> publicKey.bitcoinPublicKeyToAccountId() + // Solana's account id IS the raw public key itself - no separate hash-based derivation + // the way Ethereum/Tron/Bitcoin have (see SolanaAddress.kt's file-level doc). + isSolanaBased -> publicKey isEthereumBased -> publicKey.asEthereumPublicKey().toAccountId().value else -> publicKey.substrateAccountId() } @@ -388,6 +394,8 @@ fun Chain.isValidAddress(address: String): Boolean { // branches below would ever accept them, so this needs its own dedicated check. isTronBased -> address.isValidTronAddress() + isSolanaBased -> address.isValidSolanaAddress() + isEthereumBased -> address.asEthereumAddress().isValid() else -> { @@ -617,6 +625,19 @@ fun Chain.requireMempoolSpaceBaseUrl(): String { } } +/** + * The Solana JSON-RPC base url for a Solana-based chain - same rationale as [requireTronGridBaseUrl]/ + * [requireMempoolSpaceBaseUrl]: Solana has its own JSON-RPC (getBalance/getLatestBlockhash/sendTransaction), + * unrelated to the Substrate/EVM `nodes` WSS concept, so the configured `nodes` entry directly *is* this base url. + */ +fun Chain.requireSolanaRpcBaseUrl(): String { + require(isSolanaBased) { "Chain $id is not Solana-based" } + + return requireNotNull(nodes.nodes.minByOrNull { it.orderId }?.unformattedUrl) { + "No Solana RPC node configured for chain $id" + } +} + fun Chain.Asset.requireEquilibrium(): Type.Equilibrium { require(type is Type.Equilibrium) @@ -683,6 +704,7 @@ val Chain.Asset.onChainAssetId: String? is Type.Trc20 -> this.type.contractAddress Type.TronNative -> null Type.BitcoinNative -> null + Type.SolanaNative -> null Type.Unsupported -> error("Unsupported assetId type: ${this.type::class.simpleName}") } diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/ChainRegistry.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/ChainRegistry.kt index 56259063..4a5c2f46 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/ChainRegistry.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/ChainRegistry.kt @@ -249,7 +249,7 @@ class ChainRegistry( // ChainConnection's SocketService can only speak the latter, so attempting to set one up here would // hang indefinitely instead of failing fast. Balance/transfer operations for both go through their // own dedicated REST API clients, independent of this connection pool. - if (chain.isTronBased || chain.isBitcoinBased) return null + if (chain.isTronBased || chain.isBitcoinBased || chain.isSolanaBased) return null val connection = connectionPool.setupConnection(chain) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/ChainMappersConstants.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/ChainMappersConstants.kt index 9430b07e..587ca058 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/ChainMappersConstants.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/ChainMappersConstants.kt @@ -14,6 +14,8 @@ const val ASSET_TRC20 = "trc20" const val ASSET_BITCOIN_NATIVE = "bitcoinNative" +const val ASSET_SOLANA_NATIVE = "solanaNative" + const val ASSET_EQUILIBRIUM = "equilibrium" const val ASSET_EQUILIBRIUM_ON_CHAIN_ID = "assetId" diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/DomainToLocalChainMapper.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/DomainToLocalChainMapper.kt index ff2011c9..a3aaa47a 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/DomainToLocalChainMapper.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/DomainToLocalChainMapper.kt @@ -71,6 +71,8 @@ fun mapChainAssetTypeToRaw(type: Chain.Asset.Type): Pair ASSET_BITCOIN_NATIVE to null + Chain.Asset.Type.SolanaNative -> ASSET_SOLANA_NATIVE to null + Chain.Asset.Type.Unsupported -> ASSET_UNSUPPORTED to null } @@ -131,6 +133,7 @@ fun mapChainToLocal(chain: Chain, gson: Gson): ChainLocal { isEthereumBased = chain.isEthereumBased, isTronBased = chain.isTronBased, isBitcoinBased = chain.isBitcoinBased, + isSolanaBased = chain.isSolanaBased, isTestNet = chain.isTestNet, hasSubstrateRuntime = chain.hasSubstrateRuntime, pushSupport = chain.pushSupport, diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/LocalToDomainChainMapper.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/LocalToDomainChainMapper.kt index 27f9ffc3..174ceb3a 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/LocalToDomainChainMapper.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/LocalToDomainChainMapper.kt @@ -97,6 +97,8 @@ private fun mapChainAssetTypeFromRaw(type: String?, typeExtras: Map Chain.Asset.Type.BitcoinNative + ASSET_SOLANA_NATIVE -> Chain.Asset.Type.SolanaNative + else -> Chain.Asset.Type.Unsupported } } @@ -279,6 +281,7 @@ fun mapChainLocalToChain( isEthereumBased = isEthereumBased, isTronBased = isTronBased, isBitcoinBased = isBitcoinBased, + isSolanaBased = isSolanaBased, isTestNet = isTestNet, hasCrowdloans = hasCrowdloans, pushSupport = pushSupport, diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/RemoteToLocalChainMappers.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/RemoteToLocalChainMappers.kt index bfa3e342..9cfc7554 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/RemoteToLocalChainMappers.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/RemoteToLocalChainMappers.kt @@ -22,6 +22,7 @@ import io.novafoundation.nova.runtime.multiNetwork.chain.remote.model.ChainRemot private const val ETHEREUM_OPTION = "ethereumBased" private const val TRON_OPTION = "tronBased" private const val BITCOIN_OPTION = "bitcoinBased" +private const val SOLANA_OPTION = "solanaBased" private const val CROWDLOAN_OPTION = "crowdloans" private const val TESTNET_OPTION = "testnet" private const val PROXY_OPTION = "proxy" @@ -94,6 +95,7 @@ fun mapRemoteChainToLocal( isEthereumBased = ETHEREUM_OPTION in optionsOrEmpty, isTronBased = TRON_OPTION in optionsOrEmpty, isBitcoinBased = BITCOIN_OPTION in optionsOrEmpty, + isSolanaBased = SOLANA_OPTION in optionsOrEmpty, isTestNet = TESTNET_OPTION in optionsOrEmpty, hasCrowdloans = CROWDLOAN_OPTION in optionsOrEmpty, supportProxy = PROXY_OPTION in optionsOrEmpty, diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/model/Chain.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/model/Chain.kt index e4be32b0..646d6264 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/model/Chain.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/model/Chain.kt @@ -34,6 +34,7 @@ data class Chain( val isEthereumBased: Boolean, val isTronBased: Boolean, val isBitcoinBased: Boolean, + val isSolanaBased: Boolean, val isTestNet: Boolean, val source: Source, val hasSubstrateRuntime: Boolean, @@ -145,6 +146,12 @@ data class Chain( */ object BitcoinNative : Type() + /** + * Native SOL balance on a Solana-based chain. + * Balance is fetched from Solana's own JSON-RPC (getBalance), not a Substrate/EVM one. + */ + object SolanaNative : Type() + object Unsupported : Type() } diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/NodeHealthStateTesterFactory.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/NodeHealthStateTesterFactory.kt index 2588da8b..f643a38e 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/NodeHealthStateTesterFactory.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/NodeHealthStateTesterFactory.kt @@ -32,6 +32,11 @@ class NodeHealthStateTesterFactory( httpClient = httpClient ) + chain.isSolanaBased -> SolanaNodeHealthStateTester( + node = node, + httpClient = httpClient + ) + chain.hasSubstrateRuntime -> SubstrateNodeHealthStateTester( chain = chain, socketService = socketServiceProvider.get(), diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/SolanaNodeHealthStateTester.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/SolanaNodeHealthStateTester.kt new file mode 100644 index 00000000..46fc0bbc --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/SolanaNodeHealthStateTester.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.runtime.multiNetwork.connection.node.healthState + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import kotlin.time.ExperimentalTime +import kotlin.time.measureTime + +private val JSON_MEDIA_TYPE = "application/json".toMediaType() +private const val GET_HEALTH_REQUEST = """{"jsonrpc":"2.0","id":1,"method":"getHealth"}""" + +/** + * Solana speaks its own JSON-RPC over plain HTTP POST, not Ethereum JSON-RPC/WS or a Tron/Bitcoin-style + * plain-GET REST API - see [TronNodeHealthStateTester]/[BitcoinNodeHealthStateTester]'s docs for the same + * rationale. `getHealth` is Solana's own dedicated liveness-check method, purpose-built for exactly this - + * needs no account/address context, cheap on the node's side. + */ +class SolanaNodeHealthStateTester( + private val node: Chain.Node, + private val httpClient: OkHttpClient, +) : NodeHealthStateTester { + + @OptIn(ExperimentalTime::class) + override suspend fun testNodeHealthState(): Result = withContext(Dispatchers.IO) { + runCatching { + val request = Request.Builder() + .url(node.unformattedUrl) + .post(GET_HEALTH_REQUEST.toRequestBody(JSON_MEDIA_TYPE)) + .build() + + val duration = measureTime { + httpClient.newCall(request).execute().use { response -> + check(response.isSuccessful) { "HTTP ${response.code}" } + } + } + + duration.inWholeMilliseconds + } + } +}