mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-android.git
synced 2026-07-22 10:15:50 +00:00
Add native Solana support: address derivation, accounts, signing, backfill migration, CloudBackup wiring
Same read/send-capable chain-family pattern as Tron/Bitcoin, but for Ed25519/SLIP-0010 instead of secp256k1/BIP32 (Solana's account id IS its raw public key, so no separate address-hash derivation step is needed). - SLIP-0010 Ed25519 keypair derivation (Bip32Ed25519KeypairFactory), cross-validated against an independent slip10 reference implementation and pinned to a known-good test vector. - Chain.isSolanaBased / Chain.Asset.Type.SolanaNative wired through every chain-mapper, account-model, and signer dispatch site (mirrors the existing Tron/Bitcoin wiring exactly). - SolanaAddressBackfillMigration derives Solana keys for wallets created before this support existed, same self-healing no-stuck-flag design as the Tron/Bitcoin backfills - also fixed a latent bug in those two migrations where MetaAccountSecrets/MetaAccountLocal.addXAccount() dropped sibling chain-family fields on a re-run. - Room migration 75->76 adds isSolanaBased/solanaPublicKey/solanaAddress columns. - CloudBackup's already-reserved solanaAddress/SolanaSecrets fields are now actually read/written by RealLocalAccountsCloudBackupFacade; also fixed isCompletelyEmpty() to check solana, so a Solana-only wallet doesn't get silently dropped from backup.
This commit is contained in:
+20
@@ -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!!
|
||||||
|
|||||||
+14
@@ -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,
|
||||||
|
|||||||
+53
-1
@@ -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()
|
||||||
|
|||||||
+12
-1
@@ -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) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+8
@@ -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
|
||||||
|
|||||||
+20
-1
@@ -17,6 +17,8 @@ import io.novafoundation.nova.common.data.secrets.v2.nonce
|
|||||||
import io.novafoundation.nova.common.data.secrets.v2.privateKey
|
import io.novafoundation.nova.common.data.secrets.v2.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
|
||||||
|
|||||||
+14
@@ -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,
|
||||||
|
|||||||
+8
-2
@@ -31,6 +31,7 @@ import io.novafoundation.nova.feature_account_impl.data.mappers.mapMetaAccountTy
|
|||||||
import io.novafoundation.nova.feature_account_impl.data.mappers.mapMetaAccountWithBalanceFromLocal
|
import io.novafoundation.nova.feature_account_impl.data.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 {
|
||||||
|
|||||||
+5
-1
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -11,6 +11,8 @@ import io.novafoundation.nova.common.data.secrets.v2.ethereumDerivationPath
|
|||||||
import io.novafoundation.nova.common.data.secrets.v2.ethereumKeypair
|
import io.novafoundation.nova.common.data.secrets.v2.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)
|
||||||
|
|||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
package io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets
|
||||||
|
import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2
|
||||||
|
import io.novafoundation.nova.common.data.secrets.v2.bitcoinDerivationPath
|
||||||
|
import io.novafoundation.nova.common.data.secrets.v2.bitcoinKeypair
|
||||||
|
import io.novafoundation.nova.common.data.secrets.v2.entropy
|
||||||
|
import io.novafoundation.nova.common.data.secrets.v2.ethereumDerivationPath
|
||||||
|
import io.novafoundation.nova.common.data.secrets.v2.ethereumKeypair
|
||||||
|
import io.novafoundation.nova.common.data.secrets.v2.mapKeypairStructToKeypair
|
||||||
|
import io.novafoundation.nova.common.data.secrets.v2.seed
|
||||||
|
import io.novafoundation.nova.common.data.secrets.v2.solanaKeypair
|
||||||
|
import io.novafoundation.nova.common.data.secrets.v2.substrateDerivationPath
|
||||||
|
import io.novafoundation.nova.common.data.secrets.v2.substrateKeypair
|
||||||
|
import io.novafoundation.nova.common.data.secrets.v2.tronDerivationPath
|
||||||
|
import io.novafoundation.nova.common.data.secrets.v2.tronKeypair
|
||||||
|
import io.novafoundation.nova.common.utils.Bip32Ed25519KeypairFactory
|
||||||
|
import io.novafoundation.nova.core_db.dao.MetaAccountDao
|
||||||
|
import io.novafoundation.nova.core_db.dao.updateMetaAccount
|
||||||
|
import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal
|
||||||
|
import io.novafoundation.nova.feature_account_impl.data.secrets.SOLANA_DEFAULT_DERIVATION_PATH
|
||||||
|
import io.novafoundation.nova.feature_account_impl.data.secrets.SOLANA_DEFAULT_DERIVATION_PATH_SEGMENTS
|
||||||
|
import io.novasama.substrate_sdk_android.encrypt.mnemonic.MnemonicCreator
|
||||||
|
import io.novasama.substrate_sdk_android.encrypt.seed.bip39.Bip39SeedFactory
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
private const val TAG = "SolanaAddressBackfill"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idempotent, per-account backfill for accounts that don't yet have a Solana keypair - mirrors
|
||||||
|
* [TronAddressBackfillMigration]/[BitcoinAddressBackfillMigration]'s exact design (see those classes for the
|
||||||
|
* full rationale). Deliberately has NO "have I already run once" flag, for the same stuck-flag reason.
|
||||||
|
*
|
||||||
|
* Only touches accounts that are `Type.SECRETS` and still hold their `Entropy` in [SecretStoreV2] - same
|
||||||
|
* restriction as the Tron/Bitcoin migrations.
|
||||||
|
*
|
||||||
|
* Unlike Tron/Bitcoin (which reuse [io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory.chainAccountSecrets]
|
||||||
|
* with `isEthereum = true`, since both are secp256k1/BIP32), Solana is Ed25519/SLIP-0010 - a different curve and
|
||||||
|
* derivation scheme entirely - so this derives the seed directly via [Bip39SeedFactory.deriveSeed] (the exact
|
||||||
|
* same chain-agnostic BIP39 seed step Tron/Bitcoin/Ethereum all use) and feeds it to [Bip32Ed25519KeypairFactory]
|
||||||
|
* at [SOLANA_DEFAULT_DERIVATION_PATH_SEGMENTS], the same call `AccountSecretsFactory.metaAccountSecrets()` makes
|
||||||
|
* for a fresh account - so a backfilled account ends up with byte-for-byte the same Solana address it would
|
||||||
|
* have gotten had it been created today.
|
||||||
|
*
|
||||||
|
* Carries every other chain-family field (substrate/ethereum/tron/bitcoin) forward unchanged when rewriting
|
||||||
|
* secrets - dropping any of them here would silently wipe an already-backfilled sibling key on retry.
|
||||||
|
*/
|
||||||
|
class SolanaAddressBackfillMigration(
|
||||||
|
private val secretStoreV2: SecretStoreV2,
|
||||||
|
private val metaAccountDao: MetaAccountDao,
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend fun migrate() = withContext(Dispatchers.Default) {
|
||||||
|
val secretsAccounts = metaAccountDao.getMetaAccounts().filter { it.type == MetaAccountLocal.Type.SECRETS }
|
||||||
|
Log.d(TAG, "migrate() starting - ${secretsAccounts.size} SECRETS-type account(s): ${secretsAccounts.map { it.id }}")
|
||||||
|
|
||||||
|
secretsAccounts.forEach { account ->
|
||||||
|
try {
|
||||||
|
backfillIfNeeded(account)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.e(TAG, "backfill failed for metaId=${account.id}, continuing with remaining accounts", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "migrate() done")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun backfillIfNeeded(account: MetaAccountLocal) {
|
||||||
|
val secrets = secretStoreV2.getMetaAccountSecrets(account.id)
|
||||||
|
if (secrets == null) {
|
||||||
|
Log.d(TAG, "metaId=${account.id}: no stored secrets at all (watch-only/Ledger/etc) - skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val entropy = secrets.entropy
|
||||||
|
if (entropy == null) {
|
||||||
|
Log.d(TAG, "metaId=${account.id}: no entropy (raw-seed import, not a mnemonic) - skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (secrets.solanaKeypair != null) {
|
||||||
|
Log.d(TAG, "metaId=${account.id}: already has a SolanaKeypair - skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "metaId=${account.id}: deriving Solana keypair")
|
||||||
|
|
||||||
|
val mnemonic = MnemonicCreator.fromEntropy(entropy).words
|
||||||
|
val bip39Seed = Bip39SeedFactory.deriveSeed(mnemonic, null).seed
|
||||||
|
|
||||||
|
val solanaKeypair = Bip32Ed25519KeypairFactory.generate(bip39Seed, SOLANA_DEFAULT_DERIVATION_PATH_SEGMENTS)
|
||||||
|
|
||||||
|
val updatedSecrets = MetaAccountSecrets(
|
||||||
|
substrateKeyPair = mapKeypairStructToKeypair(secrets.substrateKeypair),
|
||||||
|
entropy = secrets.entropy,
|
||||||
|
substrateSeed = secrets.seed,
|
||||||
|
substrateDerivationPath = secrets.substrateDerivationPath,
|
||||||
|
ethereumKeypair = secrets.ethereumKeypair?.let(::mapKeypairStructToKeypair),
|
||||||
|
ethereumDerivationPath = secrets.ethereumDerivationPath,
|
||||||
|
tronKeypair = secrets.tronKeypair?.let(::mapKeypairStructToKeypair),
|
||||||
|
tronDerivationPath = secrets.tronDerivationPath,
|
||||||
|
bitcoinKeypair = secrets.bitcoinKeypair?.let(::mapKeypairStructToKeypair),
|
||||||
|
bitcoinDerivationPath = secrets.bitcoinDerivationPath,
|
||||||
|
solanaKeypair = solanaKeypair,
|
||||||
|
solanaDerivationPath = SOLANA_DEFAULT_DERIVATION_PATH
|
||||||
|
)
|
||||||
|
|
||||||
|
secretStoreV2.putMetaAccountSecrets(account.id, updatedSecrets)
|
||||||
|
|
||||||
|
// Solana's accountId IS the public key itself - see SolanaAddress.kt's doc.
|
||||||
|
metaAccountDao.updateMetaAccount(account.id) { it.addSolanaAccount(solanaKeypair.publicKey, solanaKeypair.publicKey) }
|
||||||
|
|
||||||
|
Log.d(TAG, "metaId=${account.id}: backfilled successfully, solanaAddress set (${solanaKeypair.publicKey.size} bytes)")
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-1
@@ -4,11 +4,15 @@ import android.util.Log
|
|||||||
import io.novafoundation.nova.common.data.secrets.v2.ChainAccountSecrets
|
import io.novafoundation.nova.common.data.secrets.v2.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)
|
||||||
|
|||||||
+35
@@ -6,6 +6,7 @@ import io.novafoundation.nova.common.data.network.runtime.binding.cast
|
|||||||
import io.novafoundation.nova.common.data.secrets.v2.ChainAccountSecrets
|
import io.novafoundation.nova.common.data.secrets.v2.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)
|
||||||
|
|||||||
+10
-2
@@ -7,6 +7,7 @@ import io.novafoundation.nova.common.data.secrets.v2.getChainAccountKeypair
|
|||||||
import io.novafoundation.nova.common.data.secrets.v2.getMetaAccountKeypair
|
import io.novafoundation.nova.common.data.secrets.v2.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
|
||||||
|
|||||||
+13
-1
@@ -105,6 +105,7 @@ import io.novafoundation.nova.feature_account_impl.data.repository.datasource.Re
|
|||||||
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.SecretsMetaAccountLocalFactory
|
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.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()
|
||||||
|
|
||||||
|
|||||||
+5
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -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 {
|
||||||
|
|||||||
+5
-1
@@ -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 {
|
||||||
|
|||||||
+5
-1
@@ -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 {
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -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 {
|
||||||
|
|||||||
+5
-1
@@ -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 {
|
||||||
|
|
||||||
|
|||||||
+15
-2
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+183
@@ -0,0 +1,183 @@
|
|||||||
|
package io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration
|
||||||
|
|
||||||
|
import io.novafoundation.nova.common.data.secrets.v2.KeyPairSchema
|
||||||
|
import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets
|
||||||
|
import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2
|
||||||
|
import io.novafoundation.nova.common.data.secrets.v2.mapKeypairStructToKeypair
|
||||||
|
import io.novafoundation.nova.common.data.secrets.v2.solanaKeypair
|
||||||
|
import io.novafoundation.nova.common.utils.invoke
|
||||||
|
import io.novafoundation.nova.common.utils.solanaAddressToAccountId
|
||||||
|
import io.novafoundation.nova.common.utils.toSolanaAddress
|
||||||
|
import io.novafoundation.nova.core.model.CryptoType
|
||||||
|
import io.novafoundation.nova.core_db.dao.MetaAccountDao
|
||||||
|
import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal
|
||||||
|
import io.novasama.substrate_sdk_android.encrypt.mnemonic.MnemonicCreator
|
||||||
|
import io.novasama.substrate_sdk_android.scale.EncodableStruct
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.mockito.ArgumentMatcher
|
||||||
|
import org.mockito.Mock
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import org.mockito.Mockito.never
|
||||||
|
import org.mockito.Mockito.verify
|
||||||
|
import org.mockito.junit.MockitoJUnitRunner
|
||||||
|
|
||||||
|
// Same guaranteed-non-null-return wrappers as TronAddressBackfillMigrationTest, for the same reason - see
|
||||||
|
// that test's class doc for the full writeup.
|
||||||
|
private fun <T> eq(value: T): T = Mockito.eq(value) ?: value
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun <T> any(): T {
|
||||||
|
Mockito.any<T>()
|
||||||
|
return null as T
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun <T> argThat(matcher: (T) -> Boolean): T {
|
||||||
|
Mockito.argThat(ArgumentMatcher<T> { matcher(it) })
|
||||||
|
return null as T
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> whenever(methodCall: T?) = Mockito.`when`(methodCall)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors [TronAddressBackfillMigrationTest]'s design (see that class's doc for the full context on why this
|
||||||
|
* matters). Unlike Tron/Bitcoin's migration test, [SolanaAddressBackfillMigration] doesn't go through
|
||||||
|
* [io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory] (Solana's Ed25519
|
||||||
|
* derivation is a different code path entirely - see that class's constructor), so there's no factory to mock
|
||||||
|
* or use for real - the derivation math (SLIP-0010 via [io.novafoundation.nova.common.utils.Bip32Ed25519KeypairFactory])
|
||||||
|
* runs directly. Asserts against the same live-cross-validated reference address
|
||||||
|
* [io.novafoundation.nova.common.utils.SolanaAddressTest] already established for the standard BIP39 test
|
||||||
|
* mnemonic, so this test and that one are pinned to the same known-good vector.
|
||||||
|
*/
|
||||||
|
@RunWith(MockitoJUnitRunner::class)
|
||||||
|
class SolanaAddressBackfillMigrationTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
lateinit var secretStoreV2: SecretStoreV2
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
lateinit var metaAccountDao: MetaAccountDao
|
||||||
|
|
||||||
|
private lateinit var subject: SolanaAddressBackfillMigration
|
||||||
|
|
||||||
|
private val testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||||
|
private val expectedSolanaAccountId = "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk".solanaAddressToAccountId()
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
subject = SolanaAddressBackfillMigration(secretStoreV2, metaAccountDao)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `migrate should derive and persist the well-known reference Solana address for a pre-existing mnemonic account`(): Unit = runBlocking {
|
||||||
|
val account = secretsAccount(id = 42)
|
||||||
|
val secrets = accountSecrets(entropy = MnemonicCreator.fromWords(testMnemonic).entropy, solanaKeypair = null)
|
||||||
|
|
||||||
|
whenever(metaAccountDao.getMetaAccounts()).thenReturn(listOf(account))
|
||||||
|
whenever(metaAccountDao.getMetaAccount(eq(42L))).thenReturn(account)
|
||||||
|
whenever(secretStoreV2.getMetaAccountSecrets(eq(42L))).thenReturn(secrets)
|
||||||
|
|
||||||
|
subject.migrate()
|
||||||
|
|
||||||
|
verify(metaAccountDao).updateMetaAccount(
|
||||||
|
argThat<MetaAccountLocal> { updated ->
|
||||||
|
updated.id == 42L && updated.solanaAddress.contentEquals(expectedSolanaAccountId)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
verify(secretStoreV2).putMetaAccountSecrets(
|
||||||
|
eq(42L),
|
||||||
|
argThat<EncodableStruct<MetaAccountSecrets>> { updatedSecrets ->
|
||||||
|
val solanaKeypairStruct = updatedSecrets.solanaKeypair
|
||||||
|
solanaKeypairStruct != null &&
|
||||||
|
mapKeypairStructToKeypair(solanaKeypairStruct).publicKey.toSolanaAddress() == "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `migrate should skip an account that already has a Solana keypair`(): Unit = runBlocking {
|
||||||
|
val account = secretsAccount(id = 7)
|
||||||
|
val existingSolanaKeypair = KeyPairSchema { keypair ->
|
||||||
|
keypair[KeyPairSchema.PublicKey] = ByteArray(32) { 9 }
|
||||||
|
keypair[KeyPairSchema.PrivateKey] = ByteArray(32) { 8 }
|
||||||
|
keypair[KeyPairSchema.Nonce] = null
|
||||||
|
}
|
||||||
|
val secrets = accountSecrets(entropy = MnemonicCreator.fromWords(testMnemonic).entropy, solanaKeypair = existingSolanaKeypair)
|
||||||
|
|
||||||
|
whenever(metaAccountDao.getMetaAccounts()).thenReturn(listOf(account))
|
||||||
|
whenever(secretStoreV2.getMetaAccountSecrets(eq(7L))).thenReturn(secrets)
|
||||||
|
|
||||||
|
subject.migrate()
|
||||||
|
|
||||||
|
verify(metaAccountDao, never()).updateMetaAccount(any())
|
||||||
|
verify(secretStoreV2, never()).putMetaAccountSecrets(Mockito.anyLong(), any())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `migrate should skip an account with no entropy (raw-seed import, not a mnemonic)`(): Unit = runBlocking {
|
||||||
|
val account = secretsAccount(id = 11)
|
||||||
|
val secrets = accountSecrets(entropy = null, solanaKeypair = null)
|
||||||
|
|
||||||
|
whenever(metaAccountDao.getMetaAccounts()).thenReturn(listOf(account))
|
||||||
|
whenever(secretStoreV2.getMetaAccountSecrets(eq(11L))).thenReturn(secrets)
|
||||||
|
|
||||||
|
subject.migrate()
|
||||||
|
|
||||||
|
verify(metaAccountDao, never()).updateMetaAccount(any())
|
||||||
|
verify(secretStoreV2, never()).putMetaAccountSecrets(Mockito.anyLong(), any())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `migrate should skip an account with no stored secrets at all (watch-only, Ledger, etc)`(): Unit = runBlocking {
|
||||||
|
val account = secretsAccount(id = 13)
|
||||||
|
|
||||||
|
whenever(metaAccountDao.getMetaAccounts()).thenReturn(listOf(account))
|
||||||
|
whenever(secretStoreV2.getMetaAccountSecrets(eq(13L))).thenReturn(null)
|
||||||
|
|
||||||
|
subject.migrate()
|
||||||
|
|
||||||
|
verify(metaAccountDao, never()).updateMetaAccount(any())
|
||||||
|
verify(secretStoreV2, never()).putMetaAccountSecrets(Mockito.anyLong(), any())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun secretsAccount(id: Long): MetaAccountLocal {
|
||||||
|
return MetaAccountLocal(
|
||||||
|
substratePublicKey = ByteArray(32) { 1 },
|
||||||
|
substrateCryptoType = CryptoType.SR25519,
|
||||||
|
substrateAccountId = ByteArray(32) { 2 },
|
||||||
|
ethereumPublicKey = null,
|
||||||
|
ethereumAddress = null,
|
||||||
|
name = "Test account",
|
||||||
|
parentMetaId = null,
|
||||||
|
isSelected = true,
|
||||||
|
position = 0,
|
||||||
|
type = MetaAccountLocal.Type.SECRETS,
|
||||||
|
status = MetaAccountLocal.Status.ACTIVE,
|
||||||
|
globallyUniqueId = "test-guid-$id",
|
||||||
|
typeExtras = null
|
||||||
|
).also { it.id = id }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun accountSecrets(entropy: ByteArray?, solanaKeypair: EncodableStruct<KeyPairSchema>?): EncodableStruct<MetaAccountSecrets> {
|
||||||
|
val dummySubstrateKeypair = KeyPairSchema { keypair ->
|
||||||
|
keypair[KeyPairSchema.PublicKey] = ByteArray(32) { 5 }
|
||||||
|
keypair[KeyPairSchema.PrivateKey] = ByteArray(32) { 6 }
|
||||||
|
keypair[KeyPairSchema.Nonce] = ByteArray(8) { 7 }
|
||||||
|
}
|
||||||
|
|
||||||
|
return MetaAccountSecrets(
|
||||||
|
substrateKeyPair = mapKeypairStructToKeypair(dummySubstrateKeypair),
|
||||||
|
entropy = entropy,
|
||||||
|
substrateSeed = null,
|
||||||
|
substrateDerivationPath = null,
|
||||||
|
ethereumKeypair = null,
|
||||||
|
ethereumDerivationPath = null,
|
||||||
|
solanaKeypair = solanaKeypair?.let(::mapKeypairStructToKeypair),
|
||||||
|
solanaDerivationPath = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-1
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -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)
|
||||||
|
|
||||||
|
|||||||
+2
@@ -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"
|
||||||
|
|
||||||
|
|||||||
+3
@@ -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,
|
||||||
|
|||||||
+3
@@ -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,
|
||||||
|
|||||||
+2
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
@@ -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(),
|
||||||
|
|||||||
+44
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user