mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-android.git
synced 2026-07-22 17:15:48 +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:
@@ -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