Add native Solana balance and transfer support

- SolanaTransaction: hand-rolled compact-u16/legacy-Message/signed-tx
  wire-format primitives, cross-validated byte-for-byte against the
  solders Python library's own serialization of an identical System
  Program transfer message.
- SolanaApi: JSON-RPC client (getBalance/getLatestBlockhash/
  getFeeForMessage/sendTransaction) over a single POST endpoint, unlike
  Tron/Bitcoin's path-per-resource REST style.
- SolanaNativeAssetBalance: getBalance polling, same design as Bitcoin's
  mempool.space polling (no push/subscription mechanism available).
- RealSolanaTransactionService: builds a single-instruction transfer
  message, signs it raw via the existing Ed25519 signer dispatch
  (skipMessageHashing - Solana signs the message directly, no external
  hashing), broadcasts via sendTransaction. Fee is Solana's actual
  getFeeForMessage answer, not a client-side estimate.
- TransactionExecution.Solana(hash) variant; wired into AssetsModule via
  a new SolanaAssetsModule, following the Bitcoin/Tron precedent exactly.
This commit is contained in:
2026-07-16 18:49:29 -07:00
parent d35687f2ce
commit ecdb5ee44c
18 changed files with 1008 additions and 0 deletions
@@ -32,6 +32,7 @@ class TypeBasedAssetSourceRegistry(
private val tronNativeSource: Lazy<AssetSource>,
private val trc20Source: Lazy<AssetSource>,
private val bitcoinNativeSource: Lazy<AssetSource>,
private val solanaNativeSource: Lazy<AssetSource>,
private val unsupportedBalanceSource: AssetSource,
private val nativeAssetEventDetector: NativeAssetEventDetector,
@@ -51,6 +52,7 @@ class TypeBasedAssetSourceRegistry(
is Chain.Asset.Type.TronNative -> tronNativeSource.get()
is Chain.Asset.Type.Trc20 -> trc20Source.get()
is Chain.Asset.Type.BitcoinNative -> bitcoinNativeSource.get()
is Chain.Asset.Type.SolanaNative -> solanaNativeSource.get()
Chain.Asset.Type.Unsupported -> unsupportedBalanceSource
}
}
@@ -66,6 +68,7 @@ class TypeBasedAssetSourceRegistry(
add(tronNativeSource.get())
add(trc20Source.get())
add(bitcoinNativeSource.get())
add(solanaNativeSource.get())
}
}
@@ -76,6 +79,7 @@ class TypeBasedAssetSourceRegistry(
is Chain.Asset.Type.TronNative,
is Chain.Asset.Type.Trc20,
is Chain.Asset.Type.BitcoinNative,
is Chain.Asset.Type.SolanaNative,
Chain.Asset.Type.Unsupported -> UnsupportedEventDetector()
@@ -0,0 +1,32 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.solanaNative
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
private const val SOLANA_BALANCE_POLLING_INTERVAL_MS = 30_000L
/**
* Solana's JSON-RPC has no push/subscription mechanism usable here (its websocket `accountSubscribe` would
* need a persistent per-node WS connection this app's Bitcoin/Tron-style REST clients don't otherwise use) -
* mirrors [io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.bitcoinNative.pollingBalanceFlow]'s
* exact polling design instead.
*/
internal fun pollingBalanceFlow(
intervalMs: Long = SOLANA_BALANCE_POLLING_INTERVAL_MS,
fetch: suspend () -> Balance
): Flow<Balance> = flow {
var lastEmitted: Balance? = null
while (true) {
val latest = fetch()
if (latest != lastEmitted) {
lastEmitted = latest
emit(latest)
}
delay(intervalMs)
}
}
@@ -0,0 +1,87 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.solanaNative
import io.novafoundation.nova.core.updater.SharedRequestsBuilder
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache
import io.novafoundation.nova.feature_wallet_api.data.cache.updateNonLockableAsset
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.AssetBalance
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.BalanceSyncUpdate
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.ChainAssetBalance
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.TransferableBalanceUpdatePoint
import io.novafoundation.nova.feature_wallet_impl.data.network.solana.SolanaApi
import io.novafoundation.nova.runtime.ext.addressOf
import io.novafoundation.nova.runtime.ext.requireSolanaRpcBaseUrl
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.runtime.AccountId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.map
import java.math.BigInteger
/**
* Native SOL balance on a Solana-based chain. Read-only balance (Phase 1, mirrors
* [io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.bitcoinNative.BitcoinNativeAssetBalance]'s
* exact design): fetches via Solana's `getBalance` JSON-RPC method and polls for updates, since that method has
* no push/subscription counterpart usable here.
*/
class SolanaNativeAssetBalance(
private val assetCache: AssetCache,
private val solanaApi: SolanaApi,
) : AssetBalance {
override suspend fun startSyncingBalanceLocks(
metaAccount: MetaAccount,
chain: Chain,
chainAsset: Chain.Asset,
accountId: AccountId,
subscriptionBuilder: SharedRequestsBuilder
): Flow<*> {
// Solana native balance does not support locks
return emptyFlow<Nothing>()
}
override fun isSelfSufficient(chainAsset: Chain.Asset): Boolean {
return true
}
override suspend fun existentialDeposit(chainAsset: Chain.Asset): BigInteger {
// Solana's closest analogue is per-account rent-exemption, which is a function of account *size*, not
// a fixed protocol constant the way Substrate's ED is - not modeled yet (Phase 1, same open item as
// Bitcoin's dust limit not being enforced at this layer).
return BigInteger.ZERO
}
override suspend fun queryAccountBalance(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId): ChainAssetBalance {
val balance = solanaApi.fetchNativeBalance(chain.requireSolanaRpcBaseUrl(), chain.addressOf(accountId))
return ChainAssetBalance.fromFree(chainAsset, balance)
}
override suspend fun subscribeAccountBalanceUpdatePoint(
chain: Chain,
chainAsset: Chain.Asset,
accountId: AccountId,
): Flow<TransferableBalanceUpdatePoint> {
// Only ever invoked from RealCrossChainTransactor (XCM arrival detection), which is Substrate-only -
// Solana can never be an XCM cross-chain destination, so this is intentionally never reachable.
throw UnsupportedOperationException("Solana does not support XCM-style balance update points")
}
override suspend fun startSyncingBalance(
chain: Chain,
chainAsset: Chain.Asset,
metaAccount: MetaAccount,
accountId: AccountId,
subscriptionBuilder: SharedRequestsBuilder
): Flow<BalanceSyncUpdate> {
val baseUrl = chain.requireSolanaRpcBaseUrl()
val address = chain.addressOf(accountId)
return pollingBalanceFlow { solanaApi.fetchNativeBalance(baseUrl, address) }
.map { balance ->
assetCache.updateNonLockableAsset(metaAccount.id, chainAsset, balance)
BalanceSyncUpdate.NoCause
}
}
}
@@ -0,0 +1,86 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.solanaNative
import io.novafoundation.nova.common.validation.ValidationSystem
import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.intoOrigin
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission
import io.novafoundation.nova.feature_account_api.data.model.Fee
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfer
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfers
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.WeightedAssetTransfer
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.amountInPlanks
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.model.TransferParsedFromCall
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.checkForFeeChanges
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.positiveAmount
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.recipientIsNotSystemAccount
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientBalanceInUsedAsset
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientTransferableBalanceToPayOriginFee
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.validAddress
import io.novafoundation.nova.feature_wallet_impl.data.network.solana.transaction.SolanaTransactionService
import io.novafoundation.nova.feature_wallet_impl.domain.validaiton.recipientCanAcceptTransfer
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
import kotlinx.coroutines.CoroutineScope
/**
* Native SOL transfer. Mirrors
* [io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.bitcoinNative.BitcoinNativeAssetTransfers]'s
* exact shape - see `RealSolanaTransactionService` for the message-build/sign/broadcast detail.
*/
class SolanaNativeAssetTransfers(
private val solanaTransactionService: SolanaTransactionService,
private val assetSourceRegistry: AssetSourceRegistry,
) : AssetTransfers {
override fun getValidationSystem(coroutineScope: CoroutineScope) = ValidationSystem {
validAddress()
recipientIsNotSystemAccount()
positiveAmount()
sufficientBalanceInUsedAsset()
sufficientTransferableBalanceToPayOriginFee()
recipientCanAcceptTransfer(assetSourceRegistry)
checkForFeeChanges(assetSourceRegistry, coroutineScope)
}
override suspend fun calculateFee(transfer: AssetTransfer, coroutineScope: CoroutineScope): Fee {
return solanaTransactionService.calculateFee(
chain = transfer.originChain,
origin = transfer.sender.intoOrigin(),
recipientAddress = transfer.recipient,
amountLamports = transfer.amountInPlanks
)
}
override suspend fun performTransfer(transfer: WeightedAssetTransfer, coroutineScope: CoroutineScope): Result<ExtrinsicSubmission> {
return solanaTransactionService.transact(
chain = transfer.originChain,
origin = transfer.sender.intoOrigin(),
recipientAddress = transfer.recipient,
presetFee = transfer.fee.submissionFee,
amountLamports = transfer.amountInPlanks
)
}
override suspend fun performTransferAndAwaitExecution(transfer: WeightedAssetTransfer, coroutineScope: CoroutineScope): Result<TransactionExecution> {
return solanaTransactionService.transactAndAwaitExecution(
chain = transfer.originChain,
origin = transfer.sender.intoOrigin(),
recipientAddress = transfer.recipient,
presetFee = transfer.fee.submissionFee,
amountLamports = transfer.amountInPlanks
)
}
override suspend fun areTransfersEnabled(chainAsset: Chain.Asset): Boolean {
return true
}
override suspend fun parseTransfer(call: GenericCall.Instance, chain: Chain): TransferParsedFromCall? {
return null
}
}
@@ -122,6 +122,7 @@ private fun Chain.Asset.existentialDepositError(amount: BigDecimal): WillRemoveA
is Type.EvmErc20, is Type.EvmNative -> WillRemoveAccount.WillBurnDust
is Type.Trc20, Type.TronNative -> WillRemoveAccount.WillBurnDust
Type.BitcoinNative -> WillRemoveAccount.WillBurnDust
Type.SolanaNative -> WillRemoveAccount.WillBurnDust
is Type.Equilibrium -> WillRemoveAccount.WillBurnDust
Type.Unsupported -> throw IllegalArgumentException("Unsupported")
}
@@ -0,0 +1,31 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.solana
import io.novafoundation.nova.common.data.network.UserAgent
import io.novafoundation.nova.feature_wallet_impl.data.network.solana.model.SolanaGetBalanceResponse
import io.novafoundation.nova.feature_wallet_impl.data.network.solana.model.SolanaGetFeeForMessageResponse
import io.novafoundation.nova.feature_wallet_impl.data.network.solana.model.SolanaGetLatestBlockhashResponse
import io.novafoundation.nova.feature_wallet_impl.data.network.solana.model.SolanaRpcRequest
import io.novafoundation.nova.feature_wallet_impl.data.network.solana.model.SolanaSendTransactionResponse
import retrofit2.http.Body
import retrofit2.http.Headers
import retrofit2.http.POST
import retrofit2.http.Url
interface RetrofitSolanaApi {
@POST
@Headers(UserAgent.NOVA)
suspend fun getBalance(@Url url: String, @Body body: SolanaRpcRequest): SolanaGetBalanceResponse
@POST
@Headers(UserAgent.NOVA)
suspend fun getLatestBlockhash(@Url url: String, @Body body: SolanaRpcRequest): SolanaGetLatestBlockhashResponse
@POST
@Headers(UserAgent.NOVA)
suspend fun getFeeForMessage(@Url url: String, @Body body: SolanaRpcRequest): SolanaGetFeeForMessageResponse
@POST
@Headers(UserAgent.NOVA)
suspend fun sendTransaction(@Url url: String, @Body body: SolanaRpcRequest): SolanaSendTransactionResponse
}
@@ -0,0 +1,72 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.solana
import io.novafoundation.nova.common.utils.Base58
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.feature_wallet_impl.data.network.solana.model.SolanaRpcRequest
import java.math.BigInteger
import java.util.Base64
class SolanaApiException(code: Int, message: String) : Exception("Solana RPC error $code: $message")
interface SolanaApi {
suspend fun fetchNativeBalance(baseUrl: String, address: String): Balance
/** @return the 32 raw bytes of the cluster's most recent blockhash, ready to embed in a [io.novafoundation.nova.common.utils.SolanaTransaction] message. */
suspend fun fetchLatestBlockhash(baseUrl: String): ByteArray
/** @return the exact fee (lamports) the cluster will charge for this specific compiled [message], per `getFeeForMessage`. */
suspend fun calculateFeeForMessage(baseUrl: String, message: ByteArray): BigInteger
/** @param signedTransaction a fully-signed, serialized transaction (see [io.novafoundation.nova.common.utils.SolanaTransaction.serializeSigned]). @return the broadcast transaction's signature (its id/hash). */
suspend fun broadcastTransaction(baseUrl: String, signedTransaction: ByteArray): String
}
class RealSolanaApi(
private val retrofitApi: RetrofitSolanaApi
) : SolanaApi {
override suspend fun fetchNativeBalance(baseUrl: String, address: String): Balance {
val request = SolanaRpcRequest(method = "getBalance", params = listOf(address))
val response = retrofitApi.getBalance(baseUrl, request)
response.error?.let { throw SolanaApiException(it.code, it.message) }
return response.result?.value?.toBigInteger() ?: BigInteger.ZERO
}
override suspend fun fetchLatestBlockhash(baseUrl: String): ByteArray {
val request = SolanaRpcRequest(method = "getLatestBlockhash")
val response = retrofitApi.getLatestBlockhash(baseUrl, request)
response.error?.let { throw SolanaApiException(it.code, it.message) }
val blockhashBase58 = response.result?.value?.blockhash
?: throw SolanaApiException(-1, "getLatestBlockhash returned no result")
return Base58.decode(blockhashBase58)
}
override suspend fun calculateFeeForMessage(baseUrl: String, message: ByteArray): BigInteger {
val messageBase64 = Base64.getEncoder().encodeToString(message)
val request = SolanaRpcRequest(method = "getFeeForMessage", params = listOf(messageBase64, mapOf("encoding" to "base64")))
val response = retrofitApi.getFeeForMessage(baseUrl, request)
response.error?.let { throw SolanaApiException(it.code, it.message) }
val feeLamports = response.result?.value
?: throw SolanaApiException(-1, "getFeeForMessage could not price this message (unknown/expired blockhash)")
return feeLamports.toBigInteger()
}
override suspend fun broadcastTransaction(baseUrl: String, signedTransaction: ByteArray): String {
val txBase64 = Base64.getEncoder().encodeToString(signedTransaction)
val request = SolanaRpcRequest(method = "sendTransaction", params = listOf(txBase64, mapOf("encoding" to "base64")))
val response = retrofitApi.sendTransaction(baseUrl, request)
response.error?.let { throw SolanaApiException(it.code, it.message) }
return response.result ?: throw SolanaApiException(-1, "sendTransaction returned no signature")
}
}
@@ -0,0 +1,58 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.solana.model
/**
* Solana speaks a single JSON-RPC endpoint (unlike Tron/Bitcoin's path-per-resource REST style) - every call
* is an HTTP POST of one of these envelopes to the same url, distinguished only by [method]/[params]. See
* https://solana.com/docs/rpc/http for the spec this mirrors.
*/
class SolanaRpcRequest(
val method: String,
val params: List<Any> = emptyList(),
val jsonrpc: String = "2.0",
val id: Int = 1,
)
class SolanaRpcError(
val code: Int,
val message: String,
)
class SolanaGetBalanceResponse(
val result: SolanaBalanceResult?,
val error: SolanaRpcError?,
)
class SolanaBalanceResult(
/** Lamports (1 SOL = 1_000_000_000 lamports) - Solana's native-coin base unit, same role planks/wei play elsewhere. */
val value: Long,
)
class SolanaGetLatestBlockhashResponse(
val result: SolanaLatestBlockhashResult?,
val error: SolanaRpcError?,
)
class SolanaLatestBlockhashResult(
val value: SolanaBlockhashValue,
)
class SolanaBlockhashValue(
/** Base58-encoded, same alphabet/encoding as an account address - decode with [Base58] before use. */
val blockhash: String,
)
class SolanaGetFeeForMessageResponse(
val result: SolanaFeeForMessageResult?,
val error: SolanaRpcError?,
)
class SolanaFeeForMessageResult(
/** Lamports, or null if the message's blockhash is not found (e.g. too old) - see RealSolanaApi's handling. */
val value: Long?,
)
class SolanaSendTransactionResponse(
/** The transaction's signature, base58-encoded - this doubles as the transaction id/hash used for tracking. */
val result: String?,
val error: SolanaRpcError?,
)
@@ -0,0 +1,107 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.solana.transaction
import io.novafoundation.nova.common.utils.SolanaTransaction
import io.novafoundation.nova.common.utils.emptySolanaAccountId
import io.novafoundation.nova.common.utils.isValidSolanaAddress
import io.novafoundation.nova.common.utils.solanaAddressToAccountId
import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission
import io.novafoundation.nova.feature_account_api.data.extrinsic.SubmissionOrigin
import io.novafoundation.nova.feature_account_api.data.model.Fee
import io.novafoundation.nova.feature_account_api.data.model.SolanaFee
import io.novafoundation.nova.feature_account_api.data.signer.CallExecutionType
import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider
import io.novafoundation.nova.feature_account_api.data.signer.SubmissionHierarchy
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.domain.interfaces.requireMetaAccountFor
import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution
import io.novafoundation.nova.feature_wallet_impl.data.network.solana.SolanaApi
import io.novafoundation.nova.runtime.ext.commissionAsset
import io.novafoundation.nova.runtime.ext.requireSolanaRpcBaseUrl
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.runtime.AccountId
import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw
import java.math.BigInteger
/**
* Builds, signs and broadcasts a Solana native SOL transfer from this wallet's own Ed25519 account - see
* `common/utils/SolanaTransaction.kt` for the wire-format primitives (cross-validated against the `solders`
* Python library) and `SecretsSigner`/`RealSecretsMetaAccount.multiChainEncryptionIn` for how a Solana account
* gets routed to `Signer.signEd25519` (raw-message Ed25519, no external hashing - matches Solana's own
* signing model exactly, unlike Bitcoin/Ethereum's hash-then-sign schemes).
*
* Unlike Bitcoin (UTXO selection) or Tron (server-assisted transaction construction), Solana's account model
* needs only two network round-trips beyond fee/broadcast themselves: `getLatestBlockhash` (a transaction is
* only valid against a recent one, ~60-90s window) and `getFeeForMessage` (the network's authoritative,
* flat-rate-per-signature fee for this exact message - not a client-side estimate).
*/
class RealSolanaTransactionService(
private val accountRepository: AccountRepository,
private val signerProvider: SignerProvider,
private val solanaApi: SolanaApi,
) : SolanaTransactionService {
override suspend fun calculateFee(chain: Chain, origin: TransactionOrigin, recipientAddress: String, amountLamports: BigInteger): Fee {
val submittingMetaAccount = accountRepository.requireMetaAccountFor(origin, chain.id)
val ownerAccountId = submittingMetaAccount.requireAccountIdIn(chain)
val baseUrl = chain.requireSolanaRpcBaseUrl()
val recentBlockhash = solanaApi.fetchLatestBlockhash(baseUrl)
// Solana's fee is a flat rate per required signature (always 1 here) - it does not depend on the
// recipient or amount, so a syntactically-valid placeholder recipient is fine when the real one can't
// be parsed yet (e.g. mid-typing in the send UI), same tolerance Bitcoin's calculateFee has.
val recipientAccountId = recipientAddress.takeIf { it.isValidSolanaAddress() }
?.solanaAddressToAccountId()
?: emptySolanaAccountId()
val message = SolanaTransaction.buildTransferMessage(ownerAccountId, recipientAccountId, lamports = 0L, recentBlockhash)
val feeLamports = solanaApi.calculateFeeForMessage(baseUrl, message)
return SolanaFee(feeLamports, SubmissionOrigin.singleOrigin(ownerAccountId), chain.commissionAsset)
}
override suspend fun transact(
chain: Chain,
origin: TransactionOrigin,
recipientAddress: String,
presetFee: Fee?,
amountLamports: BigInteger
): Result<ExtrinsicSubmission> = runCatching {
val submittingMetaAccount = accountRepository.requireMetaAccountFor(origin, chain.id)
val ownerAccountId = submittingMetaAccount.requireAccountIdIn(chain)
val baseUrl = chain.requireSolanaRpcBaseUrl()
val recipientAccountId = recipientAddress.solanaAddressToAccountId()
val recentBlockhash = solanaApi.fetchLatestBlockhash(baseUrl)
val message = SolanaTransaction.buildTransferMessage(ownerAccountId, recipientAccountId, amountLamports.toLong(), recentBlockhash)
val signer = signerProvider.rootSignerFor(submittingMetaAccount)
val signedRaw = signer.signRaw(SignerPayloadRaw(message = message, accountId = ownerAccountId, skipMessageHashing = true))
val signature = signedRaw.signatureWrapper.signature
val signedTransaction = SolanaTransaction.serializeSigned(message, signature)
val txSignature = solanaApi.broadcastTransaction(baseUrl, signedTransaction)
ExtrinsicSubmission(
hash = txSignature,
submissionOrigin = SubmissionOrigin.singleOrigin(ownerAccountId),
callExecutionType = CallExecutionType.IMMEDIATE,
submissionHierarchy = SubmissionHierarchy(submittingMetaAccount, CallExecutionType.IMMEDIATE)
)
}
override suspend fun transactAndAwaitExecution(
chain: Chain,
origin: TransactionOrigin,
recipientAddress: String,
presetFee: Fee?,
amountLamports: BigInteger
): Result<TransactionExecution> {
// Broadcast acceptance is already a strong signal (same posture as Bitcoin/Tron/EVM) - this sits
// outside the primary send flow's critical path, which only ever calls transact().
return transact(chain, origin, recipientAddress, presetFee, amountLamports).map { TransactionExecution.Solana(it.hash) }
}
}
@@ -0,0 +1,34 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.solana.transaction
import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission
import io.novafoundation.nova.feature_account_api.data.model.Fee
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import java.math.BigInteger
/**
* Mirrors [io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.transaction.BitcoinTransactionService]'s
* shape (calculateFee/transact/transactAndAwaitExecution over a sending origin), but for Solana's simpler
* account-model native transfer - see `RealSolanaTransactionService` for the message-build/sign/broadcast detail.
*/
interface SolanaTransactionService {
suspend fun calculateFee(chain: Chain, origin: TransactionOrigin, recipientAddress: String, amountLamports: BigInteger): Fee
suspend fun transact(
chain: Chain,
origin: TransactionOrigin,
recipientAddress: String,
presetFee: Fee?,
amountLamports: BigInteger
): Result<ExtrinsicSubmission>
suspend fun transactAndAwaitExecution(
chain: Chain,
origin: TransactionOrigin,
recipientAddress: String,
presetFee: Fee?,
amountLamports: BigInteger
): Result<TransactionExecution>
}
@@ -23,6 +23,7 @@ import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets
EquilibriumAssetsModule::class,
TronAssetsModule::class,
BitcoinAssetsModule::class,
SolanaAssetsModule::class,
UnsupportedAssetsModule::class
]
)
@@ -40,6 +41,7 @@ class AssetsModule {
@TronNativeAssets tronNative: Lazy<AssetSource>,
@Trc20Assets trc20: Lazy<AssetSource>,
@BitcoinNativeAssets bitcoinNative: Lazy<AssetSource>,
@SolanaNativeAssets solanaNative: Lazy<AssetSource>,
@UnsupportedAssets unsupported: AssetSource,
nativeAssetEventDetector: NativeAssetEventDetector,
@@ -56,6 +58,7 @@ class AssetsModule {
tronNativeSource = tronNative,
trc20Source = trc20,
bitcoinNativeSource = bitcoinNative,
solanaNativeSource = solanaNative,
unsupportedBalanceSource = unsupported,
nativeAssetEventDetector = nativeAssetEventDetector,
@@ -0,0 +1,82 @@
package io.novafoundation.nova.feature_wallet_impl.di.modules
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.common.data.network.NetworkApiCreator
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSource
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.StaticAssetSource
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.solanaNative.SolanaNativeAssetBalance
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.UnsupportedAssetHistory
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.solanaNative.SolanaNativeAssetTransfers
import io.novafoundation.nova.feature_wallet_impl.data.network.solana.RealSolanaApi
import io.novafoundation.nova.feature_wallet_impl.data.network.solana.RetrofitSolanaApi
import io.novafoundation.nova.feature_wallet_impl.data.network.solana.SolanaApi
import io.novafoundation.nova.feature_wallet_impl.data.network.solana.transaction.RealSolanaTransactionService
import io.novafoundation.nova.feature_wallet_impl.data.network.solana.transaction.SolanaTransactionService
import javax.inject.Qualifier
@Qualifier
annotation class SolanaNativeAssets
/**
* Solana support: `balance` (JSON-RPC `getBalance` polling) and `transfers` (client-side message build,
* Ed25519 signing, `sendTransaction` broadcast - see `RealSolanaTransactionService` for the full notes).
*
* `history` remains unsupported (out of scope for this phase, same as the rest of the app's `Unsupported*`
* stubs used for asset types without history support - see `BitcoinAssetsModule`/`TronAssetsModule` for the
* identical precedent).
*/
@Module
class SolanaAssetsModule {
@Provides
@FeatureScope
fun provideRetrofitSolanaApi(
networkApiCreator: NetworkApiCreator
): RetrofitSolanaApi = networkApiCreator.create(RetrofitSolanaApi::class.java)
@Provides
@FeatureScope
fun provideSolanaApi(retrofitSolanaApi: RetrofitSolanaApi): SolanaApi = RealSolanaApi(retrofitSolanaApi)
@Provides
@FeatureScope
fun provideSolanaTransactionService(
accountRepository: AccountRepository,
signerProvider: SignerProvider,
solanaApi: SolanaApi,
): SolanaTransactionService = RealSolanaTransactionService(
accountRepository = accountRepository,
signerProvider = signerProvider,
solanaApi = solanaApi
)
@Provides
@FeatureScope
fun provideSolanaNativeBalance(assetCache: AssetCache, solanaApi: SolanaApi) = SolanaNativeAssetBalance(assetCache, solanaApi)
@Provides
@FeatureScope
fun provideSolanaNativeAssetTransfers(
solanaTransactionService: SolanaTransactionService,
assetSourceRegistry: AssetSourceRegistry,
) = SolanaNativeAssetTransfers(solanaTransactionService, assetSourceRegistry)
@Provides
@SolanaNativeAssets
@FeatureScope
fun provideSolanaNativeAssetSource(
solanaNativeAssetBalance: SolanaNativeAssetBalance,
solanaNativeAssetTransfers: SolanaNativeAssetTransfers,
unsupportedAssetHistory: UnsupportedAssetHistory,
): AssetSource = StaticAssetSource(
transfers = solanaNativeAssetTransfers,
balance = solanaNativeAssetBalance,
history = unsupportedAssetHistory
)
}
@@ -0,0 +1,218 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.solana.transaction
import io.novafoundation.nova.common.utils.Base58
import io.novafoundation.nova.common.utils.Precision
import io.novafoundation.nova.common.utils.SolanaTransaction
import io.novafoundation.nova.common.utils.TokenSymbol
import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin
import io.novafoundation.nova.feature_account_api.data.signer.NovaSigner
import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_wallet_impl.data.network.solana.SolanaApi
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.encrypt.SignatureWrapper
import io.novasama.substrate_sdk_android.extensions.fromHex
import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignedRaw
import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
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.verify
import org.mockito.junit.MockitoJUnitRunner
// Same guaranteed-non-null-return wrappers as RealTronTransactionServiceTest, and 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)
/**
* Covers [RealSolanaTransactionService.transact]/[RealSolanaTransactionService.calculateFee] - the
* message-build/sign/broadcast pipeline. Reuses the exact sender/recipient/blockhash/amount fixture that
* [io.novafoundation.nova.common.utils.SolanaTransactionTest] cross-validated against the `solders` Python
* library, so a regression in what THIS service hands to the signer/broadcaster is caught independently of
* that lower-level wire-format test.
*/
@RunWith(MockitoJUnitRunner::class)
class RealSolanaTransactionServiceTest {
@Mock
lateinit var accountRepository: AccountRepository
@Mock
lateinit var signerProvider: SignerProvider
@Mock
lateinit var solanaApi: SolanaApi
@Mock
lateinit var metaAccount: MetaAccount
@Mock
lateinit var signer: NovaSigner
private lateinit var subject: RealSolanaTransactionService
private val senderPublicKey = "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5".fromHex()
private val recipientPublicKey = "c8139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b3".fromHex()
private val recipientAddress = Base58.encode(recipientPublicKey)
private val recentBlockhash = ByteArray(32)
private val lamports = 123456789L
private val baseUrl = "https://api.mainnet-beta.solana.com"
private val chain = solanaChain(baseUrl)
private val expectedTransferMessage = SolanaTransaction.buildTransferMessage(senderPublicKey, recipientPublicKey, lamports, recentBlockhash)
@Before
fun setup() {
subject = RealSolanaTransactionService(accountRepository, signerProvider, solanaApi)
whenever(metaAccount.accountIdIn(eq(chain))).thenReturn(senderPublicKey)
whenever(signerProvider.rootSignerFor(eq(metaAccount))).thenReturn(signer)
}
@Test
fun `transact should build the transfer message, sign it raw with skipMessageHashing, and broadcast the serialized signed transaction`(): Unit = runBlocking {
val signature = ByteArray(64) { it.toByte() }
val fakeSignedRaw = SignedRaw(
SignerPayloadRaw(message = expectedTransferMessage, accountId = senderPublicKey, skipMessageHashing = true),
SignatureWrapper.Ed25519(signature = signature)
)
whenever(solanaApi.fetchLatestBlockhash(eq(baseUrl))).thenReturn(recentBlockhash)
whenever(signer.signRaw(any())).thenReturn(fakeSignedRaw)
whenever(solanaApi.broadcastTransaction(eq(baseUrl), any())).thenReturn("some-broadcast-signature")
val result = subject.transact(
chain = chain,
origin = TransactionOrigin.Wallet(metaAccount),
recipientAddress = recipientAddress,
presetFee = null,
amountLamports = lamports.toBigInteger()
)
assertTrue(result.isSuccess)
assertEquals("some-broadcast-signature", result.getOrThrow().hash)
// The message actually handed to the signer must be the exact compiled transfer message, not some other
// byte sequence - a regression here would silently produce a signature over the wrong bytes.
verify(signer).signRaw(
argThat<SignerPayloadRaw> { payload ->
payload.message.contentEquals(expectedTransferMessage) &&
payload.accountId.contentEquals(senderPublicKey) &&
payload.skipMessageHashing
}
)
// The broadcast transaction must be compact-u16(1) + the raw 64-byte Ed25519 signature + the message,
// exactly as SolanaTransaction.serializeSigned defines it - no DER/other re-encoding, unlike Bitcoin.
val expectedSignedTransaction = SolanaTransaction.serializeSigned(expectedTransferMessage, signature)
verify(solanaApi).broadcastTransaction(baseUrl, expectedSignedTransaction)
}
@Test
fun `calculateFee should price a real message built against the latest blockhash and return it as SolanaFee`(): Unit = runBlocking {
whenever(solanaApi.fetchLatestBlockhash(eq(baseUrl))).thenReturn(recentBlockhash)
val expectedFeeMessage = SolanaTransaction.buildTransferMessage(senderPublicKey, recipientPublicKey, lamports = 0L, recentBlockhash)
whenever(solanaApi.calculateFeeForMessage(eq(baseUrl), eq(expectedFeeMessage))).thenReturn(5000.toBigInteger())
val fee = subject.calculateFee(
chain = chain,
origin = TransactionOrigin.Wallet(metaAccount),
recipientAddress = recipientAddress,
amountLamports = lamports.toBigInteger()
)
assertEquals(5000.toBigInteger(), fee.amount)
}
@Test
fun `calculateFee should fall back to a placeholder recipient when the real one can't be parsed yet`(): Unit = runBlocking {
whenever(solanaApi.fetchLatestBlockhash(eq(baseUrl))).thenReturn(recentBlockhash)
whenever(solanaApi.calculateFeeForMessage(eq(baseUrl), any())).thenReturn(5000.toBigInteger())
// A blank recipient (e.g. mid-typing in the send UI) must not throw - Solana's fee only depends on the
// signature count, not on which recipient is used, so this should still price successfully.
val fee = subject.calculateFee(
chain = chain,
origin = TransactionOrigin.Wallet(metaAccount),
recipientAddress = "",
amountLamports = lamports.toBigInteger()
)
assertEquals(5000.toBigInteger(), fee.amount)
}
private fun solanaChain(baseUrl: String): Chain {
val solAsset = Chain.Asset(
icon = null,
id = 0,
priceId = "solana",
chainId = "solana:mainnet",
symbol = TokenSymbol("SOL"),
precision = Precision(9),
buyProviders = emptyMap(),
sellProviders = emptyMap(),
staking = emptyList(),
type = Chain.Asset.Type.SolanaNative,
source = Chain.Asset.Source.DEFAULT,
name = "Solana",
enabled = true
)
return Chain(
id = "solana:mainnet",
name = "Solana",
assets = listOf(solAsset),
nodes = Chain.Nodes(
autoBalanceStrategy = Chain.Nodes.AutoBalanceStrategy.ROUND_ROBIN,
wssNodeSelectionStrategy = Chain.Nodes.NodeSelectionStrategy.AutoBalance,
nodes = listOf(Chain.Node(chainId = "solana:mainnet", unformattedUrl = baseUrl, name = "Solana", orderId = 0, isCustom = false))
),
explorers = emptyList(),
externalApis = emptyList(),
icon = null,
addressPrefix = 0,
legacyAddressPrefix = null,
types = null,
isEthereumBased = false,
isTronBased = false,
isBitcoinBased = false,
isSolanaBased = true,
isTestNet = false,
source = Chain.Source.DEFAULT,
hasSubstrateRuntime = false,
pushSupport = false,
hasCrowdloans = false,
supportProxy = false,
governance = emptyList(),
swap = emptyList(),
customFee = emptyList(),
multisigSupport = false,
connectionState = Chain.ConnectionState.FULL_SYNC,
parentId = null,
additional = null
)
}
}
@@ -258,6 +258,7 @@ class RealTronTransactionServiceTest {
isEthereumBased = false,
isTronBased = true,
isBitcoinBased = false,
isSolanaBased = false,
isTestNet = false,
source = Chain.Source.DEFAULT,
hasSubstrateRuntime = false,