Add native Solana support: address derivation, accounts, signing, backfill migration, CloudBackup wiring

Same read/send-capable chain-family pattern as Tron/Bitcoin, but for
Ed25519/SLIP-0010 instead of secp256k1/BIP32 (Solana's account id IS its
raw public key, so no separate address-hash derivation step is needed).

- SLIP-0010 Ed25519 keypair derivation (Bip32Ed25519KeypairFactory),
  cross-validated against an independent slip10 reference implementation
  and pinned to a known-good test vector.
- Chain.isSolanaBased / Chain.Asset.Type.SolanaNative wired through every
  chain-mapper, account-model, and signer dispatch site (mirrors the
  existing Tron/Bitcoin wiring exactly).
- SolanaAddressBackfillMigration derives Solana keys for wallets created
  before this support existed, same self-healing no-stuck-flag design as
  the Tron/Bitcoin backfills - also fixed a latent bug in those two
  migrations where MetaAccountSecrets/MetaAccountLocal.addXAccount()
  dropped sibling chain-family fields on a re-run.
- Room migration 75->76 adds isSolanaBased/solanaPublicKey/solanaAddress
  columns.
- CloudBackup's already-reserved solanaAddress/SolanaSecrets fields are
  now actually read/written by RealLocalAccountsCloudBackupFacade; also
  fixed isCompletelyEmpty() to check solana, so a Solana-only wallet
  doesn't get silently dropped from backup.
This commit is contained in:
2026-07-16 18:49:09 -07:00
parent 6fd7a56382
commit d35687f2ce
40 changed files with 861 additions and 24 deletions
@@ -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)
@@ -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"
@@ -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,
@@ -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,
@@ -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()
}
@@ -32,6 +32,11 @@ class NodeHealthStateTesterFactory(
httpClient = httpClient
)
chain.isSolanaBased -> SolanaNodeHealthStateTester(
node = node,
httpClient = httpClient
)
chain.hasSubstrateRuntime -> SubstrateNodeHealthStateTester(
chain = chain,
socketService = socketServiceProvider.get(),
@@ -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
}
}
}