mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-android.git
synced 2026-07-22 20:45:53 +00:00
Add native Solana support: address derivation, accounts, signing, backfill migration, CloudBackup wiring
Same read/send-capable chain-family pattern as Tron/Bitcoin, but for Ed25519/SLIP-0010 instead of secp256k1/BIP32 (Solana's account id IS its raw public key, so no separate address-hash derivation step is needed). - SLIP-0010 Ed25519 keypair derivation (Bip32Ed25519KeypairFactory), cross-validated against an independent slip10 reference implementation and pinned to a known-good test vector. - Chain.isSolanaBased / Chain.Asset.Type.SolanaNative wired through every chain-mapper, account-model, and signer dispatch site (mirrors the existing Tron/Bitcoin wiring exactly). - SolanaAddressBackfillMigration derives Solana keys for wallets created before this support existed, same self-healing no-stuck-flag design as the Tron/Bitcoin backfills - also fixed a latent bug in those two migrations where MetaAccountSecrets/MetaAccountLocal.addXAccount() dropped sibling chain-family fields on a re-run. - Room migration 75->76 adds isSolanaBased/solanaPublicKey/solanaAddress columns. - CloudBackup's already-reserved solanaAddress/SolanaSecrets fields are now actually read/written by RealLocalAccountsCloudBackupFacade; also fixed isCompletelyEmpty() to check solana, so a Solana-only wallet doesn't get silently dropped from backup.
This commit is contained in:
+20
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user