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
@@ -0,0 +1,112 @@
package io.novafoundation.nova.common.utils
/**
* Solana's "shortvec"/compact-u16 length-prefix encoding used throughout the wire format below - LEB128-style,
* 7 payload bits per byte with a continuation bit, matching https://solana.com/docs/rpc/http's message layout.
* Cross-validated byte-for-byte against the `solders` Python library's own serialization of a real System
* Program transfer message before being ported here (values used in this file never exceed 127, so this always
* emits a single byte in practice, but the general algorithm is implemented for correctness/robustness).
*/
object SolanaCompactU16 {
fun encode(value: Int): ByteArray {
require(value >= 0) { "compact-u16 cannot encode a negative value" }
var remaining = value
val bytes = mutableListOf<Byte>()
while (true) {
var elem = remaining and 0x7f
remaining = remaining ushr 7
if (remaining == 0) {
bytes.add(elem.toByte())
break
} else {
elem = elem or 0x80
bytes.add(elem.toByte())
}
}
return bytes.toByteArray()
}
}
/** The System Program's id is the all-zero 32-byte pubkey (base58: 32 `1` characters) - not a derived/hashed value. */
private val SOLANA_SYSTEM_PROGRAM_ID: ByteArray = ByteArray(32)
/** `Transfer` is variant index 2 of the System Program's instruction enum. */
private const val SOLANA_SYSTEM_INSTRUCTION_TRANSFER = 2
object SolanaTransaction {
/**
* A legacy (unversioned) Message containing a single System Program `Transfer` instruction - the simplest,
* maximally-compatible shape for a native SOL transfer (no address-lookup-tables/versioned-transaction
* features needed). Layout: MessageHeader(3 bytes) + compact-u16 account count + accounts(32B each) +
* recentBlockhash(32B) + compact-u16 instruction count + CompiledInstruction. Verified byte-for-byte
* against `solders`' own serialization of an identical transfer message - see the class doc.
*
* [recentBlockhash] must be 32 raw bytes (Base58-decode the RPC's `blockhash` string before calling this).
*/
fun buildTransferMessage(
senderPublicKey: ByteArray,
recipientPublicKey: ByteArray,
lamports: Long,
recentBlockhash: ByteArray,
): ByteArray {
require(senderPublicKey.size == 32) { "senderPublicKey must be 32 bytes, got ${senderPublicKey.size}" }
require(recipientPublicKey.size == 32) { "recipientPublicKey must be 32 bytes, got ${recipientPublicKey.size}" }
require(recentBlockhash.size == 32) { "recentBlockhash must be 32 bytes, got ${recentBlockhash.size}" }
require(lamports >= 0) { "lamports cannot be negative" }
val accountKeys = listOf(senderPublicKey, recipientPublicKey, SOLANA_SYSTEM_PROGRAM_ID)
val instructionData = ByteArray(4 + 8).also {
writeUInt32LE(it, 0, SOLANA_SYSTEM_INSTRUCTION_TRANSFER)
writeUInt64LE(it, 4, lamports)
}
val out = mutableListOf<Byte>()
// MessageHeader: 1 required signature (sender), 0 readonly-signed, 1 readonly-unsigned (System Program)
out.add(1)
out.add(0)
out.add(1)
out.addAll(SolanaCompactU16.encode(accountKeys.size).asList())
accountKeys.forEach { out.addAll(it.asList()) }
out.addAll(recentBlockhash.asList())
// Single instruction: System Program Transfer
out.addAll(SolanaCompactU16.encode(1).asList())
out.add(2) // program_id_index -> accountKeys[2] (System Program)
out.addAll(SolanaCompactU16.encode(2).asList())
out.add(0) // sender account index
out.add(1) // recipient account index
out.addAll(SolanaCompactU16.encode(instructionData.size).asList())
out.addAll(instructionData.asList())
return out.toByteArray()
}
/** A single-signer signed transaction: compact-u16(1) + the 64-byte Ed25519 signature + the message bytes. */
fun serializeSigned(message: ByteArray, signature: ByteArray): ByteArray {
require(signature.size == 64) { "Ed25519 signature must be 64 bytes, got ${signature.size}" }
return SolanaCompactU16.encode(1) + signature + message
}
private fun writeUInt32LE(buffer: ByteArray, offset: Int, value: Int) {
for (i in 0 until 4) {
buffer[offset + i] = (value ushr (8 * i)).toByte()
}
}
private fun writeUInt64LE(buffer: ByteArray, offset: Int, value: Long) {
for (i in 0 until 8) {
buffer[offset + i] = (value ushr (8 * i)).toByte()
}
}
}
@@ -0,0 +1,66 @@
package io.novafoundation.nova.common.utils
import io.novasama.substrate_sdk_android.extensions.fromHex
import io.novasama.substrate_sdk_android.extensions.toHexString
import org.junit.Assert.assertEquals
import org.junit.Test
class SolanaTransactionTest {
/**
* Independently built via the `solders` Python library (a real, widely-used Solana SDK, run standalone
* outside this codebase): a System Program `Transfer` message from a fixed sender to a fixed recipient, a
* fixed 123456789-lamport amount, and an all-zero 32-byte "recent blockhash" (chosen for a reproducible
* fixed vector, not a real network value). This test asserts our hand-rolled message builder produces the
* exact same bytes solders did for the identical inputs.
*/
private val senderPublicKey = "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5".fromHex()
private val recipientPublicKey = "c8139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b3".fromHex()
private val recentBlockhash = ByteArray(32)
private val lamports = 123456789L
private val expectedMessageHex = "010001038a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5" +
"c8139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b39400000000000000000000000000000000" +
"00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +
"1020200010c0200000015cd5b0700000000"
@Test
fun `buildTransferMessage should match the independently-built solders reference vector`() {
val message = SolanaTransaction.buildTransferMessage(senderPublicKey, recipientPublicKey, lamports, recentBlockhash)
assertEquals(expectedMessageHex, message.toHexString(withPrefix = false))
}
@Test
fun `serializeSigned should prefix a single-signature count byte and the signature before the message`() {
val message = SolanaTransaction.buildTransferMessage(senderPublicKey, recipientPublicKey, lamports, recentBlockhash)
val signature = ByteArray(64) { it.toByte() }
val signedTx = SolanaTransaction.serializeSigned(message, signature)
assertEquals(1, signedTx[0].toInt())
assertEquals(signature.toList(), signedTx.copyOfRange(1, 65).toList())
assertEquals(message.toList(), signedTx.copyOfRange(65, signedTx.size).toList())
}
@Test(expected = IllegalArgumentException::class)
fun `serializeSigned should reject a non-64-byte signature`() {
val message = SolanaTransaction.buildTransferMessage(senderPublicKey, recipientPublicKey, lamports, recentBlockhash)
SolanaTransaction.serializeSigned(message, ByteArray(63))
}
@Test
fun `compact-u16 should single-byte-encode every value used by a simple transfer message`() {
assertEquals(listOf<Byte>(3), SolanaCompactU16.encode(3).toList())
assertEquals(listOf<Byte>(1), SolanaCompactU16.encode(1).toList())
assertEquals(listOf<Byte>(2), SolanaCompactU16.encode(2).toList())
assertEquals(listOf<Byte>(12), SolanaCompactU16.encode(12).toList())
}
@Test
fun `compact-u16 should multi-byte-encode a value at and above 128`() {
// 128 = 0b1_0000000 -> low 7 bits (0) with continuation bit set, then high bit (1)
assertEquals(listOf(0x80.toByte(), 0x01.toByte()), SolanaCompactU16.encode(128).toList())
}
}
@@ -88,6 +88,18 @@ class TronFee(
override val asset: Chain.Asset override val asset: Chain.Asset
) : Fee ) : Fee
/**
* Fee for a Solana transaction, denominated in lamports, always paid in native SOL regardless of which asset is
* being sent (Solana has no separate "gas token" concept, same as Tron). [amount] is `getFeeForMessage`'s
* authoritative, exact answer for this specific compiled message (not an estimate the way Bitcoin's vsize-based
* fee is) - see `RealSolanaTransactionService`.
*/
class SolanaFee(
override val amount: BigInteger,
override val submissionOrigin: SubmissionOrigin,
override val asset: Chain.Asset
) : Fee
class SubstrateFeeBase( class SubstrateFeeBase(
override val amount: BigInteger, override val amount: BigInteger,
override val asset: Chain.Asset override val asset: Chain.Asset
@@ -12,4 +12,6 @@ sealed interface TransactionExecution {
class Bitcoin(val hash: String) : TransactionExecution class Bitcoin(val hash: String) : TransactionExecution
class Tron(val hash: String) : TransactionExecution class Tron(val hash: String) : TransactionExecution
class Solana(val hash: String) : TransactionExecution
} }
@@ -32,6 +32,7 @@ class TypeBasedAssetSourceRegistry(
private val tronNativeSource: Lazy<AssetSource>, private val tronNativeSource: Lazy<AssetSource>,
private val trc20Source: Lazy<AssetSource>, private val trc20Source: Lazy<AssetSource>,
private val bitcoinNativeSource: Lazy<AssetSource>, private val bitcoinNativeSource: Lazy<AssetSource>,
private val solanaNativeSource: Lazy<AssetSource>,
private val unsupportedBalanceSource: AssetSource, private val unsupportedBalanceSource: AssetSource,
private val nativeAssetEventDetector: NativeAssetEventDetector, private val nativeAssetEventDetector: NativeAssetEventDetector,
@@ -51,6 +52,7 @@ class TypeBasedAssetSourceRegistry(
is Chain.Asset.Type.TronNative -> tronNativeSource.get() is Chain.Asset.Type.TronNative -> tronNativeSource.get()
is Chain.Asset.Type.Trc20 -> trc20Source.get() is Chain.Asset.Type.Trc20 -> trc20Source.get()
is Chain.Asset.Type.BitcoinNative -> bitcoinNativeSource.get() is Chain.Asset.Type.BitcoinNative -> bitcoinNativeSource.get()
is Chain.Asset.Type.SolanaNative -> solanaNativeSource.get()
Chain.Asset.Type.Unsupported -> unsupportedBalanceSource Chain.Asset.Type.Unsupported -> unsupportedBalanceSource
} }
} }
@@ -66,6 +68,7 @@ class TypeBasedAssetSourceRegistry(
add(tronNativeSource.get()) add(tronNativeSource.get())
add(trc20Source.get()) add(trc20Source.get())
add(bitcoinNativeSource.get()) add(bitcoinNativeSource.get())
add(solanaNativeSource.get())
} }
} }
@@ -76,6 +79,7 @@ class TypeBasedAssetSourceRegistry(
is Chain.Asset.Type.TronNative, is Chain.Asset.Type.TronNative,
is Chain.Asset.Type.Trc20, is Chain.Asset.Type.Trc20,
is Chain.Asset.Type.BitcoinNative, is Chain.Asset.Type.BitcoinNative,
is Chain.Asset.Type.SolanaNative,
Chain.Asset.Type.Unsupported -> UnsupportedEventDetector() 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.EvmErc20, is Type.EvmNative -> WillRemoveAccount.WillBurnDust
is Type.Trc20, Type.TronNative -> WillRemoveAccount.WillBurnDust is Type.Trc20, Type.TronNative -> WillRemoveAccount.WillBurnDust
Type.BitcoinNative -> WillRemoveAccount.WillBurnDust Type.BitcoinNative -> WillRemoveAccount.WillBurnDust
Type.SolanaNative -> WillRemoveAccount.WillBurnDust
is Type.Equilibrium -> WillRemoveAccount.WillBurnDust is Type.Equilibrium -> WillRemoveAccount.WillBurnDust
Type.Unsupported -> throw IllegalArgumentException("Unsupported") 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, EquilibriumAssetsModule::class,
TronAssetsModule::class, TronAssetsModule::class,
BitcoinAssetsModule::class, BitcoinAssetsModule::class,
SolanaAssetsModule::class,
UnsupportedAssetsModule::class UnsupportedAssetsModule::class
] ]
) )
@@ -40,6 +41,7 @@ class AssetsModule {
@TronNativeAssets tronNative: Lazy<AssetSource>, @TronNativeAssets tronNative: Lazy<AssetSource>,
@Trc20Assets trc20: Lazy<AssetSource>, @Trc20Assets trc20: Lazy<AssetSource>,
@BitcoinNativeAssets bitcoinNative: Lazy<AssetSource>, @BitcoinNativeAssets bitcoinNative: Lazy<AssetSource>,
@SolanaNativeAssets solanaNative: Lazy<AssetSource>,
@UnsupportedAssets unsupported: AssetSource, @UnsupportedAssets unsupported: AssetSource,
nativeAssetEventDetector: NativeAssetEventDetector, nativeAssetEventDetector: NativeAssetEventDetector,
@@ -56,6 +58,7 @@ class AssetsModule {
tronNativeSource = tronNative, tronNativeSource = tronNative,
trc20Source = trc20, trc20Source = trc20,
bitcoinNativeSource = bitcoinNative, bitcoinNativeSource = bitcoinNative,
solanaNativeSource = solanaNative,
unsupportedBalanceSource = unsupported, unsupportedBalanceSource = unsupported,
nativeAssetEventDetector = nativeAssetEventDetector, 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, isEthereumBased = false,
isTronBased = true, isTronBased = true,
isBitcoinBased = false, isBitcoinBased = false,
isSolanaBased = false,
isTestNet = false, isTestNet = false,
source = Chain.Source.DEFAULT, source = Chain.Source.DEFAULT,
hasSubstrateRuntime = false, hasSubstrateRuntime = false,