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

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

- SLIP-0010 Ed25519 keypair derivation (Bip32Ed25519KeypairFactory),
  cross-validated against an independent slip10 reference implementation
  and pinned to a known-good test vector.
- Chain.isSolanaBased / Chain.Asset.Type.SolanaNative wired through every
  chain-mapper, account-model, and signer dispatch site (mirrors the
  existing Tron/Bitcoin wiring exactly).
- SolanaAddressBackfillMigration derives Solana keys for wallets created
  before this support existed, same self-healing no-stuck-flag design as
  the Tron/Bitcoin backfills - also fixed a latent bug in those two
  migrations where MetaAccountSecrets/MetaAccountLocal.addXAccount()
  dropped sibling chain-family fields on a re-run.
- Room migration 75->76 adds isSolanaBased/solanaPublicKey/solanaAddress
  columns.
- CloudBackup's already-reserved solanaAddress/SolanaSecrets fields are
  now actually read/written by RealLocalAccountsCloudBackupFacade; also
  fixed isCompletelyEmpty() to check solana, so a Solana-only wallet
  doesn't get silently dropped from backup.
This commit is contained in:
2026-07-16 18:49:09 -07:00
parent 6fd7a56382
commit d35687f2ce
40 changed files with 861 additions and 24 deletions
@@ -31,6 +31,9 @@ object MetaAccountSecrets : Schema<MetaAccountSecrets>() {
val BitcoinKeypair by schema(KeyPairSchema).optional() val BitcoinKeypair by schema(KeyPairSchema).optional()
val BitcoinDerivationPath by string().optional() val BitcoinDerivationPath by string().optional()
val SolanaKeypair by schema(KeyPairSchema).optional()
val SolanaDerivationPath by string().optional()
} }
object ChainAccountSecrets : Schema<ChainAccountSecrets>() { object ChainAccountSecrets : Schema<ChainAccountSecrets>() {
@@ -52,6 +55,8 @@ fun MetaAccountSecrets(
tronDerivationPath: String? = null, tronDerivationPath: String? = null,
bitcoinKeypair: Keypair? = null, bitcoinKeypair: Keypair? = null,
bitcoinDerivationPath: String? = null, bitcoinDerivationPath: String? = null,
solanaKeypair: Keypair? = null,
solanaDerivationPath: String? = null,
): EncodableStruct<MetaAccountSecrets> = MetaAccountSecrets { secrets -> ): EncodableStruct<MetaAccountSecrets> = MetaAccountSecrets { secrets ->
secrets[Entropy] = entropy secrets[Entropy] = entropy
secrets[SubstrateSeed] = substrateSeed secrets[SubstrateSeed] = substrateSeed
@@ -89,6 +94,15 @@ fun MetaAccountSecrets(
} }
} }
secrets[BitcoinDerivationPath] = bitcoinDerivationPath 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( fun ChainAccountSecrets(
@@ -120,6 +134,9 @@ val EncodableStruct<MetaAccountSecrets>.tronDerivationPath
val EncodableStruct<MetaAccountSecrets>.bitcoinDerivationPath val EncodableStruct<MetaAccountSecrets>.bitcoinDerivationPath
get() = get(MetaAccountSecrets.BitcoinDerivationPath) get() = get(MetaAccountSecrets.BitcoinDerivationPath)
val EncodableStruct<MetaAccountSecrets>.solanaDerivationPath
get() = get(MetaAccountSecrets.SolanaDerivationPath)
val EncodableStruct<MetaAccountSecrets>.entropy val EncodableStruct<MetaAccountSecrets>.entropy
get() = get(MetaAccountSecrets.Entropy) get() = get(MetaAccountSecrets.Entropy)
@@ -138,6 +155,9 @@ val EncodableStruct<MetaAccountSecrets>.tronKeypair
val EncodableStruct<MetaAccountSecrets>.bitcoinKeypair val EncodableStruct<MetaAccountSecrets>.bitcoinKeypair
get() = get(MetaAccountSecrets.BitcoinKeypair) get() = get(MetaAccountSecrets.BitcoinKeypair)
val EncodableStruct<MetaAccountSecrets>.solanaKeypair
get() = get(MetaAccountSecrets.SolanaKeypair)
val EncodableStruct<ChainAccountSecrets>.derivationPath val EncodableStruct<ChainAccountSecrets>.derivationPath
get() = get(ChainAccountSecrets.DerivationPath) get() = get(ChainAccountSecrets.DerivationPath)
@@ -158,10 +158,11 @@ suspend fun SecretStoreV2.getMetaAccountKeypair(
isEthereum: Boolean, isEthereum: Boolean,
isTron: Boolean = false, isTron: Boolean = false,
isBitcoin: Boolean = false, isBitcoin: Boolean = false,
isSolana: Boolean = false,
): Keypair = withContext(Dispatchers.Default) { ): Keypair = withContext(Dispatchers.Default) {
val secrets = getMetaAccountSecrets(metaId) ?: noMetaSecrets(metaId) val secrets = getMetaAccountSecrets(metaId) ?: noMetaSecrets(metaId)
mapMetaAccountSecretsToKeypair(secrets, isEthereum, isTron, isBitcoin) mapMetaAccountSecretsToKeypair(secrets, isEthereum, isTron, isBitcoin, isSolana)
} }
fun mapMetaAccountSecretsToKeypair( fun mapMetaAccountSecretsToKeypair(
@@ -169,13 +170,16 @@ fun mapMetaAccountSecretsToKeypair(
ethereum: Boolean, ethereum: Boolean,
tron: Boolean = false, tron: Boolean = false,
bitcoin: Boolean = false, bitcoin: Boolean = false,
solana: Boolean = false,
): Keypair { ): Keypair {
// Tron and Bitcoin both reuse Ethereum's secp256k1 curve but derive their own keypair under a different // 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 // 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 { val keypairStruct = when {
tron -> secrets[MetaAccountSecrets.TronKeypair] ?: noTronSecret() tron -> secrets[MetaAccountSecrets.TronKeypair] ?: noTronSecret()
bitcoin -> secrets[MetaAccountSecrets.BitcoinKeypair] ?: noBitcoinSecret() bitcoin -> secrets[MetaAccountSecrets.BitcoinKeypair] ?: noBitcoinSecret()
solana -> secrets[MetaAccountSecrets.SolanaKeypair] ?: noSolanaSecret()
ethereum -> secrets[MetaAccountSecrets.EthereumKeypair] ?: noEthereumSecret() ethereum -> secrets[MetaAccountSecrets.EthereumKeypair] ?: noEthereumSecret()
else -> secrets[MetaAccountSecrets.SubstrateKeypair] 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 noTronSecret(): Nothing = error("No tron keypair found")
private fun noSolanaSecret(): Nothing = error("No solana keypair found")
fun mapKeypairStructToKeypair(struct: EncodableStruct<KeyPairSchema>): Keypair { fun mapKeypairStructToKeypair(struct: EncodableStruct<KeyPairSchema>): Keypair {
return Keypair( return Keypair(
publicKey = struct[KeyPairSchema.PublicKey], publicKey = struct[KeyPairSchema.PublicKey],
@@ -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<Int>): 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<ByteArray, ByteArray> {
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<ByteArray, ByteArray> {
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 }
@@ -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<out Throwable>, 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")
}
}
@@ -59,6 +59,7 @@ fun chainOf(
isEthereumBased = false, isEthereumBased = false,
isTronBased = false, isTronBased = false,
isBitcoinBased = false, isBitcoinBased = false,
isSolanaBased = false,
hasCrowdloans = false, hasCrowdloans = false,
additional = "", additional = "",
governance = "governance", governance = "governance",
@@ -95,6 +95,7 @@ import io.novafoundation.nova.core_db.migrations.AddSwapOption_48_49
import io.novafoundation.nova.core_db.migrations.AddTransactionVersionToRuntime_50_51 import io.novafoundation.nova.core_db.migrations.AddTransactionVersionToRuntime_50_51
import io.novafoundation.nova.core_db.migrations.AddTransferApisTable_29_30 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.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.AddTronSupport_73_74
import io.novafoundation.nova.core_db.migrations.AddTypeExtrasToMetaAccount_68_69 import io.novafoundation.nova.core_db.migrations.AddTypeExtrasToMetaAccount_68_69
import io.novafoundation.nova.core_db.migrations.AddVersioningToGovernanceDapps_32_33 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 import io.novafoundation.nova.core_db.model.operation.TransferTypeLocal
@Database( @Database(
version = 75, version = 76,
entities = [ entities = [
AccountLocal::class, AccountLocal::class,
NodeLocal::class, NodeLocal::class,
@@ -275,6 +276,7 @@ abstract class AppDatabase : RoomDatabase() {
.addMigrations(AddGifts_71_72, AddFieldsToContributions) .addMigrations(AddGifts_71_72, AddFieldsToContributions)
.addMigrations(AddTronSupport_73_74) .addMigrations(AddTronSupport_73_74)
.addMigrations(AddBitcoinSupport_74_75) .addMigrations(AddBitcoinSupport_74_75)
.addMigrations(AddSolanaSupport_75_76)
.build() .build()
} }
return instance!! return instance!!
@@ -0,0 +1,14 @@
package io.novafoundation.nova.core_db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
val AddSolanaSupport_75_76 = object : Migration(75, 76) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE chains ADD COLUMN isSolanaBased INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE meta_accounts ADD COLUMN solanaPublicKey BLOB")
db.execSQL("ALTER TABLE meta_accounts ADD COLUMN solanaAddress BLOB")
}
}
@@ -25,6 +25,8 @@ data class ChainLocal(
val isTronBased: Boolean, val isTronBased: Boolean,
@ColumnInfo(defaultValue = "0") @ColumnInfo(defaultValue = "0")
val isBitcoinBased: Boolean, val isBitcoinBased: Boolean,
@ColumnInfo(defaultValue = "0")
val isSolanaBased: Boolean,
val isTestNet: Boolean, val isTestNet: Boolean,
@ColumnInfo(defaultValue = "1") @ColumnInfo(defaultValue = "1")
val hasSubstrateRuntime: Boolean, val hasSubstrateRuntime: Boolean,
@@ -41,6 +41,8 @@ class MetaAccountLocal(
val tronAddress: ByteArray? = null, val tronAddress: ByteArray? = null,
val bitcoinPublicKey: ByteArray? = null, val bitcoinPublicKey: ByteArray? = null,
val bitcoinAddress: ByteArray? = null, val bitcoinAddress: ByteArray? = null,
val solanaPublicKey: ByteArray? = null,
val solanaAddress: ByteArray? = null,
) { ) {
enum class Status { enum class Status {
@@ -64,6 +66,9 @@ class MetaAccountLocal(
const val BITCOIN_PUBKEY = "bitcoinPublicKey" const val BITCOIN_PUBKEY = "bitcoinPublicKey"
const val BITCOIN_ADDRESS = "bitcoinAddress" const val BITCOIN_ADDRESS = "bitcoinAddress"
const val SOLANA_PUBKEY = "solanaPublicKey"
const val SOLANA_ADDRESS = "solanaAddress"
const val NAME = "name" const val NAME = "name"
const val IS_SELECTED = "isSelected" const val IS_SELECTED = "isSelected"
const val POSITION = "position" const val POSITION = "position"
@@ -98,6 +103,8 @@ class MetaAccountLocal(
tronAddress = tronAddress, tronAddress = tronAddress,
bitcoinPublicKey = bitcoinPublicKey, bitcoinPublicKey = bitcoinPublicKey,
bitcoinAddress = bitcoinAddress, bitcoinAddress = bitcoinAddress,
solanaPublicKey = solanaPublicKey,
solanaAddress = solanaAddress,
).also { ).also {
it.id = id it.id = id
} }
@@ -126,6 +133,8 @@ class MetaAccountLocal(
tronAddress = tronAddress, tronAddress = tronAddress,
bitcoinPublicKey = bitcoinPublicKey, bitcoinPublicKey = bitcoinPublicKey,
bitcoinAddress = bitcoinAddress, bitcoinAddress = bitcoinAddress,
solanaPublicKey = solanaPublicKey,
solanaAddress = solanaAddress,
).also { ).also {
it.id = id it.id = id
} }
@@ -151,7 +160,46 @@ class MetaAccountLocal(
globallyUniqueId = globallyUniqueId, globallyUniqueId = globallyUniqueId,
typeExtras = typeExtras, typeExtras = typeExtras,
tronPublicKey = tronPublicKey, 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 { ).also {
it.id = id it.id = id
} }
@@ -172,6 +220,8 @@ class MetaAccountLocal(
if (!tronAddress.contentEquals(other.tronAddress)) return false if (!tronAddress.contentEquals(other.tronAddress)) return false
if (!bitcoinPublicKey.contentEquals(other.bitcoinPublicKey)) return false if (!bitcoinPublicKey.contentEquals(other.bitcoinPublicKey)) return false
if (!bitcoinAddress.contentEquals(other.bitcoinAddress)) 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 (name != other.name) return false
if (parentMetaId != other.parentMetaId) return false if (parentMetaId != other.parentMetaId) return false
if (isSelected != other.isSelected) return false if (isSelected != other.isSelected) return false
@@ -195,6 +245,8 @@ class MetaAccountLocal(
result = 31 * result + (tronAddress?.contentHashCode() ?: 0) result = 31 * result + (tronAddress?.contentHashCode() ?: 0)
result = 31 * result + (bitcoinPublicKey?.contentHashCode() ?: 0) result = 31 * result + (bitcoinPublicKey?.contentHashCode() ?: 0)
result = 31 * result + (bitcoinAddress?.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 + name.hashCode()
result = 31 * result + (parentMetaId?.hashCode() ?: 0) result = 31 * result + (parentMetaId?.hashCode() ?: 0)
result = 31 * result + isSelected.hashCode() result = 31 * result + isSelected.hashCode()
@@ -25,7 +25,18 @@ suspend fun SecretStoreV2.getAccountSecrets(
fun AccountSecrets.keypair(chain: Chain): Keypair { fun AccountSecrets.keypair(chain: Chain): Keypair {
return fold( 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) } right = { mapChainAccountSecretsToKeypair(it) }
) )
} }
@@ -56,6 +56,10 @@ interface LightMetaAccount {
/** Bitcoin account id (HASH160 of the compressed pubkey - see [io.novafoundation.nova.common.utils.hash160]). */ /** Bitcoin account id (HASH160 of the compressed pubkey - see [io.novafoundation.nova.common.utils.hash160]). */
val bitcoinAddress: ByteArray? val bitcoinAddress: ByteArray?
val bitcoinPublicKey: 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 isSelected: Boolean
val name: String val name: String
val type: Type val type: Type
@@ -96,6 +100,8 @@ fun LightMetaAccount(
tronPublicKey: ByteArray? = null, tronPublicKey: ByteArray? = null,
bitcoinAddress: ByteArray? = null, bitcoinAddress: ByteArray? = null,
bitcoinPublicKey: ByteArray? = null, bitcoinPublicKey: ByteArray? = null,
solanaAddress: ByteArray? = null,
solanaPublicKey: ByteArray? = null,
) = object : LightMetaAccount { ) = object : LightMetaAccount {
override val id: Long = id override val id: Long = id
override val globallyUniqueId: String = globallyUniqueId override val globallyUniqueId: String = globallyUniqueId
@@ -108,6 +114,8 @@ fun LightMetaAccount(
override val tronPublicKey: ByteArray? = tronPublicKey override val tronPublicKey: ByteArray? = tronPublicKey
override val bitcoinAddress: ByteArray? = bitcoinAddress override val bitcoinAddress: ByteArray? = bitcoinAddress
override val bitcoinPublicKey: ByteArray? = bitcoinPublicKey override val bitcoinPublicKey: ByteArray? = bitcoinPublicKey
override val solanaAddress: ByteArray? = solanaAddress
override val solanaPublicKey: ByteArray? = solanaPublicKey
override val isSelected: Boolean = isSelected override val isSelected: Boolean = isSelected
override val name: String = name override val name: String = name
override val type: LightMetaAccount.Type = type override val type: LightMetaAccount.Type = type
@@ -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.privateKey
import io.novafoundation.nova.common.data.secrets.v2.publicKey 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.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.substrateDerivationPath
import io.novafoundation.nova.common.data.secrets.v2.substrateKeypair 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.tronDerivationPath
@@ -96,6 +98,7 @@ class RealLocalAccountsCloudBackupFacade(
chainAccounts = emptyList(), chainAccounts = emptyList(),
bitcoin = baseSecrets.getBitcoinBackupSecrets(), bitcoin = baseSecrets.getBitcoinBackupSecrets(),
tron = baseSecrets.getTronBackupSecrets(), tron = baseSecrets.getTronBackupSecrets(),
solana = baseSecrets.getSolanaBackupSecrets(),
) )
return CloudBackup( return CloudBackup(
@@ -365,6 +368,7 @@ class RealLocalAccountsCloudBackupFacade(
chainAccounts = chainAccountsFromChainSecrets + chainAccountFromAdditionalSecrets, chainAccounts = chainAccountsFromChainSecrets + chainAccountFromAdditionalSecrets,
bitcoin = baseSecrets.getBitcoinBackupSecrets(), bitcoin = baseSecrets.getBitcoinBackupSecrets(),
tron = baseSecrets.getTronBackupSecrets(), tron = baseSecrets.getTronBackupSecrets(),
solana = baseSecrets.getSolanaBackupSecrets(),
) )
} }
@@ -478,7 +482,9 @@ class RealLocalAccountsCloudBackupFacade(
bitcoinKeypair = bitcoin?.keypair?.toLocalKeyPair(), bitcoinKeypair = bitcoin?.keypair?.toLocalKeyPair(),
bitcoinDerivationPath = bitcoin?.derivationPath, bitcoinDerivationPath = bitcoin?.derivationPath,
tronKeypair = tron?.keypair?.toLocalKeyPair(), 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? { private fun EncodableStruct<MetaAccountSecrets>?.getSubstrateBackupSecrets(): CloudBackup.WalletPrivateInfo.SubstrateSecrets? {
if (this == null) return null if (this == null) return null
@@ -555,6 +570,8 @@ class RealLocalAccountsCloudBackupFacade(
bitcoinPublicKey = metaAccount.bitcoinPublicKey, bitcoinPublicKey = metaAccount.bitcoinPublicKey,
tronAddress = metaAccount.tronAddress, tronAddress = metaAccount.tronAddress,
tronPublicKey = metaAccount.tronPublicKey, tronPublicKey = metaAccount.tronPublicKey,
solanaAddress = metaAccount.solanaAddress,
solanaPublicKey = metaAccount.solanaPublicKey,
) )
} }
@@ -581,6 +598,8 @@ class RealLocalAccountsCloudBackupFacade(
bitcoinPublicKey = bitcoinPublicKey, bitcoinPublicKey = bitcoinPublicKey,
tronAddress = tronAddress, tronAddress = tronAddress,
tronPublicKey = tronPublicKey, tronPublicKey = tronPublicKey,
solanaAddress = solanaAddress,
solanaPublicKey = solanaPublicKey,
).also { ).also {
if (localIdOverwrite != null) { if (localIdOverwrite != null) {
it.id = localIdOverwrite it.id = localIdOverwrite
@@ -67,6 +67,8 @@ class AccountMappers(
tronPublicKey = tronPublicKey, tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress, bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey, bitcoinPublicKey = bitcoinPublicKey,
solanaAddress = solanaAddress,
solanaPublicKey = solanaPublicKey,
isSelected = isSelected, isSelected = isSelected,
name = name, name = name,
status = mapMetaAccountStateFromLocal(status), status = mapMetaAccountStateFromLocal(status),
@@ -86,6 +88,8 @@ class AccountMappers(
tronPublicKey = tronPublicKey, tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress, bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey, bitcoinPublicKey = bitcoinPublicKey,
solanaAddress = solanaAddress,
solanaPublicKey = solanaPublicKey,
isSelected = isSelected, isSelected = isSelected,
name = name, name = name,
type = type, type = type,
@@ -107,6 +111,8 @@ class AccountMappers(
tronPublicKey = tronPublicKey, tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress, bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey, bitcoinPublicKey = bitcoinPublicKey,
solanaAddress = solanaAddress,
solanaPublicKey = solanaPublicKey,
isSelected = isSelected, isSelected = isSelected,
name = name, name = name,
type = type, type = type,
@@ -127,6 +133,8 @@ class AccountMappers(
tronPublicKey = tronPublicKey, tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress, bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey, bitcoinPublicKey = bitcoinPublicKey,
solanaAddress = solanaAddress,
solanaPublicKey = solanaPublicKey,
isSelected = isSelected, isSelected = isSelected,
name = name, name = name,
type = type, type = type,
@@ -148,6 +156,8 @@ class AccountMappers(
tronPublicKey = tronPublicKey, tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress, bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey, bitcoinPublicKey = bitcoinPublicKey,
solanaAddress = solanaAddress,
solanaPublicKey = solanaPublicKey,
isSelected = isSelected, isSelected = isSelected,
name = name, name = name,
type = type, type = type,
@@ -177,6 +187,8 @@ class AccountMappers(
tronPublicKey = tronPublicKey, tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress, bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey, bitcoinPublicKey = bitcoinPublicKey,
solanaAddress = solanaAddress,
solanaPublicKey = solanaPublicKey,
isSelected = isSelected, isSelected = isSelected,
name = name, name = name,
status = mapMetaAccountStateFromLocal(status), status = mapMetaAccountStateFromLocal(status),
@@ -197,6 +209,8 @@ class AccountMappers(
tronPublicKey = tronPublicKey, tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress, bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey, bitcoinPublicKey = bitcoinPublicKey,
solanaAddress = solanaAddress,
solanaPublicKey = solanaPublicKey,
chainAccounts = chainAccounts, chainAccounts = chainAccounts,
isSelected = isSelected, isSelected = isSelected,
name = name, name = name,
@@ -31,6 +31,7 @@ import io.novafoundation.nova.feature_account_impl.data.mappers.mapMetaAccountTy
import io.novafoundation.nova.feature_account_impl.data.mappers.mapMetaAccountWithBalanceFromLocal import io.novafoundation.nova.feature_account_impl.data.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.AccountDataMigration
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.BitcoinAddressBackfillMigration 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.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.ChainAccountInsertionData
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.model.MetaAccountInsertionData import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.model.MetaAccountInsertionData
@@ -68,11 +69,12 @@ class AccountDataSourceImpl(
accountDataMigration: AccountDataMigration, accountDataMigration: AccountDataMigration,
tronAddressBackfillMigration: TronAddressBackfillMigration, tronAddressBackfillMigration: TronAddressBackfillMigration,
bitcoinAddressBackfillMigration: BitcoinAddressBackfillMigration, bitcoinAddressBackfillMigration: BitcoinAddressBackfillMigration,
solanaAddressBackfillMigration: SolanaAddressBackfillMigration,
) : AccountDataSource, SecretStoreV1 by secretStoreV1 { ) : AccountDataSource, SecretStoreV1 by secretStoreV1 {
init { init {
// Run sequentially in one coroutine, not as independent launches - the Tron/Bitcoin backfills read // Run sequentially in one coroutine, not as independent launches - the Tron/Bitcoin/Solana backfills
// accounts/secrets that the legacy migration may still be in the middle of writing for very old // 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 // (pre-MetaAccount) installs, and separate GlobalScope.launch calls give no ordering guarantee
// relative to each other. // relative to each other.
async { async {
@@ -90,6 +92,10 @@ class AccountDataSourceImpl(
bitcoinAddressBackfillMigration.migrate() bitcoinAddressBackfillMigration.migrate()
Log.d("AccountDataSourceImpl", "about to run solanaAddressBackfillMigration")
solanaAddressBackfillMigration.migrate()
Log.d("AccountDataSourceImpl", "migrations block done") Log.d("AccountDataSourceImpl", "migrations block done")
metaAccountDao.getMetaAccounts().forEach { metaAccountDao.getMetaAccounts().forEach {
@@ -33,6 +33,7 @@ class RealSecretsMetaAccountLocalFactory : SecretsMetaAccountLocalFactory {
val ethereumPublicKey = secrets[MetaAccountSecrets.EthereumKeypair]?.get(KeyPairSchema.PublicKey) val ethereumPublicKey = secrets[MetaAccountSecrets.EthereumKeypair]?.get(KeyPairSchema.PublicKey)
val tronPublicKey = secrets[MetaAccountSecrets.TronKeypair]?.get(KeyPairSchema.PublicKey) val tronPublicKey = secrets[MetaAccountSecrets.TronKeypair]?.get(KeyPairSchema.PublicKey)
val bitcoinPublicKey = secrets[MetaAccountSecrets.BitcoinKeypair]?.get(KeyPairSchema.PublicKey) val bitcoinPublicKey = secrets[MetaAccountSecrets.BitcoinKeypair]?.get(KeyPairSchema.PublicKey)
val solanaPublicKey = secrets[MetaAccountSecrets.SolanaKeypair]?.get(KeyPairSchema.PublicKey)
return MetaAccountLocal( return MetaAccountLocal(
substratePublicKey = substratePublicKey, substratePublicKey = substratePublicKey,
@@ -55,7 +56,10 @@ class RealSecretsMetaAccountLocalFactory : SecretsMetaAccountLocalFactory {
// substrate-sdk-android's own source: ECDSAUtils.derivePublicKey -> compressedPublicKeyFromPrivate), // substrate-sdk-android's own source: ECDSAUtils.derivePublicKey -> compressedPublicKeyFromPrivate),
// which is exactly the format both BIP143 (P2WPKH) and bitcoinPublicKeyToAccountId() require - no // which is exactly the format both BIP143 (P2WPKH) and bitcoinPublicKeyToAccountId() require - no
// extra compression/decompression step needed here, unlike Ethereum's uncompressed-key derivation. // extra compression/decompression step needed here, unlike Ethereum's uncompressed-key derivation.
bitcoinAddress = bitcoinPublicKey?.bitcoinPublicKeyToAccountId() bitcoinAddress = bitcoinPublicKey?.bitcoinPublicKeyToAccountId(),
// Solana's accountId IS the public key itself, no hash/transform step - see SolanaAddress.kt's doc.
solanaPublicKey = solanaPublicKey,
solanaAddress = solanaPublicKey
) )
} }
} }
@@ -11,6 +11,8 @@ import io.novafoundation.nova.common.data.secrets.v2.ethereumDerivationPath
import io.novafoundation.nova.common.data.secrets.v2.ethereumKeypair import io.novafoundation.nova.common.data.secrets.v2.ethereumKeypair
import io.novafoundation.nova.common.data.secrets.v2.mapKeypairStructToKeypair 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.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.substrateDerivationPath
import io.novafoundation.nova.common.data.secrets.v2.substrateKeypair 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.tronDerivationPath
@@ -106,7 +108,11 @@ class BitcoinAddressBackfillMigration(
tronKeypair = secrets.tronKeypair?.let(::mapKeypairStructToKeypair), tronKeypair = secrets.tronKeypair?.let(::mapKeypairStructToKeypair),
tronDerivationPath = secrets.tronDerivationPath, tronDerivationPath = secrets.tronDerivationPath,
bitcoinKeypair = bitcoinKeypair, 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) secretStoreV2.putMetaAccountSecrets(account.id, updatedSecrets)
@@ -0,0 +1,115 @@
package io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration
import android.util.Log
import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets
import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2
import io.novafoundation.nova.common.data.secrets.v2.bitcoinDerivationPath
import io.novafoundation.nova.common.data.secrets.v2.bitcoinKeypair
import io.novafoundation.nova.common.data.secrets.v2.entropy
import io.novafoundation.nova.common.data.secrets.v2.ethereumDerivationPath
import io.novafoundation.nova.common.data.secrets.v2.ethereumKeypair
import io.novafoundation.nova.common.data.secrets.v2.mapKeypairStructToKeypair
import io.novafoundation.nova.common.data.secrets.v2.seed
import io.novafoundation.nova.common.data.secrets.v2.solanaKeypair
import io.novafoundation.nova.common.data.secrets.v2.substrateDerivationPath
import io.novafoundation.nova.common.data.secrets.v2.substrateKeypair
import io.novafoundation.nova.common.data.secrets.v2.tronDerivationPath
import io.novafoundation.nova.common.data.secrets.v2.tronKeypair
import io.novafoundation.nova.common.utils.Bip32Ed25519KeypairFactory
import io.novafoundation.nova.core_db.dao.MetaAccountDao
import io.novafoundation.nova.core_db.dao.updateMetaAccount
import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal
import io.novafoundation.nova.feature_account_impl.data.secrets.SOLANA_DEFAULT_DERIVATION_PATH
import io.novafoundation.nova.feature_account_impl.data.secrets.SOLANA_DEFAULT_DERIVATION_PATH_SEGMENTS
import io.novasama.substrate_sdk_android.encrypt.mnemonic.MnemonicCreator
import io.novasama.substrate_sdk_android.encrypt.seed.bip39.Bip39SeedFactory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
private const val TAG = "SolanaAddressBackfill"
/**
* Idempotent, per-account backfill for accounts that don't yet have a Solana keypair - mirrors
* [TronAddressBackfillMigration]/[BitcoinAddressBackfillMigration]'s exact design (see those classes for the
* full rationale). Deliberately has NO "have I already run once" flag, for the same stuck-flag reason.
*
* Only touches accounts that are `Type.SECRETS` and still hold their `Entropy` in [SecretStoreV2] - same
* restriction as the Tron/Bitcoin migrations.
*
* Unlike Tron/Bitcoin (which reuse [io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory.chainAccountSecrets]
* with `isEthereum = true`, since both are secp256k1/BIP32), Solana is Ed25519/SLIP-0010 - a different curve and
* derivation scheme entirely - so this derives the seed directly via [Bip39SeedFactory.deriveSeed] (the exact
* same chain-agnostic BIP39 seed step Tron/Bitcoin/Ethereum all use) and feeds it to [Bip32Ed25519KeypairFactory]
* at [SOLANA_DEFAULT_DERIVATION_PATH_SEGMENTS], the same call `AccountSecretsFactory.metaAccountSecrets()` makes
* for a fresh account - so a backfilled account ends up with byte-for-byte the same Solana address it would
* have gotten had it been created today.
*
* Carries every other chain-family field (substrate/ethereum/tron/bitcoin) forward unchanged when rewriting
* secrets - dropping any of them here would silently wipe an already-backfilled sibling key on retry.
*/
class SolanaAddressBackfillMigration(
private val secretStoreV2: SecretStoreV2,
private val metaAccountDao: MetaAccountDao,
) {
suspend fun migrate() = withContext(Dispatchers.Default) {
val secretsAccounts = metaAccountDao.getMetaAccounts().filter { it.type == MetaAccountLocal.Type.SECRETS }
Log.d(TAG, "migrate() starting - ${secretsAccounts.size} SECRETS-type account(s): ${secretsAccounts.map { it.id }}")
secretsAccounts.forEach { account ->
try {
backfillIfNeeded(account)
} catch (e: Throwable) {
Log.e(TAG, "backfill failed for metaId=${account.id}, continuing with remaining accounts", e)
}
}
Log.d(TAG, "migrate() done")
}
private suspend fun backfillIfNeeded(account: MetaAccountLocal) {
val secrets = secretStoreV2.getMetaAccountSecrets(account.id)
if (secrets == null) {
Log.d(TAG, "metaId=${account.id}: no stored secrets at all (watch-only/Ledger/etc) - skipping")
return
}
val entropy = secrets.entropy
if (entropy == null) {
Log.d(TAG, "metaId=${account.id}: no entropy (raw-seed import, not a mnemonic) - skipping")
return
}
if (secrets.solanaKeypair != null) {
Log.d(TAG, "metaId=${account.id}: already has a SolanaKeypair - skipping")
return
}
Log.d(TAG, "metaId=${account.id}: deriving Solana keypair")
val mnemonic = MnemonicCreator.fromEntropy(entropy).words
val bip39Seed = Bip39SeedFactory.deriveSeed(mnemonic, null).seed
val solanaKeypair = Bip32Ed25519KeypairFactory.generate(bip39Seed, SOLANA_DEFAULT_DERIVATION_PATH_SEGMENTS)
val updatedSecrets = MetaAccountSecrets(
substrateKeyPair = mapKeypairStructToKeypair(secrets.substrateKeypair),
entropy = secrets.entropy,
substrateSeed = secrets.seed,
substrateDerivationPath = secrets.substrateDerivationPath,
ethereumKeypair = secrets.ethereumKeypair?.let(::mapKeypairStructToKeypair),
ethereumDerivationPath = secrets.ethereumDerivationPath,
tronKeypair = secrets.tronKeypair?.let(::mapKeypairStructToKeypair),
tronDerivationPath = secrets.tronDerivationPath,
bitcoinKeypair = secrets.bitcoinKeypair?.let(::mapKeypairStructToKeypair),
bitcoinDerivationPath = secrets.bitcoinDerivationPath,
solanaKeypair = solanaKeypair,
solanaDerivationPath = SOLANA_DEFAULT_DERIVATION_PATH
)
secretStoreV2.putMetaAccountSecrets(account.id, updatedSecrets)
// Solana's accountId IS the public key itself - see SolanaAddress.kt's doc.
metaAccountDao.updateMetaAccount(account.id) { it.addSolanaAccount(solanaKeypair.publicKey, solanaKeypair.publicKey) }
Log.d(TAG, "metaId=${account.id}: backfilled successfully, solanaAddress set (${solanaKeypair.publicKey.size} bytes)")
}
}
@@ -4,11 +4,15 @@ import android.util.Log
import io.novafoundation.nova.common.data.secrets.v2.ChainAccountSecrets import io.novafoundation.nova.common.data.secrets.v2.ChainAccountSecrets
import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets 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.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.entropy
import io.novafoundation.nova.common.data.secrets.v2.ethereumDerivationPath 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.ethereumKeypair
import io.novafoundation.nova.common.data.secrets.v2.mapKeypairStructToKeypair 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.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.substrateDerivationPath
import io.novafoundation.nova.common.data.secrets.v2.substrateKeypair import io.novafoundation.nova.common.data.secrets.v2.substrateKeypair
import io.novafoundation.nova.common.data.secrets.v2.tronKeypair import io.novafoundation.nova.common.data.secrets.v2.tronKeypair
@@ -115,7 +119,14 @@ class TronAddressBackfillMigration(
ethereumKeypair = secrets.ethereumKeypair?.let(::mapKeypairStructToKeypair), ethereumKeypair = secrets.ethereumKeypair?.let(::mapKeypairStructToKeypair),
ethereumDerivationPath = secrets.ethereumDerivationPath, ethereumDerivationPath = secrets.ethereumDerivationPath,
tronKeypair = tronKeypair, 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) secretStoreV2.putMetaAccountSecrets(account.id, updatedSecrets)
@@ -6,6 +6,7 @@ import io.novafoundation.nova.common.data.network.runtime.binding.cast
import io.novafoundation.nova.common.data.secrets.v2.ChainAccountSecrets import io.novafoundation.nova.common.data.secrets.v2.ChainAccountSecrets
import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets
import io.novafoundation.nova.common.data.secrets.v2.mapKeypairStructToKeypair 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.castOrNull
import io.novafoundation.nova.common.utils.deriveSeed32 import io.novafoundation.nova.common.utils.deriveSeed32
import io.novafoundation.nova.core.model.CryptoType 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" 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( class AccountSecretsFactory(
private val JsonDecoder: JsonDecoder private val JsonDecoder: JsonDecoder
) { ) {
@@ -140,6 +159,7 @@ class AccountSecretsFactory(
accountSource: AccountSource, accountSource: AccountSource,
tronDerivationPath: String? = TRON_DEFAULT_DERIVATION_PATH, tronDerivationPath: String? = TRON_DEFAULT_DERIVATION_PATH,
bitcoinDerivationPath: String? = BITCOIN_DEFAULT_DERIVATION_PATH, bitcoinDerivationPath: String? = BITCOIN_DEFAULT_DERIVATION_PATH,
solanaDerivationPath: String? = SOLANA_DEFAULT_DERIVATION_PATH,
): Result<MetaAccountSecrets> = withContext(Dispatchers.Default) { ): Result<MetaAccountSecrets> = withContext(Dispatchers.Default) {
val (substrateSecrets, substrateCryptoType) = chainAccountSecrets( val (substrateSecrets, substrateCryptoType) = chainAccountSecrets(
derivationPath = substrateDerivationPath, derivationPath = substrateDerivationPath,
@@ -176,6 +196,19 @@ class AccountSecretsFactory(
Bip32EcdsaKeypairFactory.generate(seed = seed, junctions = decodedBitcoinDerivationPath?.junctions.orEmpty()) 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( val secrets = MetaAccountSecrets(
entropy = substrateSecrets[ChainAccountSecrets.Entropy], entropy = substrateSecrets[ChainAccountSecrets.Entropy],
substrateSeed = substrateSecrets[ChainAccountSecrets.Seed], substrateSeed = substrateSecrets[ChainAccountSecrets.Seed],
@@ -187,6 +220,8 @@ class AccountSecretsFactory(
tronDerivationPath = tronDerivationPath, tronDerivationPath = tronDerivationPath,
bitcoinKeypair = bitcoinKeypair, bitcoinKeypair = bitcoinKeypair,
bitcoinDerivationPath = bitcoinDerivationPath, bitcoinDerivationPath = bitcoinDerivationPath,
solanaKeypair = solanaKeypair,
solanaDerivationPath = solanaDerivationPath,
) )
Result(secrets = secrets, cryptoType = substrateCryptoType) Result(secrets = secrets, cryptoType = substrateCryptoType)
@@ -7,6 +7,7 @@ import io.novafoundation.nova.common.data.secrets.v2.getChainAccountKeypair
import io.novafoundation.nova.common.data.secrets.v2.getMetaAccountKeypair import io.novafoundation.nova.common.data.secrets.v2.getMetaAccountKeypair
import io.novafoundation.nova.common.data.secrets.v2.seed import io.novafoundation.nova.common.data.secrets.v2.seed
import io.novafoundation.nova.common.di.scope.FeatureScope 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.TwoFactorVerificationResult
import io.novafoundation.nova.common.sequrity.TwoFactorVerificationService import io.novafoundation.nova.common.sequrity.TwoFactorVerificationService
import io.novafoundation.nova.feature_account_api.data.signer.SigningContext import io.novafoundation.nova.feature_account_api.data.signer.SigningContext
@@ -144,13 +145,15 @@ class SecretsSigner(
val multiChainEncryption = metaAccount.multiChainEncryptionFor(accountId, chainsById)!! val multiChainEncryption = metaAccount.multiChainEncryptionFor(accountId, chainsById)!!
val isTronBased = metaAccount.tronAddress?.contentEquals(accountId) == true val isTronBased = metaAccount.tronAddress?.contentEquals(accountId) == true
val isBitcoinBased = metaAccount.bitcoinAddress?.contentEquals(accountId) == true val isBitcoinBased = metaAccount.bitcoinAddress?.contentEquals(accountId) == true
val isSolanaBased = metaAccount.solanaAddress?.contentEquals(accountId) == true
return secretStoreV2.getKeypair( return secretStoreV2.getKeypair(
metaAccount = metaAccount, metaAccount = metaAccount,
accountId = accountId, accountId = accountId,
isEthereumBased = multiChainEncryption is MultiChainEncryption.Ethereum, isEthereumBased = multiChainEncryption is MultiChainEncryption.Ethereum,
isTronBased = isTronBased, isTronBased = isTronBased,
isBitcoinBased = isBitcoinBased isBitcoinBased = isBitcoinBased,
isSolanaBased = isSolanaBased
) )
} }
@@ -166,10 +169,11 @@ class SecretsSigner(
isEthereumBased: Boolean, isEthereumBased: Boolean,
isTronBased: Boolean = false, isTronBased: Boolean = false,
isBitcoinBased: Boolean = false, isBitcoinBased: Boolean = false,
isSolanaBased: Boolean = false,
) = if (hasChainSecrets(metaAccount.id, accountId)) { ) = if (hasChainSecrets(metaAccount.id, accountId)) {
getChainAccountKeypair(metaAccount.id, accountId) getChainAccountKeypair(metaAccount.id, accountId)
} else { } else {
getMetaAccountKeypair(metaAccount.id, isEthereumBased, isTronBased, isBitcoinBased) getMetaAccountKeypair(metaAccount.id, isEthereumBased, isTronBased, isBitcoinBased, isSolanaBased)
} }
/** /**
@@ -185,6 +189,10 @@ class SecretsSigner(
// null -> NPE on `!!`. // null -> NPE on `!!`.
tronAddress?.contentEquals(accountId) == true -> MultiChainEncryption.Ethereum tronAddress?.contentEquals(accountId) == true -> MultiChainEncryption.Ethereum
bitcoinAddress?.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 -> { else -> {
val chainAccount = chainAccounts.values.firstOrNull { it.accountId.contentEquals(accountId) } ?: return null val chainAccount = chainAccounts.values.firstOrNull { it.accountId.contentEquals(accountId) } ?: return null
val cryptoType = chainAccount.cryptoType ?: return null val cryptoType = chainAccount.cryptoType ?: return null
@@ -105,6 +105,7 @@ import io.novafoundation.nova.feature_account_impl.data.repository.datasource.Re
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.SecretsMetaAccountLocalFactory import io.novafoundation.nova.feature_account_impl.data.repository.datasource.SecretsMetaAccountLocalFactory
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.AccountDataMigration 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.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.TronAddressBackfillMigration
import io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory import io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory
import io.novafoundation.nova.feature_account_impl.data.signer.signingContext.SigningContextFactory import io.novafoundation.nova.feature_account_impl.data.signer.signingContext.SigningContextFactory
@@ -410,6 +411,7 @@ class AccountFeatureModule {
secretStoreV2: SecretStoreV2, secretStoreV2: SecretStoreV2,
accountMappers: AccountMappers, accountMappers: AccountMappers,
bitcoinAddressBackfillMigration: BitcoinAddressBackfillMigration, bitcoinAddressBackfillMigration: BitcoinAddressBackfillMigration,
solanaAddressBackfillMigration: SolanaAddressBackfillMigration,
): AccountDataSource { ): AccountDataSource {
return AccountDataSourceImpl( return AccountDataSourceImpl(
preferences, preferences,
@@ -422,7 +424,8 @@ class AccountFeatureModule {
secretStoreV1, secretStoreV1,
accountDataMigration, accountDataMigration,
tronAddressBackfillMigration, tronAddressBackfillMigration,
bitcoinAddressBackfillMigration bitcoinAddressBackfillMigration,
solanaAddressBackfillMigration
) )
} }
@@ -436,6 +439,15 @@ class AccountFeatureModule {
return BitcoinAddressBackfillMigration(secretStoreV2, metaAccountDao, accountSecretsFactory) return BitcoinAddressBackfillMigration(secretStoreV2, metaAccountDao, accountSecretsFactory)
} }
@Provides
@FeatureScope
fun provideSolanaAddressBackfillMigration(
secretStoreV2: SecretStoreV2,
metaAccountDao: MetaAccountDao,
): SolanaAddressBackfillMigration {
return SolanaAddressBackfillMigration(secretStoreV2, metaAccountDao)
}
@Provides @Provides
fun provideNodeHostValidator() = NodeHostValidator() fun provideNodeHostValidator() = NodeHostValidator()
@@ -26,6 +26,8 @@ open class DefaultMetaAccount(
override val tronPublicKey: ByteArray? = null, override val tronPublicKey: ByteArray? = null,
override val bitcoinAddress: ByteArray? = null, override val bitcoinAddress: ByteArray? = null,
override val bitcoinPublicKey: ByteArray? = null, override val bitcoinPublicKey: ByteArray? = null,
override val solanaAddress: ByteArray? = null,
override val solanaPublicKey: ByteArray? = null,
) : MetaAccount { ) : MetaAccount {
override suspend fun supportsAddingChainAccount(chain: Chain): Boolean { override suspend fun supportsAddingChainAccount(chain: Chain): Boolean {
@@ -37,6 +39,7 @@ open class DefaultMetaAccount(
hasChainAccountIn(chain.id) -> true hasChainAccountIn(chain.id) -> true
chain.isTronBased -> tronAddress != null chain.isTronBased -> tronAddress != null
chain.isBitcoinBased -> bitcoinAddress != null chain.isBitcoinBased -> bitcoinAddress != null
chain.isSolanaBased -> solanaAddress != null
chain.isEthereumBased -> ethereumAddress != null chain.isEthereumBased -> ethereumAddress != null
else -> substrateAccountId != null else -> substrateAccountId != null
} }
@@ -47,6 +50,7 @@ open class DefaultMetaAccount(
hasChainAccountIn(chain.id) -> chainAccounts.getValue(chain.id).accountId hasChainAccountIn(chain.id) -> chainAccounts.getValue(chain.id).accountId
chain.isTronBased -> tronAddress chain.isTronBased -> tronAddress
chain.isBitcoinBased -> bitcoinAddress chain.isBitcoinBased -> bitcoinAddress
chain.isSolanaBased -> solanaAddress
chain.isEthereumBased -> ethereumAddress chain.isEthereumBased -> ethereumAddress
else -> substrateAccountId else -> substrateAccountId
} }
@@ -57,6 +61,7 @@ open class DefaultMetaAccount(
hasChainAccountIn(chain.id) -> chainAccounts.getValue(chain.id).publicKey hasChainAccountIn(chain.id) -> chainAccounts.getValue(chain.id).publicKey
chain.isTronBased -> tronPublicKey chain.isTronBased -> tronPublicKey
chain.isBitcoinBased -> bitcoinPublicKey chain.isBitcoinBased -> bitcoinPublicKey
chain.isSolanaBased -> solanaPublicKey
chain.isEthereumBased -> ethereumPublicKey chain.isEthereumBased -> ethereumPublicKey
else -> substratePublicKey else -> substratePublicKey
} }
@@ -26,6 +26,8 @@ class GenericLedgerMetaAccount(
tronPublicKey: ByteArray? = null, tronPublicKey: ByteArray? = null,
bitcoinAddress: ByteArray? = null, bitcoinAddress: ByteArray? = null,
bitcoinPublicKey: ByteArray? = null, bitcoinPublicKey: ByteArray? = null,
solanaAddress: ByteArray? = null,
solanaPublicKey: ByteArray? = null,
) : DefaultMetaAccount( ) : DefaultMetaAccount(
id = id, id = id,
globallyUniqueId = globallyUniqueId, globallyUniqueId = globallyUniqueId,
@@ -43,7 +45,9 @@ class GenericLedgerMetaAccount(
tronAddress = tronAddress, tronAddress = tronAddress,
tronPublicKey = tronPublicKey, tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress, bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey bitcoinPublicKey = bitcoinPublicKey,
solanaAddress = solanaAddress,
solanaPublicKey = solanaPublicKey
) { ) {
override suspend fun supportsAddingChainAccount(chain: Chain): Boolean { override suspend fun supportsAddingChainAccount(chain: Chain): Boolean {
@@ -26,6 +26,8 @@ class LegacyLedgerMetaAccount(
tronPublicKey: ByteArray? = null, tronPublicKey: ByteArray? = null,
bitcoinAddress: ByteArray? = null, bitcoinAddress: ByteArray? = null,
bitcoinPublicKey: ByteArray? = null, bitcoinPublicKey: ByteArray? = null,
solanaAddress: ByteArray? = null,
solanaPublicKey: ByteArray? = null,
) : DefaultMetaAccount( ) : DefaultMetaAccount(
id = id, id = id,
globallyUniqueId = globallyUniqueId, globallyUniqueId = globallyUniqueId,
@@ -43,7 +45,9 @@ class LegacyLedgerMetaAccount(
tronAddress = tronAddress, tronAddress = tronAddress,
tronPublicKey = tronPublicKey, tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress, bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey bitcoinPublicKey = bitcoinPublicKey,
solanaAddress = solanaAddress,
solanaPublicKey = solanaPublicKey
) { ) {
override suspend fun supportsAddingChainAccount(chain: Chain): Boolean { override suspend fun supportsAddingChainAccount(chain: Chain): Boolean {
@@ -32,6 +32,8 @@ class RealMultisigMetaAccount(
tronPublicKey: ByteArray? = null, tronPublicKey: ByteArray? = null,
bitcoinAddress: ByteArray? = null, bitcoinAddress: ByteArray? = null,
bitcoinPublicKey: ByteArray? = null, bitcoinPublicKey: ByteArray? = null,
solanaAddress: ByteArray? = null,
solanaPublicKey: ByteArray? = null,
) : DefaultMetaAccount( ) : DefaultMetaAccount(
id = id, id = id,
globallyUniqueId = globallyUniqueId, globallyUniqueId = globallyUniqueId,
@@ -49,7 +51,9 @@ class RealMultisigMetaAccount(
tronAddress = tronAddress, tronAddress = tronAddress,
tronPublicKey = tronPublicKey, tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress, bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey bitcoinPublicKey = bitcoinPublicKey,
solanaAddress = solanaAddress,
solanaPublicKey = solanaPublicKey
), ),
MultisigMetaAccount { MultisigMetaAccount {
@@ -24,6 +24,8 @@ class PolkadotVaultMetaAccount(
tronPublicKey: ByteArray? = null, tronPublicKey: ByteArray? = null,
bitcoinAddress: ByteArray? = null, bitcoinAddress: ByteArray? = null,
bitcoinPublicKey: ByteArray? = null, bitcoinPublicKey: ByteArray? = null,
solanaAddress: ByteArray? = null,
solanaPublicKey: ByteArray? = null,
) : DefaultMetaAccount( ) : DefaultMetaAccount(
id = id, id = id,
globallyUniqueId = globallyUniqueId, globallyUniqueId = globallyUniqueId,
@@ -41,7 +43,9 @@ class PolkadotVaultMetaAccount(
tronAddress = tronAddress, tronAddress = tronAddress,
tronPublicKey = tronPublicKey, tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress, bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey bitcoinPublicKey = bitcoinPublicKey,
solanaAddress = solanaAddress,
solanaPublicKey = solanaPublicKey
) { ) {
override suspend fun supportsAddingChainAccount(chain: Chain): Boolean { override suspend fun supportsAddingChainAccount(chain: Chain): Boolean {
@@ -26,6 +26,8 @@ internal class RealProxiedMetaAccount(
tronPublicKey: ByteArray? = null, tronPublicKey: ByteArray? = null,
bitcoinAddress: ByteArray? = null, bitcoinAddress: ByteArray? = null,
bitcoinPublicKey: ByteArray? = null, bitcoinPublicKey: ByteArray? = null,
solanaAddress: ByteArray? = null,
solanaPublicKey: ByteArray? = null,
) : DefaultMetaAccount( ) : DefaultMetaAccount(
id = id, id = id,
globallyUniqueId = globallyUniqueId, globallyUniqueId = globallyUniqueId,
@@ -43,7 +45,9 @@ internal class RealProxiedMetaAccount(
tronAddress = tronAddress, tronAddress = tronAddress,
tronPublicKey = tronPublicKey, tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress, bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey bitcoinPublicKey = bitcoinPublicKey,
solanaAddress = solanaAddress,
solanaPublicKey = solanaPublicKey
), ),
ProxiedMetaAccount { ProxiedMetaAccount {
@@ -27,6 +27,8 @@ class RealSecretsMetaAccount(
tronPublicKey: ByteArray? = null, tronPublicKey: ByteArray? = null,
bitcoinAddress: ByteArray? = null, bitcoinAddress: ByteArray? = null,
bitcoinPublicKey: ByteArray? = null, bitcoinPublicKey: ByteArray? = null,
solanaAddress: ByteArray? = null,
solanaPublicKey: ByteArray? = null,
) : DefaultMetaAccount( ) : DefaultMetaAccount(
id = id, id = id,
globallyUniqueId = globallyUniqueId, globallyUniqueId = globallyUniqueId,
@@ -44,7 +46,9 @@ class RealSecretsMetaAccount(
tronAddress = tronAddress, tronAddress = tronAddress,
tronPublicKey = tronPublicKey, tronPublicKey = tronPublicKey,
bitcoinAddress = bitcoinAddress, bitcoinAddress = bitcoinAddress,
bitcoinPublicKey = bitcoinPublicKey bitcoinPublicKey = bitcoinPublicKey,
solanaAddress = solanaAddress,
solanaPublicKey = solanaPublicKey
), ),
SecretsMetaAccount { SecretsMetaAccount {
@@ -55,9 +59,16 @@ class RealSecretsMetaAccount(
// Tron and Bitcoin both reuse the same secp256k1 keypair/signing as Ethereum - see // Tron and Bitcoin both reuse the same secp256k1 keypair/signing as Ethereum - see
// RealTronTransactionService/RealBitcoinTransactionService's use of // 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) { if (chain.isEthereumBased || chain.isTronBased || chain.isBitcoinBased) {
MultiChainEncryption.Ethereum MultiChainEncryption.Ethereum
} else if (chain.isSolanaBased) {
MultiChainEncryption.substrateFrom(CryptoType.ED25519)
} else { } else {
MultiChainEncryption.substrateFrom(cryptoType) MultiChainEncryption.substrateFrom(cryptoType)
} }
@@ -69,6 +80,8 @@ class RealSecretsMetaAccount(
chain.isBitcoinBased -> MultiChainEncryption.Ethereum chain.isBitcoinBased -> MultiChainEncryption.Ethereum
chain.isSolanaBased -> MultiChainEncryption.substrateFrom(CryptoType.ED25519)
else -> substrateCryptoType?.let(MultiChainEncryption.Companion::substrateFrom) else -> substrateCryptoType?.let(MultiChainEncryption.Companion::substrateFrom)
} }
} }
@@ -0,0 +1,183 @@
package io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration
import io.novafoundation.nova.common.data.secrets.v2.KeyPairSchema
import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets
import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2
import io.novafoundation.nova.common.data.secrets.v2.mapKeypairStructToKeypair
import io.novafoundation.nova.common.data.secrets.v2.solanaKeypair
import io.novafoundation.nova.common.utils.invoke
import io.novafoundation.nova.common.utils.solanaAddressToAccountId
import io.novafoundation.nova.common.utils.toSolanaAddress
import io.novafoundation.nova.core.model.CryptoType
import io.novafoundation.nova.core_db.dao.MetaAccountDao
import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal
import io.novasama.substrate_sdk_android.encrypt.mnemonic.MnemonicCreator
import io.novasama.substrate_sdk_android.scale.EncodableStruct
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatcher
import org.mockito.Mock
import org.mockito.Mockito
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
import org.mockito.junit.MockitoJUnitRunner
// Same guaranteed-non-null-return wrappers as TronAddressBackfillMigrationTest, for the same reason - see
// that test's class doc for the full writeup.
private fun <T> eq(value: T): T = Mockito.eq(value) ?: value
@Suppress("UNCHECKED_CAST")
private fun <T> any(): T {
Mockito.any<T>()
return null as T
}
@Suppress("UNCHECKED_CAST")
private fun <T> argThat(matcher: (T) -> Boolean): T {
Mockito.argThat(ArgumentMatcher<T> { matcher(it) })
return null as T
}
private fun <T> whenever(methodCall: T?) = Mockito.`when`(methodCall)
/**
* Mirrors [TronAddressBackfillMigrationTest]'s design (see that class's doc for the full context on why this
* matters). Unlike Tron/Bitcoin's migration test, [SolanaAddressBackfillMigration] doesn't go through
* [io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory] (Solana's Ed25519
* derivation is a different code path entirely - see that class's constructor), so there's no factory to mock
* or use for real - the derivation math (SLIP-0010 via [io.novafoundation.nova.common.utils.Bip32Ed25519KeypairFactory])
* runs directly. Asserts against the same live-cross-validated reference address
* [io.novafoundation.nova.common.utils.SolanaAddressTest] already established for the standard BIP39 test
* mnemonic, so this test and that one are pinned to the same known-good vector.
*/
@RunWith(MockitoJUnitRunner::class)
class SolanaAddressBackfillMigrationTest {
@Mock
lateinit var secretStoreV2: SecretStoreV2
@Mock
lateinit var metaAccountDao: MetaAccountDao
private lateinit var subject: SolanaAddressBackfillMigration
private val testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
private val expectedSolanaAccountId = "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk".solanaAddressToAccountId()
@Before
fun setup() {
subject = SolanaAddressBackfillMigration(secretStoreV2, metaAccountDao)
}
@Test
fun `migrate should derive and persist the well-known reference Solana address for a pre-existing mnemonic account`(): Unit = runBlocking {
val account = secretsAccount(id = 42)
val secrets = accountSecrets(entropy = MnemonicCreator.fromWords(testMnemonic).entropy, solanaKeypair = null)
whenever(metaAccountDao.getMetaAccounts()).thenReturn(listOf(account))
whenever(metaAccountDao.getMetaAccount(eq(42L))).thenReturn(account)
whenever(secretStoreV2.getMetaAccountSecrets(eq(42L))).thenReturn(secrets)
subject.migrate()
verify(metaAccountDao).updateMetaAccount(
argThat<MetaAccountLocal> { updated ->
updated.id == 42L && updated.solanaAddress.contentEquals(expectedSolanaAccountId)
}
)
verify(secretStoreV2).putMetaAccountSecrets(
eq(42L),
argThat<EncodableStruct<MetaAccountSecrets>> { updatedSecrets ->
val solanaKeypairStruct = updatedSecrets.solanaKeypair
solanaKeypairStruct != null &&
mapKeypairStructToKeypair(solanaKeypairStruct).publicKey.toSolanaAddress() == "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk"
}
)
}
@Test
fun `migrate should skip an account that already has a Solana keypair`(): Unit = runBlocking {
val account = secretsAccount(id = 7)
val existingSolanaKeypair = KeyPairSchema { keypair ->
keypair[KeyPairSchema.PublicKey] = ByteArray(32) { 9 }
keypair[KeyPairSchema.PrivateKey] = ByteArray(32) { 8 }
keypair[KeyPairSchema.Nonce] = null
}
val secrets = accountSecrets(entropy = MnemonicCreator.fromWords(testMnemonic).entropy, solanaKeypair = existingSolanaKeypair)
whenever(metaAccountDao.getMetaAccounts()).thenReturn(listOf(account))
whenever(secretStoreV2.getMetaAccountSecrets(eq(7L))).thenReturn(secrets)
subject.migrate()
verify(metaAccountDao, never()).updateMetaAccount(any())
verify(secretStoreV2, never()).putMetaAccountSecrets(Mockito.anyLong(), any())
}
@Test
fun `migrate should skip an account with no entropy (raw-seed import, not a mnemonic)`(): Unit = runBlocking {
val account = secretsAccount(id = 11)
val secrets = accountSecrets(entropy = null, solanaKeypair = null)
whenever(metaAccountDao.getMetaAccounts()).thenReturn(listOf(account))
whenever(secretStoreV2.getMetaAccountSecrets(eq(11L))).thenReturn(secrets)
subject.migrate()
verify(metaAccountDao, never()).updateMetaAccount(any())
verify(secretStoreV2, never()).putMetaAccountSecrets(Mockito.anyLong(), any())
}
@Test
fun `migrate should skip an account with no stored secrets at all (watch-only, Ledger, etc)`(): Unit = runBlocking {
val account = secretsAccount(id = 13)
whenever(metaAccountDao.getMetaAccounts()).thenReturn(listOf(account))
whenever(secretStoreV2.getMetaAccountSecrets(eq(13L))).thenReturn(null)
subject.migrate()
verify(metaAccountDao, never()).updateMetaAccount(any())
verify(secretStoreV2, never()).putMetaAccountSecrets(Mockito.anyLong(), any())
}
private fun secretsAccount(id: Long): MetaAccountLocal {
return MetaAccountLocal(
substratePublicKey = ByteArray(32) { 1 },
substrateCryptoType = CryptoType.SR25519,
substrateAccountId = ByteArray(32) { 2 },
ethereumPublicKey = null,
ethereumAddress = null,
name = "Test account",
parentMetaId = null,
isSelected = true,
position = 0,
type = MetaAccountLocal.Type.SECRETS,
status = MetaAccountLocal.Status.ACTIVE,
globallyUniqueId = "test-guid-$id",
typeExtras = null
).also { it.id = id }
}
private fun accountSecrets(entropy: ByteArray?, solanaKeypair: EncodableStruct<KeyPairSchema>?): EncodableStruct<MetaAccountSecrets> {
val dummySubstrateKeypair = KeyPairSchema { keypair ->
keypair[KeyPairSchema.PublicKey] = ByteArray(32) { 5 }
keypair[KeyPairSchema.PrivateKey] = ByteArray(32) { 6 }
keypair[KeyPairSchema.Nonce] = ByteArray(8) { 7 }
}
return MetaAccountSecrets(
substrateKeyPair = mapKeypairStructToKeypair(dummySubstrateKeypair),
entropy = entropy,
substrateSeed = null,
substrateDerivationPath = null,
ethereumKeypair = null,
ethereumDerivationPath = null,
solanaKeypair = solanaKeypair?.let(::mapKeypairStructToKeypair),
solanaDerivationPath = null
)
}
}
@@ -285,5 +285,8 @@ data class CloudBackup(
} }
fun CloudBackup.WalletPrivateInfo.isCompletelyEmpty(): Boolean { 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()
} }
@@ -126,6 +126,7 @@ class CustomChainFactory(
isEthereumBased = isEthereumBased, isEthereumBased = isEthereumBased,
isTronBased = false, isTronBased = false,
isBitcoinBased = false, isBitcoinBased = false,
isSolanaBased = false,
isTestNet = prefilledChain?.isTestNet.orFalse(), isTestNet = prefilledChain?.isTestNet.orFalse(),
source = Chain.Source.CUSTOM, source = Chain.Source.CUSTOM,
hasSubstrateRuntime = hasSubstrateRuntime, hasSubstrateRuntime = hasSubstrateRuntime,
@@ -281,6 +281,7 @@ fun Chain.addressOf(accountId: ByteArray): String {
return when { return when {
isTronBased -> accountId.toTronAddress() isTronBased -> accountId.toTronAddress()
isBitcoinBased -> accountId.toBitcoinAddress() isBitcoinBased -> accountId.toBitcoinAddress()
isSolanaBased -> accountId.toSolanaAddress()
isEthereumBased -> accountId.toEthereumAddress() isEthereumBased -> accountId.toEthereumAddress()
else -> accountId.toAddress(addressPrefix.toShort()) else -> accountId.toAddress(addressPrefix.toShort())
} }
@@ -291,7 +292,7 @@ fun Chain.addressOf(accountId: AccountIdKey): String {
} }
fun Chain.legacyAddressOfOrNull(accountId: ByteArray): String? { fun Chain.legacyAddressOfOrNull(accountId: ByteArray): String? {
return if (isEthereumBased || isTronBased || isBitcoinBased) { return if (isEthereumBased || isTronBased || isBitcoinBased || isSolanaBased) {
null null
} else { } else {
legacyAddressPrefix?.let { accountId.toAddress(it.toShort()) } 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 // opaque purposes (identicon generation, presence checks) elsewhere, never fed back into building a
// scriptPubKey - see BitcoinDestinationAddress.kt's [hash] doc. // scriptPubKey - see BitcoinDestinationAddress.kt's [hash] doc.
isBitcoinBased -> runCatching { address.bitcoinAddressToAccountId() }.getOrElse { address.decodeBitcoinDestination().hash } isBitcoinBased -> runCatching { address.bitcoinAddressToAccountId() }.getOrElse { address.decodeBitcoinDestination().hash }
isSolanaBased -> address.solanaAddressToAccountId()
isEthereumBased -> address.asEthereumAddress().toAccountId().value isEthereumBased -> address.asEthereumAddress().toAccountId().value
else -> address.toAccountId() else -> address.toAccountId()
} }
@@ -345,6 +347,7 @@ fun Chain.accountIdOrNull(address: String): ByteArray? {
fun Chain.emptyAccountId() = when { fun Chain.emptyAccountId() = when {
isTronBased -> emptyTronAccountId() isTronBased -> emptyTronAccountId()
isBitcoinBased -> emptyBitcoinAccountId() isBitcoinBased -> emptyBitcoinAccountId()
isSolanaBased -> emptySolanaAccountId()
isEthereumBased -> emptyEthereumAccountId() isEthereumBased -> emptyEthereumAccountId()
else -> emptySubstrateAccountId() else -> emptySubstrateAccountId()
} }
@@ -359,6 +362,9 @@ fun Chain.accountIdOf(publicKey: ByteArray): ByteArray {
return when { return when {
isTronBased -> publicKey.tronPublicKeyToAccountId() isTronBased -> publicKey.tronPublicKeyToAccountId()
isBitcoinBased -> publicKey.bitcoinPublicKeyToAccountId() 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 isEthereumBased -> publicKey.asEthereumPublicKey().toAccountId().value
else -> publicKey.substrateAccountId() 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. // branches below would ever accept them, so this needs its own dedicated check.
isTronBased -> address.isValidTronAddress() isTronBased -> address.isValidTronAddress()
isSolanaBased -> address.isValidSolanaAddress()
isEthereumBased -> address.asEthereumAddress().isValid() isEthereumBased -> address.asEthereumAddress().isValid()
else -> { 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 { fun Chain.Asset.requireEquilibrium(): Type.Equilibrium {
require(type is Type.Equilibrium) require(type is Type.Equilibrium)
@@ -683,6 +704,7 @@ val Chain.Asset.onChainAssetId: String?
is Type.Trc20 -> this.type.contractAddress is Type.Trc20 -> this.type.contractAddress
Type.TronNative -> null Type.TronNative -> null
Type.BitcoinNative -> null Type.BitcoinNative -> null
Type.SolanaNative -> null
Type.Unsupported -> error("Unsupported assetId type: ${this.type::class.simpleName}") Type.Unsupported -> error("Unsupported assetId type: ${this.type::class.simpleName}")
} }
@@ -249,7 +249,7 @@ class ChainRegistry(
// ChainConnection's SocketService can only speak the latter, so attempting to set one up here would // 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 // hang indefinitely instead of failing fast. Balance/transfer operations for both go through their
// own dedicated REST API clients, independent of this connection pool. // 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) val connection = connectionPool.setupConnection(chain)
@@ -14,6 +14,8 @@ const val ASSET_TRC20 = "trc20"
const val ASSET_BITCOIN_NATIVE = "bitcoinNative" const val ASSET_BITCOIN_NATIVE = "bitcoinNative"
const val ASSET_SOLANA_NATIVE = "solanaNative"
const val ASSET_EQUILIBRIUM = "equilibrium" const val ASSET_EQUILIBRIUM = "equilibrium"
const val ASSET_EQUILIBRIUM_ON_CHAIN_ID = "assetId" const val ASSET_EQUILIBRIUM_ON_CHAIN_ID = "assetId"
@@ -71,6 +71,8 @@ fun mapChainAssetTypeToRaw(type: Chain.Asset.Type): Pair<String, Map<String, Any
Chain.Asset.Type.BitcoinNative -> ASSET_BITCOIN_NATIVE to null Chain.Asset.Type.BitcoinNative -> ASSET_BITCOIN_NATIVE to null
Chain.Asset.Type.SolanaNative -> ASSET_SOLANA_NATIVE to null
Chain.Asset.Type.Unsupported -> ASSET_UNSUPPORTED to null Chain.Asset.Type.Unsupported -> ASSET_UNSUPPORTED to null
} }
@@ -131,6 +133,7 @@ fun mapChainToLocal(chain: Chain, gson: Gson): ChainLocal {
isEthereumBased = chain.isEthereumBased, isEthereumBased = chain.isEthereumBased,
isTronBased = chain.isTronBased, isTronBased = chain.isTronBased,
isBitcoinBased = chain.isBitcoinBased, isBitcoinBased = chain.isBitcoinBased,
isSolanaBased = chain.isSolanaBased,
isTestNet = chain.isTestNet, isTestNet = chain.isTestNet,
hasSubstrateRuntime = chain.hasSubstrateRuntime, hasSubstrateRuntime = chain.hasSubstrateRuntime,
pushSupport = chain.pushSupport, pushSupport = chain.pushSupport,
@@ -97,6 +97,8 @@ private fun mapChainAssetTypeFromRaw(type: String?, typeExtras: Map<String, Any?
ASSET_BITCOIN_NATIVE -> Chain.Asset.Type.BitcoinNative ASSET_BITCOIN_NATIVE -> Chain.Asset.Type.BitcoinNative
ASSET_SOLANA_NATIVE -> Chain.Asset.Type.SolanaNative
else -> Chain.Asset.Type.Unsupported else -> Chain.Asset.Type.Unsupported
} }
} }
@@ -279,6 +281,7 @@ fun mapChainLocalToChain(
isEthereumBased = isEthereumBased, isEthereumBased = isEthereumBased,
isTronBased = isTronBased, isTronBased = isTronBased,
isBitcoinBased = isBitcoinBased, isBitcoinBased = isBitcoinBased,
isSolanaBased = isSolanaBased,
isTestNet = isTestNet, isTestNet = isTestNet,
hasCrowdloans = hasCrowdloans, hasCrowdloans = hasCrowdloans,
pushSupport = pushSupport, pushSupport = pushSupport,
@@ -22,6 +22,7 @@ import io.novafoundation.nova.runtime.multiNetwork.chain.remote.model.ChainRemot
private const val ETHEREUM_OPTION = "ethereumBased" private const val ETHEREUM_OPTION = "ethereumBased"
private const val TRON_OPTION = "tronBased" private const val TRON_OPTION = "tronBased"
private const val BITCOIN_OPTION = "bitcoinBased" private const val BITCOIN_OPTION = "bitcoinBased"
private const val SOLANA_OPTION = "solanaBased"
private const val CROWDLOAN_OPTION = "crowdloans" private const val CROWDLOAN_OPTION = "crowdloans"
private const val TESTNET_OPTION = "testnet" private const val TESTNET_OPTION = "testnet"
private const val PROXY_OPTION = "proxy" private const val PROXY_OPTION = "proxy"
@@ -94,6 +95,7 @@ fun mapRemoteChainToLocal(
isEthereumBased = ETHEREUM_OPTION in optionsOrEmpty, isEthereumBased = ETHEREUM_OPTION in optionsOrEmpty,
isTronBased = TRON_OPTION in optionsOrEmpty, isTronBased = TRON_OPTION in optionsOrEmpty,
isBitcoinBased = BITCOIN_OPTION in optionsOrEmpty, isBitcoinBased = BITCOIN_OPTION in optionsOrEmpty,
isSolanaBased = SOLANA_OPTION in optionsOrEmpty,
isTestNet = TESTNET_OPTION in optionsOrEmpty, isTestNet = TESTNET_OPTION in optionsOrEmpty,
hasCrowdloans = CROWDLOAN_OPTION in optionsOrEmpty, hasCrowdloans = CROWDLOAN_OPTION in optionsOrEmpty,
supportProxy = PROXY_OPTION in optionsOrEmpty, supportProxy = PROXY_OPTION in optionsOrEmpty,
@@ -34,6 +34,7 @@ data class Chain(
val isEthereumBased: Boolean, val isEthereumBased: Boolean,
val isTronBased: Boolean, val isTronBased: Boolean,
val isBitcoinBased: Boolean, val isBitcoinBased: Boolean,
val isSolanaBased: Boolean,
val isTestNet: Boolean, val isTestNet: Boolean,
val source: Source, val source: Source,
val hasSubstrateRuntime: Boolean, val hasSubstrateRuntime: Boolean,
@@ -145,6 +146,12 @@ data class Chain(
*/ */
object BitcoinNative : Type() 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() object Unsupported : Type()
} }
@@ -32,6 +32,11 @@ class NodeHealthStateTesterFactory(
httpClient = httpClient httpClient = httpClient
) )
chain.isSolanaBased -> SolanaNodeHealthStateTester(
node = node,
httpClient = httpClient
)
chain.hasSubstrateRuntime -> SubstrateNodeHealthStateTester( chain.hasSubstrateRuntime -> SubstrateNodeHealthStateTester(
chain = chain, chain = chain,
socketService = socketServiceProvider.get(), socketService = socketServiceProvider.get(),
@@ -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<Long> = 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
}
}
}