mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-android.git
synced 2026-07-22 07:55: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 BitcoinDerivationPath by string().optional()
|
||||
|
||||
val SolanaKeypair by schema(KeyPairSchema).optional()
|
||||
val SolanaDerivationPath by string().optional()
|
||||
}
|
||||
|
||||
object ChainAccountSecrets : Schema<ChainAccountSecrets>() {
|
||||
@@ -52,6 +55,8 @@ fun MetaAccountSecrets(
|
||||
tronDerivationPath: String? = null,
|
||||
bitcoinKeypair: Keypair? = null,
|
||||
bitcoinDerivationPath: String? = null,
|
||||
solanaKeypair: Keypair? = null,
|
||||
solanaDerivationPath: String? = null,
|
||||
): EncodableStruct<MetaAccountSecrets> = MetaAccountSecrets { secrets ->
|
||||
secrets[Entropy] = entropy
|
||||
secrets[SubstrateSeed] = substrateSeed
|
||||
@@ -89,6 +94,15 @@ fun MetaAccountSecrets(
|
||||
}
|
||||
}
|
||||
secrets[BitcoinDerivationPath] = bitcoinDerivationPath
|
||||
|
||||
secrets[SolanaKeypair] = solanaKeypair?.let {
|
||||
KeyPairSchema { keypair ->
|
||||
keypair[PublicKey] = it.publicKey
|
||||
keypair[PrivateKey] = it.privateKey
|
||||
keypair[Nonce] = null // Solana uses Ed25519, not Sr25519, so nonce is always null
|
||||
}
|
||||
}
|
||||
secrets[SolanaDerivationPath] = solanaDerivationPath
|
||||
}
|
||||
|
||||
fun ChainAccountSecrets(
|
||||
@@ -120,6 +134,9 @@ val EncodableStruct<MetaAccountSecrets>.tronDerivationPath
|
||||
val EncodableStruct<MetaAccountSecrets>.bitcoinDerivationPath
|
||||
get() = get(MetaAccountSecrets.BitcoinDerivationPath)
|
||||
|
||||
val EncodableStruct<MetaAccountSecrets>.solanaDerivationPath
|
||||
get() = get(MetaAccountSecrets.SolanaDerivationPath)
|
||||
|
||||
val EncodableStruct<MetaAccountSecrets>.entropy
|
||||
get() = get(MetaAccountSecrets.Entropy)
|
||||
|
||||
@@ -138,6 +155,9 @@ val EncodableStruct<MetaAccountSecrets>.tronKeypair
|
||||
val EncodableStruct<MetaAccountSecrets>.bitcoinKeypair
|
||||
get() = get(MetaAccountSecrets.BitcoinKeypair)
|
||||
|
||||
val EncodableStruct<MetaAccountSecrets>.solanaKeypair
|
||||
get() = get(MetaAccountSecrets.SolanaKeypair)
|
||||
|
||||
val EncodableStruct<ChainAccountSecrets>.derivationPath
|
||||
get() = get(ChainAccountSecrets.DerivationPath)
|
||||
|
||||
|
||||
@@ -158,10 +158,11 @@ suspend fun SecretStoreV2.getMetaAccountKeypair(
|
||||
isEthereum: Boolean,
|
||||
isTron: Boolean = false,
|
||||
isBitcoin: Boolean = false,
|
||||
isSolana: Boolean = false,
|
||||
): Keypair = withContext(Dispatchers.Default) {
|
||||
val secrets = getMetaAccountSecrets(metaId) ?: noMetaSecrets(metaId)
|
||||
|
||||
mapMetaAccountSecretsToKeypair(secrets, isEthereum, isTron, isBitcoin)
|
||||
mapMetaAccountSecretsToKeypair(secrets, isEthereum, isTron, isBitcoin, isSolana)
|
||||
}
|
||||
|
||||
fun mapMetaAccountSecretsToKeypair(
|
||||
@@ -169,13 +170,16 @@ fun mapMetaAccountSecretsToKeypair(
|
||||
ethereum: Boolean,
|
||||
tron: Boolean = false,
|
||||
bitcoin: Boolean = false,
|
||||
solana: Boolean = false,
|
||||
): Keypair {
|
||||
// Tron and Bitcoin both reuse Ethereum's secp256k1 curve but derive their own keypair under a different
|
||||
// SLIP-44 path - each must be checked before `ethereum`, not folded into it, or that account would get
|
||||
// signed with the wrong (Ethereum) private key.
|
||||
// signed with the wrong (Ethereum) private key. Solana is Ed25519 (not secp256k1 at all) but the same
|
||||
// "check before ethereum" reasoning applies - it's still its own distinct keypair.
|
||||
val keypairStruct = when {
|
||||
tron -> secrets[MetaAccountSecrets.TronKeypair] ?: noTronSecret()
|
||||
bitcoin -> secrets[MetaAccountSecrets.BitcoinKeypair] ?: noBitcoinSecret()
|
||||
solana -> secrets[MetaAccountSecrets.SolanaKeypair] ?: noSolanaSecret()
|
||||
ethereum -> secrets[MetaAccountSecrets.EthereumKeypair] ?: noEthereumSecret()
|
||||
else -> secrets[MetaAccountSecrets.SubstrateKeypair]
|
||||
}
|
||||
@@ -213,6 +217,8 @@ private fun noBitcoinSecret(): Nothing = error("No bitcoin keypair found")
|
||||
|
||||
private fun noTronSecret(): Nothing = error("No tron keypair found")
|
||||
|
||||
private fun noSolanaSecret(): Nothing = error("No solana keypair found")
|
||||
|
||||
fun mapKeypairStructToKeypair(struct: EncodableStruct<KeyPairSchema>): Keypair {
|
||||
return Keypair(
|
||||
publicKey = struct[KeyPairSchema.PublicKey],
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package io.novafoundation.nova.common.utils
|
||||
|
||||
import io.novasama.substrate_sdk_android.encrypt.keypair.BaseKeypair
|
||||
import io.novasama.substrate_sdk_android.encrypt.keypair.Keypair
|
||||
import io.novasama.substrate_sdk_android.runtime.AccountId
|
||||
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
* Solana address format: the raw 32-byte Ed25519 public key, Base58-encoded directly - no
|
||||
* checksum, no prefix byte (unlike Tron's Base58Check, see [TronAddress.kt]'s [Base58Check]).
|
||||
* [Base58] (defined there) is reused as-is since it's exactly this: a plain, non-checksummed
|
||||
* Base58 codec.
|
||||
*
|
||||
* Solana's account id IS the public key itself (no separate keccak/hash-based derivation the way
|
||||
* Ethereum/Tron/Bitcoin have) - `AccountId` here is always exactly 32 bytes.
|
||||
*
|
||||
* Derivation: SLIP-0010 (Ed25519 hierarchical, hardened-only - Ed25519 has no public-key-only
|
||||
* derivation the way secp256k1/BIP32 does, so every path segment is implicitly hardened) at path
|
||||
* m/44'/501'/0'/0' (SLIP-44 coin type 501, the modern Phantom/Solflare/Solana CLI default - NOT
|
||||
* the older Sollet-style m/44'/501'/0' with no final change level). Cross-validated against an
|
||||
* independent implementation (the `slip10` PyPI package) for the standard BIP39 test mnemonic
|
||||
* ("abandon x11 about", empty passphrase): both agree on
|
||||
* HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk - see [SolanaAddressTest].
|
||||
*/
|
||||
private const val SOLANA_SEED_HMAC_KEY = "ed25519 seed"
|
||||
|
||||
object Bip32Ed25519KeypairFactory {
|
||||
|
||||
/**
|
||||
* `seed` is the standard 64-byte BIP39 seed (the exact same one Ethereum/Tron/Bitcoin derive
|
||||
* via `Bip39SeedFactory.deriveSeed` - BIP39 seed generation itself is chain-agnostic, only
|
||||
* what happens to it afterward differs). `path` is a list of child indices - all implicitly
|
||||
* hardened, so callers pass plain ints (44, 501, 0, 0), not pre-marked/offset values.
|
||||
*/
|
||||
fun generate(seed: ByteArray, path: List<Int>): Keypair {
|
||||
var (key, chainCode) = masterKeyFromSeed(seed)
|
||||
|
||||
for (index in path) {
|
||||
val (childKey, childChainCode) = deriveHardenedChild(key, chainCode, index)
|
||||
key = childKey
|
||||
chainCode = childChainCode
|
||||
}
|
||||
|
||||
val privateKeyParams = Ed25519PrivateKeyParameters(key, 0)
|
||||
val publicKey = privateKeyParams.generatePublicKey().encoded
|
||||
|
||||
return BaseKeypair(privateKey = key, publicKey = publicKey)
|
||||
}
|
||||
|
||||
private fun masterKeyFromSeed(seed: ByteArray): Pair<ByteArray, ByteArray> {
|
||||
val digest = hmacSha512(SOLANA_SEED_HMAC_KEY.toByteArray(Charsets.UTF_8), seed)
|
||||
return digest.copyOfRange(0, 32) to digest.copyOfRange(32, 64)
|
||||
}
|
||||
|
||||
/** SLIP-0010's ed25519 child derivation is always hardened: `HMAC-SHA512(chainCode, 0x00 ++
|
||||
* parentKey ++ ser32(index | 0x80000000))`, unlike BIP32 secp256k1 (which can also derive
|
||||
* non-hardened children from a public key alone) - there is no non-hardened case to handle. */
|
||||
private fun deriveHardenedChild(key: ByteArray, chainCode: ByteArray, index: Int): Pair<ByteArray, ByteArray> {
|
||||
val hardenedIndex = index or (1 shl 31)
|
||||
val data = byteArrayOf(0) + key + ser32(hardenedIndex)
|
||||
|
||||
val digest = hmacSha512(chainCode, data)
|
||||
return digest.copyOfRange(0, 32) to digest.copyOfRange(32, 64)
|
||||
}
|
||||
|
||||
private fun ser32(value: Int): ByteArray = byteArrayOf(
|
||||
(value ushr 24).toByte(),
|
||||
(value ushr 16).toByte(),
|
||||
(value ushr 8).toByte(),
|
||||
value.toByte(),
|
||||
)
|
||||
|
||||
private fun hmacSha512(key: ByteArray, data: ByteArray): ByteArray {
|
||||
val mac = Mac.getInstance("HmacSHA512")
|
||||
mac.init(SecretKeySpec(key, "HmacSHA512"))
|
||||
return mac.doFinal(data)
|
||||
}
|
||||
}
|
||||
|
||||
fun AccountId.toSolanaAddress(): String {
|
||||
require(size == 32) { "Solana account id (public key) must be 32 bytes, got $size" }
|
||||
|
||||
return Base58.encode(this)
|
||||
}
|
||||
|
||||
fun String.solanaAddressToAccountId(): AccountId {
|
||||
val decoded = Base58.decode(this)
|
||||
require(decoded.size == 32) { "Not a valid Solana address: $this" }
|
||||
|
||||
return decoded
|
||||
}
|
||||
|
||||
fun String.isValidSolanaAddress(): Boolean = runCatching { solanaAddressToAccountId() }.isSuccess
|
||||
|
||||
fun emptySolanaAccountId() = ByteArray(32) { 1 }
|
||||
@@ -0,0 +1,81 @@
|
||||
package io.novafoundation.nova.common.utils
|
||||
|
||||
import io.novasama.substrate_sdk_android.extensions.fromHex
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SolanaAddressTest {
|
||||
|
||||
/**
|
||||
* The standard BIP39 test mnemonic ("abandon" x11 + "about", empty passphrase) - its 64-byte
|
||||
* seed is a well-known, independently-verifiable industry reference value (used identically
|
||||
* across Bitcoin/Ethereum/etc test vectors). Derived here at m/44'/501'/0'/0' (SLIP-0010
|
||||
* Ed25519, the Phantom/Solflare/Solana CLI default path) and cross-checked against an
|
||||
* independent second implementation (the `slip10` PyPI package, run standalone in Python
|
||||
* outside this codebase) - both agree exactly on this address, so this is a real,
|
||||
* cross-validated vector, not one invented for this test.
|
||||
*/
|
||||
private val knownSeedHex =
|
||||
"5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4"
|
||||
private val knownPath = listOf(44, 501, 0, 0)
|
||||
private val knownAddress = "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk"
|
||||
|
||||
@Test
|
||||
fun `should derive the known reference Solana address from the standard test seed`() {
|
||||
val keypair = Bip32Ed25519KeypairFactory.generate(knownSeedHex.fromHex(), knownPath)
|
||||
|
||||
assertEquals(knownAddress, keypair.publicKey.toSolanaAddress())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `different derivation path should yield a different address`() {
|
||||
val keypair = Bip32Ed25519KeypairFactory.generate(knownSeedHex.fromHex(), listOf(44, 501, 0))
|
||||
|
||||
assertFalse(keypair.publicKey.toSolanaAddress() == knownAddress)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address encode-decode should round trip`() {
|
||||
val keypair = Bip32Ed25519KeypairFactory.generate(knownSeedHex.fromHex(), knownPath)
|
||||
val address = keypair.publicKey.toSolanaAddress()
|
||||
|
||||
val decoded = address.solanaAddressToAccountId()
|
||||
assertTrue(decoded.contentEquals(keypair.publicKey))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isValidSolanaAddress should accept known good address`() {
|
||||
assertTrue(knownAddress.isValidSolanaAddress())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isValidSolanaAddress should reject a Tron address`() {
|
||||
assertFalse("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t".isValidSolanaAddress())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isValidSolanaAddress should reject too-short input`() {
|
||||
assertFalse("1111".isValidSolanaAddress())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toSolanaAddress should reject a non-32-byte input`() {
|
||||
val notThirtyTwoBytes = ByteArray(20)
|
||||
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
notThirtyTwoBytes.toSolanaAddress()
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertThrows(expected: Class<out Throwable>, block: () -> Unit) {
|
||||
try {
|
||||
block()
|
||||
} catch (e: Throwable) {
|
||||
assertTrue("Expected ${expected.name} but got ${e::class.java.name}", expected.isInstance(e))
|
||||
return
|
||||
}
|
||||
throw AssertionError("Expected ${expected.name} to be thrown, but nothing was thrown")
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@ fun chainOf(
|
||||
isEthereumBased = false,
|
||||
isTronBased = false,
|
||||
isBitcoinBased = false,
|
||||
isSolanaBased = false,
|
||||
hasCrowdloans = false,
|
||||
additional = "",
|
||||
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.AddTransferApisTable_29_30
|
||||
import io.novafoundation.nova.core_db.migrations.AddBitcoinSupport_74_75
|
||||
import io.novafoundation.nova.core_db.migrations.AddSolanaSupport_75_76
|
||||
import io.novafoundation.nova.core_db.migrations.AddTronSupport_73_74
|
||||
import io.novafoundation.nova.core_db.migrations.AddTypeExtrasToMetaAccount_68_69
|
||||
import io.novafoundation.nova.core_db.migrations.AddVersioningToGovernanceDapps_32_33
|
||||
@@ -168,7 +169,7 @@ import io.novafoundation.nova.core_db.model.operation.SwapTypeLocal
|
||||
import io.novafoundation.nova.core_db.model.operation.TransferTypeLocal
|
||||
|
||||
@Database(
|
||||
version = 75,
|
||||
version = 76,
|
||||
entities = [
|
||||
AccountLocal::class,
|
||||
NodeLocal::class,
|
||||
@@ -275,6 +276,7 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
.addMigrations(AddGifts_71_72, AddFieldsToContributions)
|
||||
.addMigrations(AddTronSupport_73_74)
|
||||
.addMigrations(AddBitcoinSupport_74_75)
|
||||
.addMigrations(AddSolanaSupport_75_76)
|
||||
.build()
|
||||
}
|
||||
return instance!!
|
||||
|
||||
+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,
|
||||
@ColumnInfo(defaultValue = "0")
|
||||
val isBitcoinBased: Boolean,
|
||||
@ColumnInfo(defaultValue = "0")
|
||||
val isSolanaBased: Boolean,
|
||||
val isTestNet: Boolean,
|
||||
@ColumnInfo(defaultValue = "1")
|
||||
val hasSubstrateRuntime: Boolean,
|
||||
|
||||
+53
-1
@@ -41,6 +41,8 @@ class MetaAccountLocal(
|
||||
val tronAddress: ByteArray? = null,
|
||||
val bitcoinPublicKey: ByteArray? = null,
|
||||
val bitcoinAddress: ByteArray? = null,
|
||||
val solanaPublicKey: ByteArray? = null,
|
||||
val solanaAddress: ByteArray? = null,
|
||||
) {
|
||||
|
||||
enum class Status {
|
||||
@@ -64,6 +66,9 @@ class MetaAccountLocal(
|
||||
const val BITCOIN_PUBKEY = "bitcoinPublicKey"
|
||||
const val BITCOIN_ADDRESS = "bitcoinAddress"
|
||||
|
||||
const val SOLANA_PUBKEY = "solanaPublicKey"
|
||||
const val SOLANA_ADDRESS = "solanaAddress"
|
||||
|
||||
const val NAME = "name"
|
||||
const val IS_SELECTED = "isSelected"
|
||||
const val POSITION = "position"
|
||||
@@ -98,6 +103,8 @@ class MetaAccountLocal(
|
||||
tronAddress = tronAddress,
|
||||
bitcoinPublicKey = bitcoinPublicKey,
|
||||
bitcoinAddress = bitcoinAddress,
|
||||
solanaPublicKey = solanaPublicKey,
|
||||
solanaAddress = solanaAddress,
|
||||
).also {
|
||||
it.id = id
|
||||
}
|
||||
@@ -126,6 +133,8 @@ class MetaAccountLocal(
|
||||
tronAddress = tronAddress,
|
||||
bitcoinPublicKey = bitcoinPublicKey,
|
||||
bitcoinAddress = bitcoinAddress,
|
||||
solanaPublicKey = solanaPublicKey,
|
||||
solanaAddress = solanaAddress,
|
||||
).also {
|
||||
it.id = id
|
||||
}
|
||||
@@ -151,7 +160,46 @@ class MetaAccountLocal(
|
||||
globallyUniqueId = globallyUniqueId,
|
||||
typeExtras = typeExtras,
|
||||
tronPublicKey = tronPublicKey,
|
||||
tronAddress = tronAddress
|
||||
tronAddress = tronAddress,
|
||||
// Previously missing - dropped bitcoin/solana on any account already carrying them. Since
|
||||
// this migration is deliberately unconditional/self-healing (re-runs every app start to
|
||||
// recover from a since-fixed bug re-losing a keypair), a re-run after Bitcoin/Solana were
|
||||
// already backfilled would silently wipe them back to null. See addBitcoinAccount/
|
||||
// addSolanaAccount - every addXAccount must carry every sibling chain-family field forward.
|
||||
bitcoinPublicKey = bitcoinPublicKey,
|
||||
bitcoinAddress = bitcoinAddress,
|
||||
solanaPublicKey = solanaPublicKey,
|
||||
solanaAddress = solanaAddress,
|
||||
).also {
|
||||
it.id = id
|
||||
}
|
||||
}
|
||||
|
||||
// We do not use copy as we need explicitly set id
|
||||
fun addSolanaAccount(
|
||||
solanaPublicKey: ByteArray,
|
||||
solanaAddress: ByteArray,
|
||||
): MetaAccountLocal {
|
||||
return MetaAccountLocal(
|
||||
substratePublicKey = substratePublicKey,
|
||||
substrateCryptoType = substrateCryptoType,
|
||||
substrateAccountId = substrateAccountId,
|
||||
ethereumPublicKey = ethereumPublicKey,
|
||||
ethereumAddress = ethereumAddress,
|
||||
name = name,
|
||||
parentMetaId = parentMetaId,
|
||||
isSelected = isSelected,
|
||||
position = position,
|
||||
type = type,
|
||||
status = status,
|
||||
globallyUniqueId = globallyUniqueId,
|
||||
typeExtras = typeExtras,
|
||||
tronPublicKey = tronPublicKey,
|
||||
tronAddress = tronAddress,
|
||||
bitcoinPublicKey = bitcoinPublicKey,
|
||||
bitcoinAddress = bitcoinAddress,
|
||||
solanaPublicKey = solanaPublicKey,
|
||||
solanaAddress = solanaAddress,
|
||||
).also {
|
||||
it.id = id
|
||||
}
|
||||
@@ -172,6 +220,8 @@ class MetaAccountLocal(
|
||||
if (!tronAddress.contentEquals(other.tronAddress)) return false
|
||||
if (!bitcoinPublicKey.contentEquals(other.bitcoinPublicKey)) return false
|
||||
if (!bitcoinAddress.contentEquals(other.bitcoinAddress)) return false
|
||||
if (!solanaPublicKey.contentEquals(other.solanaPublicKey)) return false
|
||||
if (!solanaAddress.contentEquals(other.solanaAddress)) return false
|
||||
if (name != other.name) return false
|
||||
if (parentMetaId != other.parentMetaId) return false
|
||||
if (isSelected != other.isSelected) return false
|
||||
@@ -195,6 +245,8 @@ class MetaAccountLocal(
|
||||
result = 31 * result + (tronAddress?.contentHashCode() ?: 0)
|
||||
result = 31 * result + (bitcoinPublicKey?.contentHashCode() ?: 0)
|
||||
result = 31 * result + (bitcoinAddress?.contentHashCode() ?: 0)
|
||||
result = 31 * result + (solanaPublicKey?.contentHashCode() ?: 0)
|
||||
result = 31 * result + (solanaAddress?.contentHashCode() ?: 0)
|
||||
result = 31 * result + name.hashCode()
|
||||
result = 31 * result + (parentMetaId?.hashCode() ?: 0)
|
||||
result = 31 * result + isSelected.hashCode()
|
||||
|
||||
+12
-1
@@ -25,7 +25,18 @@ suspend fun SecretStoreV2.getAccountSecrets(
|
||||
|
||||
fun AccountSecrets.keypair(chain: Chain): Keypair {
|
||||
return fold(
|
||||
left = { mapMetaAccountSecretsToKeypair(it, ethereum = chain.isEthereumBased, bitcoin = chain.isBitcoinBased) },
|
||||
// `tron` was missing here already (found while adding `solana`) - without it, exporting a
|
||||
// Tron account's private key from this call site would have silently returned the wrong
|
||||
// (Ethereum) keypair instead of the Tron-specific one. Fixed alongside, not a separate change.
|
||||
left = {
|
||||
mapMetaAccountSecretsToKeypair(
|
||||
it,
|
||||
ethereum = chain.isEthereumBased,
|
||||
tron = chain.isTronBased,
|
||||
bitcoin = chain.isBitcoinBased,
|
||||
solana = chain.isSolanaBased,
|
||||
)
|
||||
},
|
||||
right = { mapChainAccountSecretsToKeypair(it) }
|
||||
)
|
||||
}
|
||||
|
||||
+8
@@ -56,6 +56,10 @@ interface LightMetaAccount {
|
||||
/** Bitcoin account id (HASH160 of the compressed pubkey - see [io.novafoundation.nova.common.utils.hash160]). */
|
||||
val bitcoinAddress: ByteArray?
|
||||
val bitcoinPublicKey: ByteArray?
|
||||
|
||||
/** Solana account id, which IS the raw Ed25519 public key itself - see SolanaAddress.kt's doc. */
|
||||
val solanaAddress: ByteArray?
|
||||
val solanaPublicKey: ByteArray?
|
||||
val isSelected: Boolean
|
||||
val name: String
|
||||
val type: Type
|
||||
@@ -96,6 +100,8 @@ fun LightMetaAccount(
|
||||
tronPublicKey: ByteArray? = null,
|
||||
bitcoinAddress: ByteArray? = null,
|
||||
bitcoinPublicKey: ByteArray? = null,
|
||||
solanaAddress: ByteArray? = null,
|
||||
solanaPublicKey: ByteArray? = null,
|
||||
) = object : LightMetaAccount {
|
||||
override val id: Long = id
|
||||
override val globallyUniqueId: String = globallyUniqueId
|
||||
@@ -108,6 +114,8 @@ fun LightMetaAccount(
|
||||
override val tronPublicKey: ByteArray? = tronPublicKey
|
||||
override val bitcoinAddress: ByteArray? = bitcoinAddress
|
||||
override val bitcoinPublicKey: ByteArray? = bitcoinPublicKey
|
||||
override val solanaAddress: ByteArray? = solanaAddress
|
||||
override val solanaPublicKey: ByteArray? = solanaPublicKey
|
||||
override val isSelected: Boolean = isSelected
|
||||
override val name: String = name
|
||||
override val type: LightMetaAccount.Type = type
|
||||
|
||||
+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.publicKey
|
||||
import io.novafoundation.nova.common.data.secrets.v2.seed
|
||||
import io.novafoundation.nova.common.data.secrets.v2.solanaDerivationPath
|
||||
import io.novafoundation.nova.common.data.secrets.v2.solanaKeypair
|
||||
import io.novafoundation.nova.common.data.secrets.v2.substrateDerivationPath
|
||||
import io.novafoundation.nova.common.data.secrets.v2.substrateKeypair
|
||||
import io.novafoundation.nova.common.data.secrets.v2.tronDerivationPath
|
||||
@@ -96,6 +98,7 @@ class RealLocalAccountsCloudBackupFacade(
|
||||
chainAccounts = emptyList(),
|
||||
bitcoin = baseSecrets.getBitcoinBackupSecrets(),
|
||||
tron = baseSecrets.getTronBackupSecrets(),
|
||||
solana = baseSecrets.getSolanaBackupSecrets(),
|
||||
)
|
||||
|
||||
return CloudBackup(
|
||||
@@ -365,6 +368,7 @@ class RealLocalAccountsCloudBackupFacade(
|
||||
chainAccounts = chainAccountsFromChainSecrets + chainAccountFromAdditionalSecrets,
|
||||
bitcoin = baseSecrets.getBitcoinBackupSecrets(),
|
||||
tron = baseSecrets.getTronBackupSecrets(),
|
||||
solana = baseSecrets.getSolanaBackupSecrets(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -478,7 +482,9 @@ class RealLocalAccountsCloudBackupFacade(
|
||||
bitcoinKeypair = bitcoin?.keypair?.toLocalKeyPair(),
|
||||
bitcoinDerivationPath = bitcoin?.derivationPath,
|
||||
tronKeypair = tron?.keypair?.toLocalKeyPair(),
|
||||
tronDerivationPath = tron?.derivationPath
|
||||
tronDerivationPath = tron?.derivationPath,
|
||||
solanaKeypair = solana?.keypair?.toLocalKeyPair(),
|
||||
solanaDerivationPath = solana?.derivationPath
|
||||
)
|
||||
}
|
||||
|
||||
@@ -509,6 +515,15 @@ class RealLocalAccountsCloudBackupFacade(
|
||||
)
|
||||
}
|
||||
|
||||
private fun EncodableStruct<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? {
|
||||
if (this == null) return null
|
||||
|
||||
@@ -555,6 +570,8 @@ class RealLocalAccountsCloudBackupFacade(
|
||||
bitcoinPublicKey = metaAccount.bitcoinPublicKey,
|
||||
tronAddress = metaAccount.tronAddress,
|
||||
tronPublicKey = metaAccount.tronPublicKey,
|
||||
solanaAddress = metaAccount.solanaAddress,
|
||||
solanaPublicKey = metaAccount.solanaPublicKey,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -581,6 +598,8 @@ class RealLocalAccountsCloudBackupFacade(
|
||||
bitcoinPublicKey = bitcoinPublicKey,
|
||||
tronAddress = tronAddress,
|
||||
tronPublicKey = tronPublicKey,
|
||||
solanaAddress = solanaAddress,
|
||||
solanaPublicKey = solanaPublicKey,
|
||||
).also {
|
||||
if (localIdOverwrite != null) {
|
||||
it.id = localIdOverwrite
|
||||
|
||||
+14
@@ -67,6 +67,8 @@ class AccountMappers(
|
||||
tronPublicKey = tronPublicKey,
|
||||
bitcoinAddress = bitcoinAddress,
|
||||
bitcoinPublicKey = bitcoinPublicKey,
|
||||
solanaAddress = solanaAddress,
|
||||
solanaPublicKey = solanaPublicKey,
|
||||
isSelected = isSelected,
|
||||
name = name,
|
||||
status = mapMetaAccountStateFromLocal(status),
|
||||
@@ -86,6 +88,8 @@ class AccountMappers(
|
||||
tronPublicKey = tronPublicKey,
|
||||
bitcoinAddress = bitcoinAddress,
|
||||
bitcoinPublicKey = bitcoinPublicKey,
|
||||
solanaAddress = solanaAddress,
|
||||
solanaPublicKey = solanaPublicKey,
|
||||
isSelected = isSelected,
|
||||
name = name,
|
||||
type = type,
|
||||
@@ -107,6 +111,8 @@ class AccountMappers(
|
||||
tronPublicKey = tronPublicKey,
|
||||
bitcoinAddress = bitcoinAddress,
|
||||
bitcoinPublicKey = bitcoinPublicKey,
|
||||
solanaAddress = solanaAddress,
|
||||
solanaPublicKey = solanaPublicKey,
|
||||
isSelected = isSelected,
|
||||
name = name,
|
||||
type = type,
|
||||
@@ -127,6 +133,8 @@ class AccountMappers(
|
||||
tronPublicKey = tronPublicKey,
|
||||
bitcoinAddress = bitcoinAddress,
|
||||
bitcoinPublicKey = bitcoinPublicKey,
|
||||
solanaAddress = solanaAddress,
|
||||
solanaPublicKey = solanaPublicKey,
|
||||
isSelected = isSelected,
|
||||
name = name,
|
||||
type = type,
|
||||
@@ -148,6 +156,8 @@ class AccountMappers(
|
||||
tronPublicKey = tronPublicKey,
|
||||
bitcoinAddress = bitcoinAddress,
|
||||
bitcoinPublicKey = bitcoinPublicKey,
|
||||
solanaAddress = solanaAddress,
|
||||
solanaPublicKey = solanaPublicKey,
|
||||
isSelected = isSelected,
|
||||
name = name,
|
||||
type = type,
|
||||
@@ -177,6 +187,8 @@ class AccountMappers(
|
||||
tronPublicKey = tronPublicKey,
|
||||
bitcoinAddress = bitcoinAddress,
|
||||
bitcoinPublicKey = bitcoinPublicKey,
|
||||
solanaAddress = solanaAddress,
|
||||
solanaPublicKey = solanaPublicKey,
|
||||
isSelected = isSelected,
|
||||
name = name,
|
||||
status = mapMetaAccountStateFromLocal(status),
|
||||
@@ -197,6 +209,8 @@ class AccountMappers(
|
||||
tronPublicKey = tronPublicKey,
|
||||
bitcoinAddress = bitcoinAddress,
|
||||
bitcoinPublicKey = bitcoinPublicKey,
|
||||
solanaAddress = solanaAddress,
|
||||
solanaPublicKey = solanaPublicKey,
|
||||
chainAccounts = chainAccounts,
|
||||
isSelected = isSelected,
|
||||
name = name,
|
||||
|
||||
+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.repository.datasource.migration.AccountDataMigration
|
||||
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.BitcoinAddressBackfillMigration
|
||||
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.SolanaAddressBackfillMigration
|
||||
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.TronAddressBackfillMigration
|
||||
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.model.ChainAccountInsertionData
|
||||
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.model.MetaAccountInsertionData
|
||||
@@ -68,11 +69,12 @@ class AccountDataSourceImpl(
|
||||
accountDataMigration: AccountDataMigration,
|
||||
tronAddressBackfillMigration: TronAddressBackfillMigration,
|
||||
bitcoinAddressBackfillMigration: BitcoinAddressBackfillMigration,
|
||||
solanaAddressBackfillMigration: SolanaAddressBackfillMigration,
|
||||
) : AccountDataSource, SecretStoreV1 by secretStoreV1 {
|
||||
|
||||
init {
|
||||
// Run sequentially in one coroutine, not as independent launches - the Tron/Bitcoin backfills read
|
||||
// accounts/secrets that the legacy migration may still be in the middle of writing for very old
|
||||
// Run sequentially in one coroutine, not as independent launches - the Tron/Bitcoin/Solana backfills
|
||||
// read accounts/secrets that the legacy migration may still be in the middle of writing for very old
|
||||
// (pre-MetaAccount) installs, and separate GlobalScope.launch calls give no ordering guarantee
|
||||
// relative to each other.
|
||||
async {
|
||||
@@ -90,6 +92,10 @@ class AccountDataSourceImpl(
|
||||
|
||||
bitcoinAddressBackfillMigration.migrate()
|
||||
|
||||
Log.d("AccountDataSourceImpl", "about to run solanaAddressBackfillMigration")
|
||||
|
||||
solanaAddressBackfillMigration.migrate()
|
||||
|
||||
Log.d("AccountDataSourceImpl", "migrations block done")
|
||||
|
||||
metaAccountDao.getMetaAccounts().forEach {
|
||||
|
||||
+5
-1
@@ -33,6 +33,7 @@ class RealSecretsMetaAccountLocalFactory : SecretsMetaAccountLocalFactory {
|
||||
val ethereumPublicKey = secrets[MetaAccountSecrets.EthereumKeypair]?.get(KeyPairSchema.PublicKey)
|
||||
val tronPublicKey = secrets[MetaAccountSecrets.TronKeypair]?.get(KeyPairSchema.PublicKey)
|
||||
val bitcoinPublicKey = secrets[MetaAccountSecrets.BitcoinKeypair]?.get(KeyPairSchema.PublicKey)
|
||||
val solanaPublicKey = secrets[MetaAccountSecrets.SolanaKeypair]?.get(KeyPairSchema.PublicKey)
|
||||
|
||||
return MetaAccountLocal(
|
||||
substratePublicKey = substratePublicKey,
|
||||
@@ -55,7 +56,10 @@ class RealSecretsMetaAccountLocalFactory : SecretsMetaAccountLocalFactory {
|
||||
// substrate-sdk-android's own source: ECDSAUtils.derivePublicKey -> compressedPublicKeyFromPrivate),
|
||||
// which is exactly the format both BIP143 (P2WPKH) and bitcoinPublicKeyToAccountId() require - no
|
||||
// extra compression/decompression step needed here, unlike Ethereum's uncompressed-key derivation.
|
||||
bitcoinAddress = bitcoinPublicKey?.bitcoinPublicKeyToAccountId()
|
||||
bitcoinAddress = bitcoinPublicKey?.bitcoinPublicKeyToAccountId(),
|
||||
// Solana's accountId IS the public key itself, no hash/transform step - see SolanaAddress.kt's doc.
|
||||
solanaPublicKey = solanaPublicKey,
|
||||
solanaAddress = solanaPublicKey
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+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.mapKeypairStructToKeypair
|
||||
import io.novafoundation.nova.common.data.secrets.v2.seed
|
||||
import io.novafoundation.nova.common.data.secrets.v2.solanaDerivationPath
|
||||
import io.novafoundation.nova.common.data.secrets.v2.solanaKeypair
|
||||
import io.novafoundation.nova.common.data.secrets.v2.substrateDerivationPath
|
||||
import io.novafoundation.nova.common.data.secrets.v2.substrateKeypair
|
||||
import io.novafoundation.nova.common.data.secrets.v2.tronDerivationPath
|
||||
@@ -106,7 +108,11 @@ class BitcoinAddressBackfillMigration(
|
||||
tronKeypair = secrets.tronKeypair?.let(::mapKeypairStructToKeypair),
|
||||
tronDerivationPath = secrets.tronDerivationPath,
|
||||
bitcoinKeypair = bitcoinKeypair,
|
||||
bitcoinDerivationPath = BITCOIN_DEFAULT_DERIVATION_PATH
|
||||
bitcoinDerivationPath = BITCOIN_DEFAULT_DERIVATION_PATH,
|
||||
// Must carry this forward unchanged - dropping it would silently wipe an already-backfilled
|
||||
// Solana keypair, since this migration re-runs unconditionally on every app start (see class doc).
|
||||
solanaKeypair = secrets.solanaKeypair?.let(::mapKeypairStructToKeypair),
|
||||
solanaDerivationPath = secrets.solanaDerivationPath,
|
||||
)
|
||||
|
||||
secretStoreV2.putMetaAccountSecrets(account.id, updatedSecrets)
|
||||
|
||||
+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.MetaAccountSecrets
|
||||
import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2
|
||||
import io.novafoundation.nova.common.data.secrets.v2.bitcoinDerivationPath
|
||||
import io.novafoundation.nova.common.data.secrets.v2.bitcoinKeypair
|
||||
import io.novafoundation.nova.common.data.secrets.v2.entropy
|
||||
import io.novafoundation.nova.common.data.secrets.v2.ethereumDerivationPath
|
||||
import io.novafoundation.nova.common.data.secrets.v2.ethereumKeypair
|
||||
import io.novafoundation.nova.common.data.secrets.v2.mapKeypairStructToKeypair
|
||||
import io.novafoundation.nova.common.data.secrets.v2.seed
|
||||
import io.novafoundation.nova.common.data.secrets.v2.solanaDerivationPath
|
||||
import io.novafoundation.nova.common.data.secrets.v2.solanaKeypair
|
||||
import io.novafoundation.nova.common.data.secrets.v2.substrateDerivationPath
|
||||
import io.novafoundation.nova.common.data.secrets.v2.substrateKeypair
|
||||
import io.novafoundation.nova.common.data.secrets.v2.tronKeypair
|
||||
@@ -115,7 +119,14 @@ class TronAddressBackfillMigration(
|
||||
ethereumKeypair = secrets.ethereumKeypair?.let(::mapKeypairStructToKeypair),
|
||||
ethereumDerivationPath = secrets.ethereumDerivationPath,
|
||||
tronKeypair = tronKeypair,
|
||||
tronDerivationPath = TRON_DEFAULT_DERIVATION_PATH
|
||||
tronDerivationPath = TRON_DEFAULT_DERIVATION_PATH,
|
||||
// Must carry these forward unchanged - dropping them would silently wipe an
|
||||
// already-backfilled sibling keypair, since this migration re-runs unconditionally
|
||||
// on every app start (see class doc).
|
||||
bitcoinKeypair = secrets.bitcoinKeypair?.let(::mapKeypairStructToKeypair),
|
||||
bitcoinDerivationPath = secrets.bitcoinDerivationPath,
|
||||
solanaKeypair = secrets.solanaKeypair?.let(::mapKeypairStructToKeypair),
|
||||
solanaDerivationPath = secrets.solanaDerivationPath,
|
||||
)
|
||||
|
||||
secretStoreV2.putMetaAccountSecrets(account.id, updatedSecrets)
|
||||
|
||||
+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.MetaAccountSecrets
|
||||
import io.novafoundation.nova.common.data.secrets.v2.mapKeypairStructToKeypair
|
||||
import io.novafoundation.nova.common.utils.Bip32Ed25519KeypairFactory
|
||||
import io.novafoundation.nova.common.utils.castOrNull
|
||||
import io.novafoundation.nova.common.utils.deriveSeed32
|
||||
import io.novafoundation.nova.core.model.CryptoType
|
||||
@@ -42,6 +43,24 @@ const val TRON_DEFAULT_DERIVATION_PATH = "//44//195//0/0/0"
|
||||
*/
|
||||
const val BITCOIN_DEFAULT_DERIVATION_PATH = "//84//0//0/0/0"
|
||||
|
||||
/**
|
||||
* SLIP-44 coin type 501 is Solana's registered BIP44 coin type. Path is m/44'/501'/0'/0' (all
|
||||
* hardened, SLIP-0010 Ed25519) - the Phantom/Solflare/Solana CLI default, NOT the older
|
||||
* Sollet-style m/44'/501'/0' (no final change level). Not user-configurable via the "Advanced
|
||||
* Encryption" UI yet, same as Tron/Bitcoin - always derived at this fixed path.
|
||||
*
|
||||
* This is deliberately a plain "44/501/0/0" segment list, not the junction-string format
|
||||
* ("//44//501//0/0") Tron/Bitcoin use - unlike those (which reuse [Bip32EcdsaKeypairFactory]/
|
||||
* [DerivationPathDecoder]'s junction decoding, built for secp256k1), Solana's Ed25519 derivation
|
||||
* has no such decoder in this codebase and is handled directly by [Bip32Ed25519KeypairFactory].
|
||||
*/
|
||||
val SOLANA_DEFAULT_DERIVATION_PATH_SEGMENTS = listOf(44, 501, 0, 0)
|
||||
|
||||
/** Human-readable form of [SOLANA_DEFAULT_DERIVATION_PATH_SEGMENTS], stored/displayed the same way
|
||||
* [TRON_DEFAULT_DERIVATION_PATH]/[BITCOIN_DEFAULT_DERIVATION_PATH] are - not itself parsed back
|
||||
* into segments anywhere, purely for display/backup/consistency with the other two. */
|
||||
const val SOLANA_DEFAULT_DERIVATION_PATH = "m/44'/501'/0'/0'"
|
||||
|
||||
class AccountSecretsFactory(
|
||||
private val JsonDecoder: JsonDecoder
|
||||
) {
|
||||
@@ -140,6 +159,7 @@ class AccountSecretsFactory(
|
||||
accountSource: AccountSource,
|
||||
tronDerivationPath: String? = TRON_DEFAULT_DERIVATION_PATH,
|
||||
bitcoinDerivationPath: String? = BITCOIN_DEFAULT_DERIVATION_PATH,
|
||||
solanaDerivationPath: String? = SOLANA_DEFAULT_DERIVATION_PATH,
|
||||
): Result<MetaAccountSecrets> = withContext(Dispatchers.Default) {
|
||||
val (substrateSecrets, substrateCryptoType) = chainAccountSecrets(
|
||||
derivationPath = substrateDerivationPath,
|
||||
@@ -176,6 +196,19 @@ class AccountSecretsFactory(
|
||||
Bip32EcdsaKeypairFactory.generate(seed = seed, junctions = decodedBitcoinDerivationPath?.junctions.orEmpty())
|
||||
}
|
||||
|
||||
// Solana's Ed25519 SLIP-0010 derivation is a fundamentally different scheme from
|
||||
// Tron/Bitcoin's secp256k1/BIP32 (see Bip32Ed25519KeypairFactory's doc) - it still starts
|
||||
// from the exact same standard BIP39 seed (seed generation itself is chain-agnostic), just
|
||||
// via fixed segments rather than junction-string decoding (no ed25519 path decoder exists
|
||||
// in this codebase, so this doesn't reuse decodeDerivationPath/DerivationPathDecoder).
|
||||
val solanaKeypair = accountSource.castOrNull<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(
|
||||
entropy = substrateSecrets[ChainAccountSecrets.Entropy],
|
||||
substrateSeed = substrateSecrets[ChainAccountSecrets.Seed],
|
||||
@@ -187,6 +220,8 @@ class AccountSecretsFactory(
|
||||
tronDerivationPath = tronDerivationPath,
|
||||
bitcoinKeypair = bitcoinKeypair,
|
||||
bitcoinDerivationPath = bitcoinDerivationPath,
|
||||
solanaKeypair = solanaKeypair,
|
||||
solanaDerivationPath = solanaDerivationPath,
|
||||
)
|
||||
|
||||
Result(secrets = secrets, cryptoType = substrateCryptoType)
|
||||
|
||||
+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.seed
|
||||
import io.novafoundation.nova.common.di.scope.FeatureScope
|
||||
import io.novafoundation.nova.core.model.CryptoType
|
||||
import io.novafoundation.nova.common.sequrity.TwoFactorVerificationResult
|
||||
import io.novafoundation.nova.common.sequrity.TwoFactorVerificationService
|
||||
import io.novafoundation.nova.feature_account_api.data.signer.SigningContext
|
||||
@@ -144,13 +145,15 @@ class SecretsSigner(
|
||||
val multiChainEncryption = metaAccount.multiChainEncryptionFor(accountId, chainsById)!!
|
||||
val isTronBased = metaAccount.tronAddress?.contentEquals(accountId) == true
|
||||
val isBitcoinBased = metaAccount.bitcoinAddress?.contentEquals(accountId) == true
|
||||
val isSolanaBased = metaAccount.solanaAddress?.contentEquals(accountId) == true
|
||||
|
||||
return secretStoreV2.getKeypair(
|
||||
metaAccount = metaAccount,
|
||||
accountId = accountId,
|
||||
isEthereumBased = multiChainEncryption is MultiChainEncryption.Ethereum,
|
||||
isTronBased = isTronBased,
|
||||
isBitcoinBased = isBitcoinBased
|
||||
isBitcoinBased = isBitcoinBased,
|
||||
isSolanaBased = isSolanaBased
|
||||
)
|
||||
}
|
||||
|
||||
@@ -166,10 +169,11 @@ class SecretsSigner(
|
||||
isEthereumBased: Boolean,
|
||||
isTronBased: Boolean = false,
|
||||
isBitcoinBased: Boolean = false,
|
||||
isSolanaBased: Boolean = false,
|
||||
) = if (hasChainSecrets(metaAccount.id, accountId)) {
|
||||
getChainAccountKeypair(metaAccount.id, accountId)
|
||||
} else {
|
||||
getMetaAccountKeypair(metaAccount.id, isEthereumBased, isTronBased, isBitcoinBased)
|
||||
getMetaAccountKeypair(metaAccount.id, isEthereumBased, isTronBased, isBitcoinBased, isSolanaBased)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -185,6 +189,10 @@ class SecretsSigner(
|
||||
// null -> NPE on `!!`.
|
||||
tronAddress?.contentEquals(accountId) == true -> MultiChainEncryption.Ethereum
|
||||
bitcoinAddress?.contentEquals(accountId) == true -> MultiChainEncryption.Ethereum
|
||||
// Solana is Ed25519, not secp256k1, so it doesn't join the Tron/Bitcoin group above -
|
||||
// it reuses the same MultiChainEncryption.Substrate(ED25519) path Substrate's own
|
||||
// ed25519 accounts use (see RealSecretsMetaAccount.multiChainEncryptionIn).
|
||||
solanaAddress?.contentEquals(accountId) == true -> MultiChainEncryption.substrateFrom(CryptoType.ED25519)
|
||||
else -> {
|
||||
val chainAccount = chainAccounts.values.firstOrNull { it.accountId.contentEquals(accountId) } ?: return null
|
||||
val cryptoType = chainAccount.cryptoType ?: return null
|
||||
|
||||
+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.migration.AccountDataMigration
|
||||
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.BitcoinAddressBackfillMigration
|
||||
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.SolanaAddressBackfillMigration
|
||||
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.TronAddressBackfillMigration
|
||||
import io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory
|
||||
import io.novafoundation.nova.feature_account_impl.data.signer.signingContext.SigningContextFactory
|
||||
@@ -410,6 +411,7 @@ class AccountFeatureModule {
|
||||
secretStoreV2: SecretStoreV2,
|
||||
accountMappers: AccountMappers,
|
||||
bitcoinAddressBackfillMigration: BitcoinAddressBackfillMigration,
|
||||
solanaAddressBackfillMigration: SolanaAddressBackfillMigration,
|
||||
): AccountDataSource {
|
||||
return AccountDataSourceImpl(
|
||||
preferences,
|
||||
@@ -422,7 +424,8 @@ class AccountFeatureModule {
|
||||
secretStoreV1,
|
||||
accountDataMigration,
|
||||
tronAddressBackfillMigration,
|
||||
bitcoinAddressBackfillMigration
|
||||
bitcoinAddressBackfillMigration,
|
||||
solanaAddressBackfillMigration
|
||||
)
|
||||
}
|
||||
|
||||
@@ -436,6 +439,15 @@ class AccountFeatureModule {
|
||||
return BitcoinAddressBackfillMigration(secretStoreV2, metaAccountDao, accountSecretsFactory)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@FeatureScope
|
||||
fun provideSolanaAddressBackfillMigration(
|
||||
secretStoreV2: SecretStoreV2,
|
||||
metaAccountDao: MetaAccountDao,
|
||||
): SolanaAddressBackfillMigration {
|
||||
return SolanaAddressBackfillMigration(secretStoreV2, metaAccountDao)
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun provideNodeHostValidator() = NodeHostValidator()
|
||||
|
||||
|
||||
+5
@@ -26,6 +26,8 @@ open class DefaultMetaAccount(
|
||||
override val tronPublicKey: ByteArray? = null,
|
||||
override val bitcoinAddress: ByteArray? = null,
|
||||
override val bitcoinPublicKey: ByteArray? = null,
|
||||
override val solanaAddress: ByteArray? = null,
|
||||
override val solanaPublicKey: ByteArray? = null,
|
||||
) : MetaAccount {
|
||||
|
||||
override suspend fun supportsAddingChainAccount(chain: Chain): Boolean {
|
||||
@@ -37,6 +39,7 @@ open class DefaultMetaAccount(
|
||||
hasChainAccountIn(chain.id) -> true
|
||||
chain.isTronBased -> tronAddress != null
|
||||
chain.isBitcoinBased -> bitcoinAddress != null
|
||||
chain.isSolanaBased -> solanaAddress != null
|
||||
chain.isEthereumBased -> ethereumAddress != null
|
||||
else -> substrateAccountId != null
|
||||
}
|
||||
@@ -47,6 +50,7 @@ open class DefaultMetaAccount(
|
||||
hasChainAccountIn(chain.id) -> chainAccounts.getValue(chain.id).accountId
|
||||
chain.isTronBased -> tronAddress
|
||||
chain.isBitcoinBased -> bitcoinAddress
|
||||
chain.isSolanaBased -> solanaAddress
|
||||
chain.isEthereumBased -> ethereumAddress
|
||||
else -> substrateAccountId
|
||||
}
|
||||
@@ -57,6 +61,7 @@ open class DefaultMetaAccount(
|
||||
hasChainAccountIn(chain.id) -> chainAccounts.getValue(chain.id).publicKey
|
||||
chain.isTronBased -> tronPublicKey
|
||||
chain.isBitcoinBased -> bitcoinPublicKey
|
||||
chain.isSolanaBased -> solanaPublicKey
|
||||
chain.isEthereumBased -> ethereumPublicKey
|
||||
else -> substratePublicKey
|
||||
}
|
||||
|
||||
+5
-1
@@ -26,6 +26,8 @@ class GenericLedgerMetaAccount(
|
||||
tronPublicKey: ByteArray? = null,
|
||||
bitcoinAddress: ByteArray? = null,
|
||||
bitcoinPublicKey: ByteArray? = null,
|
||||
solanaAddress: ByteArray? = null,
|
||||
solanaPublicKey: ByteArray? = null,
|
||||
) : DefaultMetaAccount(
|
||||
id = id,
|
||||
globallyUniqueId = globallyUniqueId,
|
||||
@@ -43,7 +45,9 @@ class GenericLedgerMetaAccount(
|
||||
tronAddress = tronAddress,
|
||||
tronPublicKey = tronPublicKey,
|
||||
bitcoinAddress = bitcoinAddress,
|
||||
bitcoinPublicKey = bitcoinPublicKey
|
||||
bitcoinPublicKey = bitcoinPublicKey,
|
||||
solanaAddress = solanaAddress,
|
||||
solanaPublicKey = solanaPublicKey
|
||||
) {
|
||||
|
||||
override suspend fun supportsAddingChainAccount(chain: Chain): Boolean {
|
||||
|
||||
+5
-1
@@ -26,6 +26,8 @@ class LegacyLedgerMetaAccount(
|
||||
tronPublicKey: ByteArray? = null,
|
||||
bitcoinAddress: ByteArray? = null,
|
||||
bitcoinPublicKey: ByteArray? = null,
|
||||
solanaAddress: ByteArray? = null,
|
||||
solanaPublicKey: ByteArray? = null,
|
||||
) : DefaultMetaAccount(
|
||||
id = id,
|
||||
globallyUniqueId = globallyUniqueId,
|
||||
@@ -43,7 +45,9 @@ class LegacyLedgerMetaAccount(
|
||||
tronAddress = tronAddress,
|
||||
tronPublicKey = tronPublicKey,
|
||||
bitcoinAddress = bitcoinAddress,
|
||||
bitcoinPublicKey = bitcoinPublicKey
|
||||
bitcoinPublicKey = bitcoinPublicKey,
|
||||
solanaAddress = solanaAddress,
|
||||
solanaPublicKey = solanaPublicKey
|
||||
) {
|
||||
|
||||
override suspend fun supportsAddingChainAccount(chain: Chain): Boolean {
|
||||
|
||||
+5
-1
@@ -32,6 +32,8 @@ class RealMultisigMetaAccount(
|
||||
tronPublicKey: ByteArray? = null,
|
||||
bitcoinAddress: ByteArray? = null,
|
||||
bitcoinPublicKey: ByteArray? = null,
|
||||
solanaAddress: ByteArray? = null,
|
||||
solanaPublicKey: ByteArray? = null,
|
||||
) : DefaultMetaAccount(
|
||||
id = id,
|
||||
globallyUniqueId = globallyUniqueId,
|
||||
@@ -49,7 +51,9 @@ class RealMultisigMetaAccount(
|
||||
tronAddress = tronAddress,
|
||||
tronPublicKey = tronPublicKey,
|
||||
bitcoinAddress = bitcoinAddress,
|
||||
bitcoinPublicKey = bitcoinPublicKey
|
||||
bitcoinPublicKey = bitcoinPublicKey,
|
||||
solanaAddress = solanaAddress,
|
||||
solanaPublicKey = solanaPublicKey
|
||||
),
|
||||
MultisigMetaAccount {
|
||||
|
||||
|
||||
+5
-1
@@ -24,6 +24,8 @@ class PolkadotVaultMetaAccount(
|
||||
tronPublicKey: ByteArray? = null,
|
||||
bitcoinAddress: ByteArray? = null,
|
||||
bitcoinPublicKey: ByteArray? = null,
|
||||
solanaAddress: ByteArray? = null,
|
||||
solanaPublicKey: ByteArray? = null,
|
||||
) : DefaultMetaAccount(
|
||||
id = id,
|
||||
globallyUniqueId = globallyUniqueId,
|
||||
@@ -41,7 +43,9 @@ class PolkadotVaultMetaAccount(
|
||||
tronAddress = tronAddress,
|
||||
tronPublicKey = tronPublicKey,
|
||||
bitcoinAddress = bitcoinAddress,
|
||||
bitcoinPublicKey = bitcoinPublicKey
|
||||
bitcoinPublicKey = bitcoinPublicKey,
|
||||
solanaAddress = solanaAddress,
|
||||
solanaPublicKey = solanaPublicKey
|
||||
) {
|
||||
|
||||
override suspend fun supportsAddingChainAccount(chain: Chain): Boolean {
|
||||
|
||||
+5
-1
@@ -26,6 +26,8 @@ internal class RealProxiedMetaAccount(
|
||||
tronPublicKey: ByteArray? = null,
|
||||
bitcoinAddress: ByteArray? = null,
|
||||
bitcoinPublicKey: ByteArray? = null,
|
||||
solanaAddress: ByteArray? = null,
|
||||
solanaPublicKey: ByteArray? = null,
|
||||
) : DefaultMetaAccount(
|
||||
id = id,
|
||||
globallyUniqueId = globallyUniqueId,
|
||||
@@ -43,7 +45,9 @@ internal class RealProxiedMetaAccount(
|
||||
tronAddress = tronAddress,
|
||||
tronPublicKey = tronPublicKey,
|
||||
bitcoinAddress = bitcoinAddress,
|
||||
bitcoinPublicKey = bitcoinPublicKey
|
||||
bitcoinPublicKey = bitcoinPublicKey,
|
||||
solanaAddress = solanaAddress,
|
||||
solanaPublicKey = solanaPublicKey
|
||||
),
|
||||
ProxiedMetaAccount {
|
||||
|
||||
|
||||
+15
-2
@@ -27,6 +27,8 @@ class RealSecretsMetaAccount(
|
||||
tronPublicKey: ByteArray? = null,
|
||||
bitcoinAddress: ByteArray? = null,
|
||||
bitcoinPublicKey: ByteArray? = null,
|
||||
solanaAddress: ByteArray? = null,
|
||||
solanaPublicKey: ByteArray? = null,
|
||||
) : DefaultMetaAccount(
|
||||
id = id,
|
||||
globallyUniqueId = globallyUniqueId,
|
||||
@@ -44,7 +46,9 @@ class RealSecretsMetaAccount(
|
||||
tronAddress = tronAddress,
|
||||
tronPublicKey = tronPublicKey,
|
||||
bitcoinAddress = bitcoinAddress,
|
||||
bitcoinPublicKey = bitcoinPublicKey
|
||||
bitcoinPublicKey = bitcoinPublicKey,
|
||||
solanaAddress = solanaAddress,
|
||||
solanaPublicKey = solanaPublicKey
|
||||
),
|
||||
SecretsMetaAccount {
|
||||
|
||||
@@ -55,9 +59,16 @@ class RealSecretsMetaAccount(
|
||||
|
||||
// Tron and Bitcoin both reuse the same secp256k1 keypair/signing as Ethereum - see
|
||||
// RealTronTransactionService/RealBitcoinTransactionService's use of
|
||||
// Signer.sign(MultiChainEncryption.Ethereum, ...).
|
||||
// Signer.sign(MultiChainEncryption.Ethereum, ...). Solana is Ed25519, not
|
||||
// secp256k1, so it can't join that group - it reuses MultiChainEncryption.Substrate
|
||||
// (EncryptionType.ED25519) instead, the exact same code path Substrate's own
|
||||
// ed25519 accounts already use (Signer.signEd25519 doesn't care which chain family
|
||||
// the keypair belongs to, only that it's a raw Ed25519 keypair - confirmed by
|
||||
// reading substrate-sdk-android's Signer.kt directly).
|
||||
if (chain.isEthereumBased || chain.isTronBased || chain.isBitcoinBased) {
|
||||
MultiChainEncryption.Ethereum
|
||||
} else if (chain.isSolanaBased) {
|
||||
MultiChainEncryption.substrateFrom(CryptoType.ED25519)
|
||||
} else {
|
||||
MultiChainEncryption.substrateFrom(cryptoType)
|
||||
}
|
||||
@@ -69,6 +80,8 @@ class RealSecretsMetaAccount(
|
||||
|
||||
chain.isBitcoinBased -> MultiChainEncryption.Ethereum
|
||||
|
||||
chain.isSolanaBased -> MultiChainEncryption.substrateFrom(CryptoType.ED25519)
|
||||
|
||||
else -> substrateCryptoType?.let(MultiChainEncryption.Companion::substrateFrom)
|
||||
}
|
||||
}
|
||||
|
||||
+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 {
|
||||
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,
|
||||
isTronBased = false,
|
||||
isBitcoinBased = false,
|
||||
isSolanaBased = false,
|
||||
isTestNet = prefilledChain?.isTestNet.orFalse(),
|
||||
source = Chain.Source.CUSTOM,
|
||||
hasSubstrateRuntime = hasSubstrateRuntime,
|
||||
|
||||
@@ -281,6 +281,7 @@ fun Chain.addressOf(accountId: ByteArray): String {
|
||||
return when {
|
||||
isTronBased -> accountId.toTronAddress()
|
||||
isBitcoinBased -> accountId.toBitcoinAddress()
|
||||
isSolanaBased -> accountId.toSolanaAddress()
|
||||
isEthereumBased -> accountId.toEthereumAddress()
|
||||
else -> accountId.toAddress(addressPrefix.toShort())
|
||||
}
|
||||
@@ -291,7 +292,7 @@ fun Chain.addressOf(accountId: AccountIdKey): String {
|
||||
}
|
||||
|
||||
fun Chain.legacyAddressOfOrNull(accountId: ByteArray): String? {
|
||||
return if (isEthereumBased || isTronBased || isBitcoinBased) {
|
||||
return if (isEthereumBased || isTronBased || isBitcoinBased || isSolanaBased) {
|
||||
null
|
||||
} else {
|
||||
legacyAddressPrefix?.let { accountId.toAddress(it.toShort()) }
|
||||
@@ -311,6 +312,7 @@ fun Chain.accountIdOf(address: String): ByteArray {
|
||||
// opaque purposes (identicon generation, presence checks) elsewhere, never fed back into building a
|
||||
// scriptPubKey - see BitcoinDestinationAddress.kt's [hash] doc.
|
||||
isBitcoinBased -> runCatching { address.bitcoinAddressToAccountId() }.getOrElse { address.decodeBitcoinDestination().hash }
|
||||
isSolanaBased -> address.solanaAddressToAccountId()
|
||||
isEthereumBased -> address.asEthereumAddress().toAccountId().value
|
||||
else -> address.toAccountId()
|
||||
}
|
||||
@@ -345,6 +347,7 @@ fun Chain.accountIdOrNull(address: String): ByteArray? {
|
||||
fun Chain.emptyAccountId() = when {
|
||||
isTronBased -> emptyTronAccountId()
|
||||
isBitcoinBased -> emptyBitcoinAccountId()
|
||||
isSolanaBased -> emptySolanaAccountId()
|
||||
isEthereumBased -> emptyEthereumAccountId()
|
||||
else -> emptySubstrateAccountId()
|
||||
}
|
||||
@@ -359,6 +362,9 @@ fun Chain.accountIdOf(publicKey: ByteArray): ByteArray {
|
||||
return when {
|
||||
isTronBased -> publicKey.tronPublicKeyToAccountId()
|
||||
isBitcoinBased -> publicKey.bitcoinPublicKeyToAccountId()
|
||||
// Solana's account id IS the raw public key itself - no separate hash-based derivation
|
||||
// the way Ethereum/Tron/Bitcoin have (see SolanaAddress.kt's file-level doc).
|
||||
isSolanaBased -> publicKey
|
||||
isEthereumBased -> publicKey.asEthereumPublicKey().toAccountId().value
|
||||
else -> publicKey.substrateAccountId()
|
||||
}
|
||||
@@ -388,6 +394,8 @@ fun Chain.isValidAddress(address: String): Boolean {
|
||||
// branches below would ever accept them, so this needs its own dedicated check.
|
||||
isTronBased -> address.isValidTronAddress()
|
||||
|
||||
isSolanaBased -> address.isValidSolanaAddress()
|
||||
|
||||
isEthereumBased -> address.asEthereumAddress().isValid()
|
||||
|
||||
else -> {
|
||||
@@ -617,6 +625,19 @@ fun Chain.requireMempoolSpaceBaseUrl(): String {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Solana JSON-RPC base url for a Solana-based chain - same rationale as [requireTronGridBaseUrl]/
|
||||
* [requireMempoolSpaceBaseUrl]: Solana has its own JSON-RPC (getBalance/getLatestBlockhash/sendTransaction),
|
||||
* unrelated to the Substrate/EVM `nodes` WSS concept, so the configured `nodes` entry directly *is* this base url.
|
||||
*/
|
||||
fun Chain.requireSolanaRpcBaseUrl(): String {
|
||||
require(isSolanaBased) { "Chain $id is not Solana-based" }
|
||||
|
||||
return requireNotNull(nodes.nodes.minByOrNull { it.orderId }?.unformattedUrl) {
|
||||
"No Solana RPC node configured for chain $id"
|
||||
}
|
||||
}
|
||||
|
||||
fun Chain.Asset.requireEquilibrium(): Type.Equilibrium {
|
||||
require(type is Type.Equilibrium)
|
||||
|
||||
@@ -683,6 +704,7 @@ val Chain.Asset.onChainAssetId: String?
|
||||
is Type.Trc20 -> this.type.contractAddress
|
||||
Type.TronNative -> null
|
||||
Type.BitcoinNative -> null
|
||||
Type.SolanaNative -> null
|
||||
Type.Unsupported -> error("Unsupported assetId type: ${this.type::class.simpleName}")
|
||||
}
|
||||
|
||||
|
||||
@@ -249,7 +249,7 @@ class ChainRegistry(
|
||||
// ChainConnection's SocketService can only speak the latter, so attempting to set one up here would
|
||||
// hang indefinitely instead of failing fast. Balance/transfer operations for both go through their
|
||||
// own dedicated REST API clients, independent of this connection pool.
|
||||
if (chain.isTronBased || chain.isBitcoinBased) return null
|
||||
if (chain.isTronBased || chain.isBitcoinBased || chain.isSolanaBased) return null
|
||||
|
||||
val connection = connectionPool.setupConnection(chain)
|
||||
|
||||
|
||||
+2
@@ -14,6 +14,8 @@ const val ASSET_TRC20 = "trc20"
|
||||
|
||||
const val ASSET_BITCOIN_NATIVE = "bitcoinNative"
|
||||
|
||||
const val ASSET_SOLANA_NATIVE = "solanaNative"
|
||||
|
||||
const val ASSET_EQUILIBRIUM = "equilibrium"
|
||||
const val ASSET_EQUILIBRIUM_ON_CHAIN_ID = "assetId"
|
||||
|
||||
|
||||
+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.SolanaNative -> ASSET_SOLANA_NATIVE to null
|
||||
|
||||
Chain.Asset.Type.Unsupported -> ASSET_UNSUPPORTED to null
|
||||
}
|
||||
|
||||
@@ -131,6 +133,7 @@ fun mapChainToLocal(chain: Chain, gson: Gson): ChainLocal {
|
||||
isEthereumBased = chain.isEthereumBased,
|
||||
isTronBased = chain.isTronBased,
|
||||
isBitcoinBased = chain.isBitcoinBased,
|
||||
isSolanaBased = chain.isSolanaBased,
|
||||
isTestNet = chain.isTestNet,
|
||||
hasSubstrateRuntime = chain.hasSubstrateRuntime,
|
||||
pushSupport = chain.pushSupport,
|
||||
|
||||
+3
@@ -97,6 +97,8 @@ private fun mapChainAssetTypeFromRaw(type: String?, typeExtras: Map<String, Any?
|
||||
|
||||
ASSET_BITCOIN_NATIVE -> Chain.Asset.Type.BitcoinNative
|
||||
|
||||
ASSET_SOLANA_NATIVE -> Chain.Asset.Type.SolanaNative
|
||||
|
||||
else -> Chain.Asset.Type.Unsupported
|
||||
}
|
||||
}
|
||||
@@ -279,6 +281,7 @@ fun mapChainLocalToChain(
|
||||
isEthereumBased = isEthereumBased,
|
||||
isTronBased = isTronBased,
|
||||
isBitcoinBased = isBitcoinBased,
|
||||
isSolanaBased = isSolanaBased,
|
||||
isTestNet = isTestNet,
|
||||
hasCrowdloans = hasCrowdloans,
|
||||
pushSupport = pushSupport,
|
||||
|
||||
+2
@@ -22,6 +22,7 @@ import io.novafoundation.nova.runtime.multiNetwork.chain.remote.model.ChainRemot
|
||||
private const val ETHEREUM_OPTION = "ethereumBased"
|
||||
private const val TRON_OPTION = "tronBased"
|
||||
private const val BITCOIN_OPTION = "bitcoinBased"
|
||||
private const val SOLANA_OPTION = "solanaBased"
|
||||
private const val CROWDLOAN_OPTION = "crowdloans"
|
||||
private const val TESTNET_OPTION = "testnet"
|
||||
private const val PROXY_OPTION = "proxy"
|
||||
@@ -94,6 +95,7 @@ fun mapRemoteChainToLocal(
|
||||
isEthereumBased = ETHEREUM_OPTION in optionsOrEmpty,
|
||||
isTronBased = TRON_OPTION in optionsOrEmpty,
|
||||
isBitcoinBased = BITCOIN_OPTION in optionsOrEmpty,
|
||||
isSolanaBased = SOLANA_OPTION in optionsOrEmpty,
|
||||
isTestNet = TESTNET_OPTION in optionsOrEmpty,
|
||||
hasCrowdloans = CROWDLOAN_OPTION in optionsOrEmpty,
|
||||
supportProxy = PROXY_OPTION in optionsOrEmpty,
|
||||
|
||||
@@ -34,6 +34,7 @@ data class Chain(
|
||||
val isEthereumBased: Boolean,
|
||||
val isTronBased: Boolean,
|
||||
val isBitcoinBased: Boolean,
|
||||
val isSolanaBased: Boolean,
|
||||
val isTestNet: Boolean,
|
||||
val source: Source,
|
||||
val hasSubstrateRuntime: Boolean,
|
||||
@@ -145,6 +146,12 @@ data class Chain(
|
||||
*/
|
||||
object BitcoinNative : Type()
|
||||
|
||||
/**
|
||||
* Native SOL balance on a Solana-based chain.
|
||||
* Balance is fetched from Solana's own JSON-RPC (getBalance), not a Substrate/EVM one.
|
||||
*/
|
||||
object SolanaNative : Type()
|
||||
|
||||
object Unsupported : Type()
|
||||
}
|
||||
|
||||
|
||||
+5
@@ -32,6 +32,11 @@ class NodeHealthStateTesterFactory(
|
||||
httpClient = httpClient
|
||||
)
|
||||
|
||||
chain.isSolanaBased -> SolanaNodeHealthStateTester(
|
||||
node = node,
|
||||
httpClient = httpClient
|
||||
)
|
||||
|
||||
chain.hasSubstrateRuntime -> SubstrateNodeHealthStateTester(
|
||||
chain = chain,
|
||||
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