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 BitcoinDerivationPath by string().optional()
val SolanaKeypair by schema(KeyPairSchema).optional()
val SolanaDerivationPath by string().optional()
}
object ChainAccountSecrets : Schema<ChainAccountSecrets>() {
@@ -52,6 +55,8 @@ fun MetaAccountSecrets(
tronDerivationPath: String? = null,
bitcoinKeypair: Keypair? = null,
bitcoinDerivationPath: String? = null,
solanaKeypair: Keypair? = null,
solanaDerivationPath: String? = null,
): EncodableStruct<MetaAccountSecrets> = 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<MetaAccountSecrets>.tronDerivationPath
val EncodableStruct<MetaAccountSecrets>.bitcoinDerivationPath
get() = get(MetaAccountSecrets.BitcoinDerivationPath)
val EncodableStruct<MetaAccountSecrets>.solanaDerivationPath
get() = get(MetaAccountSecrets.SolanaDerivationPath)
val EncodableStruct<MetaAccountSecrets>.entropy
get() = get(MetaAccountSecrets.Entropy)
@@ -138,6 +155,9 @@ val EncodableStruct<MetaAccountSecrets>.tronKeypair
val EncodableStruct<MetaAccountSecrets>.bitcoinKeypair
get() = get(MetaAccountSecrets.BitcoinKeypair)
val EncodableStruct<MetaAccountSecrets>.solanaKeypair
get() = get(MetaAccountSecrets.SolanaKeypair)
val EncodableStruct<ChainAccountSecrets>.derivationPath
get() = get(ChainAccountSecrets.DerivationPath)
@@ -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<KeyPairSchema>): Keypair {
return Keypair(
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")
}
}